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>
This commit is contained in:
Garry Tan
2026-05-29 07:04:37 -07:00
parent cc57d9d62c
commit 065f7ed9f6
2 changed files with 255 additions and 0 deletions
+89
View File
@@ -261,6 +261,82 @@ 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
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 $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)..."
$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)..."
$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)..."
$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 +509,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"