Offer the manual as PDF and EPUB, laid out for print
Benchmarks / CPU and I/O (per commit) (push) Waiting to run
Benchmarks / Frame budget (on demand) (push) Waiting to run
Build and test / Desktop (Linux) (push) Waiting to run
Build and test / Android (aarch64) (push) Blocked by required conditions
Build and test / Windows (x86_64, cross) (push) Blocked by required conditions
Build and test / Layer separation (push) Waiting to run
Build and test / Publish the release (push) Blocked by required conditions
Build and test / android-image (push) Waiting to run
🐳 Android image / Build and push (push) Waiting to run
Build and test / windows-image (push) Waiting to run
🐳 Windows image / Build and push (push) Waiting to run
Manual pages / Publish the manual (push) Waiting to run
Traceability / Requirement traces (push) Waiting to run

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.
This commit is contained in:
2026-10-08 22:41:07 -04:00
parent 90d0985db8
commit b53d4dc391
4 changed files with 186 additions and 1 deletions
+25 -1
View File
@@ -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"
+36
View File
@@ -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; }
}
</style>
</head>
<body>
<div class="page">
<header class="cover"><p class="cover-title">DarkRoom, shown</p><p class="cover-sub">The DarkRoom manual · https://pages.tourolle.paris/dtourolle/darkroom/</p></header>
<nav class="toc" aria-label="Contents">
<p class="toc-title">Contents</p>
<ul>
@@ -137,6 +172,7 @@ th { color: var(--ink-dim); font-weight: 600; }
</ul>
</nav>
<main>
<p class="downloads">Also as <a href="https://pages.tourolle.paris/dtourolle/darkroom/darkroom-manual.pdf" download>PDF</a> · <a href="https://pages.tourolle.paris/dtourolle/darkroom/darkroom-manual.epub" download>EPUB</a> · <a href="https://pages.tourolle.paris/dtourolle/darkroom/">online</a></p>
<h1 id="darkroom-shown">DarkRoom, shown</h1>
<p>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
+57
View File
@@ -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])
+68
View File
@@ -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("<style>\n");
page.push_str(STYLE);
page.push_str("</style>\n</head>\n<body>\n<div class=\"page\">\n");
// The PDF's first page. Hidden on screen, where the title heads the text.
page.push_str(&format!(
"<header class=\"cover\"><p class=\"cover-title\">{}</p>\
<p class=\"cover-sub\">The DarkRoom manual · {PAGES_SITE}</p></header>\n",
escape(&title)
));
page.push_str(
"<nav class=\"toc\" aria-label=\"Contents\">\n<p class=\"toc-title\">Contents</p>\n",
);
page.push_str(&toc);
page.push_str("</nav>\n<main>\n");
page.push_str(&format!(
"<p class=\"downloads\">Also as <a href=\"{PAGES_SITE}{PDF_NAME}\" download>PDF</a> \
· <a href=\"{PAGES_SITE}{EPUB_NAME}\" download>EPUB</a> · \
<a href=\"{PAGES_SITE}\">online</a></p>\n"
));
page.push_str(&body);
page.push_str("</main>\n</div>\n</body>\n</html>\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() {