From 62024d114c1bdeca9a4b2587fbe39c236a22b7ec Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 29 May 2026 18:06:19 -0700 Subject: [PATCH] =?UTF-8?q?v1.52.2.0=20fix(make-pdf):=20render=20emoji=20i?= =?UTF-8?q?nstead=20of=20tofu=20(=E2=96=AF)=20on=20Linux=20(#1787)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) * 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) * 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) * chore: bump version and changelog (v1.52.2.0) Co-Authored-By: Claude Opus 4.8 (1M context) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/make-pdf-gate.yml | 13 +- CHANGELOG.md | 36 +++++ VERSION | 2 +- make-pdf/SKILL.md | 7 + make-pdf/SKILL.md.tmpl | 7 + make-pdf/src/pdftotext.ts | 28 ++++ make-pdf/src/print-css.ts | 26 +++- make-pdf/test/e2e/emoji-gate.test.ts | 197 +++++++++++++++++++++++++++ make-pdf/test/fixtures/emoji-gate.md | 12 ++ make-pdf/test/render.test.ts | 40 ++++++ package.json | 3 +- setup | 91 +++++++++++++ test/setup-emoji-font.test.ts | 172 +++++++++++++++++++++++ 13 files changed, 625 insertions(+), 9 deletions(-) create mode 100644 make-pdf/test/e2e/emoji-gate.test.ts create mode 100644 make-pdf/test/fixtures/emoji-gate.md create mode 100644 test/setup-emoji-font.test.ts diff --git a/.github/workflows/make-pdf-gate.yml b/.github/workflows/make-pdf-gate.yml index 60d9a1405..769fccd2b 100644 --- a/.github/workflows/make-pdf-gate.yml +++ b/.github/workflows/make-pdf-gate.yml @@ -51,6 +51,15 @@ jobs: if: matrix.os == 'ubicloud-standard-8' run: sudo apt-get update && sudo apt-get install -y poppler-utils + # Install a color-emoji font BEFORE Chromium launches so the emoji render + # gate has a fallback font. macOS ships Apple Color Emoji already. + - name: Install color-emoji font (Ubuntu) + if: matrix.os == 'ubicloud-standard-8' + run: | + sudo apt-get install -y fonts-noto-color-emoji + fc-cache -f || true + fc-match -f '%{family[0]}\t%{color}\n' ':lang=und-zsye:charset=1F600' || true + - name: Install Playwright Chromium run: bunx playwright install chromium @@ -74,7 +83,7 @@ jobs: - name: Run make-pdf unit tests run: bun test make-pdf/test/*.test.ts - - name: Run combined-features copy-paste gate (P0) + - name: Run E2E gates (combined-features copy-paste + emoji render) env: BROWSE_BIN: ${{ github.workspace }}/browse/dist/browse - run: bun test make-pdf/test/e2e/combined-gate.test.ts + run: bun test make-pdf/test/e2e/ diff --git a/CHANGELOG.md b/CHANGELOG.md index c7bdc31a9..139ca8ac5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # Changelog +## [1.52.2.0] - 2026-05-29 + +## **Emoji render in make-pdf PDFs on every platform. Linux stops printing tofu boxes, and setup installs the font for you.** + +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. + +## The numbers that matter + +Source: the emoji render gate, `bun test make-pdf/test/e2e/emoji-gate.test.ts`, rendering a fixture of color emoji at 100 dpi. + +| Metric | Before | After | Δ | +|---|---|---|---| +| Saturated (color) pixels in the rendered emoji region | ~0 (tofu) | ~1,650 | real color render | +| Platforms that render emoji correctly | macOS, Windows | macOS, Windows, Linux | +Linux | +| Emoji-bearing font stacks with a fallback family | 0 | 2 | body + running header | +| Deterministic render-proof gates | 0 | 1 | pdffonts + pixel | + +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. + +## What this means for builders + +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. + +### Itemized changes + +#### Added +- `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. +- 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. +- `resolvePopplerTool()` resolver for `pdffonts` / `pdfimages` / `pdftoppm`. +- The Ubuntu make-pdf CI gate installs `fonts-noto-color-emoji` before Chromium launches. + +#### Changed +- 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. + +#### Fixed +- make-pdf no longer renders emoji as `.notdef` tofu (▯) on Linux. ## [1.52.1.0] - 2026-05-27 ## **Brain-aware planning lands. Five planning skills read structured context from any personal gbrain before asking — same questions, smarter answers, no token tax.** diff --git a/VERSION b/VERSION index d71257561..d7f9d8f6c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.52.1.0 +1.52.2.0 diff --git a/make-pdf/SKILL.md b/make-pdf/SKILL.md index 229f082cf..141c60a31 100644 --- a/make-pdf/SKILL.md +++ b/make-pdf/SKILL.md @@ -542,6 +542,13 @@ On Linux, install `fonts-liberation` for correct rendering — Helvetica and Ari aren't present by default, and Liberation Sans is the standard metric-compatible fallback. CI and Docker builds install it automatically via Dockerfile.ci. +Emoji need a color-emoji font. macOS (Apple Color Emoji) and Windows (Segoe UI +Emoji) ship one; most Linux distros and containers ship none, so emoji render as +empty boxes (▯). `./setup` auto-installs `fonts-noto-color-emoji` on Linux +(apt/dnf/pacman/apk, best-effort) and the print CSS falls back through Apple / +Segoe / Noto emoji families. Set `GSTACK_SKIP_FONTS=1` to skip the install (CI +without sudo, managed or offline machines). + ## Core patterns ### 80% case — memo/letter diff --git a/make-pdf/SKILL.md.tmpl b/make-pdf/SKILL.md.tmpl index d134ee62a..bfd90441b 100644 --- a/make-pdf/SKILL.md.tmpl +++ b/make-pdf/SKILL.md.tmpl @@ -41,6 +41,13 @@ On Linux, install `fonts-liberation` for correct rendering — Helvetica and Ari aren't present by default, and Liberation Sans is the standard metric-compatible fallback. CI and Docker builds install it automatically via Dockerfile.ci. +Emoji need a color-emoji font. macOS (Apple Color Emoji) and Windows (Segoe UI +Emoji) ship one; most Linux distros and containers ship none, so emoji render as +empty boxes (▯). `./setup` auto-installs `fonts-noto-color-emoji` on Linux +(apt/dnf/pacman/apk, best-effort) and the print CSS falls back through Apple / +Segoe / Noto emoji families. Set `GSTACK_SKIP_FONTS=1` to skip the install (CI +without sudo, managed or offline machines). + ## Core patterns ### 80% case — memo/letter diff --git a/make-pdf/src/pdftotext.ts b/make-pdf/src/pdftotext.ts index 54cc55118..5cdb51e81 100644 --- a/make-pdf/src/pdftotext.ts +++ b/make-pdf/src/pdftotext.ts @@ -114,6 +114,34 @@ export function resolvePdftotext(env: NodeJS.ProcessEnv = process.env): Pdftotex ].join("\n")); } +/** + * Locate a poppler companion tool (pdffonts, pdfimages, pdftoppm) used by the + * emoji render gate. Mirrors resolvePdftotext's resolution order: + * 1. $GSTACK__BIN env override (e.g. GSTACK_PDFFONTS_BIN) + * 2. PATH via Bun.which + * 3. standard POSIX locations (Homebrew + distro) + * + * Returns null (does NOT throw) when the tool is missing — the emoji gate skips + * cleanly rather than failing on a box without full poppler-utils. + */ +export function resolvePopplerTool( + tool: "pdffonts" | "pdfimages" | "pdftoppm", + env: NodeJS.ProcessEnv = process.env, +): string | null { + const override = resolveOverride(env[`GSTACK_${tool.toUpperCase()}_BIN`], env); + if (override) return override; + + const PATH = env.PATH ?? env.Path ?? ""; + const onPath = Bun.which(tool, { PATH }); + if (onPath) return onPath; + + for (const dir of ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"]) { + const candidate = findExecutable(path.join(dir, tool)); + if (candidate) return candidate; + } + return null; +} + function isExecutable(p: string): boolean { try { fs.accessSync(p, fs.constants.X_OK); 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/e2e/emoji-gate.test.ts b/make-pdf/test/e2e/emoji-gate.test.ts new file mode 100644 index 000000000..0e3a42c29 --- /dev/null +++ b/make-pdf/test/e2e/emoji-gate.test.ts @@ -0,0 +1,197 @@ +/** + * Emoji render gate — proves emoji code points render as real color glyphs in + * the output PDF instead of .notdef tofu boxes (▯). This is the regression gate + * for fix/make-pdf-emoji-tofu. + * + * Why not just check pdftotext? Because text extraction is a FALSE oracle for + * emoji: Skia preserves the Unicode in the text cluster even when the displayed + * glyph is .notdef, so pdftotext can report the emoji survived on a render that + * actually drew tofu. Verified empirically on macOS — pdftotext extracts 😀 + * regardless of whether a color font was available. + * + * Two assertions that DO distinguish a real render from tofu: + * 1. pdffonts shows an emoji family embedded in the PDF (the cascade selected + * a real emoji font — AppleColorEmoji as Type 3 on macOS, NotoColorEmoji + * on Linux). Missing-fallback => no emoji font embedded. + * 2. pdftoppm rasterizes the page and we count saturated (colored) pixels. + * A color-emoji render has hundreds (measured: ~1650 at 100dpi); a tofu + * render is a monochrome black outline on white (~0 saturated). Tolerant + * threshold, not an exact-pixel fixture diff, to dodge cross-platform AA + * and font-version variance. + * + * Note: pdfimages -list is intentionally NOT used — macOS embeds color emoji as + * Type 3 fonts, so pdfimages lists nothing even on a correct render. + * + * Gating: runs only when the compiled binary + browse + pdffonts + pdftoppm are + * available AND a color-emoji font is installed for Chromium to fall back to. + * In CI (process.env.CI set) missing prerequisites are a HARD FAILURE, not a + * skip — CI is expected to install poppler-utils + fonts-noto-color-emoji, so a + * silent skip there would let the tofu regression ship behind a green build. + * Local dev without those tools skips cleanly. + */ + +import { describe, expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +import { resolvePopplerTool } from "../../src/pdftotext"; + +const FIXTURE = path.resolve(__dirname, "../fixtures/emoji-gate.md"); +const ROOT = path.resolve(__dirname, "../../.."); +const PDF_BIN = path.join(ROOT, "make-pdf/dist/pdf"); +const BROWSE_BIN = path.join(ROOT, "browse/dist/browse"); + +// Saturated-pixel floor. Measured ~1650 at 100dpi for the fixture's color +// emoji; a tofu render yields ~0. 200 sits well clear of both. +const SATURATED_PIXEL_FLOOR = 200; +// A pixel is "colored" when its max-min channel spread exceeds this. Black text, +// gray rules, and white background all stay near 0; color emoji spike high. +const SATURATION_DELTA = 40; +// Per-child wall-clock bound. Bun's test timeout doesn't reliably interrupt a +// synchronous execFileSync, so each child gets its own ceiling — a wedged +// browser/poppler binary (or a hostile GSTACK_*_BIN override) fails instead of +// hanging the whole job. +const CHILD_TIMEOUT_MS = 25_000; + +/** Is a color-emoji font available for Chromium to fall back to? */ +function emojiFontAvailable(): boolean { + if (process.platform === "darwin") { + return fs.existsSync("/System/Library/Fonts/Apple Color Emoji.ttc"); + } + if (process.platform === "linux") { + const fcMatch = Bun.which("fc-match"); + if (!fcMatch) return false; + try { + const out = execFileSync( + fcMatch, + ["-f", "%{color}\n", ":lang=und-zsye:charset=1F600"], + { encoding: "utf8", timeout: CHILD_TIMEOUT_MS }, + ); + return /true/i.test(out); + } catch { + return false; + } + } + return false; +} + +function prerequisitesAvailable(): { ok: true } | { ok: false; reason: string } { + if (!fs.existsSync(PDF_BIN)) return { ok: false, reason: `make-pdf binary missing (${PDF_BIN}). Run bun run build.` }; + if (!fs.existsSync(BROWSE_BIN)) return { ok: false, reason: `browse binary missing (${BROWSE_BIN}).` }; + if (!fs.existsSync(FIXTURE)) return { ok: false, reason: `fixture missing (${FIXTURE}).` }; + if (!resolvePopplerTool("pdffonts")) return { ok: false, reason: "pdffonts not found (install poppler-utils)." }; + if (!resolvePopplerTool("pdftoppm")) return { ok: false, reason: "pdftoppm not found (install poppler-utils)." }; + if (!emojiFontAvailable()) return { ok: false, reason: "no color-emoji font installed; run ./setup (Linux) or install one." }; + return { ok: true }; +} + +/** + * Count pixels in a P6 (binary) PPM whose RGB channel spread exceeds delta. + * Validates the header and buffer length so malformed/variant output is a hard + * diagnostic (thrown), never a silently-wrong count. + */ +function countSaturatedPixels(ppmPath: string, delta: number): number { + const b = fs.readFileSync(ppmPath); + let i = 0; + const skipWhitespaceAndComments = () => { + for (;;) { + while (i < b.length && (b[i] === 0x20 || b[i] === 0x0a || b[i] === 0x09 || b[i] === 0x0d)) i++; + if (b[i] === 0x23) { // '#': comment runs to end of line + while (i < b.length && b[i] !== 0x0a) i++; + continue; + } + break; + } + }; + const token = (): string => { + skipWhitespaceAndComments(); + const s = i; + while (i < b.length && b[i] !== 0x20 && b[i] !== 0x0a && b[i] !== 0x09 && b[i] !== 0x0d) i++; + return b.slice(s, i).toString("ascii"); + }; + const magic = token(); + if (magic !== "P6") throw new Error(`expected P6 PPM, got "${magic}"`); + const w = Number(token()); + const h = Number(token()); + const maxval = Number(token()); + if (!Number.isInteger(w) || w <= 0 || !Number.isInteger(h) || h <= 0) { + throw new Error(`invalid PPM dimensions: ${w}x${h}`); + } + if (maxval !== 255) { + // pdftoppm emits 8-bit P6 (maxval 255). 16-bit would be 2 bytes/channel and + // would break the byte math below — fail loudly rather than miscount. + throw new Error(`unexpected PPM maxval ${maxval} (expected 255)`); + } + i++; // single whitespace byte after maxval precedes the pixel block + const total = w * h; + if (b.length - i < total * 3) { + throw new Error(`PPM pixel buffer too short: have ${b.length - i}, need ${total * 3}`); + } + let sat = 0; + for (let p = 0; p < total; p++) { + const o = i + p * 3; + const r = b[o], g = b[o + 1], bl = b[o + 2]; + if (Math.max(r, g, bl) - Math.min(r, g, bl) > delta) sat++; + } + return sat; +} + +describe("emoji render gate", () => { + const avail = prerequisitesAvailable(); + + test.skipIf(!avail.ok)("emoji render as color glyphs, not tofu", () => { + if (!avail.ok) return; // type narrowing + // Private temp dir under /tmp: browse's validateOutputPath only allows + // /tmp and /private/tmp (not os.tmpdir()'s /var/folders), and mkdtemp + // dodges the predictable-path symlink/collision risk. + const workDir = fs.mkdtempSync("/tmp/make-pdf-emoji-gate-"); + const outputPdf = path.join(workDir, "out.pdf"); + const ppmPrefix = path.join(workDir, "page"); + const ppmPath = `${ppmPrefix}.ppm`; + try { + execFileSync(PDF_BIN, ["generate", FIXTURE, outputPdf, "--quiet"], { + encoding: "utf8", + env: { ...process.env, BROWSE_BIN }, + stdio: ["ignore", "pipe", "pipe"], + timeout: CHILD_TIMEOUT_MS, + }); + expect(fs.existsSync(outputPdf)).toBe(true); + + // 1. An emoji family must be embedded — the cascade found a real emoji + // font instead of falling through to .notdef. + const pdffonts = resolvePopplerTool("pdffonts")!; + const fontList = execFileSync(pdffonts, [outputPdf], { encoding: "utf8", timeout: CHILD_TIMEOUT_MS }); + if (!/emoji/i.test(fontList)) { + process.stderr.write(`\n--- pdffonts ---\n${fontList}\n--- END ---\n`); + } + expect(/emoji/i.test(fontList)).toBe(true); + + // 2. The page must actually rasterize to color, not a monochrome tofu box. + const pdftoppm = resolvePopplerTool("pdftoppm")!; + execFileSync(pdftoppm, ["-r", "100", "-singlefile", outputPdf, ppmPrefix], { + stdio: ["ignore", "pipe", "pipe"], + timeout: CHILD_TIMEOUT_MS, + }); + expect(fs.existsSync(ppmPath)).toBe(true); + const saturated = countSaturatedPixels(ppmPath, SATURATION_DELTA); + if (saturated < SATURATED_PIXEL_FLOOR) { + process.stderr.write(`\n[emoji-gate] saturated pixels: ${saturated} (floor ${SATURATED_PIXEL_FLOOR})\n`); + } + expect(saturated).toBeGreaterThanOrEqual(SATURATED_PIXEL_FLOOR); + } finally { + try { fs.rmSync(workDir, { recursive: true, force: true }); } catch { /* ignore */ } + } + }, 60000); + + if (!avail.ok) { + // In CI, missing prerequisites are a hard failure — a silent skip would let + // the Linux tofu regression ship behind a green build. Locally, just warn. + test("emoji gate prerequisites are present (hard-required in CI)", () => { + if (process.env.CI) { + throw new Error(`emoji gate prerequisites missing in CI: ${avail.reason}`); + } + console.warn(`[skip] ${avail.reason}`); + }); + } +}); diff --git a/make-pdf/test/fixtures/emoji-gate.md b/make-pdf/test/fixtures/emoji-gate.md new file mode 100644 index 000000000..d12319454 --- /dev/null +++ b/make-pdf/test/fixtures/emoji-gate.md @@ -0,0 +1,12 @@ +# Emoji rendering gate 😀 + +This fixture exists to prove that emoji code points render as real color +glyphs in the output PDF, not as `.notdef` tofu boxes (▯). + +Color emoji on one line: 😀 ❤️ 🚀 ✅ 💡 + +A variation-selector sequence (FE0F) renders color: ❤️ — the bare code point +❤ is text-style. Both must come from a font in the cascade, never tofu. + +Non-emoji Unicode (unchanged, regression guard): em dash —, times ×, arrow →, +bullet •, ellipsis … 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 ─────────────── diff --git a/package.json b/package.json index 6944285d4..a08f31dc7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.52.1.0", + "version": "1.52.2.0", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", @@ -14,7 +14,6 @@ "dev:make-pdf": "bun run make-pdf/src/cli.ts", "dev:design": "bun run design/src/cli.ts", "gen:skill-docs": "bun run scripts/gen-skill-docs.ts", - "gen:skill-docs:user": "bun run scripts/gen-skill-docs.ts --respect-detection", "dev": "bun run browse/src/cli.ts", "server": "bun run browse/src/server.ts", "test": "bun test browse/test/ test/ make-pdf/test/ --ignore 'test/skill-e2e-*.test.ts' --ignore test/skill-llm-eval.test.ts --ignore test/skill-routing-e2e.test.ts --ignore test/codex-e2e.test.ts --ignore test/gemini-e2e.test.ts && (bun run slop:diff 2>/dev/null || true)", diff --git a/setup b/setup index f2d3b6501..1fae915a9 100755 --- a/setup +++ b/setup @@ -261,6 +261,84 @@ ensure_playwright_browser() { fi } +# Ensure a color-emoji font is installed (Linux only). +# +# Chromium renders emoji code points as .notdef "tofu" (▯) when no color-emoji +# font is installed. macOS ships "Apple Color Emoji" and Windows ships "Segoe UI +# Emoji", so they're fine out of the box. Most Linux distros and containers ship +# NO color-emoji font, which is why make-pdf output shows tofu in headers/tables +# that contain emoji. Install Noto Color Emoji to fix it. +# +# Best-effort: warn (don't fail) if we can't install — PDFs still generate, they +# just fall back to tofu for emoji as before. Skip entirely with +# GSTACK_SKIP_FONTS=1 (CI without sudo, managed machines, offline envs). +# +# Returns 0 and sets EMOJI_FONT_INSTALLED=1 when it actually installs a font. +EMOJI_FONT_INSTALLED=0 +ensure_emoji_font() { + # macOS/Windows ship a color-emoji font; nothing to do. + [ "$(uname -s)" = "Linux" ] || return 0 + [ "${GSTACK_SKIP_FONTS:-0}" = "1" ] && return 0 + + # Idempotency: a real COLOR emoji font that resolves for an actual emoji code + # point (U+1F600). `fc-list :lang=und-zsye` is too broad — it matches symbol + # and last-resort fallback fonts — so we use fc-match and require color=True. + if command -v fc-match >/dev/null 2>&1; then + if fc-match -f '%{family[0]}\t%{color}\n' ':lang=und-zsye:charset=1F600' 2>/dev/null | grep -qi 'True'; then + return 0 + fi + fi + + local sudo="" + if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then + # -n: never prompt. If a password is required we fail fast into the + # warn-not-fail path below instead of hanging a non-interactive setup. + sudo="sudo -n" + fi + + # Every package-manager call is wrapped in `timeout` so a stuck dpkg/rpm lock + # or a wedged mirror fails fast into the warn path instead of hanging setup. + if command -v apt-get >/dev/null 2>&1; then + echo "Installing color-emoji font (fonts-noto-color-emoji) so make-pdf emoji render (set GSTACK_SKIP_FONTS=1 to skip)..." + DEBIAN_FRONTEND=noninteractive timeout 30 $sudo apt-get update -qq >/dev/null 2>&1 || true + DEBIAN_FRONTEND=noninteractive timeout 120 $sudo apt-get install -y -qq fonts-noto-color-emoji >/dev/null 2>&1 || return 1 + elif command -v dnf >/dev/null 2>&1; then + echo "Installing color-emoji font (google-noto-color-emoji-fonts)..." + timeout 120 $sudo dnf install -y google-noto-color-emoji-fonts >/dev/null 2>&1 || return 1 + elif command -v pacman >/dev/null 2>&1; then + echo "Installing color-emoji font (noto-fonts-emoji)..." + timeout 120 $sudo pacman -Sy --noconfirm noto-fonts-emoji >/dev/null 2>&1 || return 1 + elif command -v apk >/dev/null 2>&1; then + echo "Installing color-emoji font (font-noto-emoji)..." + timeout 120 $sudo apk add --no-cache font-noto-emoji >/dev/null 2>&1 || return 1 + else + return 1 + fi + + # Refresh fontconfig cache so Chromium picks up the new font. Run under sudo + # for the system cache dirs (unprivileged fc-cache fails on unwritable dirs). + if command -v fc-cache >/dev/null 2>&1; then + $sudo fc-cache -f >/dev/null 2>&1 || fc-cache -f >/dev/null 2>&1 || true + fi + EMOJI_FONT_INSTALLED=1 + return 0 +} + +# After a fresh font install, stop any running browse render daemon so the next +# make-pdf render spawns a fresh Chromium that sees the new font. Chromium +# caches its font list at process start, so a daemon that was alive before the +# install would keep emitting tofu. `browse stop` is the graceful API; the +# daemon auto-respawns on the next render. Best-effort and per-project-root, so +# we also print a note for daemons in other roots. +refresh_browse_daemon_for_fonts() { + [ "$EMOJI_FONT_INSTALLED" -eq 1 ] || return 0 + if [ -x "$BROWSE_BIN" ]; then + "$BROWSE_BIN" stop >/dev/null 2>&1 || true + fi + echo " Installed a color-emoji font. The next make-pdf render will show emoji." + echo " If a gstack browser is running in another project, restart it to pick up the font." +} + prepare_bun_for_windows_compile() { BUN_CMD="bun" BUN_CMD_WAS_COPIED=0 @@ -433,6 +511,19 @@ if ! ensure_playwright_browser; then exit 1 fi +# 2b. Ensure a color-emoji font is installed so make-pdf emoji render (Linux). +# Best-effort: warn instead of failing if it can't install. +if ! ensure_emoji_font; then + echo " Note: could not auto-install a color-emoji font. Emoji in make-pdf" >&2 + echo " output may render as boxes (▯). Install one manually, e.g.:" >&2 + echo " Debian/Ubuntu: sudo apt-get install fonts-noto-color-emoji" >&2 + echo " Fedora: sudo dnf install google-noto-color-emoji-fonts" >&2 + echo " Arch: sudo pacman -S noto-fonts-emoji" >&2 + echo " Alpine: sudo apk add font-noto-emoji" >&2 +else + refresh_browse_daemon_for_fonts +fi + # 3. Ensure ~/.gstack global state directory exists mkdir -p "$HOME/.gstack/projects" diff --git a/test/setup-emoji-font.test.ts b/test/setup-emoji-font.test.ts new file mode 100644 index 000000000..7e8668c2d --- /dev/null +++ b/test/setup-emoji-font.test.ts @@ -0,0 +1,172 @@ +import { describe, test, expect } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const SETUP_SCRIPT = path.join(ROOT, 'setup'); +const SETUP_SRC = fs.readFileSync(SETUP_SCRIPT, 'utf-8'); + +// Slice out the ensure_emoji_font helper body via anchors so the test is +// resilient to line-number drift (same pattern as setup-windows-fallback). +function extractHelper(): string { + const start = SETUP_SRC.indexOf('ensure_emoji_font() {'); + const end = SETUP_SRC.indexOf('\n}\n', start); + if (start < 0 || end < 0) throw new Error('Could not locate ensure_emoji_font() in setup'); + return SETUP_SRC.slice(start, end + 2); +} + +describe('setup: ensure_emoji_font static invariants', () => { + const helper = extractHelper(); + + test('helper is defined and Linux-guarded', () => { + expect(SETUP_SRC).toContain('ensure_emoji_font() {'); + expect(helper).toContain('[ "$(uname -s)" = "Linux" ] || return 0'); + }); + + test('honors the GSTACK_SKIP_FONTS escape hatch', () => { + expect(helper).toContain('GSTACK_SKIP_FONTS'); + }); + + test('detects an installed COLOR emoji font via fc-match (not the broad fc-list query)', () => { + expect(helper).toContain('fc-match'); + expect(helper).toContain(':lang=und-zsye:charset=1F600'); + // Must gate on color=True so symbol / last-resort fallback fonts don't + // false-positive and skip a needed install. + expect(helper).toMatch(/grep -qi ['"]True['"]/); + // The broad fc-list query that matched LastResort is NOT used for detection. + // (Check executable lines only — the docblock may mention fc-list to explain + // why we avoid it.) + const codeLines = helper + .split('\n') + .filter((l) => !l.trim().startsWith('#')) + .join('\n'); + expect(codeLines).not.toContain('fc-list'); + }); + + test('uses non-interactive sudo so a password prompt fails fast (no hang)', () => { + expect(helper).toContain('sudo -n'); + }); + + test('install path is non-interactive and timeout-guarded', () => { + expect(helper).toContain('DEBIAN_FRONTEND=noninteractive'); + expect(helper).toMatch(/timeout 30 .*apt-get update/); + // Every package-manager INSTALL (not just apt update) must be timeout-bound + // so a stuck lock/mirror fails fast instead of hanging setup. + expect(helper).toMatch(/timeout \d+ .*apt-get install/); + expect(helper).toMatch(/timeout \d+ .*dnf install/); + expect(helper).toMatch(/timeout \d+ .*pacman -Sy/); + expect(helper).toMatch(/timeout \d+ .*apk add/); + }); + + test('covers all four package managers with the correct package names', () => { + expect(helper).toContain('apt-get install -y -qq fonts-noto-color-emoji'); + expect(helper).toContain('dnf install -y google-noto-color-emoji-fonts'); + expect(helper).toContain('pacman -Sy --noconfirm noto-fonts-emoji'); + expect(helper).toContain('apk add --no-cache font-noto-emoji'); + }); + + test('refreshes the fontconfig cache under sudo after install', () => { + expect(helper).toMatch(/\$sudo fc-cache -f/); + }); + + test('marks EMOJI_FONT_INSTALLED on success and warns (not fails) elsewhere', () => { + expect(helper).toContain('EMOJI_FONT_INSTALLED=1'); + // Failure branches return 1 (caller warns) rather than `exit`. + expect(helper).not.toContain('exit 1'); + }); + + test('refresh_browse_daemon_for_fonts stops the daemon gracefully (no broad pkill)', () => { + const dStart = SETUP_SRC.indexOf('refresh_browse_daemon_for_fonts() {'); + const dEnd = SETUP_SRC.indexOf('\n}\n', dStart); + expect(dStart).toBeGreaterThanOrEqual(0); + const body = SETUP_SRC.slice(dStart, dEnd); + expect(body).toContain('"$BROWSE_BIN" stop'); + expect(body).not.toMatch(/pkill/); + }); + + test('the call site warns-not-fails and never aborts setup', () => { + expect(SETUP_SRC).toContain('if ! ensure_emoji_font; then'); + expect(SETUP_SRC).toContain('refresh_browse_daemon_for_fonts'); + }); +}); + +// Behavior matrix: source the extracted helper into a temp shell with a faked +// PATH so we exercise the real control flow without touching the host system. +// We fake `uname` to report Linux so the guard doesn't short-circuit on the +// macOS/Linux test runner, and fake the package managers with sentinel-touching +// stubs so we can assert whether an install was attempted. +describe.skipIf(process.platform === 'win32')('setup: ensure_emoji_font behavior', () => { + function runHelper(fcMatchOutput: string): { + exit: number; + installInstalled: string; + aptCalled: boolean; + fcCacheCalled: boolean; + stderr: string; + } { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-emoji-')); + try { + const bin = path.join(tmp, 'bin'); + fs.mkdirSync(bin); + const sentinelApt = path.join(tmp, 'apt-called'); + const sentinelCache = path.join(tmp, 'fc-cache-called'); + + const stub = (name: string, body: string) => { + const p = path.join(bin, name); + fs.writeFileSync(p, `#!/usr/bin/env bash\n${body}\n`); + fs.chmodSync(p, 0o755); + }; + stub('uname', 'echo Linux'); + // fc-match prints whatever the case wants; supports the -f format arg. + stub('fc-match', `printf '%s\\n' ${JSON.stringify(fcMatchOutput)}`); + stub('apt-get', `touch ${JSON.stringify(sentinelApt)}; exit 0`); + stub('fc-cache', `touch ${JSON.stringify(sentinelCache)}; exit 0`); + stub('sudo', 'shift; "$@"'); // sudo -n → run directly + stub('command', ''); // never used; `command -v` is a builtin + stub('timeout', 'shift; "$@"'); // timeout 30 → run + stub('id', 'echo 1000'); // non-root so the sudo branch is taken + + const helper = extractHelper(); + const script = [ + 'set -e', + 'EMOJI_FONT_INSTALLED=0', + helper, + 'ensure_emoji_font; rc=$?', + 'echo "EXIT=$rc"', + 'echo "INSTALLED=$EMOJI_FONT_INSTALLED"', + ].join('\n'); + + const result = spawnSync('bash', ['-c', script], { + encoding: 'utf-8', + timeout: 10000, + env: { ...process.env, PATH: `${bin}:${process.env.PATH}` }, + }); + const out = result.stdout ?? ''; + return { + exit: Number((out.match(/EXIT=(\d+)/) ?? [])[1] ?? -1), + installInstalled: (out.match(/INSTALLED=(\d+)/) ?? [])[1] ?? '?', + aptCalled: fs.existsSync(sentinelApt), + fcCacheCalled: fs.existsSync(sentinelCache), + stderr: result.stderr ?? '', + }; + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + } + + test('short-circuits when a color emoji font already resolves (no install)', () => { + const r = runHelper('Noto Color Emoji\tTrue'); + expect(r.exit).toBe(0); + expect(r.aptCalled).toBe(false); + expect(r.installInstalled).toBe('0'); + }); + + test('installs when only a non-color fallback resolves (color=False)', () => { + const r = runHelper('LastResort\tFalse'); + expect(r.exit).toBe(0); + expect(r.aptCalled).toBe(true); + expect(r.fcCacheCalled).toBe(true); + expect(r.installInstalled).toBe('1'); + }); +});