diff --git a/Gemfile b/Gemfile index 349fd2cd..6f52e427 100644 --- a/Gemfile +++ b/Gemfile @@ -23,6 +23,7 @@ gem "stimulus-rails" # Other gem "jbuilder" +gem "prawn", "~> 2.5" gem "redcarpet", "~> 3.6" gem "rouge", "~> 4.5" gem "bcrypt", "~> 3.1.7" diff --git a/Gemfile.lock b/Gemfile.lock index 204ec003..0361471f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -115,7 +115,7 @@ GEM ast (2.4.2) base64 (0.3.0) bcrypt (3.1.22) - bigdecimal (4.1.0) + bigdecimal (3.3.1) bindex (0.8.1) brakeman (7.0.2) racc @@ -215,8 +215,13 @@ GEM parser (3.3.3.0) ast (~> 2.4.1) racc + pdf-core (0.10.0) pp (0.6.3) prettyprint + prawn (2.5.0) + matrix (~> 0.4) + pdf-core (~> 0.10.0) + ttfunk (~> 1.8) prettyprint (0.2.0) prism (1.9.0) propshaft (1.0.0) @@ -347,6 +352,8 @@ GEM tilt (2.4.0) timeout (0.6.1) tsort (0.2.0) + ttfunk (1.8.0) + bigdecimal (~> 3.1) turbo-rails (2.0.11) actionpack (>= 6.0.0) railties (>= 6.0.0) @@ -386,6 +393,7 @@ DEPENDENCIES image_processing (~> 1.13) importmap-rails jbuilder + prawn (~> 2.5) propshaft puma (~> 7.2, >= 7.2.1) rails! diff --git a/app/assets/fonts/inter/Inter-Bold.ttf b/app/assets/fonts/inter/Inter-Bold.ttf new file mode 100644 index 00000000..be359192 Binary files /dev/null and b/app/assets/fonts/inter/Inter-Bold.ttf differ diff --git a/app/assets/fonts/inter/Inter-BoldItalic.ttf b/app/assets/fonts/inter/Inter-BoldItalic.ttf new file mode 100644 index 00000000..d433dd0d Binary files /dev/null and b/app/assets/fonts/inter/Inter-BoldItalic.ttf differ diff --git a/app/assets/fonts/inter/Inter-Italic.ttf b/app/assets/fonts/inter/Inter-Italic.ttf new file mode 100644 index 00000000..1069d569 Binary files /dev/null and b/app/assets/fonts/inter/Inter-Italic.ttf differ diff --git a/app/assets/fonts/inter/Inter-Regular.ttf b/app/assets/fonts/inter/Inter-Regular.ttf new file mode 100644 index 00000000..5ac1f221 Binary files /dev/null and b/app/assets/fonts/inter/Inter-Regular.ttf differ diff --git a/app/assets/fonts/inter/OFL.txt b/app/assets/fonts/inter/OFL.txt new file mode 100644 index 00000000..9b2ca37b --- /dev/null +++ b/app/assets/fonts/inter/OFL.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/controllers/books_controller.rb b/app/controllers/books_controller.rb index eda26210..32101cda 100644 --- a/app/controllers/books_controller.rb +++ b/app/controllers/books_controller.rb @@ -27,6 +27,14 @@ def show respond_to do |format| format.html format.md + format.pdf do + size = pdf_page_size + # Conditional GET: skip regeneration when the book is unchanged. + if stale?(etag: [ @book, size ], last_modified: @book.updated_at, public: @book.published?) + send_data Book::Pdf.new(@book, @leaves, page_size: size).render, + filename: pdf_filename(size), type: "application/pdf", disposition: "inline" + end + end end end @@ -48,6 +56,15 @@ def destroy end private + # Default to US Letter; `.A4.pdf` selects A4 (`.letter.pdf` is the same as `.pdf`). + def pdf_page_size + params[:size].to_s.casecmp?("A4") ? "A4" : "LETTER" + end + + def pdf_filename(size) + size == "A4" ? "#{@book.slug}.A4.pdf" : "#{@book.slug}.pdf" + end + def set_book @book = Book.accessable_or_published.find params[:id] end diff --git a/app/models/book/pdf.rb b/app/models/book/pdf.rb new file mode 100644 index 00000000..6b316b23 --- /dev/null +++ b/app/models/book/pdf.rb @@ -0,0 +1,334 @@ +require "prawn" + +# Renders a Book as a PDF: full-bleed cover, table of contents, then one +# section/page/picture per leaf, with page numbers in the footer. Pure Ruby +# (Prawn + Nokogiri), so it adds no system dependencies. Reached via the `.pdf` +# format on books#show, mirroring the `.md` export. +# +# Page bodies are markdown; we render that source to HTML with the same renderer +# the app uses elsewhere (Page.preview_renderer) and walk it with Nokogiri, +# emitting Prawn text. Images and tables are pulled out and drawn by Prawn +# directly, since flowing text can't place or size them. +class Book::Pdf + FONT_DIR = Rails.root.join("app/assets/fonts/inter") + + DEFAULT_PAGE_SIZE = "LETTER" + MARGIN = 56 + TOC_PER_PAGE = 30 # conservative, so the TOC never overflows its reserved pages + LINE = 18 + HEADING_SIZES = { "h1" => 18, "h2" => 16, "h3" => 14, "h4" => 13, "h5" => 12, "h6" => 11 }.freeze + LIST_INDENT = 16 + QUOTE_INDENT = 16 + + def initialize(book, leaves, page_size: DEFAULT_PAGE_SIZE) + @book = book + @leaves = leaves.to_a + @page_size = page_size + @entries = [] + end + + def render + @pdf = Prawn::Document.new( + page_size: @page_size, margin: MARGIN, + info: { Title: @book.title, Author: @book.author.presence }) + use_inter_font + + cover_page + reserve_toc_pages + content_pages + fill_toc + number_content_pages + + @pdf.render + end + + private + # Inter (bundled under app/assets/fonts/inter) covers Latin, Greek, Cyrillic, + # etc.; Prawn's built-in AFM fonts only cover Windows-1252. + def use_inter_font + @pdf.font_families.update("Inter" => { + normal: FONT_DIR.join("Inter-Regular.ttf").to_s, + bold: FONT_DIR.join("Inter-Bold.ttf").to_s, + italic: FONT_DIR.join("Inter-Italic.ttf").to_s, + bold_italic: FONT_DIR.join("Inter-BoldItalic.ttf").to_s }) + @pdf.font "Inter" + end + + def cover_page + @book.cover.attached? ? image_cover(blob_bytes(@book.cover)) : text_cover + end + + # Fill the whole page edge-to-edge (cover, cropping overflow), no margins. + def image_cover(bytes) + width, height = image_dimensions(bytes) + return text_cover unless width + + @pdf.canvas do + page_w, page_h = @pdf.bounds.width, @pdf.bounds.height + scale = [ page_w / width.to_f, page_h / height.to_f ].max + w, h = width * scale, height * scale + @pdf.image StringIO.new(bytes), width: w, height: h, + at: [ (page_w - w) / 2, page_h - (page_h - h) / 2 ] + end + rescue Prawn::Errors::UnsupportedImageType + text_cover + end + + def text_cover + @pdf.move_down 220 + @pdf.text @book.title, size: 32, style: :bold, align: :center + @pdf.move_down 14 + @pdf.text @book.subtitle, size: 18, align: :center if @book.subtitle.present? + @pdf.move_down 28 + @pdf.text @book.author, size: 14, align: :center if @book.author.present? + end + + # Reserve blank pages now; each leaf's page number isn't known until the + # content is laid out, so we go back and fill these in afterwards. + def reserve_toc_pages + @toc_start = @pdf.page_number + 1 + @toc_count = [ (@leaves.size / TOC_PER_PAGE.to_f).ceil, 1 ].max + @toc_count.times { @pdf.start_new_page } + @first_content_page = @pdf.page_number + 1 + end + + def fill_toc + page = @toc_start + @pdf.go_to_page(page) + @pdf.move_cursor_to @pdf.bounds.top + @pdf.text @book.title, size: 22, style: :bold # header mirrors the web sidebar + @pdf.move_down LINE + + @entries.each do |entry| + if @pdf.cursor < LINE && page < @toc_start + @toc_count - 1 + page += 1 + @pdf.go_to_page(page) + @pdf.move_cursor_to @pdf.bounds.top + end + toc_entry(entry) + end + end + + NUMBER_WIDTH = 28 + + # Flat list of leaf titles, mirroring the web sidebar TOC. + def toc_entry(entry) + number = (entry[:page] - @first_content_page + 1).to_s + style = entry[:section] ? :bold : :normal # section leaves are bold, like the web + title = entry[:title].to_s + top = @pdf.cursor + right = @pdf.bounds.width + + @pdf.text_box link(entry[:anchor], escape(title)), at: [ 0, top ], inline_format: true, + width: right - NUMBER_WIDTH, height: LINE, overflow: :truncate, style: style + @pdf.text_box link(entry[:anchor], number), at: [ right - NUMBER_WIDTH, top ], inline_format: true, + width: NUMBER_WIDTH, height: LINE, align: :right + dotted_leader(@pdf.width_of(title, style: style) + 4, right - NUMBER_WIDTH - 4, top - 12) + @pdf.move_down LINE + end + + def dotted_leader(from, to, y) + return unless to > from + @pdf.dash(1, space: 2) + @pdf.stroke_color "aaaaaa" + @pdf.stroke_horizontal_line from, to, at: y + @pdf.stroke_color "000000" + @pdf.undash + end + + def link(anchor, content) + %(#{content}) + end + + def content_pages + @leaves.each_with_index do |leaf, index| + @pdf.start_new_page + anchor = "leaf-#{index}" + @entries << { title: leaf.title, page: @pdf.page_number, anchor: anchor, section: leaf.section? } + @pdf.add_dest anchor, @pdf.dest_fit + render_leaf(leaf) + end + end + + def render_leaf(leaf) + if leaf.section? + @pdf.move_down 160 + @pdf.text leaf.section.body.to_s, size: 26, style: :bold, align: :center + elsif leaf.picture? + chapter_title leaf.title + place_image blob_bytes(leaf.picture.image) if leaf.picture.image.attached? + @pdf.move_down 8 + @pdf.text leaf.picture.caption, style: :italic, align: :center, size: 11 if leaf.picture.caption.present? + else + render_page(leaf) + end + end + + def render_page(leaf) + html = Page.preview_renderer.render(leaf.page.markable) + chapter_title leaf.title + # Pull images and tables out and draw them directly; flowing text can't + # place or size them. Splitting the raw HTML means an image mid-paragraph + # breaks its paragraph, but markdown keeps images on their own line. + strip_leading_title(html, leaf.title).split(%r{(]*>|)}i).each do |part| + next if part.blank? + case part + when /\A]*>(.*?)}mi).map do |(row)| + row.scan(%r{]*>(.*?)}mi).map { |(cell)| CGI.unescapeHTML(cell.gsub(/<[^>]+>/, " ")).squish } + end + return if rows.empty? + @pdf.move_down 4 + rows.each { |cells| @pdf.text cells.join(" | "), size: 10 } + @pdf.move_down 4 + end + + def chapter_title(title) + return if title.blank? + @pdf.text title, size: 20, style: :bold + @pdf.move_down 12 + end + + # Walk the Redcarpet-rendered HTML with Nokogiri and emit Prawn text. Only + # the tags markdown produces are handled; unknown tags fall through to their + # inline content. + def append_html(html) + Nokogiri::HTML.fragment(html).children.each do |node| + render_block(node) + rescue StandardError => e + # Skip a node we can't render rather than dropping the rest of the page. + Rails.logger.warn("Book::Pdf skipped <#{node.name}>: #{e.message}") + end + end + + def render_block(node) + case node.name + when "h1", "h2", "h3", "h4", "h5", "h6" + @pdf.move_down 6 + @pdf.text inline(node), inline_format: true, size: HEADING_SIZES[node.name], style: :bold + @pdf.move_down 4 + when "ul", "ol" + render_list(node, ordered: node.name == "ol") + when "blockquote" + @pdf.indent(QUOTE_INDENT) { node.children.each { |child| render_block(child) } } + when "pre" + @pdf.move_down 4 + @pdf.text node.text.strip, size: 10 + @pdf.move_down 4 + when "hr" + @pdf.move_down 6 + @pdf.stroke_horizontal_rule + @pdf.move_down 6 + when "text" + @pdf.text escape(node.text), inline_format: true unless node.text.strip.empty? + else # p, and anything else carrying inline content + body = inline(node) + return if body.strip.empty? + @pdf.text body, inline_format: true + @pdf.move_down 6 + end + end + + def render_list(node, ordered:) + index = 0 + node.element_children.each do |item| + next unless item.name == "li" + index += 1 + marker = ordered ? "#{index}." : "•" + @pdf.indent(LIST_INDENT) do + inline_parts = item.children.reject { |c| c.element? && %w[ ul ol ].include?(c.name) } + text = inline_parts.map { |c| inline_node(c) }.join.strip + @pdf.text "#{marker} #{text}", inline_format: true unless text.empty? + item.children.each { |c| render_block(c) if c.element? && %w[ ul ol ].include?(c.name) } + end + end + @pdf.move_down 4 + end + + # Convert a node's children into a Prawn inline-format string. + def inline(node) + node.children.map { |child| inline_node(child) }.join + end + + def inline_node(node) + case node.name + when "text" then escape(node.text) + when "strong", "b" then "#{inline(node)}" + when "em", "i" then "#{inline(node)}" + when "del", "s", "strike" then "#{inline(node)}" + when "code" then escape(node.text) # no monospace face bundled + when "br" then "\n" + when "a" then %(#{inline(node)}) + else inline(node) + end + end + + def place_inline_image(img_tag) + src = img_tag[/src=["']([^"']+)["']/i, 1] + bytes = upload_bytes(src) + place_image(bytes) if bytes + end + + # Resolve a `/u/` markdown upload to its blob. External URLs (no + # matching attachment) are skipped — we can't fetch them. + def upload_bytes(src) + slug = src[%r{/u/(.+)\z}, 1] + slug && blob_bytes(ActiveStorage::Attachment.find_by(slug: slug)) + end + + # Download an attachment's bytes, returning nil if it's missing or the blob + # file is gone (a missing image shouldn't 500 the whole export). + def blob_bytes(attachment) + attachment&.download + rescue StandardError + nil + end + + def place_image(bytes) + return unless bytes + @pdf.start_new_page if @pdf.cursor < 220 + @pdf.move_down 6 + @pdf.image StringIO.new(bytes), fit: [ @pdf.bounds.width, @pdf.cursor - 12 ], position: :center + @pdf.move_down 6 + rescue Prawn::Errors::UnsupportedImageType + # Prawn embeds only PNG/JPG; skip other formats (webp/gif). + end + + def image_dimensions(bytes) + image = Prawn.image_handler.find(bytes).new(bytes) + [ image.width, image.height ] + rescue StandardError + [ nil, nil ] + end + + # Drop a leading heading that just repeats the leaf title (pages + # conventionally open with `# Title`), so we don't print it twice. + def strip_leading_title(html, title) + html.sub(/\A\s*(.*?)<\/h\1>\s*/m) do + $2.gsub(/<[^>]+>/, "").strip.casecmp?(title.to_s.strip) ? "" : $& + end + end + + def number_content_pages + first = @first_content_page + @pdf.number_pages "", + start_count_at: 1, page_filter: ->(i) { i >= first }, + at: [ 0, 0 ], width: @pdf.bounds.width, align: :center, size: 10 + end + + # Escape for Prawn inline formatting. Only &, <, > are markup to Prawn, so + # (unlike CGI/ERB escapers) we must not touch quotes — Prawn would render + # `"` literally. + def escape(string) + string.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">") + end +end diff --git a/app/views/books/show.html.erb b/app/views/books/show.html.erb index b90644d1..605bf682 100644 --- a/app/views/books/show.html.erb +++ b/app/views/books/show.html.erb @@ -3,6 +3,7 @@ <% content_for :head do %> <%= tag.link rel: "alternate", type: "text/markdown", href: book_slug_path(@book, format: :md) %> + <%= tag.link rel: "alternate", type: "application/pdf", href: book_slug_path(@book, format: :pdf) %> <%= tag.meta property: "og:title", content: @book.title %> <%= tag.meta property: "og:description", content: @book.subtitle %> <%= tag.meta property: "og:image", content: @book.cover.blank? ? asset_url("covers/cover-#{@book.theme}-og.png") : "#{root_url}#{url_for(@book.cover)}" %> diff --git a/config/routes.rb b/config/routes.rb index 6a759da6..afaad021 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -36,6 +36,9 @@ resources :pages end + # PDF page size via suffix: `.pdf` defaults to Letter; `.A4.pdf`/`.letter.pdf` force a size. + get "/:id/:slug.:size.pdf", to: "books#show", constraints: { id: /\d+/, size: /A4|letter/i }, + format: false, defaults: { format: "pdf" }, as: :sized_book_pdf get "/:id/:slug", to: "books#show", constraints: { id: /\d+/ }, as: :slugged_book get "/:book_id/:book_slug/:id/:slug", to: "leafables#show", constraints: { book_id: /\d+/, id: /\d+/ }, as: :slugged_leafable diff --git a/test/controllers/books_controller_test.rb b/test/controllers/books_controller_test.rb index 81d5f486..97b4e32a 100644 --- a/test/controllers/books_controller_test.rb +++ b/test/controllers/books_controller_test.rb @@ -114,4 +114,55 @@ class BooksControllerTest < ActionDispatch::IntegrationTest assert_response :success assert_select "link[rel=\"alternate\"][type=\"text/markdown\"][href=\"#{book_slug_path(books(:handbook), format: :md)}\"]" end + + test "show with pdf format returns a pdf document" do + leaves(:welcome_page).leafable.update!(body: "# Welcome Content") + + get book_slug_path(books(:handbook), format: :pdf) + + assert_response :success + assert_equal "application/pdf", response.media_type + assert response.body.start_with?("%PDF"), "expected a PDF body" + end + + test "show includes link to pdf format" do + get book_slug_path(books(:handbook)) + + assert_response :success + assert_select "link[rel=\"alternate\"][type=\"application/pdf\"][href=\"#{book_slug_path(books(:handbook), format: :pdf)}\"]" + end + + test "pdf is cacheable via conditional GET" do + get book_slug_path(books(:handbook), format: :pdf) + assert_response :success + assert response.etag.present? + + get book_slug_path(books(:handbook), format: :pdf), headers: { "If-None-Match" => response.etag } + assert_response :not_modified + end + + test "pdf is available in letter (default) and A4" do + book = books(:handbook) + paths = [ + book_slug_path(book, format: :pdf), + sized_book_pdf_path(book, book.slug, "letter"), + sized_book_pdf_path(book, book.slug, "A4") + ] + paths.each do |path| + get path + assert_response :success, "#{path} should render a PDF" + assert_equal "application/pdf", response.media_type + assert response.body.start_with?("%PDF"), "#{path} should be a PDF" + end + end + + test "editing a book busts the pdf cache" do + get book_slug_path(books(:handbook), format: :pdf) + etag = response.etag + + books(:handbook).update!(title: "Renamed") + + get book_slug_path(books(:handbook), format: :pdf), headers: { "If-None-Match" => etag } + assert_response :success + end end diff --git a/test/models/book/pdf_test.rb b/test/models/book/pdf_test.rb new file mode 100644 index 00000000..12932149 --- /dev/null +++ b/test/models/book/pdf_test.rb @@ -0,0 +1,62 @@ +require "test_helper" + +class Book::PdfTest < ActiveSupport::TestCase + setup do + @book = books(:handbook) + @leaves = @book.leaves.active.with_leafables.positioned + end + + test "renders a valid pdf across every leaf type" do + leaves(:welcome_page).leafable.update!(body: "# Heading\n\nSome **bold** text and a list:\n\n- one\n- two") + + pdf = Book::Pdf.new(@book, @leaves).render + + assert pdf.start_with?("%PDF"), "expected PDF header" + assert_includes pdf, "%%EOF" + assert_operator pdf.bytesize, :>, 1000 + end + + test "respects the requested page size" do + letter = Book::Pdf.new(@book, @leaves, page_size: "LETTER").render + a4 = Book::Pdf.new(@book, @leaves, page_size: "A4").render + + assert_match %r{/MediaBox \[0 0 612}, letter # US Letter: 612 x 792 pt + assert_match %r{/MediaBox \[0 0 595}, a4 # A4: 595.28 x 841.89 pt + end + + test "does not raise on non-Latin (Greek) content" do + leaves(:welcome_page).leafable.update!(body: "# Καλωσήρθες\n\nπερίληψη") + + assert_nothing_raised do + Book::Pdf.new(@book, @leaves).render + end + end + + test "renders every markdown construct without raising" do + leaves(:welcome_page).leafable.update!(body: <<~MD) + Paragraph with **bold**, _italic_, ~~struck~~, `code`, and a [link](https://example.com/?a=1&b=2). + + 1. first + 2. second + - nested a + - nested b + + > A quote. + + ``` + code block + ``` + + --- + + | Name | R&D | + | ---- | --- | + | a | b | + MD + + pdf = Book::Pdf.new(@book, @leaves).render + + assert pdf.start_with?("%PDF") + assert_includes pdf, "%%EOF" + end +end