From b53d4dc3916fa9053df8f9264ce5f6eb567b84ca Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Thu, 8 Oct 2026 22:41:07 -0400 Subject: [PATCH] Offer the manual as PDF and EPUB, laid out for print The page gains a print stylesheet: A4 with a cover, a contents page whose entries carry their page numbers, a chapter to a page with its name at the head of each page and the page count at the foot, figures and table rows never split, and the light palette. A line above the title links the PDF and the EPUB on the published site, absolutely, since an installed copy has none beside it. The Manual pages workflow makes both: tools/manual/pdf.py renders the page with WeasyPrint from JPEG copies of its pictures, which takes the PDF from 28 MB to 8, and pandoc makes the EPUB from the Markdown. --- .gitea/workflows/manual-pages.yml | 26 +++++++++++- docs/manual/index.html | 36 ++++++++++++++++ tools/manual/pdf.py | 57 ++++++++++++++++++++++++++ tools/traceability/src/manual.rs | 68 +++++++++++++++++++++++++++++++ 4 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 tools/manual/pdf.py diff --git a/.gitea/workflows/manual-pages.yml b/.gitea/workflows/manual-pages.yml index c6cc5ae..ecb237c 100644 --- a/.gitea/workflows/manual-pages.yml +++ b/.gitea/workflows/manual-pages.yml @@ -7,7 +7,8 @@ name: Manual pages # No site generator. docs/manual/index.html is already the rendered page — # `cargo run -p traceability -- manual` writes it from README.md, and the # Traceability workflow fails a push whose page disagrees with the README — -# so this job only copies the page and its pictures onto the branch. +# so this job copies the page and its pictures onto the branch, with a PDF +# and an EPUB made from them. on: push: @@ -56,6 +57,28 @@ jobs: echo "LFS pointers where pictures should be:"; echo "$bad"; exit 1 fi + # The downloads the page links to (traceability's PAGES_SITE, PDF_NAME, + # EPUB_NAME). The PDF is the page itself through its print stylesheet — + # A4, a cover and a contents page with page numbers, a chapter a page — + # so it says what the page says; tools/manual/pdf.py says why it goes + # through JPEG copies of the pictures first. + # The EPUB comes from the Markdown, which pandoc reads better than a page. + - name: Make the PDF and the EPUB + run: | + set -e + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \ + pandoc python3-pip python3-venv libpango-1.0-0 libpangoft2-1.0-0 \ + fonts-noto-core fonts-dejavu-core > /dev/null + python3 -m venv /tmp/wp + /tmp/wp/bin/pip install -q weasyprint + mkdir -p site + /tmp/wp/bin/python tools/manual/pdf.py docs/manual site/darkroom-manual.pdf + title=$(sed -n 's/^# //p' docs/manual/README.md | head -1) + (cd docs/manual && pandoc README.md -o ../../site/darkroom-manual.epub \ + --metadata title="$title" --toc --toc-depth=2 --epub-chapter-level=2) + ls -l site + # One commit, force-pushed: the branch is a build output, and its history # is in master's. - name: Push to gitea-pages @@ -64,6 +87,7 @@ jobs: run: | set -e site=$(mktemp -d) + cp site/darkroom-manual.pdf site/darkroom-manual.epub "$site/" cp docs/manual/index.html "$site/" cp -r docs/manual/media "$site/" cd "$site" diff --git a/docs/manual/index.html b/docs/manual/index.html index 599bf9c..a22ec2a 100644 --- a/docs/manual/index.html +++ b/docs/manual/index.html @@ -90,15 +90,50 @@ figcaption { margin-top: 0.4rem; color: var(--ink-dim); font-size: 0.9rem; } table { border-collapse: collapse; width: 100%; font-size: 0.95rem; } th, td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--rule); vertical-align: top; } th { color: var(--ink-dim); font-weight: 600; } +td img { aspect-ratio: auto 1 / 1; } +.downloads { margin: 0 0 1.2rem; color: var(--ink-dim); font-size: 0.9rem; } +.cover { display: none; } @media (max-width: 52rem) { .page { grid-template-columns: minmax(0, 1fr); gap: 1rem; padding: 1rem 16px 3rem; } .toc { position: static; max-height: none; border: 1px solid var(--rule); border-radius: 6px; padding: 0.8rem 1rem; background: var(--panel); } body { font-size: 16px; } } +@page { + size: A4; + margin: 18mm 16mm 20mm; + @top-right { content: string(chapter); font: 8.5pt system-ui, sans-serif; color: #5c5955; } + @bottom-center { content: counter(page) " / " counter(pages); font: 8.5pt system-ui, sans-serif; color: #5c5955; } +} +@page :first { @top-right { content: none; } @bottom-center { content: none; } } +@media print { + :root { --bg: #fff; --ink: #1d1c1a; --ink-dim: #5c5955; --rule: #dedad4; --accent: #8a4b12; --panel: #f1eee9; --mark: transparent; } + body { font-size: 10.5pt; line-height: 1.5; } + .page { display: block; padding: 0; } + .downloads { display: none; } + .cover { display: block; break-after: page; padding-top: 80mm; } + .cover-title { font-size: 34pt; font-weight: 700; margin: 0 0 6mm; } + .cover-sub { color: var(--ink-dim); margin: 0; } + .toc { position: static; max-height: none; overflow: visible; break-after: page; font-size: 10pt; } + .toc-title { font-size: 9pt; } + .toc a { color: var(--ink); } + .toc a::after { content: leader('.') target-counter(attr(href), page); color: var(--ink-dim); } + .toc ul ul a { color: var(--ink-dim); } + main { display: contents; } + h1 { font-size: 24pt; } + h2 { break-before: page; border-top: none; padding-top: 0; margin-top: 0; string-set: chapter content(); } + h2, h3 { break-after: avoid; } + figure, tr, img { break-inside: avoid; } + figure img, main img { max-height: 100mm; width: auto; max-width: 100%; margin: 0 auto; } + figure { margin: 4mm 0; } + td img { max-height: 60mm; } + a { color: inherit; text-decoration: none; } + p, li { orphans: 3; widows: 3; } +}
+

DarkRoom, shown

The DarkRoom manual · https://pages.tourolle.paris/dtourolle/darkroom/

+

Also as PDF · EPUB · online

DarkRoom, shown

A tour of what the application does, one picture per thing. Every image on this page was captured from the desktop build driving itself — nothing is a diff --git a/tools/manual/pdf.py b/tools/manual/pdf.py new file mode 100644 index 0000000..9a27b62 --- /dev/null +++ b/tools/manual/pdf.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""The manual as a PDF: docs/manual/index.html through its print stylesheet. + + python3 tools/manual/pdf.py docs/manual out.pdf + +Needs WeasyPrint (and so Pillow): `pip install weasyprint`. The Manual pages +workflow runs this and publishes the result beside the page. + +The page's pictures are PNG screenshots and GIF recordings, 16 MB and 46 MB +of them, and WeasyPrint embeds a picture as it finds it: losslessly, a GIF +whole. So the PDF is made from a copy of the page whose pictures are JPEGs +(a recording's first frame, which is all a page can show) at the size they +were recorded. Full chroma, because a 4:2:0 JPEG smears the coloured noise +the AI denoise close-ups exist to show. +""" +import os +import re +import shutil +import sys +import tempfile + +from PIL import Image +from weasyprint import HTML + +QUALITY = 88 + + +def as_jpeg(src, dst): + with Image.open(src) as im: + im.seek(0) + im.convert('RGB').save(dst, 'JPEG', quality=QUALITY, subsampling=0, optimize=True) + + +def main(manual_dir, out): + with tempfile.TemporaryDirectory() as tmp: + os.mkdir(os.path.join(tmp, 'media')) + renamed = {} + for name in sorted(os.listdir(os.path.join(manual_dir, 'media'))): + stem, ext = os.path.splitext(name) + if ext.lower() not in ('.png', '.gif'): + continue + jpg = f'{stem}.{ext[1:].lower()}.jpg' # two pictures may share a stem + as_jpeg(os.path.join(manual_dir, 'media', name), os.path.join(tmp, 'media', jpg)) + renamed[name] = jpg + page = open(os.path.join(manual_dir, 'index.html'), encoding='utf-8').read() + page = re.sub(r'src="media/([^"]+)"', + lambda m: f'src="media/{renamed.get(m.group(1), m.group(1))}"', page) + with open(os.path.join(tmp, 'index.html'), 'w', encoding='utf-8') as f: + f.write(page) + HTML(os.path.join(tmp, 'index.html')).write_pdf(os.path.join(tmp, 'manual.pdf')) + shutil.move(os.path.join(tmp, 'manual.pdf'), out) + + +if __name__ == '__main__': + if len(sys.argv) != 3: + sys.exit(__doc__) + main(sys.argv[1], sys.argv[2]) diff --git a/tools/traceability/src/manual.rs b/tools/traceability/src/manual.rs index 5ec330c..034ee78 100644 --- a/tools/traceability/src/manual.rs +++ b/tools/traceability/src/manual.rs @@ -39,6 +39,16 @@ use pulldown_cmark::{CowStr, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; /// is where the reader of a design document is anyway. pub const FORGE_TREE: &str = "https://gitea.tourolle.paris/dtourolle/DarkRoom/src/branch/master/"; +/// Where the manual is published, with its PDF and EPUB beside the page +/// (`.gitea/workflows/manual-pages.yml`). The page links them absolutely: an +/// installed copy has no PDF next to it, and a download is a thing done +/// online anyway. +pub const PAGES_SITE: &str = "https://pages.tourolle.paris/dtourolle/darkroom/"; + +/// The downloads' file names on [`PAGES_SITE`], which the workflow writes. +pub const PDF_NAME: &str = "darkroom-manual.pdf"; +pub const EPUB_NAME: &str = "darkroom-manual.epub"; + /// The manual's directory inside the repository, for resolving its links. const MANUAL_DIR: &str = "docs/manual"; @@ -274,11 +284,22 @@ pub fn render_html(markdown: &str) -> String { page.push_str("\n\n\n

\n"); + // The PDF's first page. Hidden on screen, where the title heads the text. + page.push_str(&format!( + "

{}

\ +

The DarkRoom manual · {PAGES_SITE}

\n", + escape(&title) + )); page.push_str( "\n
\n"); + page.push_str(&format!( + "

Also as PDF \ + · EPUB · \ + online

\n" + )); page.push_str(&body); page.push_str("
\n
\n\n\n"); page @@ -420,11 +441,45 @@ figcaption { margin-top: 0.4rem; color: var(--ink-dim); font-size: 0.9rem; } table { border-collapse: collapse; width: 100%; font-size: 0.95rem; } th, td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--rule); vertical-align: top; } th { color: var(--ink-dim); font-weight: 600; } +td img { aspect-ratio: auto 1 / 1; } +.downloads { margin: 0 0 1.2rem; color: var(--ink-dim); font-size: 0.9rem; } +.cover { display: none; } @media (max-width: 52rem) { .page { grid-template-columns: minmax(0, 1fr); gap: 1rem; padding: 1rem 16px 3rem; } .toc { position: static; max-height: none; border: 1px solid var(--rule); border-radius: 6px; padding: 0.8rem 1rem; background: var(--panel); } body { font-size: 16px; } } +@page { + size: A4; + margin: 18mm 16mm 20mm; + @top-right { content: string(chapter); font: 8.5pt system-ui, sans-serif; color: #5c5955; } + @bottom-center { content: counter(page) " / " counter(pages); font: 8.5pt system-ui, sans-serif; color: #5c5955; } +} +@page :first { @top-right { content: none; } @bottom-center { content: none; } } +@media print { + :root { --bg: #fff; --ink: #1d1c1a; --ink-dim: #5c5955; --rule: #dedad4; --accent: #8a4b12; --panel: #f1eee9; --mark: transparent; } + body { font-size: 10.5pt; line-height: 1.5; } + .page { display: block; padding: 0; } + .downloads { display: none; } + .cover { display: block; break-after: page; padding-top: 80mm; } + .cover-title { font-size: 34pt; font-weight: 700; margin: 0 0 6mm; } + .cover-sub { color: var(--ink-dim); margin: 0; } + .toc { position: static; max-height: none; overflow: visible; break-after: page; font-size: 10pt; } + .toc-title { font-size: 9pt; } + .toc a { color: var(--ink); } + .toc a::after { content: leader('.') target-counter(attr(href), page); color: var(--ink-dim); } + .toc ul ul a { color: var(--ink-dim); } + main { display: contents; } + h1 { font-size: 24pt; } + h2 { break-before: page; border-top: none; padding-top: 0; margin-top: 0; string-set: chapter content(); } + h2, h3 { break-after: avoid; } + figure, tr, img { break-inside: avoid; } + figure img, main img { max-height: 100mm; width: auto; max-width: 100%; margin: 0 auto; } + figure { margin: 4mm 0; } + td img { max-height: 60mm; } + a { color: inherit; text-decoration: none; } + p, li { orphans: 3; widows: 3; } +} "#; #[cfg(test)] @@ -486,6 +541,19 @@ mod tests { assert_eq!(rewrite_link("https://x.org/"), "https://x.org/"); } + #[test] + fn the_page_offers_its_downloads_from_the_published_site() { + let html = render_html("# Title\n"); + assert!( + html.contains(&format!("href=\"{PAGES_SITE}{PDF_NAME}\"")), + "{html}" + ); + assert!( + html.contains(&format!("href=\"{PAGES_SITE}{EPUB_NAME}\"")), + "{html}" + ); + } + /// The real manual: one page, every section in the contents, nothing lost. #[test] fn the_real_manual_renders_with_its_contents() {