fix(v1.4.1.0): /make-pdf — page numbers, entity escape, Linux fonts (#1098)

* fix(make-pdf): single-source page numbers via CSS, honor --no-page-numbers end-to-end

Two page-number sources were stacking in every PDF: Chromium's native footer
and our @page @bottom-center CSS. The CLI flag --page-numbers/--no-page-numbers
also never reached the CSS layer, because RenderOptions didn't carry it.
Passing --footer-template likewise dropped the "custom footer replaces stock
footer" semantic.

- orchestrator.ts: browseClient.pdf() gets pageNumbers:false unconditionally.
  CSS is the single source of truth. Chromium native numbering always off.
- render.ts: RenderOptions gains pageNumbers + footerTemplate. render() computes
  showPageNumbers = pageNumbers !== false && !footerTemplate and passes to
  printCss(), preserving the prior footerTemplate-suppresses-stock semantic.
- print-css.ts: PrintCssOptions.pageNumbers wraps @bottom-center in a conditional
  matching the existing showConfidential pattern.
- types.ts: PreviewOptions.pageNumbers so preview path compiles and matches CLI.
- render.test.ts: 7 regression tests covering printCss({pageNumbers}) in
  isolation AND the full render() data flow incl. footerTemplate path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(make-pdf): decode HTML entities in titles and TOC to prevent double-escape

A markdown title like "# Herbert & Garry" rendered as "Herbert &amp;amp; Garry"
in <title>, cover block, and TOC entries. marked emits "&amp;" (correct HTML),
but extractFirstHeading and extractHeadings only stripTags — leaving the entity
intact. That string then flows through escapeHtml, producing the double-encode.

- render.ts: new decodeTextEntities helper, distinct from decodeTypographicEntities
  (which runs on in-pipeline HTML and intentionally preserves &amp;). Covers
  named entities (lt/gt/quot/apos/39/x27/amp) AND numeric (decimal + hex) so
  inputs like "&#169;" or "&#x2014;" don't create the same partial-fix bug.
  Amp-last ordering prevents double-decode on "&amp;lt;" et al.
- Apply in both extractFirstHeading and extractHeadings. extractHeadings feeds
  buildTocBlock → escapeHtml, so the TOC site had the same bug.
- render.test.ts: 8 tests covering the contract — parameterized across &, <, >,
  ©, — chars; single-escape in <title>/cover; TOC double-escape check; numeric
  entity decode; smartypants-interacts-with-quotes contract (no raw equality).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 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>

* chore: bump version and changelog (v1.4.1.0)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: anonymize test fixtures, drop VC-partner framing

- CHANGELOG + render.test.ts fixtures use "Faber & Faber" instead of a
  personal name. Same regression coverage (ampersand in <title>, cover,
  TOC, body), neutral subject.
- make-pdf/SKILL.md.tmpl description drops the "send to a VC partner, a
  book agent, a judge, or Rick Rubin's team" line. "Not a draft artifact
  — a finished artifact" stands on its own without the audience posturing.
- SKILL.md regenerated.

No functional changes. All 58 make-pdf tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-20 22:32:58 +08:00
committed by GitHub
parent 97584f9a59
commit e23ff280a1
11 changed files with 285 additions and 24 deletions
+7 -5
View File
@@ -5,11 +5,9 @@ version: 1.0.0
description: |
Turn any markdown file into a publication-quality PDF. Proper 1in margins,
intelligent page breaks, page numbers, cover pages, running headers, curly
quotes and em dashes, clickable TOC, diagonal DRAFT watermark. Output you'd
send to a VC partner, a book agent, a judge, or Rick Rubin's team. Not a
draft artifact — a finished artifact. Use when asked to "make a PDF",
"export to PDF", "turn this markdown into a PDF", or "generate a document".
(gstack)
quotes and em dashes, clickable TOC, diagonal DRAFT watermark. Not a draft
artifact — a finished artifact. Use when asked to "make a PDF", "export to
PDF", "turn this markdown into a PDF", or "generate a document". (gstack)
Voice triggers (speech-to-text aliases): "make this a pdf", "make it a pdf", "export to pdf", "turn this into a pdf", "turn this markdown into a pdf", "generate a pdf", "make a pdf from", "pdf this markdown".
triggers:
- markdown to pdf
@@ -470,6 +468,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
+7 -5
View File
@@ -5,11 +5,9 @@ version: 1.0.0
description: |
Turn any markdown file into a publication-quality PDF. Proper 1in margins,
intelligent page breaks, page numbers, cover pages, running headers, curly
quotes and em dashes, clickable TOC, diagonal DRAFT watermark. Output you'd
send to a VC partner, a book agent, a judge, or Rick Rubin's team. Not a
draft artifact — a finished artifact. Use when asked to "make a PDF",
"export to PDF", "turn this markdown into a PDF", or "generate a document".
(gstack)
quotes and em dashes, clickable TOC, diagonal DRAFT watermark. Not a draft
artifact — a finished artifact. Use when asked to "make a PDF", "export to
PDF", "turn this markdown into a PDF", or "generate a document". (gstack)
voice-triggers:
- "make this a pdf"
- "make it a pdf"
@@ -39,6 +37,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
+7 -1
View File
@@ -94,6 +94,8 @@ export async function generate(opts: GenerateOptions): Promise<string> {
confidential: opts.confidential,
pageSize: opts.pageSize,
margins: opts.margins,
pageNumbers: opts.pageNumbers,
footerTemplate: opts.footerTemplate,
});
progress.end("Rendering HTML", `${rendered.meta.wordCount} words`);
@@ -136,7 +138,10 @@ export async function generate(opts: GenerateOptions): Promise<string> {
marginLeft: opts.marginLeft ?? opts.margins ?? "1in",
headerTemplate: opts.headerTemplate,
footerTemplate: opts.footerTemplate,
pageNumbers: opts.pageNumbers !== false && !opts.footerTemplate,
// CSS is the single source of truth for page numbers (see print-css.ts
// @bottom-center). Chromium's native numbering always off to avoid double
// footers. The CSS layer honors pageNumbers + footerTemplate via render().
pageNumbers: false,
tagged: opts.tagged !== false,
outline: opts.outline !== false,
printBackground: !!opts.watermark,
@@ -183,6 +188,7 @@ export async function preview(opts: PreviewOptions): Promise<string> {
watermark: opts.watermark,
noChapterBreaks: opts.noChapterBreaks,
confidential: opts.confidential,
pageNumbers: opts.pageNumbers,
});
progress.end("Rendering HTML", `${rendered.meta.wordCount} words`);
+19 -8
View File
@@ -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 <link>, 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 {
@@ -37,6 +40,11 @@ export interface PrintCssOptions {
// Margins (default 1in)
margins?: string;
// Whether to render "N of M" page numbers in the @page @bottom-center rule.
// Default true. Set false to suppress CSS numbering (used when the caller
// supplies a custom Chromium footerTemplate, or when --no-page-numbers).
pageNumbers?: boolean;
}
/**
@@ -69,17 +77,20 @@ export function printCss(opts: PrintCssOptions = {}): string {
function pageRules(size: string, margin: string, opts: PrintCssOptions): string {
const runningHeader = escapeCssString(opts.runningHeader ?? "");
const showConfidential = opts.confidential !== false;
const showPageNumbers = opts.pageNumbers !== false;
return [
`@page {`,
` 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, "Liberation Sans", Arial, sans-serif; font-size: 9pt; color: #666; }`
: ``,
` @bottom-center { content: counter(page) " of " counter(pages); font-family: Helvetica, 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; }`
: ``,
`}`,
``,
@@ -96,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;`,
+37 -2
View File
@@ -34,6 +34,11 @@ export interface RenderOptions {
// Page layout
pageSize?: "letter" | "a4" | "legal" | "tabloid";
margins?: string;
// Footer behavior. pageNumbers defaults to true. When footerTemplate is set,
// CSS page numbers are suppressed so the custom Chromium footer wins cleanly.
pageNumbers?: boolean;
footerTemplate?: string;
}
export interface RenderResult {
@@ -74,6 +79,10 @@ export function render(opts: RenderOptions): RenderResult {
const derivedDate = opts.date ?? formatToday();
// 5. Build CSS
// CSS is the single source of truth for page numbers (Chromium native
// numbering is always off in orchestrator). If the caller supplied a custom
// footerTemplate, suppress CSS page numbers too so their footer wins.
const showPageNumbers = opts.pageNumbers !== false && !opts.footerTemplate;
const cssOptions: PrintCssOptions = {
cover: opts.cover,
toc: opts.toc,
@@ -83,6 +92,7 @@ export function render(opts: RenderOptions): RenderResult {
runningHeader: derivedTitle,
pageSize: opts.pageSize,
margins: opts.margins,
pageNumbers: showPageNumbers,
};
const css = printCss(cssOptions);
@@ -278,7 +288,7 @@ function extractHeadings(html: string): Array<{ level: number; text: string }> {
let match;
while ((match = re.exec(html)) !== null) {
const level = parseInt(match[1].slice(1), 10);
const text = stripTags(match[2]).trim();
const text = decodeTextEntities(stripTags(match[2]).trim());
if (text) headings.push({ level, text });
}
return headings;
@@ -314,7 +324,32 @@ function wrapChaptersByH1(html: string): string {
function extractFirstHeading(html: string): string | null {
const m = html.match(/<h1\b[^>]*>([\s\S]*?)<\/h1>/i);
return m ? stripTags(m[1]).trim() : null;
return m ? decodeTextEntities(stripTags(m[1]).trim()) : null;
}
/**
* Decode HTML entities in plain text extracted from rendered HTML. Distinct
* from decodeTypographicEntities (which runs on in-pipeline HTML and preserves
* &amp; because &amp;amp; can be legitimate there). This runs on text destined
* for <title>, cover, and TOC entries where &amp; MUST become & or escapeHtml
* produces &amp;amp;.
*
* Amp-last ordering: input "&amp;#169;" decodes to "&#169;" in the named pass,
* then the numeric pass decodes "&#169;" to "©". Decoding &amp; first would
* produce "&#169;" and the numeric pass would consume it — different end state
* but risks double-decode on inputs like "&amp;lt;".
*/
function decodeTextEntities(s: string): string {
return s
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'")
.replace(/&#x27;/g, "'")
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(parseInt(n, 10)))
.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCodePoint(parseInt(n, 16)))
.replace(/&amp;/g, "&");
}
function stripTags(html: string): string {
+1
View File
@@ -63,6 +63,7 @@ export interface PreviewOptions {
watermark?: string;
noChapterBreaks?: boolean;
confidential?: boolean;
pageNumbers?: boolean;
allowNetwork?: boolean;
title?: string;
author?: string;
+135
View File
@@ -311,4 +311,139 @@ describe("printCss", () => {
// Confirm no p-indent slipped in
expect(css).not.toMatch(/p\s*\+\s*p\s*\{[^}]*text-indent/);
});
test("emits @bottom-center page-number rule by default", () => {
const css = printCss();
expect(css).toMatch(/@bottom-center\s*\{\s*content:\s*counter\(page\)/);
});
test("suppresses @bottom-center page-number rule when pageNumbers=false", () => {
const css = printCss({ pageNumbers: false });
expect(css).not.toMatch(/@bottom-center\s*\{\s*content:\s*counter\(page\)/);
});
test("still emits @bottom-center when pageNumbers=true (explicit)", () => {
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 ───────────────
describe("render() — pageNumbers data flow", () => {
test("CSS footer renders by default", () => {
const result = render({ markdown: `# Doc\n\nBody.` });
expect(result.printCss).toMatch(/@bottom-center\s*\{\s*content:\s*counter\(page\)/);
});
test("--no-page-numbers reaches the CSS layer", () => {
const result = render({ markdown: `# Doc\n\nBody.`, pageNumbers: false });
expect(result.printCss).not.toMatch(/@bottom-center\s*\{\s*content:\s*counter\(page\)/);
});
test("footerTemplate suppresses CSS page numbers (custom footer wins)", () => {
const result = render({
markdown: `# Doc\n\nBody.`,
footerTemplate: `<div class="foo">custom</div>`,
});
expect(result.printCss).not.toMatch(/@bottom-center\s*\{\s*content:\s*counter\(page\)/);
});
test("pageNumbers=true + no footerTemplate keeps CSS footer", () => {
const result = render({ markdown: `# Doc`, pageNumbers: true });
expect(result.printCss).toMatch(/@bottom-center\s*\{\s*content:\s*counter\(page\)/);
});
});
// ─── render() — HTML entity handling in titles, cover, TOC ───────────
describe("render() — no double HTML entity escaping", () => {
type Case = { char: string; inTitle: string; expectedTitleMeta: string };
// Only characters that should flow through unchanged. `"` and `'` are
// omitted from this set because smartypants converts them to curly quotes
// before heading extraction — asserted separately below.
const cases: Case[] = [
{ char: "&", inTitle: "A & B", expectedTitleMeta: "A & B" },
{ char: "<", inTitle: "A < B", expectedTitleMeta: "A < B" },
{ char: ">", inTitle: "A > B", expectedTitleMeta: "A > B" },
{ char: "©", inTitle: "A © B", expectedTitleMeta: "A © B" },
{ char: "—", inTitle: "A — B", expectedTitleMeta: "A — B" },
];
for (const { char, inTitle, expectedTitleMeta } of cases) {
test(`"${char}" in H1 has no double-escape in <title> or cover`, () => {
const result = render({
markdown: `# ${inTitle}\n\nBody.`,
cover: true,
author: "A",
});
// Meta: decoded plain text.
expect(result.meta.title).toBe(expectedTitleMeta);
// HTML: <title>...</title> never contains double-escape patterns.
expect(result.html).not.toMatch(/<title>[^<]*&amp;amp;/);
expect(result.html).not.toMatch(/<title>[^<]*&amp;lt;/);
expect(result.html).not.toMatch(/<title>[^<]*&amp;gt;/);
expect(result.html).not.toMatch(/<title>[^<]*&amp;#\d+;/);
expect(result.html).not.toMatch(/<title>[^<]*&amp;#x[0-9a-fA-F]+;/);
// Cover block also single-escape.
expect(result.html).not.toMatch(/class="cover-title"[^>]*>[^<]*&amp;amp;/);
});
}
test('ampersand in <title> renders as exactly one "&amp;"', () => {
const result = render({ markdown: `# Faber & Faber\n\nBody.` });
expect(result.html).toContain("<title>Faber &amp; Faber</title>");
expect(result.html).not.toContain("&amp;amp;");
});
test("TOC entries have no double-escape when a heading contains '&'", () => {
const result = render({
markdown: `# Doc\n\n## Faber & Faber\n\nBody.\n\n## Other\n\nMore.`,
toc: true,
});
// TOC renders the heading text through escapeHtml; must be single-escaped.
expect(result.html).toContain("Faber &amp; Faber");
expect(result.html).not.toContain("&amp;amp;");
});
test('numeric entity in H1 (e.g. "&#169;") decodes cleanly to <title>', () => {
// Marked passes through numeric entities verbatim in the HTML output,
// so the decoder must handle them.
const result = render({ markdown: `# A &#169; B\n\nBody.` });
expect(result.meta.title).toBe("A © B");
expect(result.html).toContain("<title>A © B</title>");
});
test("smartypants converts raw quotes in title BEFORE extraction (contract)", () => {
// We do NOT assert raw `"` survives — smartypants is expected to convert it.
// The contract is: no double-escape of the encoded form.
const result = render({ markdown: `# Say "hi"\n\nBody.` });
expect(result.html).not.toContain("&amp;quot;");
expect(result.html).not.toContain("&amp;#39;");
// And <title> contains exactly one level of escaping.
const titleMatch = result.html.match(/<title>([^<]*)<\/title>/);
expect(titleMatch).toBeTruthy();
if (titleMatch) {
// Never contains a double-encoded entity.
expect(titleMatch[1]).not.toMatch(/&amp;(amp|lt|gt|quot|#\d+);/);
}
});
});