From 9397e7f847076eae319883ec3c5e5308e28edb2e Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 12 Jun 2026 06:32:55 -0700 Subject: [PATCH] feat(make-pdf): typography scale-up, zero image truncation, landscape vertical centering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dogfooding round on the repo README surfaced four output-quality bugs: - Type was too small everywhere: body 11→12pt, h1 22→26pt, h2 15→18pt, cover title 32→56pt with poster spacing, cover meta 10→13pt, TOC 11→12pt with tighter leading, code 9.5→10.5pt, tables 10→11pt. - Zero image truncation, ever: the max-width cap was figure-scoped, but markdown images render as

— a 1850px GitHub screenshot ran off the page edge. Global img { max-width: 100%; height: auto; } cap. - hyphens: auto put real 'dif-\nferent' breaks into the PDF text layer the moment 12pt made lines wrap (combined-gate caught it). Clean copy-paste is the product contract; left-aligned rag doesn't need hyphenation → hyphens: manual. - Promoted landscape blocks now vertically center. CSS flex/min-height centering fragments into phantom empty landscape pages in Chromium (bisected: min-height at ANY value; 3 promotions printed 5 pages), so image-policy computes an inline margin-top from each block's known aspect ratio against the landscape content box instead — fragmentation handles margins fine. .page-wide also drops its explicit break-before/ after (the page-name change already breaks on both sides). Co-Authored-By: Claude Fable 5 --- make-pdf/src/diagram-prepass.ts | 33 +++++++++++++ make-pdf/src/image-policy.ts | 32 +++++++++++- make-pdf/src/orchestrator.ts | 7 ++- make-pdf/src/print-css.ts | 87 ++++++++++++++++++++------------- 4 files changed, 122 insertions(+), 37 deletions(-) diff --git a/make-pdf/src/diagram-prepass.ts b/make-pdf/src/diagram-prepass.ts index 851bd7367..e8a40392c 100644 --- a/make-pdf/src/diagram-prepass.ts +++ b/make-pdf/src/diagram-prepass.ts @@ -661,6 +661,39 @@ export function contentWidthInches(opts: { return Math.max(1, pageW - left - right); } +const PAGE_HEIGHTS_IN: Record = { + letter: 11, + a4: 11.69, + legal: 14, + tabloid: 17, +}; + +/** + * Content box of the rotated (landscape) named page: portrait page HEIGHT + * becomes the landscape width; portrait WIDTH becomes the landscape height. + * Used by image-policy to vertically center promoted blocks. + */ +export function landscapeContentBox(opts: { + pageSize?: string; + margins?: string; + marginLeft?: string; + marginRight?: string; + marginTop?: string; + marginBottom?: string; +}): { contentWIn: number; contentHIn: number } { + const size = opts.pageSize ?? "letter"; + const pageH = PAGE_HEIGHTS_IN[size] ?? 11; + const pageW = PAGE_WIDTHS_IN[size] ?? 8.5; + const left = dimToInches(opts.marginLeft ?? opts.margins, 1); + const right = dimToInches(opts.marginRight ?? opts.margins, 1); + const top = dimToInches(opts.marginTop ?? opts.margins, 1); + const bottom = dimToInches(opts.marginBottom ?? opts.margins, 1); + return { + contentWIn: Math.max(1, pageH - left - right), + contentHIn: Math.max(1, pageW - top - bottom), + }; +} + // ─── tiny helpers ───────────────────────────────────────────────────── function escapeHtml(s: string): string { diff --git a/make-pdf/src/image-policy.ts b/make-pdf/src/image-policy.ts index 030d65860..80148a0a4 100644 --- a/make-pdf/src/image-policy.ts +++ b/make-pdf/src/image-policy.ts @@ -37,6 +37,13 @@ export interface ImagePolicyOptions { /** Physical content-box width in inches (page width minus margins). */ contentWidthIn: number; + /** + * Landscape named-page content box (inches). Used to vertically center a + * promoted block via a computed inline margin-top — CSS flex/min-height + * centering fragments into phantom landscape pages in Chromium, so the + * margin is computed here from the block's known aspect ratio instead. + */ + landscape: { contentWIn: number; contentHIn: number }; warn: (msg: string) => void; } @@ -124,7 +131,9 @@ export function applyImagePolicy(html: string, opts: ImagePolicyOptions): ImageP if (!decision.promote) return full; hasLandscape = true; opts.warn(`promoting image to a landscape page (${decision.reason})`); - return `

${tag}
`; + const w = num(attrValue(tag, "data-gstack-px-width")); + const h = num(attrValue(tag, "data-gstack-px-height")); + return wrapPageWide(tag, w && h ? h / w : null, opts.landscape); }); // 2c. landscape promotion — rendered diagram figures (provenance is @@ -137,13 +146,32 @@ export function applyImagePolicy(html: string, opts: ImagePolicyOptions): ImageP if (!decision.promote) return figure; hasLandscape = true; opts.warn(`promoting diagram to a landscape page (${decision.reason})`); - return `
${figure}
`; + const dims = svgCssDims(figure); + return wrapPageWide(figure, dims ? dims.height / dims.width : null, opts.landscape); }, ); return { html: out, hasLandscape }; } +/** + * Wrap a promoted block in the wide-page div, vertically centered via a + * computed margin-top: placed height = landscape content width × aspect, + * centered in the landscape content height. Unknown aspect → no margin + * (top placement beats a wrong guess). + */ +function wrapPageWide( + inner: string, + aspectHoverW: number | null, + landscape: { contentWIn: number; contentHIn: number }, +): string { + if (!aspectHoverW) return `
${inner}
`; + const placedHIn = landscape.contentWIn * aspectHoverW; + const marginIn = Math.max(0, (landscape.contentHIn - placedHIn) / 2); + if (marginIn < 0.1) return `
${inner}
`; + return `
${inner}
`; +} + interface PromotionDecision { promote: boolean; reason: string; diff --git a/make-pdf/src/orchestrator.ts b/make-pdf/src/orchestrator.ts index 684384f6a..75eb2367e 100644 --- a/make-pdf/src/orchestrator.ts +++ b/make-pdf/src/orchestrator.ts @@ -30,6 +30,7 @@ import { convertDiagnosticsForDocx, extractDiagramFences, inlineLocalImages, + landscapeContentBox, rasterizeDiagramFigures, renderFenceSlots, substituteSlots, @@ -170,7 +171,11 @@ export async function generate(opts: GenerateOptions): Promise { progress.end("Inlining images"); // Width directives + conservative auto-landscape (image-policy). - const policy = applyImagePolicy(finalHtml, { contentWidthIn, warn }); + const policy = applyImagePolicy(finalHtml, { + contentWidthIn, + landscape: landscapeContentBox(opts), + warn, + }); finalHtml = policy.html; hasLandscape = policy.hasLandscape; diff --git a/make-pdf/src/print-css.ts b/make-pdf/src/print-css.ts index 1c4de690b..bdcc1aa00 100644 --- a/make-pdf/src/print-css.ts +++ b/make-pdf/src/print-css.ts @@ -122,15 +122,22 @@ function pageRules(size: string, margin: string, opts: PrintCssOptions): string // Landscape named page for promoted wide diagrams/images (image-policy). // Chromium-only — exactly the engine this pipeline always prints with. // Honored only when the print call passes preferCSSPageSize (orchestrator - // sets it when a promotion exists). + // sets it when a promotion exists). The block is flex-centered: a diagram + // alone on a rotated page should sit in the middle, not hug the header. `@page wide {`, ` size: ${size} landscape;`, ` margin: ${margin};`, `}`, + // No explicit break-before/after (the page-name CHANGE already forces a + // break on both sides) and NO height/flex centering: a flex .page-wide + // with min-height fragments into a phantom empty landscape page in + // Chromium (landscape-gate counted 5 pages for 3 promotions; bisected to + // min-height at any value). Vertical centering is done by image-policy + // instead — it knows each promoted block's aspect ratio and emits an + // inline margin-top, which fragmentation handles fine. `.page-wide {`, ` page: wide;`, - ` break-before: page;`, - ` break-after: page;`, + ` text-align: center;`, `}`, `.page-wide img, .page-wide svg { width: 100%; height: auto; max-width: none; }`, `.page-wide figure.diagram > svg { max-width: none; }`, @@ -157,13 +164,23 @@ export function screenCss(): string { function rootTypography(): string { return [ `html { lang: en; }`, + // Zero image truncation, ever: every image caps at the content box, + // whatever element it lives in. Markdown images render as

(no + // figure), so a figure-scoped cap alone lets a 1900px screenshot run off + // the page edge. .page-wide deliberately overrides to fill its landscape + // box — still bounded, never clipped. + `img { max-width: 100%; height: auto; }`, `body {`, ` font-family: ${SANS_STACK}, ${CJK_STACK}, ${EMOJI_FAMILIES}, sans-serif;`, - ` font-size: 11pt;`, + ` font-size: 12pt;`, ` line-height: 1.5;`, ` color: #111;`, ` background: white;`, - ` hyphens: auto;`, + // No auto-hyphenation: it puts real "dif-\nferent" breaks into the PDF + // text layer, and clean copy-paste is the product contract (the + // combined-gate caught this the moment 12pt body made lines wrap). + // Left-aligned rag doesn't need hyphenation. + ` hyphens: manual;`, ` font-variant-ligatures: common-ligatures;`, ` font-kerning: normal;`, ` text-rendering: geometricPrecision;`, @@ -176,45 +193,47 @@ function rootTypography(): string { function coverRules(enabled: boolean): string { if (!enabled) return ""; return [ + // Poster scale: the cover is the one page where type should feel huge. `.cover {`, ` page: first;`, ` page-break-after: always;`, ` break-after: page;`, ` text-align: left;`, + ` padding-top: 1.4in;`, `}`, `.cover .eyebrow {`, - ` font-size: 9pt;`, + ` font-size: 11pt;`, ` 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-size: 56pt;`, + ` line-height: 1.08;`, ` font-weight: 700;`, - ` letter-spacing: -0.01em;`, - ` margin: 0 0 18pt;`, - ` max-width: 5.5in;`, + ` letter-spacing: -0.02em;`, + ` margin: 0 0 24pt;`, + ` max-width: 6in;`, ` text-align: left;`, `}`, `.cover .cover-subtitle {`, - ` font-size: 14pt;`, - ` line-height: 1.4;`, + ` font-size: 18pt;`, + ` line-height: 1.35;`, ` font-weight: 400;`, ` color: #333;`, ` margin: 0 0 36pt;`, - ` max-width: 5in;`, + ` max-width: 5.5in;`, ` text-align: left;`, `}`, `.cover hr.rule {`, ` width: 2.5in;`, ` height: 0;`, ` border: 0;`, - ` border-top: 1px solid #111;`, - ` margin: 0 0 18pt 0;`, + ` border-top: 1.5px solid #111;`, + ` margin: 0 0 24pt 0;`, `}`, - `.cover .cover-meta { font-size: 10pt; line-height: 1.6; color: #333; text-align: left; }`, + `.cover .cover-meta { font-size: 13pt; line-height: 1.6; color: #333; text-align: left; }`, `.cover .cover-meta strong { font-weight: 700; }`, ].join("\n"); } @@ -224,12 +243,12 @@ function tocRules(enabled: boolean): string { return [ `.toc { page-break-after: always; break-after: page; }`, `.toc h2 {`, - ` font-size: 13pt;`, + ` font-size: 16pt;`, ` text-transform: uppercase;`, ` letter-spacing: 0.15em;`, - ` color: #666;`, - ` font-weight: 600;`, - ` margin: 0 0 0.5in;`, + ` color: #444;`, + ` font-weight: 700;`, + ` margin: 0 0 0.4in;`, `}`, `.toc ol {`, ` list-style: none;`, @@ -240,14 +259,14 @@ function tocRules(enabled: boolean): string { ` display: flex;`, ` align-items: baseline;`, ` gap: 0.25in;`, - ` font-size: 11pt;`, - ` line-height: 2;`, - ` padding: 4pt 0;`, + ` font-size: 12pt;`, + ` line-height: 1.7;`, + ` padding: 3pt 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.level-2 { padding-left: 0.35in; font-size: 11pt; }`, `.toc li a { color: inherit; text-decoration: none; }`, ].join("\n"); } @@ -262,7 +281,7 @@ function chapterRules(noChapterBreaks: boolean): string { return [ breakRule, `h1 {`, - ` font-size: 22pt;`, + ` font-size: 26pt;`, ` line-height: 1.2;`, ` font-weight: 700;`, ` letter-spacing: -0.01em;`, @@ -270,9 +289,9 @@ function chapterRules(noChapterBreaks: boolean): string { ` 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; }`, + `h2 { font-size: 18pt; line-height: 1.3; font-weight: 700; margin: 26pt 0 8pt; break-after: avoid; page-break-after: avoid; }`, + `h3 { font-size: 13.5pt; line-height: 1.4; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #333; margin: 20pt 0 5pt; break-after: avoid; page-break-after: avoid; }`, + `h4 { font-size: 12pt; font-weight: 700; margin: 14pt 0 5pt; break-after: avoid; page-break-after: avoid; }`, ].join("\n"); } @@ -287,7 +306,7 @@ function blockRules(): string { ` orphans: 3;`, `}`, `p:first-child { margin-top: 0; }`, - `p.lead { font-size: 13pt; line-height: 1.45; color: #222; margin: 0 0 18pt; }`, + `p.lead { font-size: 14pt; line-height: 1.45; color: #222; margin: 0 0 18pt; }`, ].join("\n"); } @@ -308,7 +327,7 @@ function codeRules(): string { return [ `code {`, ` font-family: "SF Mono", Menlo, Consolas, monospace;`, - ` font-size: 9.5pt;`, + ` font-size: 10.5pt;`, ` background: #f4f4f4;`, ` padding: 1pt 3pt;`, ` border-radius: 2pt;`, @@ -316,7 +335,7 @@ function codeRules(): string { `}`, `pre {`, ` font-family: "SF Mono", Menlo, Consolas, monospace;`, - ` font-size: 9pt;`, + ` font-size: 10pt;`, ` line-height: 1.4;`, ` background: #f7f7f5;`, ` padding: 10pt 12pt;`, @@ -356,7 +375,7 @@ 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; }`, + `figcaption { font-size: 10pt; color: #666; margin-top: 6pt; font-style: italic; }`, // Diagram figures (diagram-prepass): rendered mermaid/excalidraw SVG. // SVGs scale to the content box and never split across pages. `figure.diagram { break-inside: avoid; text-align: center; }`, @@ -374,7 +393,7 @@ function figureRules(): string { function tableRules(): string { return [ - `table { width: 100%; border-collapse: collapse; margin: 12pt 0; font-size: 10pt; }`, + `table { width: 100%; border-collapse: collapse; margin: 12pt 0; font-size: 11pt; }`, `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");