fix(make-pdf): emoji font fallback in print CSS

Emoji code points rendered as .notdef tofu (▯) because the body and
@top-center font stacks had no emoji family for Chromium to fall back to.
Add SANS_STACK / CJK_STACK / EMOJI_FAMILIES constants (one source of truth
per family list) and append the emoji families before the generic
sans-serif in the two stacks that can hold emoji. The @bottom-* boxes hold
counters / a fixed CONFIDENTIAL string, so they share SANS_STACK without
emoji. Non-emoji output is byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-29 07:04:36 -07:00
parent 19770ea8b4
commit cc57d9d62c
2 changed files with 62 additions and 4 deletions
+22 -4
View File
@@ -20,8 +20,26 @@
* - 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.
* - 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;`,
+40
View File
@@ -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 ───────────────