mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 11:45:20 +02:00
e23ff280a1
* 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; Garry"
in <title>, cover block, and TOC entries. marked emits "&" (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 &). Covers
named entities (lt/gt/quot/apos/39/x27/amp) AND numeric (decimal + hex) so
inputs like "©" or "—" don't create the same partial-fix bug.
Amp-last ordering prevents double-decode on "&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>
362 lines
12 KiB
TypeScript
362 lines
12 KiB
TypeScript
/**
|
|
* Print stylesheet generator.
|
|
*
|
|
* Source of truth: .context/designs/make-pdf-print-reference.html and siblings.
|
|
* Mirror those CSS rules here. The HTML references were approved via
|
|
* /plan-design-review with explicit design decisions locked in the plan:
|
|
*
|
|
* - 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
|
|
* center, no inset padding, no vertical centering. Distinction comes
|
|
* from eyebrow + larger title + hairline rule, not from centering.
|
|
* - `@page :first` suppresses running header/footer but does NOT override
|
|
* the 1in margin.
|
|
* - No <link>, no external CSS/fonts — everything inlined.
|
|
* - CJK fallback: Helvetica, Liberation Sans, Arial, Hiragino Kaku Gothic
|
|
* ProN, Noto Sans CJK JP, Microsoft YaHei, sans-serif.
|
|
*/
|
|
|
|
export interface PrintCssOptions {
|
|
// Document structure
|
|
cover?: boolean;
|
|
toc?: boolean;
|
|
noChapterBreaks?: boolean;
|
|
|
|
// Branding
|
|
watermark?: string;
|
|
confidential?: boolean;
|
|
|
|
// Header (running title, top of page)
|
|
runningHeader?: string;
|
|
|
|
// Page size (in CSS `@page size:` terms)
|
|
pageSize?: "letter" | "a4" | "legal" | "tabloid";
|
|
|
|
// 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;
|
|
}
|
|
|
|
/**
|
|
* Produce a CSS block (no <style> wrapper) for inline injection.
|
|
*/
|
|
export function printCss(opts: PrintCssOptions = {}): string {
|
|
const size = opts.pageSize ?? "letter";
|
|
const margin = opts.margins ?? "1in";
|
|
const hasWatermark = typeof opts.watermark === "string" && opts.watermark.length > 0;
|
|
|
|
return [
|
|
pageRules(size, margin, opts),
|
|
rootTypography(),
|
|
coverRules(opts.cover === true),
|
|
tocRules(opts.toc === true),
|
|
chapterRules(opts.noChapterBreaks === true),
|
|
blockRules(),
|
|
inlineRules(),
|
|
codeRules(),
|
|
quoteRules(),
|
|
figureRules(),
|
|
tableRules(),
|
|
listRules(),
|
|
footnoteRules(),
|
|
hasWatermark ? watermarkRules() : "",
|
|
breakAvoidRules(),
|
|
].filter(Boolean).join("\n\n");
|
|
}
|
|
|
|
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, "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; }`
|
|
: ``,
|
|
showConfidential
|
|
? ` @bottom-right { content: "CONFIDENTIAL"; font-family: Helvetica, "Liberation Sans", Arial, sans-serif; font-size: 8pt; color: #aaa; letter-spacing: 0.05em; }`
|
|
: ``,
|
|
`}`,
|
|
``,
|
|
// Cover page: suppress running header/footer but keep margins.
|
|
`@page :first {`,
|
|
` @top-center { content: none; }`,
|
|
` @bottom-center { content: none; }`,
|
|
` @bottom-right { content: none; }`,
|
|
`}`,
|
|
].filter(line => line !== "").join("\n");
|
|
}
|
|
|
|
function rootTypography(): string {
|
|
return [
|
|
`html { lang: en; }`,
|
|
`body {`,
|
|
` 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;`,
|
|
` background: white;`,
|
|
` hyphens: auto;`,
|
|
` font-variant-ligatures: common-ligatures;`,
|
|
` font-kerning: normal;`,
|
|
` text-rendering: geometricPrecision;`,
|
|
` margin: 0;`,
|
|
` padding: 0;`,
|
|
`}`,
|
|
].join("\n");
|
|
}
|
|
|
|
function coverRules(enabled: boolean): string {
|
|
if (!enabled) return "";
|
|
return [
|
|
`.cover {`,
|
|
` page: first;`,
|
|
` page-break-after: always;`,
|
|
` break-after: page;`,
|
|
` text-align: left;`,
|
|
`}`,
|
|
`.cover .eyebrow {`,
|
|
` font-size: 9pt;`,
|
|
` letter-spacing: 0.2em;`,
|
|
` text-transform: uppercase;`,
|
|
` color: #666;`,
|
|
` margin: 0 0 36pt;`,
|
|
`}`,
|
|
`.cover h1.cover-title {`,
|
|
` font-size: 32pt;`,
|
|
` line-height: 1.15;`,
|
|
` font-weight: 700;`,
|
|
` letter-spacing: -0.01em;`,
|
|
` margin: 0 0 18pt;`,
|
|
` max-width: 5.5in;`,
|
|
` text-align: left;`,
|
|
`}`,
|
|
`.cover .cover-subtitle {`,
|
|
` font-size: 14pt;`,
|
|
` line-height: 1.4;`,
|
|
` font-weight: 400;`,
|
|
` color: #333;`,
|
|
` margin: 0 0 36pt;`,
|
|
` max-width: 5in;`,
|
|
` text-align: left;`,
|
|
`}`,
|
|
`.cover hr.rule {`,
|
|
` width: 2.5in;`,
|
|
` height: 0;`,
|
|
` border: 0;`,
|
|
` border-top: 1px solid #111;`,
|
|
` margin: 0 0 18pt 0;`,
|
|
`}`,
|
|
`.cover .cover-meta { font-size: 10pt; line-height: 1.6; color: #333; text-align: left; }`,
|
|
`.cover .cover-meta strong { font-weight: 700; }`,
|
|
].join("\n");
|
|
}
|
|
|
|
function tocRules(enabled: boolean): string {
|
|
if (!enabled) return "";
|
|
return [
|
|
`.toc { page-break-after: always; break-after: page; }`,
|
|
`.toc h2 {`,
|
|
` font-size: 13pt;`,
|
|
` text-transform: uppercase;`,
|
|
` letter-spacing: 0.15em;`,
|
|
` color: #666;`,
|
|
` font-weight: 600;`,
|
|
` margin: 0 0 0.5in;`,
|
|
`}`,
|
|
`.toc ol {`,
|
|
` list-style: none;`,
|
|
` padding: 0;`,
|
|
` margin: 0;`,
|
|
`}`,
|
|
`.toc li {`,
|
|
` display: flex;`,
|
|
` align-items: baseline;`,
|
|
` gap: 0.25in;`,
|
|
` font-size: 11pt;`,
|
|
` line-height: 2;`,
|
|
` padding: 4pt 0;`,
|
|
`}`,
|
|
`.toc li .toc-title { flex: 0 0 auto; }`,
|
|
`.toc li .toc-dots { flex: 1 1 auto; border-bottom: 1px dotted #aaa; margin: 0 6pt; transform: translateY(-4pt); }`,
|
|
`.toc li .toc-page { flex: 0 0 auto; color: #666; font-variant-numeric: tabular-nums; }`,
|
|
`.toc li.level-2 { padding-left: 0.35in; font-size: 10pt; }`,
|
|
`.toc li a { color: inherit; text-decoration: none; }`,
|
|
].join("\n");
|
|
}
|
|
|
|
function chapterRules(noChapterBreaks: boolean): string {
|
|
const breakRule = noChapterBreaks
|
|
? `/* chapter breaks disabled */`
|
|
: [
|
|
`.chapter { break-before: page; page-break-before: always; }`,
|
|
`.chapter:first-of-type { break-before: auto; page-break-before: auto; }`,
|
|
].join("\n");
|
|
return [
|
|
breakRule,
|
|
`h1 {`,
|
|
` font-size: 22pt;`,
|
|
` line-height: 1.2;`,
|
|
` font-weight: 700;`,
|
|
` letter-spacing: -0.01em;`,
|
|
` margin: 0 0 0.25in;`,
|
|
` break-after: avoid;`,
|
|
` page-break-after: avoid;`,
|
|
`}`,
|
|
`h2 { font-size: 15pt; line-height: 1.3; font-weight: 700; margin: 24pt 0 6pt; break-after: avoid; page-break-after: avoid; }`,
|
|
`h3 { font-size: 12pt; line-height: 1.4; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #333; margin: 18pt 0 4pt; break-after: avoid; page-break-after: avoid; }`,
|
|
`h4 { font-size: 11pt; font-weight: 700; margin: 12pt 0 4pt; break-after: avoid; page-break-after: avoid; }`,
|
|
].join("\n");
|
|
}
|
|
|
|
function blockRules(): string {
|
|
// Flush-left paragraphs, no indent, 12pt gap. No justify.
|
|
// Rule from the plan's "Body paragraph rule (post-review fix)".
|
|
return [
|
|
`p {`,
|
|
` margin: 0 0 12pt;`,
|
|
` text-align: left;`,
|
|
` widows: 3;`,
|
|
` orphans: 3;`,
|
|
`}`,
|
|
`p:first-child { margin-top: 0; }`,
|
|
`p.lead { font-size: 13pt; line-height: 1.45; color: #222; margin: 0 0 18pt; }`,
|
|
].join("\n");
|
|
}
|
|
|
|
function inlineRules(): string {
|
|
return [
|
|
`a {`,
|
|
` color: #0055cc;`,
|
|
` text-decoration: underline;`,
|
|
` text-decoration-thickness: 0.5pt;`,
|
|
` text-underline-offset: 1.5pt;`,
|
|
`}`,
|
|
`strong { font-weight: 700; }`,
|
|
`em { font-style: italic; }`,
|
|
].join("\n");
|
|
}
|
|
|
|
function codeRules(): string {
|
|
return [
|
|
`code {`,
|
|
` font-family: "SF Mono", Menlo, Consolas, monospace;`,
|
|
` font-size: 9.5pt;`,
|
|
` background: #f4f4f4;`,
|
|
` padding: 1pt 3pt;`,
|
|
` border-radius: 2pt;`,
|
|
` border: 0.5pt solid #e4e4e4;`,
|
|
`}`,
|
|
`pre {`,
|
|
` font-family: "SF Mono", Menlo, Consolas, monospace;`,
|
|
` font-size: 9pt;`,
|
|
` line-height: 1.4;`,
|
|
` background: #f7f7f5;`,
|
|
` padding: 10pt 12pt;`,
|
|
` border: 0.5pt solid #e0e0e0;`,
|
|
` border-radius: 3pt;`,
|
|
` margin: 12pt 0;`,
|
|
` overflow: hidden;`,
|
|
` white-space: pre-wrap;`,
|
|
`}`,
|
|
`pre code { background: none; border: 0; padding: 0; font-size: inherit; }`,
|
|
// highlight.js minimal palette (kept neutral, prints well)
|
|
`.hljs-keyword { color: #8b0000; font-weight: 500; }`,
|
|
`.hljs-string { color: #0d6608; }`,
|
|
`.hljs-comment { color: #888; font-style: italic; }`,
|
|
`.hljs-function, .hljs-title { color: #0044aa; }`,
|
|
`.hljs-number { color: #a64d00; }`,
|
|
].join("\n");
|
|
}
|
|
|
|
function quoteRules(): string {
|
|
return [
|
|
`blockquote {`,
|
|
` margin: 12pt 0;`,
|
|
` padding: 0 0 0 18pt;`,
|
|
` border-left: 2pt solid #111;`,
|
|
` color: #333;`,
|
|
` font-size: 11pt;`,
|
|
` line-height: 1.5;`,
|
|
`}`,
|
|
`blockquote p { margin-bottom: 6pt; text-align: left; }`,
|
|
`blockquote cite { display: block; margin-top: 6pt; font-style: normal; font-size: 9.5pt; color: #666; letter-spacing: 0.02em; }`,
|
|
`blockquote cite::before { content: "— "; }`,
|
|
].join("\n");
|
|
}
|
|
|
|
function figureRules(): string {
|
|
return [
|
|
`figure { margin: 12pt 0; }`,
|
|
`figure img { display: block; max-width: 100%; height: auto; }`,
|
|
`figcaption { font-size: 9pt; color: #666; margin-top: 6pt; font-style: italic; }`,
|
|
].join("\n");
|
|
}
|
|
|
|
function tableRules(): string {
|
|
return [
|
|
`table { width: 100%; border-collapse: collapse; margin: 12pt 0; font-size: 10pt; }`,
|
|
`th, td { border-bottom: 0.5pt solid #ccc; padding: 5pt 8pt; text-align: left; vertical-align: top; }`,
|
|
`th { font-weight: 700; border-bottom: 1pt solid #111; background: transparent; }`,
|
|
].join("\n");
|
|
}
|
|
|
|
function listRules(): string {
|
|
return [
|
|
`ul, ol { margin: 0 0 12pt 0; padding-left: 20pt; }`,
|
|
`li { margin-bottom: 3pt; line-height: 1.45; }`,
|
|
`li > ul, li > ol { margin-top: 3pt; margin-bottom: 0; }`,
|
|
].join("\n");
|
|
}
|
|
|
|
function footnoteRules(): string {
|
|
return [
|
|
`.footnote-ref { font-size: 0.75em; vertical-align: super; line-height: 0; text-decoration: none; color: #0055cc; }`,
|
|
`.footnotes { margin-top: 24pt; padding-top: 12pt; border-top: 0.5pt solid #ccc; font-size: 9.5pt; line-height: 1.4; }`,
|
|
`.footnotes ol { padding-left: 18pt; }`,
|
|
].join("\n");
|
|
}
|
|
|
|
function watermarkRules(): string {
|
|
return [
|
|
`.watermark {`,
|
|
` position: fixed;`,
|
|
` top: 50%;`,
|
|
` left: 50%;`,
|
|
` transform: translate(-50%, -50%) rotate(-30deg);`,
|
|
` font-size: 140pt;`,
|
|
` font-weight: 700;`,
|
|
` color: rgba(200, 0, 0, 0.06);`,
|
|
` letter-spacing: 0.08em;`,
|
|
` pointer-events: none;`,
|
|
` z-index: 9999;`,
|
|
` user-select: none;`,
|
|
` white-space: nowrap;`,
|
|
`}`,
|
|
].join("\n");
|
|
}
|
|
|
|
function breakAvoidRules(): string {
|
|
return `blockquote, pre, code, table, figure, li, .keep-together { break-inside: avoid; page-break-inside: avoid; }`;
|
|
}
|
|
|
|
function escapeCssString(s: string): string {
|
|
return s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
|
|
}
|