Skip to content

Add .pdf export for books#443

Open
tsangiotis wants to merge 1 commit into
basecamp:mainfrom
tsangiotis:pdf-export
Open

Add .pdf export for books#443
tsangiotis wants to merge 1 commit into
basecamp:mainfrom
tsangiotis:pdf-export

Conversation

@tsangiotis

@tsangiotis tsangiotis commented Jun 30, 2026

Copy link
Copy Markdown

Adding .pdf to a book's URL downloads it as a PDF, the same way .md already returns its markdown. There's no new UI — /1/the-writebook-manual.pdf just works, with a <link rel="alternate"> alongside the markdown one.

The PDF keeps the cover (full-bleed), opens with a linked table of contents that mirrors the sidebar, and numbers the pages. It renders with Prawn straight from each page's markdown source — the same content .md exports — so there's nothing to keep in sync. Pages, sections and pictures each get their own layout; images and tables are placed directly, since flowing text can't size them. US Letter is the default, .A4.pdf gives A4, and unchanged requests get a 304 instead of re-rendering.

It's pure Ruby — no headless browser, no wkhtmltopdf, nothing new in the Docker image. The one real cost is the bundled Inter font (~1.4 MB, OFL): Prawn's built-in fonts only cover Windows-1252, and a book can be in any language, so the font ships with the app to keep rendering deterministic. If you'd rather not carry it, I'm glad to drop it and fall back to the built-in fonts for Latin.

Controller and renderer are tested and the suite is green. Happy to trim the scope or change the approach — let me know.

Examples:

Add `.pdf` to any book URL to download a PDF, mirroring the `.md` export.
The PDF has a full-bleed cover, a linked table of contents, and numbered
pages. Defaults to US Letter; `.A4.pdf` produces an A4 version.

Rendered with Prawn from each page's markdown source, walking the
Redcarpet HTML with Nokogiri. Bundles the Inter font (OFL) so non-Latin
text renders without any system font dependency.
Copilot AI review requested due to automatic review settings June 30, 2026 09:30

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds first-class PDF export for books by supporting the .pdf format on BooksController#show, generating PDFs via a new Book::Pdf Prawn renderer, and exposing the export via an alternate link tag (mirroring the existing .md export behavior).

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

Changes:

  • Add .pdf (and .A4.pdf/.letter.pdf) routing and controller handling for book PDF export with conditional GET support.
  • Introduce Book::Pdf to render covers, TOC, and leaf content into a Prawn-generated PDF (including bundled Inter font licensing).
  • Add model/controller tests covering PDF rendering, routing, and basic caching behavior.

Reviewed changes

Copilot reviewed 8 out of 13 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
test/models/book/pdf_test.rb Adds coverage for PDF rendering validity, page size selection, and non‑Latin content.
test/controllers/books_controller_test.rb Adds request-level tests for PDF responses, alternate link tag, and conditional GET behavior.
Gemfile.lock Locks new dependencies required for PDF generation (Prawn + transitive deps).
Gemfile Adds prawn dependency for PDF rendering.
config/routes.rb Adds a sized PDF route (.A4.pdf / .letter.pdf) alongside existing book show routing.
app/views/books/show.html.erb Adds an <link rel="alternate" type="application/pdf"> for PDF export discovery.
app/models/book/pdf.rb Implements the PDF renderer (cover, TOC, per-leaf rendering) using Prawn/Nokogiri.
app/controllers/books_controller.rb Serves PDFs from show and applies conditional GET headers.
app/assets/fonts/inter/OFL.txt Includes Inter font license text for bundled font distribution.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +30 to +37
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
Comment thread config/routes.rb
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 },
Comment thread app/models/book/pdf.rb
Comment on lines +1 to +2
require "prawn"

Comment thread app/models/book/pdf.rb
Comment on lines +268 to +271
when "code" then escape(node.text) # no monospace face bundled
when "br" then "\n"
when "a" then %(<link href="#{escape(node["href"])}">#{inline(node)}</link>)
else inline(node)
Comment on lines +159 to 168
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
@tsangiotis tsangiotis closed this Jun 30, 2026
@tsangiotis tsangiotis reopened this Jun 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants