mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 03:35:09 +02:00
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:
@@ -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`);
|
||||
|
||||
|
||||
@@ -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; }`
|
||||
: ``,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ export interface PreviewOptions {
|
||||
watermark?: string;
|
||||
noChapterBreaks?: boolean;
|
||||
confidential?: boolean;
|
||||
pageNumbers?: boolean;
|
||||
allowNetwork?: boolean;
|
||||
title?: string;
|
||||
author?: string;
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user