diff --git a/make-pdf/src/orchestrator.ts b/make-pdf/src/orchestrator.ts index 31710ecf..cf8dffae 100644 --- a/make-pdf/src/orchestrator.ts +++ b/make-pdf/src/orchestrator.ts @@ -94,6 +94,8 @@ export async function generate(opts: GenerateOptions): Promise { 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 { 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 { watermark: opts.watermark, noChapterBreaks: opts.noChapterBreaks, confidential: opts.confidential, + pageNumbers: opts.pageNumbers, }); progress.end("Rendering HTML", `${rendered.meta.wordCount} words`); diff --git a/make-pdf/src/print-css.ts b/make-pdf/src/print-css.ts index a4b71dae..a9f15563 100644 --- a/make-pdf/src/print-css.ts +++ b/make-pdf/src/print-css.ts @@ -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; }` : ``, diff --git a/make-pdf/src/render.ts b/make-pdf/src/render.ts index 03bf43cd..eafe6fc8 100644 --- a/make-pdf/src/render.ts +++ b/make-pdf/src/render.ts @@ -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); diff --git a/make-pdf/src/types.ts b/make-pdf/src/types.ts index 4d170975..6d4e6710 100644 --- a/make-pdf/src/types.ts +++ b/make-pdf/src/types.ts @@ -63,6 +63,7 @@ export interface PreviewOptions { watermark?: string; noChapterBreaks?: boolean; confidential?: boolean; + pageNumbers?: boolean; allowNetwork?: boolean; title?: string; author?: string; diff --git a/make-pdf/test/render.test.ts b/make-pdf/test/render.test.ts index 5ddb5da4..0e64f871 100644 --- a/make-pdf/test/render.test.ts +++ b/make-pdf/test/render.test.ts @@ -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: `
custom
`, + }); + 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\)/); + }); });