print-css.ts
452 lines · make-pdf/src/print-css.ts at 3f79d81
1/**
2 * Print stylesheet generator.
3 *
4 * Source of truth: .context/designs/make-pdf-print-reference.html and siblings.
5 * Mirror those CSS rules here. The HTML references were approved via
6 * /plan-design-review with explicit design decisions locked in the plan:
7 *
8 * - Helvetica first, with Liberation Sans as a metric-compatible Linux
9 * fallback (Helvetica and Arial aren't installed on most Linux distros;
10 * Liberation Sans ships via the fonts-liberation package). No bundled
11 * webfonts — dodges the per-glyph Tj bug that
12 * breaks copy-paste extraction.
13 * - All paragraphs flush-left. No first-line indent, no justify, no
14 * p+p indent. text-align: left everywhere. 12pt margin-bottom.
15 * - Cover page (v1.58.0.0 poster revision, user-directed): 56pt title,
16 * 13pt meta, padding-top 1.4in for poster placement. Still no flexbox
17 * and no vertical centering; the inset is a deliberate top-third drop.
18 * (Supersedes the original "no inset padding" lock from the first
19 * /plan-design-review — the 32pt cover read as too small in print.)
20 * - `@page :first` suppresses running header/footer but does NOT override
21 * the 1in margin.
22 * - No <link>, no external CSS/fonts — everything inlined.
23 * - CJK fallback: Helvetica, Liberation Sans, Arial, Hiragino Kaku Gothic
24 * ProN, Noto Sans CJK JP, Microsoft YaHei, sans-serif.
25 * - Emoji fallback: the body and @top-center running-header stacks end in an
26 * emoji family group ("Apple Color Emoji", "Segoe UI Emoji", "Noto Color
27 * Emoji"), placed BEFORE the generic `sans-serif` so Chromium has a glyph
28 * source for emoji code points instead of emitting .notdef tofu (▯). The
29 * @bottom-* margin boxes hold only counters / a fixed "CONFIDENTIAL"
30 * string, so they get no emoji families. On Linux this requires an
31 * installed color-emoji font — `setup` installs fonts-noto-color-emoji.
32 *
33 * Font stacks are composed from the constants below so each family list has a
34 * single source of truth (DRY) and every stack stays in sync.
35 */
37// Metric-compatible sans stack: Helvetica (macOS), Liberation Sans (Linux,
38// ships via fonts-liberation), Arial (Windows). Shared by every text surface.
39const SANS_STACK = `Helvetica, "Liberation Sans", Arial`;
40// CJK fallback families (Simplified-Chinese first), appended to the body stack only.
41const CJK_STACK = `"PingFang SC", "Heiti SC", "Noto Sans CJK SC", "Source Han Sans SC", "Microsoft YaHei", "Hiragino Kaku Gothic ProN", "Noto Sans CJK JP"`;
42// Color-emoji families: Apple (macOS), Segoe (Windows), Noto (Linux).
43const EMOJI_FAMILIES = `"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji"`;
45export interface PrintCssOptions {
46 // Document structure
47 cover?: boolean;
48 toc?: boolean;
49 noChapterBreaks?: boolean;
51 // Branding
52 watermark?: string;
53 confidential?: boolean;
55 // Header (running title, top of page)
56 runningHeader?: string;
58 // Page size (in CSS `@page size:` terms)
59 pageSize?: "letter" | "a4" | "legal" | "tabloid";
61 // Margins (default 1in)
62 margins?: string;
64 // Whether to render "N of M" page numbers in the @page @bottom-center rule.
65 // Default true. Set false to suppress CSS numbering (used when the caller
66 // supplies a custom Chromium footerTemplate, or when --no-page-numbers).
67 pageNumbers?: boolean;
68}
70/**
71 * Produce a CSS block (no <style> wrapper) for inline injection.
72 */
73export function printCss(opts: PrintCssOptions = {}): string {
74 const size = opts.pageSize ?? "letter";
75 const margin = opts.margins ?? "1in";
76 const hasWatermark = typeof opts.watermark === "string" && opts.watermark.length > 0;
78 return [
79 pageRules(size, margin, opts),
80 rootTypography(),
81 coverRules(opts.cover === true),
82 tocRules(opts.toc === true),
83 chapterRules(opts.noChapterBreaks === true),
84 blockRules(),
85 inlineRules(),
86 codeRules(),
87 quoteRules(),
88 figureRules(),
89 tableRules(),
90 listRules(),
91 footnoteRules(),
92 hasWatermark ? watermarkRules() : "",
93 breakAvoidRules(),
94 ].filter(Boolean).join("\n\n");
95}
97function pageRules(size: string, margin: string, opts: PrintCssOptions): string {
98 const runningHeader = escapeCssString(opts.runningHeader ?? "");
99 const showConfidential = opts.confidential !== false;
100 const showPageNumbers = opts.pageNumbers !== false;
102 return [
103 `@page {`,
104 ` size: ${size};`,
105 ` margin: ${margin};`,
106 runningHeader
107 ? ` @top-center { content: "${runningHeader}"; font-family: ${SANS_STACK}, ${EMOJI_FAMILIES}, sans-serif; font-size: 9pt; color: #666; }`
108 : ``,
109 showPageNumbers
110 ? ` @bottom-center { content: counter(page) " of " counter(pages); font-family: ${SANS_STACK}, sans-serif; font-size: 9pt; color: #666; }`
111 : ``,
112 showConfidential
113 ? ` @bottom-right { content: "CONFIDENTIAL"; font-family: ${SANS_STACK}, sans-serif; font-size: 8pt; color: #aaa; letter-spacing: 0.05em; }`
114 : ``,
115 `}`,
116 ``,
117 // Cover page: suppress running header/footer but keep margins.
118 `@page :first {`,
119 ` @top-center { content: none; }`,
120 ` @bottom-center { content: none; }`,
121 ` @bottom-right { content: none; }`,
122 `}`,
123 ``,
124 // Landscape named page for promoted wide diagrams/images (image-policy).
125 // Chromium-only — exactly the engine this pipeline always prints with.
126 // Honored only when the print call passes preferCSSPageSize (orchestrator
127 // sets it when a promotion exists). Vertical centering is NOT done here —
128 // image-policy emits a computed inline margin-top instead (see the
129 // .page-wide comment below for why).
130 `@page wide {`,
131 ` size: ${size} landscape;`,
132 ` margin: ${margin};`,
133 `}`,
134 // No explicit break-before/after (the page-name CHANGE already forces a
135 // break on both sides) and NO height/flex centering: a flex .page-wide
136 // with min-height fragments into a phantom empty landscape page in
137 // Chromium (landscape-gate counted 5 pages for 3 promotions; bisected to
138 // min-height at any value). Vertical centering is done by image-policy
139 // instead — it knows each promoted block's aspect ratio and emits an
140 // inline margin-top, which fragmentation handles fine.
141 `.page-wide {`,
142 ` page: wide;`,
143 ` text-align: center;`,
144 `}`,
145 // width: 100% stretch is intentional for promoted content: auto-promoted
146 // rasters are >=~1600px (≈190dpi at the 9in landscape box — prints fine),
147 // and a directive-forced small image is the user's explicit call.
148 `.page-wide img, .page-wide svg { width: 100%; height: auto; max-width: none; }`,
149 `.page-wide figure.diagram > svg { max-width: none; }`,
150 ].filter(line => line !== "").join("\n");
151}
153/**
154 * Screen layer appended for `--to html` exports. The print CSS stays the
155 * source of truth; this only makes the same document readable in a browser
156 * (centered measure, padding, no print-only chapter breaks forcing scroll
157 * gaps). Print output is unaffected — media-scoped.
158 */
159export function screenCss(): string {
160 return [
161 `@media screen {`,
162 // ~42em at 12pt ≈ 70-75 characters per line — the readable ceiling.
163 ` body { max-width: 42em; margin: 0 auto; padding: 2.5em 1.5em; }`,
164 ` .chapter { break-before: auto; }`,
165 ` .watermark { display: none; }`,
166 ` figure.diagram { overflow-x: auto; }`,
167 // Page numbers only exist in print; hide the empty spans + dot leaders.
168 ` .toc li .toc-page, .toc li .toc-dots { display: none; }`,
169 `}`,
170 ].join("\n");
171}
173function rootTypography(): string {
174 return [
175 `html { lang: en; }`,
176 // Zero image truncation, ever: every image caps at the content box,
177 // whatever element it lives in. Markdown images render as <p><img> (no
178 // figure), so a figure-scoped cap alone lets a 1900px screenshot run off
179 // the page edge. .page-wide deliberately overrides to fill its landscape
180 // box — still bounded, never clipped.
181 `img { max-width: 100%; height: auto; }`,
182 `body {`,
183 ` font-family: ${SANS_STACK}, ${CJK_STACK}, ${EMOJI_FAMILIES}, sans-serif;`,
184 ` font-size: 12pt;`,
185 ` line-height: 1.5;`,
186 ` color: #111;`,
187 ` background: white;`,
188 // No auto-hyphenation: it puts real "dif-\nferent" breaks into the PDF
189 // text layer, and clean copy-paste is the product contract (the
190 // combined-gate caught this the moment 12pt body made lines wrap).
191 // Left-aligned rag doesn't need hyphenation.
192 ` hyphens: manual;`,
193 ` font-variant-ligatures: common-ligatures;`,
194 ` font-kerning: normal;`,
195 ` text-rendering: geometricPrecision;`,
196 ` margin: 0;`,
197 ` padding: 0;`,
198 `}`,
199 ].join("\n");
200}
202function coverRules(enabled: boolean): string {
203 if (!enabled) return "";
204 return [
205 // Poster scale: the cover is the one page where type should feel huge.
206 `.cover {`,
207 ` page: first;`,
208 ` page-break-after: always;`,
209 ` break-after: page;`,
210 ` text-align: left;`,
211 ` padding-top: 1.4in;`,
212 `}`,
213 `.cover .eyebrow {`,
214 ` font-size: 11pt;`,
215 ` letter-spacing: 0.2em;`,
216 ` text-transform: uppercase;`,
217 ` color: #666;`,
218 ` margin: 0 0 36pt;`,
219 `}`,
220 `.cover h1.cover-title {`,
221 ` font-size: 56pt;`,
222 ` line-height: 1.08;`,
223 ` font-weight: 700;`,
224 ` letter-spacing: -0.02em;`,
225 ` margin: 0 0 24pt;`,
226 ` max-width: 6in;`,
227 ` text-align: left;`,
228 `}`,
229 `.cover .cover-subtitle {`,
230 ` font-size: 18pt;`,
231 ` line-height: 1.35;`,
232 ` font-weight: 400;`,
233 ` color: #333;`,
234 ` margin: 0 0 36pt;`,
235 ` max-width: 5.5in;`,
236 ` text-align: left;`,
237 `}`,
238 `.cover hr.rule {`,
239 ` width: 2.5in;`,
240 ` height: 0;`,
241 ` border: 0;`,
242 ` border-top: 1.5px solid #111;`,
243 ` margin: 0 0 24pt 0;`,
244 `}`,
245 `.cover .cover-meta { font-size: 13pt; line-height: 1.6; color: #333; text-align: left; }`,
246 `.cover .cover-meta strong { font-weight: 700; }`,
247 ].join("\n");
248}
250function tocRules(enabled: boolean): string {
251 if (!enabled) return "";
252 return [
253 `.toc { page-break-after: always; break-after: page; }`,
254 `.toc h2 {`,
255 ` font-size: 16pt;`,
256 ` text-transform: uppercase;`,
257 ` letter-spacing: 0.15em;`,
258 ` color: #444;`,
259 ` font-weight: 700;`,
260 ` margin: 0 0 0.4in;`,
261 `}`,
262 `.toc ol {`,
263 ` list-style: none;`,
264 ` padding: 0;`,
265 ` margin: 0;`,
266 `}`,
267 `.toc li {`,
268 ` display: flex;`,
269 ` align-items: baseline;`,
270 ` gap: 0.25in;`,
271 ` font-size: 12pt;`,
272 ` line-height: 1.7;`,
273 ` padding: 3pt 0;`,
274 `}`,
275 `.toc li .toc-title { flex: 0 0 auto; }`,
276 `.toc li .toc-dots { flex: 1 1 auto; border-bottom: 1px dotted #aaa; margin: 0 6pt; transform: translateY(-4pt); }`,
277 `.toc li .toc-page { flex: 0 0 auto; color: #666; font-variant-numeric: tabular-nums; }`,
278 `.toc li.level-2 { padding-left: 0.35in; font-size: 11pt; }`,
279 `.toc li a { color: inherit; text-decoration: none; }`,
280 ].join("\n");
281}
283function chapterRules(noChapterBreaks: boolean): string {
284 const breakRule = noChapterBreaks
285 ? `/* chapter breaks disabled */`
286 : [
287 `.chapter { break-before: page; page-break-before: always; }`,
288 `.chapter:first-of-type { break-before: auto; page-break-before: auto; }`,
289 ].join("\n");
290 return [
291 breakRule,
292 `h1 {`,
293 ` font-size: 26pt;`,
294 ` line-height: 1.2;`,
295 ` font-weight: 700;`,
296 ` letter-spacing: -0.01em;`,
297 ` margin: 0 0 0.25in;`,
298 ` break-after: avoid;`,
299 ` page-break-after: avoid;`,
300 `}`,
301 `h2 { font-size: 18pt; line-height: 1.3; font-weight: 700; margin: 26pt 0 8pt; break-after: avoid; page-break-after: avoid; }`,
302 `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; }`,
303 `h4 { font-size: 12pt; font-weight: 700; margin: 14pt 0 5pt; break-after: avoid; page-break-after: avoid; }`,
304 ].join("\n");
305}
307function blockRules(): string {
308 // Flush-left paragraphs, no indent, 12pt gap. No justify.
309 // Rule from the plan's "Body paragraph rule (post-review fix)".
310 return [
311 `p {`,
312 ` margin: 0 0 12pt;`,
313 ` text-align: left;`,
314 ` widows: 3;`,
315 ` orphans: 3;`,
316 `}`,
317 `p:first-child { margin-top: 0; }`,
318 `p.lead { font-size: 14pt; line-height: 1.45; color: #222; margin: 0 0 18pt; }`,
319 ].join("\n");
320}
322function inlineRules(): string {
323 return [
324 `a {`,
325 ` color: #0055cc;`,
326 ` text-decoration: underline;`,
327 ` text-decoration-thickness: 0.5pt;`,
328 ` text-underline-offset: 1.5pt;`,
329 `}`,
330 `strong { font-weight: 700; }`,
331 `em { font-style: italic; }`,
332 ].join("\n");
333}
335function codeRules(): string {
336 return [
337 `code {`,
338 ` font-family: "SF Mono", Menlo, Consolas, monospace;`,
339 ` font-size: 10.5pt;`,
340 ` background: #f4f4f4;`,
341 ` padding: 1pt 3pt;`,
342 ` border-radius: 2pt;`,
343 ` border: 0.5pt solid #e4e4e4;`,
344 `}`,
345 `pre {`,
346 ` font-family: "SF Mono", Menlo, Consolas, monospace;`,
347 ` font-size: 10pt;`,
348 ` line-height: 1.4;`,
349 ` background: #f7f7f5;`,
350 ` padding: 10pt 12pt;`,
351 ` border: 0.5pt solid #e0e0e0;`,
352 ` border-radius: 3pt;`,
353 ` margin: 12pt 0;`,
354 ` overflow: hidden;`,
355 ` white-space: pre-wrap;`,
356 `}`,
357 `pre code { background: none; border: 0; padding: 0; font-size: inherit; }`,
358 // highlight.js minimal palette (kept neutral, prints well)
359 `.hljs-keyword { color: #8b0000; font-weight: 500; }`,
360 `.hljs-string { color: #0d6608; }`,
361 `.hljs-comment { color: #888; font-style: italic; }`,
362 `.hljs-function, .hljs-title { color: #0044aa; }`,
363 `.hljs-number { color: #a64d00; }`,
364 ].join("\n");
365}
367function quoteRules(): string {
368 return [
369 `blockquote {`,
370 ` margin: 12pt 0;`,
371 ` padding: 0 0 0 18pt;`,
372 ` border-left: 2pt solid #111;`,
373 ` color: #333;`,
374 ` font-size: 12pt;`,
375 ` line-height: 1.5;`,
376 `}`,
377 `blockquote p { margin-bottom: 6pt; text-align: left; }`,
378 `blockquote cite { display: block; margin-top: 6pt; font-style: normal; font-size: 10pt; color: #666; letter-spacing: 0.02em; }`,
379 `blockquote cite::before { content: "— "; }`,
380 ].join("\n");
381}
383function figureRules(): string {
384 return [
385 `figure { margin: 12pt 0; }`,
386 `figure img { display: block; max-width: 100%; height: auto; }`,
387 `figcaption { font-size: 10pt; color: #666; margin-top: 6pt; font-style: italic; }`,
388 // Diagram figures (diagram-prepass): rendered mermaid/excalidraw SVG.
389 // SVGs scale to the content box and never split across pages.
390 `figure.diagram { break-inside: avoid; text-align: center; }`,
391 `figure.diagram > svg { max-width: 100%; height: auto; }`,
392 `figure.diagram .diagram-caption { text-align: center; }`,
393 // Diagnostic block for a fence that failed to render — loud, boxed,
394 // unmistakably an error (never silent raw code).
395 `figure.diagram-error { border: 1.5pt solid #b00020; padding: 8pt 10pt; text-align: left; }`,
396 `figure.diagram-error .diagram-error-title { font-weight: 700; color: #b00020; font-style: normal; margin: 0 0 6pt; }`,
397 `figure.diagram-error .diagram-error-detail { font-size: 8.5pt; white-space: pre-wrap; margin: 0; }`,
398 // Missing local image placeholder (non-strict mode).
399 `.image-missing { display: inline-block; border: 1pt dashed #b00020; color: #b00020; padding: 4pt 8pt; font-size: 9pt; }`,
400 ].join("\n");
401}
403function tableRules(): string {
404 return [
405 `table { width: 100%; border-collapse: collapse; margin: 12pt 0; font-size: 11pt; }`,
406 `th, td { border-bottom: 0.5pt solid #ccc; padding: 5pt 8pt; text-align: left; vertical-align: top; }`,
407 `th { font-weight: 700; border-bottom: 1pt solid #111; background: transparent; }`,
408 ].join("\n");
409}
411function listRules(): string {
412 return [
413 `ul, ol { margin: 0 0 12pt 0; padding-left: 20pt; }`,
414 `li { margin-bottom: 3pt; line-height: 1.45; }`,
415 `li > ul, li > ol { margin-top: 3pt; margin-bottom: 0; }`,
416 ].join("\n");
417}
419function footnoteRules(): string {
420 return [
421 `.footnote-ref { font-size: 0.75em; vertical-align: super; line-height: 0; text-decoration: none; color: #0055cc; }`,
422 `.footnotes { margin-top: 24pt; padding-top: 12pt; border-top: 0.5pt solid #ccc; font-size: 10pt; line-height: 1.4; }`,
423 `.footnotes ol { padding-left: 18pt; }`,
424 ].join("\n");
425}
427function watermarkRules(): string {
428 return [
429 `.watermark {`,
430 ` position: fixed;`,
431 ` top: 50%;`,
432 ` left: 50%;`,
433 ` transform: translate(-50%, -50%) rotate(-30deg);`,
434 ` font-size: 140pt;`,
435 ` font-weight: 700;`,
436 ` color: rgba(200, 0, 0, 0.06);`,
437 ` letter-spacing: 0.08em;`,
438 ` pointer-events: none;`,
439 ` z-index: 9999;`,
440 ` user-select: none;`,
441 ` white-space: nowrap;`,
442 `}`,
443 ].join("\n");
444}
446function breakAvoidRules(): string {
447 return `blockquote, pre, code, table, figure, li, .keep-together { break-inside: avoid; page-break-inside: avoid; }`;
448}
450function escapeCssString(s: string): string {
451 return s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
452}