diff --git a/.github/docker/Dockerfile.ci b/.github/docker/Dockerfile.ci index 60986d66..beb4bb0d 100644 --- a/.github/docker/Dockerfile.ci +++ b/.github/docker/Dockerfile.ci @@ -72,6 +72,18 @@ RUN npm i -g @anthropic-ai/claude-code # Playwright system deps (Chromium) — needed for browse E2E tests RUN npx playwright install-deps chromium +# Linux has neither Helvetica nor Arial. make-pdf's print CSS stacks fall back +# to Liberation Sans (metric-compatible Arial clone, SIL OFL 1.1) so PDFs don't +# render in DejaVu Sans. playwright install-deps happens to pull this in today, +# but the dep is implicit and could change — install explicitly so upgrades +# can't silently regress rendering. +RUN for i in 1 2 3; do \ + apt-get update && apt-get install -y --no-install-recommends fonts-liberation fontconfig && break || \ + (echo "fonts-liberation install retry $i/3"; sleep 10); \ + done \ + && fc-cache -f \ + && rm -rf /var/lib/apt/lists/* + # Pre-install dependencies (cached layer — only rebuilds when package.json changes) COPY package.json /workspace/ WORKDIR /workspace @@ -84,7 +96,9 @@ RUN npx playwright install chromium \ # Verify everything works RUN bun --version && node --version && claude --version && jq --version && gh --version \ - && npx playwright --version + && npx playwright --version \ + && fc-match "Liberation Sans" | grep -qi "Liberation" \ + || (echo "ERROR: fonts-liberation not installed — make-pdf PDFs will render in DejaVu Sans" && exit 1) # At runtime: checkout overwrites /workspace, but node_modules persists # if we move it out of the way and symlink back diff --git a/make-pdf/SKILL.md b/make-pdf/SKILL.md index a22cc89e..06e4d6a0 100644 --- a/make-pdf/SKILL.md +++ b/make-pdf/SKILL.md @@ -470,6 +470,10 @@ left-aligned body, Helvetica throughout, curly quotes and em dashes, optional cover page and clickable TOC, diagonal DRAFT watermark when you need it. Copy-paste from the PDF produces clean words, never "S a i l i n g". +On Linux, install `fonts-liberation` for correct rendering — Helvetica and Arial +aren't present by default, and Liberation Sans is the standard metric-compatible +fallback. CI and Docker builds install it automatically via Dockerfile.ci. + ## MAKE-PDF SETUP (run this check BEFORE any make-pdf command) ```bash diff --git a/make-pdf/SKILL.md.tmpl b/make-pdf/SKILL.md.tmpl index 38668290..a3b0c362 100644 --- a/make-pdf/SKILL.md.tmpl +++ b/make-pdf/SKILL.md.tmpl @@ -39,6 +39,10 @@ left-aligned body, Helvetica throughout, curly quotes and em dashes, optional cover page and clickable TOC, diagonal DRAFT watermark when you need it. Copy-paste from the PDF produces clean words, never "S a i l i n g". +On Linux, install `fonts-liberation` for correct rendering — Helvetica and Arial +aren't present by default, and Liberation Sans is the standard metric-compatible +fallback. CI and Docker builds install it automatically via Dockerfile.ci. + {{MAKE_PDF_SETUP}} ## Core patterns diff --git a/make-pdf/src/print-css.ts b/make-pdf/src/print-css.ts index a9f15563..14d78bd5 100644 --- a/make-pdf/src/print-css.ts +++ b/make-pdf/src/print-css.ts @@ -5,8 +5,11 @@ * Mirror those CSS rules here. The HTML references were approved via * /plan-design-review with explicit design decisions locked in the plan: * - * - Helvetica only (system font, no bundled webfonts — dodges the - * per-glyph Tj bug that breaks copy-paste extraction). + * - Helvetica first, with Liberation Sans as a metric-compatible Linux + * fallback (Helvetica and Arial aren't installed on most Linux distros; + * Liberation Sans ships via the fonts-liberation package and Playwright's + * install-deps). No bundled webfonts — dodges the per-glyph Tj bug that + * breaks copy-paste extraction. * - All paragraphs flush-left. No first-line indent, no justify, no * p+p indent. text-align: left everywhere. 12pt margin-bottom. * - Cover page has the same 1in margins as every other page. No flexbox @@ -15,8 +18,8 @@ * - `@page :first` suppresses running header/footer but does NOT override * the 1in margin. * - No , no external CSS/fonts — everything inlined. - * - CJK fallback: Helvetica, Arial, Hiragino Kaku Gothic ProN, Noto Sans - * CJK JP, Microsoft YaHei, sans-serif. + * - CJK fallback: Helvetica, Liberation Sans, Arial, Hiragino Kaku Gothic + * ProN, Noto Sans CJK JP, Microsoft YaHei, sans-serif. */ export interface PrintCssOptions { @@ -81,13 +84,13 @@ function pageRules(size: string, margin: string, opts: PrintCssOptions): string ` size: ${size};`, ` margin: ${margin};`, runningHeader - ? ` @top-center { content: "${runningHeader}"; font-family: Helvetica, Arial, sans-serif; font-size: 9pt; color: #666; }` + ? ` @top-center { content: "${runningHeader}"; font-family: Helvetica, "Liberation Sans", Arial, sans-serif; font-size: 9pt; color: #666; }` : ``, showPageNumbers - ? ` @bottom-center { content: counter(page) " of " counter(pages); font-family: Helvetica, Arial, sans-serif; font-size: 9pt; color: #666; }` + ? ` @bottom-center { content: counter(page) " of " counter(pages); font-family: Helvetica, "Liberation Sans", Arial, sans-serif; font-size: 9pt; color: #666; }` : ``, showConfidential - ? ` @bottom-right { content: "CONFIDENTIAL"; font-family: Helvetica, Arial, sans-serif; font-size: 8pt; color: #aaa; letter-spacing: 0.05em; }` + ? ` @bottom-right { content: "CONFIDENTIAL"; font-family: Helvetica, "Liberation Sans", Arial, sans-serif; font-size: 8pt; color: #aaa; letter-spacing: 0.05em; }` : ``, `}`, ``, @@ -104,7 +107,7 @@ function rootTypography(): string { return [ `html { lang: en; }`, `body {`, - ` font-family: Helvetica, Arial, "Hiragino Kaku Gothic ProN", "Noto Sans CJK JP", "Microsoft YaHei", sans-serif;`, + ` font-family: Helvetica, "Liberation Sans", Arial, "Hiragino Kaku Gothic ProN", "Noto Sans CJK JP", "Microsoft YaHei", sans-serif;`, ` font-size: 11pt;`, ` line-height: 1.5;`, ` color: #111;`, diff --git a/make-pdf/test/render.test.ts b/make-pdf/test/render.test.ts index 89a92537..4d643e7e 100644 --- a/make-pdf/test/render.test.ts +++ b/make-pdf/test/render.test.ts @@ -326,6 +326,23 @@ describe("printCss", () => { const css = printCss({ pageNumbers: true }); expect(css).toMatch(/@bottom-center\s*\{\s*content:\s*counter\(page\)/); }); + + test("font stacks include Liberation Sans adjacent to Helvetica", () => { + const css = printCss({ confidential: true }); + // Body stack + expect(css).toMatch(/font-family:\s*Helvetica,\s*"Liberation Sans",\s*Arial/); + // At least one @page margin box (running header / page number / CONFIDENTIAL) + // should also have the updated stack. + const marginBoxStacks = css.match(/@(top|bottom)-(center|right)\s*\{[^}]*Liberation Sans/g) ?? []; + expect(marginBoxStacks.length).toBeGreaterThanOrEqual(1); + }); + + test("all four original Helvetica stacks now include Liberation Sans", () => { + const css = printCss({ runningHeader: "Running Title", confidential: true }); + // Count: body (1) + running header (1) + page numbers (1) + confidential (1) = 4 + const occurrences = (css.match(/"Liberation Sans"/g) ?? []).length; + expect(occurrences).toBeGreaterThanOrEqual(4); + }); }); // ─── render() — pageNumbers / footerTemplate data flow ───────────────