mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-08 23:36:06 +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:
co-authored by
Claude Opus 4.7
parent
d0782c4c4d
commit
cf875d1e41
@@ -94,6 +94,8 @@ export async function generate(opts: GenerateOptions): Promise<string> {
|
|||||||
confidential: opts.confidential,
|
confidential: opts.confidential,
|
||||||
pageSize: opts.pageSize,
|
pageSize: opts.pageSize,
|
||||||
margins: opts.margins,
|
margins: opts.margins,
|
||||||
|
pageNumbers: opts.pageNumbers,
|
||||||
|
footerTemplate: opts.footerTemplate,
|
||||||
});
|
});
|
||||||
progress.end("Rendering HTML", `${rendered.meta.wordCount} words`);
|
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",
|
marginLeft: opts.marginLeft ?? opts.margins ?? "1in",
|
||||||
headerTemplate: opts.headerTemplate,
|
headerTemplate: opts.headerTemplate,
|
||||||
footerTemplate: opts.footerTemplate,
|
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,
|
tagged: opts.tagged !== false,
|
||||||
outline: opts.outline !== false,
|
outline: opts.outline !== false,
|
||||||
printBackground: !!opts.watermark,
|
printBackground: !!opts.watermark,
|
||||||
@@ -183,6 +188,7 @@ export async function preview(opts: PreviewOptions): Promise<string> {
|
|||||||
watermark: opts.watermark,
|
watermark: opts.watermark,
|
||||||
noChapterBreaks: opts.noChapterBreaks,
|
noChapterBreaks: opts.noChapterBreaks,
|
||||||
confidential: opts.confidential,
|
confidential: opts.confidential,
|
||||||
|
pageNumbers: opts.pageNumbers,
|
||||||
});
|
});
|
||||||
progress.end("Rendering HTML", `${rendered.meta.wordCount} words`);
|
progress.end("Rendering HTML", `${rendered.meta.wordCount} words`);
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ export interface PrintCssOptions {
|
|||||||
|
|
||||||
// Margins (default 1in)
|
// Margins (default 1in)
|
||||||
margins?: string;
|
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 {
|
function pageRules(size: string, margin: string, opts: PrintCssOptions): string {
|
||||||
const runningHeader = escapeCssString(opts.runningHeader ?? "");
|
const runningHeader = escapeCssString(opts.runningHeader ?? "");
|
||||||
const showConfidential = opts.confidential !== false;
|
const showConfidential = opts.confidential !== false;
|
||||||
|
const showPageNumbers = opts.pageNumbers !== false;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
`@page {`,
|
`@page {`,
|
||||||
@@ -77,7 +83,9 @@ function pageRules(size: string, margin: string, opts: PrintCssOptions): string
|
|||||||
runningHeader
|
runningHeader
|
||||||
? ` @top-center { content: "${runningHeader}"; font-family: Helvetica, Arial, sans-serif; font-size: 9pt; color: #666; }`
|
? ` @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
|
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, Arial, sans-serif; font-size: 8pt; color: #aaa; letter-spacing: 0.05em; }`
|
||||||
: ``,
|
: ``,
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ export interface RenderOptions {
|
|||||||
// Page layout
|
// Page layout
|
||||||
pageSize?: "letter" | "a4" | "legal" | "tabloid";
|
pageSize?: "letter" | "a4" | "legal" | "tabloid";
|
||||||
margins?: string;
|
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 {
|
export interface RenderResult {
|
||||||
@@ -74,6 +79,10 @@ export function render(opts: RenderOptions): RenderResult {
|
|||||||
const derivedDate = opts.date ?? formatToday();
|
const derivedDate = opts.date ?? formatToday();
|
||||||
|
|
||||||
// 5. Build CSS
|
// 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 = {
|
const cssOptions: PrintCssOptions = {
|
||||||
cover: opts.cover,
|
cover: opts.cover,
|
||||||
toc: opts.toc,
|
toc: opts.toc,
|
||||||
@@ -83,6 +92,7 @@ export function render(opts: RenderOptions): RenderResult {
|
|||||||
runningHeader: derivedTitle,
|
runningHeader: derivedTitle,
|
||||||
pageSize: opts.pageSize,
|
pageSize: opts.pageSize,
|
||||||
margins: opts.margins,
|
margins: opts.margins,
|
||||||
|
pageNumbers: showPageNumbers,
|
||||||
};
|
};
|
||||||
const css = printCss(cssOptions);
|
const css = printCss(cssOptions);
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export interface PreviewOptions {
|
|||||||
watermark?: string;
|
watermark?: string;
|
||||||
noChapterBreaks?: boolean;
|
noChapterBreaks?: boolean;
|
||||||
confidential?: boolean;
|
confidential?: boolean;
|
||||||
|
pageNumbers?: boolean;
|
||||||
allowNetwork?: boolean;
|
allowNetwork?: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
|
|||||||
@@ -311,4 +311,46 @@ describe("printCss", () => {
|
|||||||
// Confirm no p-indent slipped in
|
// Confirm no p-indent slipped in
|
||||||
expect(css).not.toMatch(/p\s*\+\s*p\s*\{[^}]*text-indent/);
|
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