Skip to content

Commit 62024d1

Browse files
garrytanclaude
andauthored
v1.52.2.0 fix(make-pdf): render emoji instead of tofu (▯) on Linux (garrytan#1787)
* 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> * feat(setup): auto-install color-emoji font on Linux macOS and Windows ship a color-emoji font; most Linux distros/containers ship none, so make-pdf emits tofu there. ensure_emoji_font() best-effort installs fonts-noto-color-emoji (apt, with dnf/pacman/apk fallbacks) and refreshes the fontconfig cache. Hardened: Linux-only guard, GSTACK_SKIP_FONTS escape hatch, fc-match color=True detection (the broad fc-list query false-matched LastResort), sudo -n so a password prompt fails fast instead of hanging, DEBIAN_FRONTEND=noninteractive, timeout 30 on apt update, and fc-cache under sudo. Warns instead of failing. After a fresh install, refresh_browse_daemon_for_fonts() runs 'browse stop' so the next render spawns a Chromium that sees the new font (font fallback is process-cached). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(make-pdf): emoji render gate (pdffonts + pixel proof) pdftotext is a false oracle for emoji: Skia preserves the Unicode in the text cluster even when the glyph drew as .notdef tofu, so extraction passes on a broken render. The gate instead asserts (1) pdffonts shows an emoji family embedded and (2) pdftoppm rasterizes the page to color (measured ~1650 saturated pixels vs ~0 for tofu). pdfimages is not used: macOS embeds color emoji as Type 3 fonts, so it lists nothing even on a correct render. Adds resolvePopplerTool() (DRY resolver, returns null for clean skips) and a fixture exercising FE0F variation-selector emoji. Skips cleanly when poppler tools or a color-emoji font are unavailable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(make-pdf): install emoji font + run emoji gate on Ubuntu Install fonts-noto-color-emoji before Chromium launches on the Ubuntu leg (macOS already ships Apple Color Emoji), refresh fontconfig, and log the fc-match result. Run the whole make-pdf/test/e2e/ dir so the emoji gate runs alongside the combined-features copy-paste gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * harden(make-pdf): emoji gate + font install per adversarial review Codex adversarial pass on the implementation diff flagged five robustness gaps, all fixed here: - emoji-gate skipped green in CI when poppler/font prerequisites were absent, which could let the tofu regression ship behind a green build. Missing prerequisites are now a HARD FAILURE when process.env.CI is set; local dev still skips cleanly. - execFileSync children (make-pdf, pdffonts, pdftoppm, fc-match) had no timeout; a wedged binary or hostile GSTACK_*_BIN override could hang the job past Bun's test timeout. Each child now has a 25s ceiling. - PPM parser trusted header tokens blindly; malformed/variant output gave a silently-wrong count. Now validates magic/dimensions/maxval and pixel-buffer length, handles header comments, throws a hard diagnostic on mismatch. - predictable /tmp paths were collision/symlink-prone; now mkdtempSync under /tmp (kept under /tmp for browse's validateOutputPath allowlist). - only apt-get update was timeout-wrapped; dnf/pacman/apk installs and apt install can hang on locks/mirrors. All package installs now timeout-bound. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v1.52.2.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(make-pdf): document color-emoji font requirement + GSTACK_SKIP_FONTS Extend the Linux font note to cover the color-emoji font that make-pdf emoji rendering needs: setup auto-installs fonts-noto-color-emoji, the print CSS falls back through Apple/Segoe/Noto emoji families, and GSTACK_SKIP_FONTS=1 opts out. Edit the .tmpl and regenerate SKILL.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 070722a commit 62024d1

13 files changed

Lines changed: 625 additions & 9 deletions

File tree

.github/workflows/make-pdf-gate.yml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,15 @@ jobs:
5151
if: matrix.os == 'ubicloud-standard-8'
5252
run: sudo apt-get update && sudo apt-get install -y poppler-utils
5353

54+
# Install a color-emoji font BEFORE Chromium launches so the emoji render
55+
# gate has a fallback font. macOS ships Apple Color Emoji already.
56+
- name: Install color-emoji font (Ubuntu)
57+
if: matrix.os == 'ubicloud-standard-8'
58+
run: |
59+
sudo apt-get install -y fonts-noto-color-emoji
60+
fc-cache -f || true
61+
fc-match -f '%{family[0]}\t%{color}\n' ':lang=und-zsye:charset=1F600' || true
62+
5463
- name: Install Playwright Chromium
5564
run: bunx playwright install chromium
5665

@@ -74,7 +83,7 @@ jobs:
7483
- name: Run make-pdf unit tests
7584
run: bun test make-pdf/test/*.test.ts
7685

77-
- name: Run combined-features copy-paste gate (P0)
86+
- name: Run E2E gates (combined-features copy-paste + emoji render)
7887
env:
7988
BROWSE_BIN: ${{ github.workspace }}/browse/dist/browse
80-
run: bun test make-pdf/test/e2e/combined-gate.test.ts
89+
run: bun test make-pdf/test/e2e/

CHANGELOG.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,41 @@
11
# Changelog
22

3+
## [1.52.2.0] - 2026-05-29
4+
5+
## **Emoji render in make-pdf PDFs on every platform. Linux stops printing tofu boxes, and setup installs the font for you.**
6+
7+
make-pdf used to render emoji code points as `.notdef` tofu (▯) on Linux. The cause was a missing fallback: the print CSS font stacks had no emoji family, and most Linux distros and containers ship no color-emoji font at all, so Skia drew empty boxes in every header and table that used emoji. Now the body and running-header stacks fall back through Apple Color Emoji, Segoe UI Emoji, and Noto Color Emoji, and `./setup` best-effort installs `fonts-noto-color-emoji` on Linux (apt, with dnf/pacman/apk fallbacks), refreshes the font cache, and restarts a running browser daemon so the next render picks it up. macOS and Windows already shipped an emoji font and are unchanged. Non-emoji Unicode (em dash, times, arrow, bullet, ellipsis) always worked and still does.
8+
9+
## The numbers that matter
10+
11+
Source: the emoji render gate, `bun test make-pdf/test/e2e/emoji-gate.test.ts`, rendering a fixture of color emoji at 100 dpi.
12+
13+
| Metric | Before | After | Δ |
14+
|---|---|---|---|
15+
| Saturated (color) pixels in the rendered emoji region | ~0 (tofu) | ~1,650 | real color render |
16+
| Platforms that render emoji correctly | macOS, Windows | macOS, Windows, Linux | +Linux |
17+
| Emoji-bearing font stacks with a fallback family | 0 | 2 | body + running header |
18+
| Deterministic render-proof gates | 0 | 1 | pdffonts + pixel |
19+
20+
A tofu box is a near-monochrome outline (close to zero colored pixels). A real emoji render lands about 1,650 saturated pixels. The gate asserts both that an emoji font embedded (`pdffonts`) and that the page actually rasterizes to color (`pdftoppm`), because PDF text extraction passes even when the glyph drew as tofu, so it cannot be trusted as the proof.
21+
22+
## What this means for builders
23+
24+
If you generate PDFs on Linux or inside a container, emoji in section headers and table status columns now render instead of ▯. Run `./setup` once on Linux to install the font; there is nothing to do on macOS or Windows. Set `GSTACK_SKIP_FONTS=1` to opt out on locked-down or offline machines.
25+
26+
### Itemized changes
27+
28+
#### Added
29+
- `ensure_emoji_font()` in `setup`: Linux color-emoji install across apt/dnf/pacman/apk, `fc-match` color-font detection (idempotent, skips when a real color font already resolves), `fc-cache` refresh under sudo, and a browse-daemon restart so a running render server sees the new font. Opt out with `GSTACK_SKIP_FONTS=1`. Non-interactive `sudo -n` and timeout-bound package calls so it never hangs setup.
30+
- Emoji render gate (`make-pdf/test/e2e/emoji-gate.test.ts`) with a variation-selector (`❤️`, FE0F) fixture: asserts an emoji font embeds and the page rasterizes to color. Hard-fails in CI when poppler or the font is missing, so prerequisite drift can't hide a regression behind a green build.
31+
- `resolvePopplerTool()` resolver for `pdffonts` / `pdfimages` / `pdftoppm`.
32+
- The Ubuntu make-pdf CI gate installs `fonts-noto-color-emoji` before Chromium launches.
33+
34+
#### Changed
35+
- Print CSS body and `@top-center` running-header font stacks fall back through Apple Color Emoji, Segoe UI Emoji, and Noto Color Emoji, placed before the generic `sans-serif`. All font stacks are now composed from shared constants.
36+
37+
#### Fixed
38+
- make-pdf no longer renders emoji as `.notdef` tofu (▯) on Linux.
339
## [1.52.1.0] - 2026-05-27
440

541
## **Brain-aware planning lands. Five planning skills read structured context from any personal gbrain before asking — same questions, smarter answers, no token tax.**

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.52.1.0
1+
1.52.2.0

make-pdf/SKILL.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,13 @@ On Linux, install `fonts-liberation` for correct rendering — Helvetica and Ari
542542
aren't present by default, and Liberation Sans is the standard metric-compatible
543543
fallback. CI and Docker builds install it automatically via Dockerfile.ci.
544544

545+
Emoji need a color-emoji font. macOS (Apple Color Emoji) and Windows (Segoe UI
546+
Emoji) ship one; most Linux distros and containers ship none, so emoji render as
547+
empty boxes (▯). `./setup` auto-installs `fonts-noto-color-emoji` on Linux
548+
(apt/dnf/pacman/apk, best-effort) and the print CSS falls back through Apple /
549+
Segoe / Noto emoji families. Set `GSTACK_SKIP_FONTS=1` to skip the install (CI
550+
without sudo, managed or offline machines).
551+
545552
## Core patterns
546553

547554
### 80% case — memo/letter

make-pdf/SKILL.md.tmpl

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ On Linux, install `fonts-liberation` for correct rendering — Helvetica and Ari
4141
aren't present by default, and Liberation Sans is the standard metric-compatible
4242
fallback. CI and Docker builds install it automatically via Dockerfile.ci.
4343

44+
Emoji need a color-emoji font. macOS (Apple Color Emoji) and Windows (Segoe UI
45+
Emoji) ship one; most Linux distros and containers ship none, so emoji render as
46+
empty boxes (▯). `./setup` auto-installs `fonts-noto-color-emoji` on Linux
47+
(apt/dnf/pacman/apk, best-effort) and the print CSS falls back through Apple /
48+
Segoe / Noto emoji families. Set `GSTACK_SKIP_FONTS=1` to skip the install (CI
49+
without sudo, managed or offline machines).
50+
4451
## Core patterns
4552

4653
### 80% case — memo/letter

make-pdf/src/pdftotext.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,34 @@ export function resolvePdftotext(env: NodeJS.ProcessEnv = process.env): Pdftotex
114114
].join("\n"));
115115
}
116116

117+
/**
118+
* Locate a poppler companion tool (pdffonts, pdfimages, pdftoppm) used by the
119+
* emoji render gate. Mirrors resolvePdftotext's resolution order:
120+
* 1. $GSTACK_<TOOL>_BIN env override (e.g. GSTACK_PDFFONTS_BIN)
121+
* 2. PATH via Bun.which
122+
* 3. standard POSIX locations (Homebrew + distro)
123+
*
124+
* Returns null (does NOT throw) when the tool is missing — the emoji gate skips
125+
* cleanly rather than failing on a box without full poppler-utils.
126+
*/
127+
export function resolvePopplerTool(
128+
tool: "pdffonts" | "pdfimages" | "pdftoppm",
129+
env: NodeJS.ProcessEnv = process.env,
130+
): string | null {
131+
const override = resolveOverride(env[`GSTACK_${tool.toUpperCase()}_BIN`], env);
132+
if (override) return override;
133+
134+
const PATH = env.PATH ?? env.Path ?? "";
135+
const onPath = Bun.which(tool, { PATH });
136+
if (onPath) return onPath;
137+
138+
for (const dir of ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"]) {
139+
const candidate = findExecutable(path.join(dir, tool));
140+
if (candidate) return candidate;
141+
}
142+
return null;
143+
}
144+
117145
function isExecutable(p: string): boolean {
118146
try {
119147
fs.accessSync(p, fs.constants.X_OK);

make-pdf/src/print-css.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,26 @@
2020
* - No <link>, no external CSS/fonts — everything inlined.
2121
* - CJK fallback: Helvetica, Liberation Sans, Arial, Hiragino Kaku Gothic
2222
* ProN, Noto Sans CJK JP, Microsoft YaHei, sans-serif.
23+
* - Emoji fallback: the body and @top-center running-header stacks end in an
24+
* emoji family group ("Apple Color Emoji", "Segoe UI Emoji", "Noto Color
25+
* Emoji"), placed BEFORE the generic `sans-serif` so Chromium has a glyph
26+
* source for emoji code points instead of emitting .notdef tofu (▯). The
27+
* @bottom-* margin boxes hold only counters / a fixed "CONFIDENTIAL"
28+
* string, so they get no emoji families. On Linux this requires an
29+
* installed color-emoji font — `setup` installs fonts-noto-color-emoji.
30+
*
31+
* Font stacks are composed from the constants below so each family list has a
32+
* single source of truth (DRY) and every stack stays in sync.
2333
*/
2434

35+
// Metric-compatible sans stack: Helvetica (macOS), Liberation Sans (Linux,
36+
// ships via fonts-liberation), Arial (Windows). Shared by every text surface.
37+
const SANS_STACK = `Helvetica, "Liberation Sans", Arial`;
38+
// CJK fallback families, appended to the body stack only.
39+
const CJK_STACK = `"Hiragino Kaku Gothic ProN", "Noto Sans CJK JP", "Microsoft YaHei"`;
40+
// Color-emoji families: Apple (macOS), Segoe (Windows), Noto (Linux).
41+
const EMOJI_FAMILIES = `"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji"`;
42+
2543
export interface PrintCssOptions {
2644
// Document structure
2745
cover?: boolean;
@@ -84,13 +102,13 @@ function pageRules(size: string, margin: string, opts: PrintCssOptions): string
84102
` size: ${size};`,
85103
` margin: ${margin};`,
86104
runningHeader
87-
? ` @top-center { content: "${runningHeader}"; font-family: Helvetica, "Liberation Sans", Arial, sans-serif; font-size: 9pt; color: #666; }`
105+
? ` @top-center { content: "${runningHeader}"; font-family: ${SANS_STACK}, ${EMOJI_FAMILIES}, sans-serif; font-size: 9pt; color: #666; }`
88106
: ``,
89107
showPageNumbers
90-
? ` @bottom-center { content: counter(page) " of " counter(pages); font-family: Helvetica, "Liberation Sans", Arial, sans-serif; font-size: 9pt; color: #666; }`
108+
? ` @bottom-center { content: counter(page) " of " counter(pages); font-family: ${SANS_STACK}, sans-serif; font-size: 9pt; color: #666; }`
91109
: ``,
92110
showConfidential
93-
? ` @bottom-right { content: "CONFIDENTIAL"; font-family: Helvetica, "Liberation Sans", Arial, sans-serif; font-size: 8pt; color: #aaa; letter-spacing: 0.05em; }`
111+
? ` @bottom-right { content: "CONFIDENTIAL"; font-family: ${SANS_STACK}, sans-serif; font-size: 8pt; color: #aaa; letter-spacing: 0.05em; }`
94112
: ``,
95113
`}`,
96114
``,
@@ -107,7 +125,7 @@ function rootTypography(): string {
107125
return [
108126
`html { lang: en; }`,
109127
`body {`,
110-
` font-family: Helvetica, "Liberation Sans", Arial, "Hiragino Kaku Gothic ProN", "Noto Sans CJK JP", "Microsoft YaHei", sans-serif;`,
128+
` font-family: ${SANS_STACK}, ${CJK_STACK}, ${EMOJI_FAMILIES}, sans-serif;`,
111129
` font-size: 11pt;`,
112130
` line-height: 1.5;`,
113131
` color: #111;`,
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
/**
2+
* Emoji render gate — proves emoji code points render as real color glyphs in
3+
* the output PDF instead of .notdef tofu boxes (▯). This is the regression gate
4+
* for fix/make-pdf-emoji-tofu.
5+
*
6+
* Why not just check pdftotext? Because text extraction is a FALSE oracle for
7+
* emoji: Skia preserves the Unicode in the text cluster even when the displayed
8+
* glyph is .notdef, so pdftotext can report the emoji survived on a render that
9+
* actually drew tofu. Verified empirically on macOS — pdftotext extracts 😀
10+
* regardless of whether a color font was available.
11+
*
12+
* Two assertions that DO distinguish a real render from tofu:
13+
* 1. pdffonts shows an emoji family embedded in the PDF (the cascade selected
14+
* a real emoji font — AppleColorEmoji as Type 3 on macOS, NotoColorEmoji
15+
* on Linux). Missing-fallback => no emoji font embedded.
16+
* 2. pdftoppm rasterizes the page and we count saturated (colored) pixels.
17+
* A color-emoji render has hundreds (measured: ~1650 at 100dpi); a tofu
18+
* render is a monochrome black outline on white (~0 saturated). Tolerant
19+
* threshold, not an exact-pixel fixture diff, to dodge cross-platform AA
20+
* and font-version variance.
21+
*
22+
* Note: pdfimages -list is intentionally NOT used — macOS embeds color emoji as
23+
* Type 3 fonts, so pdfimages lists nothing even on a correct render.
24+
*
25+
* Gating: runs only when the compiled binary + browse + pdffonts + pdftoppm are
26+
* available AND a color-emoji font is installed for Chromium to fall back to.
27+
* In CI (process.env.CI set) missing prerequisites are a HARD FAILURE, not a
28+
* skip — CI is expected to install poppler-utils + fonts-noto-color-emoji, so a
29+
* silent skip there would let the tofu regression ship behind a green build.
30+
* Local dev without those tools skips cleanly.
31+
*/
32+
33+
import { describe, expect, test } from "bun:test";
34+
import { execFileSync } from "node:child_process";
35+
import * as fs from "node:fs";
36+
import * as path from "node:path";
37+
38+
import { resolvePopplerTool } from "../../src/pdftotext";
39+
40+
const FIXTURE = path.resolve(__dirname, "../fixtures/emoji-gate.md");
41+
const ROOT = path.resolve(__dirname, "../../..");
42+
const PDF_BIN = path.join(ROOT, "make-pdf/dist/pdf");
43+
const BROWSE_BIN = path.join(ROOT, "browse/dist/browse");
44+
45+
// Saturated-pixel floor. Measured ~1650 at 100dpi for the fixture's color
46+
// emoji; a tofu render yields ~0. 200 sits well clear of both.
47+
const SATURATED_PIXEL_FLOOR = 200;
48+
// A pixel is "colored" when its max-min channel spread exceeds this. Black text,
49+
// gray rules, and white background all stay near 0; color emoji spike high.
50+
const SATURATION_DELTA = 40;
51+
// Per-child wall-clock bound. Bun's test timeout doesn't reliably interrupt a
52+
// synchronous execFileSync, so each child gets its own ceiling — a wedged
53+
// browser/poppler binary (or a hostile GSTACK_*_BIN override) fails instead of
54+
// hanging the whole job.
55+
const CHILD_TIMEOUT_MS = 25_000;
56+
57+
/** Is a color-emoji font available for Chromium to fall back to? */
58+
function emojiFontAvailable(): boolean {
59+
if (process.platform === "darwin") {
60+
return fs.existsSync("/System/Library/Fonts/Apple Color Emoji.ttc");
61+
}
62+
if (process.platform === "linux") {
63+
const fcMatch = Bun.which("fc-match");
64+
if (!fcMatch) return false;
65+
try {
66+
const out = execFileSync(
67+
fcMatch,
68+
["-f", "%{color}\n", ":lang=und-zsye:charset=1F600"],
69+
{ encoding: "utf8", timeout: CHILD_TIMEOUT_MS },
70+
);
71+
return /true/i.test(out);
72+
} catch {
73+
return false;
74+
}
75+
}
76+
return false;
77+
}
78+
79+
function prerequisitesAvailable(): { ok: true } | { ok: false; reason: string } {
80+
if (!fs.existsSync(PDF_BIN)) return { ok: false, reason: `make-pdf binary missing (${PDF_BIN}). Run bun run build.` };
81+
if (!fs.existsSync(BROWSE_BIN)) return { ok: false, reason: `browse binary missing (${BROWSE_BIN}).` };
82+
if (!fs.existsSync(FIXTURE)) return { ok: false, reason: `fixture missing (${FIXTURE}).` };
83+
if (!resolvePopplerTool("pdffonts")) return { ok: false, reason: "pdffonts not found (install poppler-utils)." };
84+
if (!resolvePopplerTool("pdftoppm")) return { ok: false, reason: "pdftoppm not found (install poppler-utils)." };
85+
if (!emojiFontAvailable()) return { ok: false, reason: "no color-emoji font installed; run ./setup (Linux) or install one." };
86+
return { ok: true };
87+
}
88+
89+
/**
90+
* Count pixels in a P6 (binary) PPM whose RGB channel spread exceeds delta.
91+
* Validates the header and buffer length so malformed/variant output is a hard
92+
* diagnostic (thrown), never a silently-wrong count.
93+
*/
94+
function countSaturatedPixels(ppmPath: string, delta: number): number {
95+
const b = fs.readFileSync(ppmPath);
96+
let i = 0;
97+
const skipWhitespaceAndComments = () => {
98+
for (;;) {
99+
while (i < b.length && (b[i] === 0x20 || b[i] === 0x0a || b[i] === 0x09 || b[i] === 0x0d)) i++;
100+
if (b[i] === 0x23) { // '#': comment runs to end of line
101+
while (i < b.length && b[i] !== 0x0a) i++;
102+
continue;
103+
}
104+
break;
105+
}
106+
};
107+
const token = (): string => {
108+
skipWhitespaceAndComments();
109+
const s = i;
110+
while (i < b.length && b[i] !== 0x20 && b[i] !== 0x0a && b[i] !== 0x09 && b[i] !== 0x0d) i++;
111+
return b.slice(s, i).toString("ascii");
112+
};
113+
const magic = token();
114+
if (magic !== "P6") throw new Error(`expected P6 PPM, got "${magic}"`);
115+
const w = Number(token());
116+
const h = Number(token());
117+
const maxval = Number(token());
118+
if (!Number.isInteger(w) || w <= 0 || !Number.isInteger(h) || h <= 0) {
119+
throw new Error(`invalid PPM dimensions: ${w}x${h}`);
120+
}
121+
if (maxval !== 255) {
122+
// pdftoppm emits 8-bit P6 (maxval 255). 16-bit would be 2 bytes/channel and
123+
// would break the byte math below — fail loudly rather than miscount.
124+
throw new Error(`unexpected PPM maxval ${maxval} (expected 255)`);
125+
}
126+
i++; // single whitespace byte after maxval precedes the pixel block
127+
const total = w * h;
128+
if (b.length - i < total * 3) {
129+
throw new Error(`PPM pixel buffer too short: have ${b.length - i}, need ${total * 3}`);
130+
}
131+
let sat = 0;
132+
for (let p = 0; p < total; p++) {
133+
const o = i + p * 3;
134+
const r = b[o], g = b[o + 1], bl = b[o + 2];
135+
if (Math.max(r, g, bl) - Math.min(r, g, bl) > delta) sat++;
136+
}
137+
return sat;
138+
}
139+
140+
describe("emoji render gate", () => {
141+
const avail = prerequisitesAvailable();
142+
143+
test.skipIf(!avail.ok)("emoji render as color glyphs, not tofu", () => {
144+
if (!avail.ok) return; // type narrowing
145+
// Private temp dir under /tmp: browse's validateOutputPath only allows
146+
// /tmp and /private/tmp (not os.tmpdir()'s /var/folders), and mkdtemp
147+
// dodges the predictable-path symlink/collision risk.
148+
const workDir = fs.mkdtempSync("/tmp/make-pdf-emoji-gate-");
149+
const outputPdf = path.join(workDir, "out.pdf");
150+
const ppmPrefix = path.join(workDir, "page");
151+
const ppmPath = `${ppmPrefix}.ppm`;
152+
try {
153+
execFileSync(PDF_BIN, ["generate", FIXTURE, outputPdf, "--quiet"], {
154+
encoding: "utf8",
155+
env: { ...process.env, BROWSE_BIN },
156+
stdio: ["ignore", "pipe", "pipe"],
157+
timeout: CHILD_TIMEOUT_MS,
158+
});
159+
expect(fs.existsSync(outputPdf)).toBe(true);
160+
161+
// 1. An emoji family must be embedded — the cascade found a real emoji
162+
// font instead of falling through to .notdef.
163+
const pdffonts = resolvePopplerTool("pdffonts")!;
164+
const fontList = execFileSync(pdffonts, [outputPdf], { encoding: "utf8", timeout: CHILD_TIMEOUT_MS });
165+
if (!/emoji/i.test(fontList)) {
166+
process.stderr.write(`\n--- pdffonts ---\n${fontList}\n--- END ---\n`);
167+
}
168+
expect(/emoji/i.test(fontList)).toBe(true);
169+
170+
// 2. The page must actually rasterize to color, not a monochrome tofu box.
171+
const pdftoppm = resolvePopplerTool("pdftoppm")!;
172+
execFileSync(pdftoppm, ["-r", "100", "-singlefile", outputPdf, ppmPrefix], {
173+
stdio: ["ignore", "pipe", "pipe"],
174+
timeout: CHILD_TIMEOUT_MS,
175+
});
176+
expect(fs.existsSync(ppmPath)).toBe(true);
177+
const saturated = countSaturatedPixels(ppmPath, SATURATION_DELTA);
178+
if (saturated < SATURATED_PIXEL_FLOOR) {
179+
process.stderr.write(`\n[emoji-gate] saturated pixels: ${saturated} (floor ${SATURATED_PIXEL_FLOOR})\n`);
180+
}
181+
expect(saturated).toBeGreaterThanOrEqual(SATURATED_PIXEL_FLOOR);
182+
} finally {
183+
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch { /* ignore */ }
184+
}
185+
}, 60000);
186+
187+
if (!avail.ok) {
188+
// In CI, missing prerequisites are a hard failure — a silent skip would let
189+
// the Linux tofu regression ship behind a green build. Locally, just warn.
190+
test("emoji gate prerequisites are present (hard-required in CI)", () => {
191+
if (process.env.CI) {
192+
throw new Error(`emoji gate prerequisites missing in CI: ${avail.reason}`);
193+
}
194+
console.warn(`[skip] ${avail.reason}`);
195+
});
196+
}
197+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Emoji rendering gate 😀
2+
3+
This fixture exists to prove that emoji code points render as real color
4+
glyphs in the output PDF, not as `.notdef` tofu boxes (▯).
5+
6+
Color emoji on one line: 😀 ❤️ 🚀 ✅ 💡
7+
8+
A variation-selector sequence (FE0F) renders color: ❤️ — the bare code point
9+
❤ is text-style. Both must come from a font in the cascade, never tofu.
10+
11+
Non-emoji Unicode (unchanged, regression guard): em dash —, times ×, arrow →,
12+
bullet •, ellipsis …

0 commit comments

Comments
 (0)