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{(]*>|