fix(make-pdf): Liberation Sans font fallback for Linux rendering

On Linux (Docker, CI, servers), neither Helvetica nor Arial exist. Our CSS
stacks were falling through to DejaVu Sans — wider letterforms that look like
Verdana, not the intended Helvetica/Faber look. Liberation Sans is the standard
metric-compatible Arial clone (SIL OFL 1.1, apt package fonts-liberation).

- print-css.ts: all four font stacks (body + @top-center + @bottom-center +
  @bottom-right CONFIDENTIAL) gain "Liberation Sans" between Helvetica and
  Arial. File-header docblock updated to reflect the new stack.
- .github/docker/Dockerfile.ci: explicit apt-get install fonts-liberation +
  fontconfig with retry, fc-cache -f, and a verify step that fails the build
  loud if the font disappears. Playwright's install-deps happens to pull this
  in today but the dep is implicit and could silently regress.
- SKILL.md.tmpl: one-sentence note pointing Linux users at fonts-liberation.
- SKILL.md: regenerated via bun run gen:skill-docs --host all (only make-pdf's
  generated file changed — verified clean diff scope).
- render.test.ts: 2 assertions — Liberation Sans in body stack AND in at least
  one @page margin-box rule (proves all four intended stacks got touched, not
  just one).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-20 21:42:53 +08:00
parent 70b59ec91b
commit db33e52e70
5 changed files with 51 additions and 9 deletions
+15 -1
View File
@@ -72,6 +72,18 @@ RUN npm i -g @anthropic-ai/claude-code
# Playwright system deps (Chromium) — needed for browse E2E tests # Playwright system deps (Chromium) — needed for browse E2E tests
RUN npx playwright install-deps chromium 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) # Pre-install dependencies (cached layer — only rebuilds when package.json changes)
COPY package.json /workspace/ COPY package.json /workspace/
WORKDIR /workspace WORKDIR /workspace
@@ -84,7 +96,9 @@ RUN npx playwright install chromium \
# Verify everything works # Verify everything works
RUN bun --version && node --version && claude --version && jq --version && gh --version \ 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 # At runtime: checkout overwrites /workspace, but node_modules persists
# if we move it out of the way and symlink back # if we move it out of the way and symlink back
+4
View File
@@ -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. 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". 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) ## MAKE-PDF SETUP (run this check BEFORE any make-pdf command)
```bash ```bash
+4
View File
@@ -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. 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". 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}} {{MAKE_PDF_SETUP}}
## Core patterns ## Core patterns
+11 -8
View File
@@ -5,8 +5,11 @@
* Mirror those CSS rules here. The HTML references were approved via * Mirror those CSS rules here. The HTML references were approved via
* /plan-design-review with explicit design decisions locked in the plan: * /plan-design-review with explicit design decisions locked in the plan:
* *
* - Helvetica only (system font, no bundled webfonts dodges the * - Helvetica first, with Liberation Sans as a metric-compatible Linux
* per-glyph Tj bug that breaks copy-paste extraction). * 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 * - All paragraphs flush-left. No first-line indent, no justify, no
* p+p indent. text-align: left everywhere. 12pt margin-bottom. * p+p indent. text-align: left everywhere. 12pt margin-bottom.
* - Cover page has the same 1in margins as every other page. No flexbox * - 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 * - `@page :first` suppresses running header/footer but does NOT override
* the 1in margin. * the 1in margin.
* - No <link>, no external CSS/fonts everything inlined. * - No <link>, no external CSS/fonts everything inlined.
* - CJK fallback: Helvetica, Arial, Hiragino Kaku Gothic ProN, Noto Sans * - CJK fallback: Helvetica, Liberation Sans, Arial, Hiragino Kaku Gothic
* CJK JP, Microsoft YaHei, sans-serif. * ProN, Noto Sans CJK JP, Microsoft YaHei, sans-serif.
*/ */
export interface PrintCssOptions { export interface PrintCssOptions {
@@ -81,13 +84,13 @@ function pageRules(size: string, margin: string, opts: PrintCssOptions): string
` size: ${size};`, ` size: ${size};`,
` margin: ${margin};`, ` margin: ${margin};`,
runningHeader 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 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 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 [ return [
`html { lang: en; }`, `html { lang: en; }`,
`body {`, `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;`, ` font-size: 11pt;`,
` line-height: 1.5;`, ` line-height: 1.5;`,
` color: #111;`, ` color: #111;`,
+17
View File
@@ -326,6 +326,23 @@ describe("printCss", () => {
const css = printCss({ pageNumbers: true }); const css = printCss({ pageNumbers: true });
expect(css).toMatch(/@bottom-center\s*\{\s*content:\s*counter\(page\)/); 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 ─────────────── // ─── render() — pageNumbers / footerTemplate data flow ───────────────