mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-17 07:10:12 +02:00
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:
@@ -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;`,
|
||||
|
||||
@@ -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 ───────────────
|
||||
|
||||
Reference in New Issue
Block a user