diff --git a/make-pdf/src/print-css.ts b/make-pdf/src/print-css.ts index 14d78bd5a..2366f42b9 100644 --- a/make-pdf/src/print-css.ts +++ b/make-pdf/src/print-css.ts @@ -20,8 +20,26 @@ * - No , no external CSS/fonts — everything inlined. * - CJK fallback: Helvetica, Liberation Sans, Arial, Hiragino Kaku Gothic * ProN, Noto Sans CJK JP, Microsoft YaHei, sans-serif. + * - Emoji fallback: the body and @top-center running-header stacks end in an + * emoji family group ("Apple Color Emoji", "Segoe UI Emoji", "Noto Color + * Emoji"), placed BEFORE the generic `sans-serif` so Chromium has a glyph + * source for emoji code points instead of emitting .notdef tofu (▯). The + * @bottom-* margin boxes hold only counters / a fixed "CONFIDENTIAL" + * string, so they get no emoji families. On Linux this requires an + * installed color-emoji font — `setup` installs fonts-noto-color-emoji. + * + * Font stacks are composed from the constants below so each family list has a + * single source of truth (DRY) and every stack stays in sync. */ +// Metric-compatible sans stack: Helvetica (macOS), Liberation Sans (Linux, +// ships via fonts-liberation), Arial (Windows). Shared by every text surface. +const SANS_STACK = `Helvetica, "Liberation Sans", Arial`; +// CJK fallback families, appended to the body stack only. +const CJK_STACK = `"Hiragino Kaku Gothic ProN", "Noto Sans CJK JP", "Microsoft YaHei"`; +// Color-emoji families: Apple (macOS), Segoe (Windows), Noto (Linux). +const EMOJI_FAMILIES = `"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji"`; + export interface PrintCssOptions { // Document structure cover?: boolean; @@ -84,13 +102,13 @@ function pageRules(size: string, margin: string, opts: PrintCssOptions): string ` size: ${size};`, ` margin: ${margin};`, runningHeader - ? ` @top-center { content: "${runningHeader}"; font-family: Helvetica, "Liberation Sans", Arial, sans-serif; font-size: 9pt; color: #666; }` + ? ` @top-center { content: "${runningHeader}"; font-family: ${SANS_STACK}, ${EMOJI_FAMILIES}, 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; }` + ? ` @bottom-center { content: counter(page) " of " counter(pages); font-family: ${SANS_STACK}, 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; }` + ? ` @bottom-right { content: "CONFIDENTIAL"; font-family: ${SANS_STACK}, sans-serif; font-size: 8pt; color: #aaa; letter-spacing: 0.05em; }` : ``, `}`, ``, @@ -107,7 +125,7 @@ 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-family: ${SANS_STACK}, ${CJK_STACK}, ${EMOJI_FAMILIES}, sans-serif;`, ` font-size: 11pt;`, ` line-height: 1.5;`, ` color: #111;`, diff --git a/make-pdf/test/render.test.ts b/make-pdf/test/render.test.ts index a61dea504..413de1f98 100644 --- a/make-pdf/test/render.test.ts +++ b/make-pdf/test/render.test.ts @@ -343,6 +343,46 @@ describe("printCss", () => { const occurrences = (css.match(/"Liberation Sans"/g) ?? []).length; expect(occurrences).toBeGreaterThanOrEqual(4); }); + + // ─── emoji fallback (fix/make-pdf-emoji-tofu) ──────────────── + // Body + @top-center running header get the color-emoji families so + // Chromium has a glyph source for emoji code points instead of tofu (▯). + // The @bottom-* boxes hold counters / "CONFIDENTIAL" only — no emoji. + + test("body stack includes all three emoji families before sans-serif", () => { + const css = printCss(); + expect(css).toContain(`"Apple Color Emoji"`); + expect(css).toContain(`"Segoe UI Emoji"`); + expect(css).toContain(`"Noto Color Emoji"`); + // Emoji families must precede the generic family so per-character fallback + // reaches them before terminating at sans-serif. + expect(css).toMatch(/"Noto Color Emoji",\s*sans-serif/); + }); + + test("@top-center running header includes emoji families", () => { + const css = printCss({ runningHeader: "Q3 Report 🚀" }); + const topCenter = css.match(/@top-center\s*\{[^}]*\}/)?.[0] ?? ""; + expect(topCenter).toContain(`"Apple Color Emoji"`); + expect(topCenter).toContain(`"Noto Color Emoji"`); + }); + + test("@bottom-center and @bottom-right do NOT include emoji families", () => { + const css = printCss({ confidential: true }); + const bottomCenter = css.match(/@bottom-center\s*\{[^}]*\}/)?.[0] ?? ""; + const bottomRight = css.match(/@bottom-right\s*\{[^}]*\}/)?.[0] ?? ""; + expect(bottomCenter).not.toContain("Emoji"); + expect(bottomRight).not.toContain("Emoji"); + // ...but they still share the sans stack via the SANS_STACK constant. + expect(bottomCenter).toContain(`"Liberation Sans"`); + expect(bottomRight).toContain(`"Liberation Sans"`); + }); + + test("emoji families appear in exactly the two emoji-bearing stacks", () => { + const css = printCss({ runningHeader: "Title", confidential: true }); + // body (1) + @top-center (1) = 2 occurrences of the emoji group. + const occurrences = (css.match(/"Apple Color Emoji"/g) ?? []).length; + expect(occurrences).toBe(2); + }); }); // ─── render() — pageNumbers / footerTemplate data flow ───────────────