Skip to content

Commit 84183f8

Browse files
chpollinclaude
andcommitted
Phase 14: PDF aus Domaenenmodell via WeasyPrint
- src/render/pdf.py kapselt WeasyPrint mit lazy Import; Pango/Cairo-Fehler erscheinen erst beim Aufruf, nicht beim Modul-Load - src/build.py: --pdf rendert jeweils ride.N.M.pdf neben index.html; fehlende Systemlibs werden geloggt, Build laeuft weiter - templates/html/review.html: print-only DOI-Zeile im Header (A6: DOI muss auf Seite 1 des PDFs erscheinen, da Sidebar im Print verschwindet) - static/css/ride.css: print-Stylesheet ausgebaut — @page A4, Chrome weg, page-break-after auf Headings, Link-URLs als Klammertext - .github/workflows/build.yml: apt-install libpango/libpangoft2/ libharfbuzz-subset; CI ruft jetzt python -m src.build --pdf Tests: - 4 neue (PDF-Magic-Bytes, DOI-im-PDF, print-CSS-Contract, DOI-Header-Line); WeasyPrint-Tests skippen sauber wenn GTK fehlt (lokales Windows) - 455 grun + 2 skipped (waren 452) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0de85ca commit 84183f8

9 files changed

Lines changed: 291 additions & 12 deletions

File tree

.github/workflows/build.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ jobs:
6464
cache: pip
6565
cache-dependency-path: ride-static/requirements.txt
6666

67+
# WeasyPrint links Pango/Cairo at import time. The Ubuntu runner
68+
# ships a partial GTK stack; install the WeasyPrint-listed runtime
69+
# libs explicitly so `import weasyprint` succeeds in CI.
70+
- name: Install WeasyPrint system libraries
71+
run: sudo apt-get update && sudo apt-get install -y libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz-subset0
72+
6773
- name: Install dependencies
6874
working-directory: ride-static
6975
run: pip install -r requirements.txt
@@ -119,7 +125,7 @@ jobs:
119125
working-directory: ride-static
120126
run: |
121127
if [ -f src/build.py ] || python -c "import src.build" 2>/dev/null; then
122-
python -m src.build --base-url=/${{ github.event.repository.name }}
128+
python -m src.build --base-url=/${{ github.event.repository.name }} --pdf
123129
else
124130
echo "::warning::src.build is not implemented yet (Phase 8). Emitting placeholder site/."
125131
mkdir -p site

requirements.txt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ pyyaml>=6.0
1616
markdown>=3.5
1717

1818
# PDF (Phase 14)
19-
# weasyprint>=60.0 # uncomment when Phase 14 starts
19+
# WeasyPrint pulls Pango/Cairo at import time. Local Windows installs
20+
# additionally need the GTK3 runtime; CI on Ubuntu uses libpango1.0-0
21+
# + libpangoft2-1.0-0 from apt (see .github/workflows/build.yml).
22+
weasyprint>=60.0
2023

2124
# Search (Phase 11)
2225
# pagefind is a binary fetched by the build script, not a Python package.

src/build.py

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ def build(
270270
linkcheck: bool = False,
271271
matomo_url: str = "",
272272
matomo_site_id: str = "",
273+
pdf: bool = False,
273274
) -> int:
274275
"""Run the build. Returns the number of review pages written."""
275276
if not corpus_dir.exists():
@@ -337,6 +338,11 @@ def build(
337338
oai_files = _write_oai_pmh_snapshot(tuple(rendered), site, out_root)
338339
redirect_count = write_redirects(tuple(rendered), out_root, base_url=site.base_url)
339340

341+
pdf_count = 0
342+
pdf_failed: list[tuple[str, str]] = []
343+
if pdf:
344+
pdf_count, pdf_failed = _render_pdfs(parsed, out_root)
345+
340346
# Phase 13 / Welle 10: validation + link-probe + aggregated build report.
341347
validation_report = None
342348
if validate:
@@ -380,13 +386,59 @@ def build(
380386
if link_report:
381387
print(f"Linkcheck: {link_report.alive} alive, {link_report.dead} dead "
382388
f"({link_report.probed} probed)")
389+
if pdf:
390+
print(f"PDF: {pdf_count} rendered, {len(pdf_failed)} failed")
383391
print("Wrote api/build-info.json")
384392

385393
_print_asset_summary(asset_reports)
386394

387395
return len(rendered)
388396

389397

398+
def _render_pdfs(
399+
parsed: list,
400+
out_root: Path,
401+
) -> tuple[int, list[tuple[str, str]]]:
402+
"""Render every parsed review's HTML to a sibling PDF.
403+
404+
Returns ``(success_count, failures)`` where ``failures`` is a list
405+
of ``(review_id, error_message)`` pairs. The whole pass surfaces
406+
cleanly (count = 0) when WeasyPrint or its system libraries
407+
cannot be loaded, so a missing GTK install on a developer machine
408+
does not block the rest of the build.
409+
410+
Phase 14 / Welle 11. The HTML is read from disk so the print
411+
output reflects exactly what was deployed to the static tree —
412+
no separate template, no second render pass.
413+
"""
414+
try:
415+
from src.render.pdf import render_review_pdf
416+
except (ImportError, OSError) as exc:
417+
print(
418+
"PDF: WeasyPrint unavailable, skipping. "
419+
f"Install instructions: https://doc.courtbouillon.org/weasyprint/ ({exc})",
420+
file=sys.stderr,
421+
)
422+
return 0, []
423+
424+
count = 0
425+
failed: list[tuple[str, str]] = []
426+
for path, review in parsed:
427+
review_id = review.id or path.stem
428+
page_dir = out_root / "issues" / (review.issue or "0") / review_id
429+
html_path = page_dir / "index.html"
430+
if not html_path.exists():
431+
continue # render pass skipped this review (already in `failed`)
432+
pdf_path = page_dir / f"{review_id}.pdf"
433+
try:
434+
render_review_pdf(html_path, pdf_path)
435+
count += 1
436+
except Exception as exc: # noqa: BLE001 — keep building on per-file failure
437+
failed.append((review_id, str(exc)))
438+
print(f"PDF failed: {review_id}: {exc}", file=sys.stderr)
439+
return count, failed
440+
441+
390442
def _write_build_info(
391443
*,
392444
out_root: Path,
@@ -535,16 +587,13 @@ def main(argv: Optional[list[str]] = None) -> int:
535587
parser = argparse.ArgumentParser(description="Build the ride.i-d-e.de static site.")
536588
parser.add_argument("--reviews", type=int, default=None, help="Limit to first N reviews (for iteration)")
537589
parser.add_argument("--base-url", default="", help="Deploy URL prefix; empty for relative paths")
538-
parser.add_argument("--pdf", action="store_true", help="Also run the WeasyPrint PDF pass (Phase 14)")
590+
parser.add_argument("--pdf", action="store_true", help="Render a PDF next to every review's HTML via WeasyPrint")
539591
parser.add_argument("--no-validate", action="store_true", help="Skip the RelaxNG validation pre-check")
540592
parser.add_argument("--linkcheck", action="store_true", help="Probe external bibliography URLs (slow ~5min, off by default)")
541593
parser.add_argument("--matomo-url", default="", help="Matomo tracker URL (e.g. https://matomo.example.org/); empty disables tracking")
542594
parser.add_argument("--matomo-site-id", default="", help="Matomo site id; required when --matomo-url is set")
543595
args = parser.parse_args(argv)
544596

545-
if args.pdf:
546-
print("--pdf is a Phase 14 placeholder; no PDF rendered yet.", file=sys.stderr)
547-
548597
if bool(args.matomo_url) != bool(args.matomo_site_id):
549598
parser.error("--matomo-url and --matomo-site-id must be set together")
550599

@@ -555,6 +604,7 @@ def main(argv: Optional[list[str]] = None) -> int:
555604
linkcheck=args.linkcheck,
556605
matomo_url=args.matomo_url,
557606
matomo_site_id=args.matomo_site_id,
607+
pdf=args.pdf,
558608
)
559609
print(f"Wrote {written} review pages to {SITE_DIR}")
560610
return 0

src/render/pdf.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""PDF rendering via WeasyPrint — Phase 14.
2+
3+
Per [[requirements#R3 Rezension herunterladen]] and
4+
[[requirements#A6 PDF-Pfad]] every review ships a PDF next to its HTML
5+
page. We feed WeasyPrint the already-rendered ``index.html`` so the
6+
print output reflects the same domain model and templates as the web
7+
view; the ``@media print`` block in ``ride.css`` strips chrome
8+
(nav, sidebar, WIP banner) and surfaces a DOI line on the first page.
9+
10+
WeasyPrint pulls Pango/Cairo at import time. The import lives inside
11+
:func:`render_review_pdf` so missing system libraries surface as a
12+
per-call ``ImportError`` rather than aborting :mod:`src.build` at
13+
module load — callers can then decide between skip and hard-fail.
14+
"""
15+
from __future__ import annotations
16+
17+
from pathlib import Path
18+
19+
20+
def render_review_pdf(html_path: Path, pdf_path: Path) -> None:
21+
"""Render an already-written review HTML file to PDF on disk.
22+
23+
Relative asset URLs (figures, stylesheet, fonts) resolve relative
24+
to ``html_path``'s directory — the build writes everything next
25+
to ``index.html`` so this matches the deployed layout.
26+
27+
Raises ``ImportError`` when WeasyPrint or its system libraries
28+
cannot be loaded. The caller is expected to print a helpful
29+
install hint and continue without PDFs rather than crash.
30+
"""
31+
from weasyprint import HTML # local import — see module docstring
32+
33+
HTML(filename=str(html_path)).write_pdf(target=str(pdf_path))

static/css/ride.css

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -984,13 +984,33 @@ main.ride-shell {
984984
*, *::before, *::after { animation: none !important; transition: none !important; }
985985
}
986986

987-
/* ─── 8. Print stylesheet (Phase 14 placeholder) ─────────────────────── */
988-
989-
/* Phase 14 (PDF via WeasyPrint) replaces this block with a full print style.
990-
Until then a minimal fallback keeps the HTML-Druck halbwegs lesbar. */
987+
/* Print-only DOI line in the review header — hidden on screen, shown
988+
when the page is rendered to PDF via WeasyPrint (requirements A6:
989+
DOI on the first page). The Meta sidebar carries the DOI for web
990+
readers; in print, the sidebar is suppressed. */
991+
.ride-review__doi-print { display: none; }
992+
993+
/* ─── 8. Print stylesheet (Phase 14) ─────────────────────────────────── */
994+
995+
/* Used by the browser's Drucken-Dialog and by WeasyPrint when generating
996+
the per-review PDF. Strips chrome, drops the sidebar, surfaces the
997+
first-page DOI line. WeasyPrint honours @page, page margins and
998+
page-break-* properties — kept simple here; tune per-template if a
999+
review's tables overflow. */
9911000
@media print {
992-
.ride-sidebar, .ride-search-slot, .ride-skip { display: none; }
993-
.ride-page { grid-template-columns: minmax(0, 1fr); }
1001+
@page { size: A4; margin: 18mm 16mm; }
1002+
body { font-size: 11pt; }
1003+
.ride-wip, .ride-header, .ride-nav, .ride-footer,
1004+
.ride-sidebar, .ride-search-slot, .ride-skip,
1005+
.ride-cite__btns, .ride-cite-data { display: none; }
1006+
.ride-page, .ride-review { grid-template-columns: minmax(0, 1fr); }
1007+
.ride-content, .ride-shell { max-width: 100%; padding: 0; margin: 0; }
1008+
.ride-review__doi-print {
1009+
display: block; margin: 0 0 var(--ride-space-4) 0;
1010+
font-size: 10pt; color: #444;
1011+
}
9941012
a { color: inherit; text-decoration: none; }
9951013
a[href^="http"]::after { content: " (" attr(href) ")"; font-size: 0.9em; color: #555; }
1014+
h1, h2, h3 { page-break-after: avoid; }
1015+
figure, table { page-break-inside: avoid; }
9961016
}

templates/html/review.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,16 @@ <h1 class="ride-review__title">{{ review.title }}</h1>
7373
{%- endif %}
7474
</header>
7575

76+
{#- Print-only DOI line per requirements A6 — must appear on the
77+
first page of the PDF rendering. Hidden on screen because the
78+
Meta sidebar already exposes the DOI to web readers; in the
79+
PDF the sidebar is suppressed by ``@media print``. -#}
80+
{%- if review.doi %}
81+
<p class="ride-review__doi-print">
82+
DOI: <a href="https://doi.org/{{ review.doi }}">{{ review.doi }}</a>
83+
</p>
84+
{%- endif %}
85+
7686
{%- if abstract_section -%}
7787
<section class="ride-review__abstract" aria-labelledby="abstract-heading">
7888
<h3 id="abstract-heading">{{ site.strings.abstract | default('Abstract') }}</h3>

tests/test_css_contract.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,30 @@ def test_tag_pills_meet_target_size_minimum():
6464
assert "min-height: 24px" in rule
6565

6666

67+
def test_print_stylesheet_hides_chrome_and_shows_doi():
68+
"""Phase 14 print stylesheet drives the WeasyPrint output.
69+
70+
Three load-bearing rules:
71+
- chrome (nav, sidebar, footer) is suppressed so the PDF is
72+
body-only;
73+
- .ride-review__doi-print flips from display:none to display:block
74+
in print so the DOI lands on page 1 (requirements A6);
75+
- @page sets paper size + margins for a predictable PDF.
76+
"""
77+
css = _css()
78+
print_block_start = css.find("@media print")
79+
assert print_block_start != -1
80+
# Slice generously — the @media block contains nested rules with
81+
# their own braces, so we just take everything from @media print to
82+
# the next top-level marker.
83+
block = css[print_block_start:]
84+
assert "@page" in block
85+
assert ".ride-sidebar" in block
86+
# display:block on the DOI line is the load-bearing flip.
87+
assert ".ride-review__doi-print" in block
88+
assert "display: block" in block
89+
90+
6791
def test_reduced_motion_preference_is_honoured():
6892
"""WCAG 2.3.3 (AAA, but project policy): users who set
6993
``prefers-reduced-motion: reduce`` get no animations, no

tests/test_render_html.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,30 @@ def test_console_banner_omitted_when_build_info_missing():
498498
assert "console.info(" not in html
499499

500500

501+
# ── Print-only DOI line (Phase 14, A6) ───────────────────────────────
502+
503+
504+
def test_review_html_carries_print_only_doi_line_when_doi_set():
505+
"""A6: DOI must surface on the first page of the PDF rendering.
506+
507+
The Meta sidebar carries the DOI for web readers but is hidden in
508+
the print stylesheet. The print-only line in the header guarantees
509+
the DOI lands on page 1 of the PDF.
510+
"""
511+
review = _minimal_review(doi="10.18716/ride.a.13.7")
512+
html = render_review(review)
513+
assert 'class="ride-review__doi-print"' in html
514+
assert "10.18716/ride.a.13.7" in html
515+
assert 'href="https://doi.org/10.18716/ride.a.13.7"' in html
516+
517+
518+
def test_review_html_omits_print_doi_line_when_doi_missing():
519+
"""No DOI → no orphan label in the PDF (corpus has reviews without DOI)."""
520+
review = _minimal_review(doi=None)
521+
html = render_review(review)
522+
assert 'class="ride-review__doi-print"' not in html
523+
524+
501525
# ── Cookieless Matomo (R16) ──────────────────────────────────────────
502526

503527

0 commit comments

Comments
 (0)