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>
This commit is contained in:
Garry Tan
2026-04-20 21:40:27 +08:00
parent d0782c4c4d
commit cf875d1e41
5 changed files with 69 additions and 2 deletions
+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`);
+9 -1
View File
@@ -37,6 +37,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,6 +74,7 @@ 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 {`,
@@ -77,7 +83,9 @@ function pageRules(size: string, margin: string, opts: PrintCssOptions): string
runningHeader
? ` @top-center { content: "${runningHeader}"; font-family: Helvetica, 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; }`,
showPageNumbers
? ` @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; }`
: ``,
+10
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);
+1
View File
@@ -63,6 +63,7 @@ export interface PreviewOptions {
watermark?: string;
noChapterBreaks?: boolean;
confidential?: boolean;
pageNumbers?: boolean;
allowNetwork?: boolean;
title?: string;
author?: string;
+42
View File
@@ -311,4 +311,46 @@ 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\)/);
});
});
// ─── 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\)/);
});
});