Add .pdf export for books#443
Open
tsangiotis wants to merge 1 commit into
Open
Conversation
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.
There was a problem hiding this comment.
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::Pdfto 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 |
| 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 on lines
+1
to
+2
| require "prawn" | ||
|
|
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adding
.pdfto a book's URL downloads it as a PDF, the same way.mdalready returns its markdown. There's no new UI —/1/the-writebook-manual.pdfjust 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
.mdexports — 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.pdfgives 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: