From 584fdefe6fa3d1e5b32bae6b253c3c908ff73e73 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 18 Jul 2026 21:36:21 -0400 Subject: [PATCH] fix(simulation): render screenshot symbol glyphs (#37691) --- bun.lock | 9 +++++++ packages/simulation/package.json | 3 +++ packages/simulation/src/frontend/png.ts | 21 ++++++++++++--- packages/simulation/test/png.test.ts | 36 ++++++++++++++++++++++--- 4 files changed, 62 insertions(+), 7 deletions(-) diff --git a/bun.lock b/bun.lock index 1d50a2d541..7b3bfa7452 100644 --- a/bun.lock +++ b/bun.lock @@ -900,6 +900,9 @@ "version": "1.17.13", "dependencies": { "@fontsource/commit-mono": "5.2.5", + "@fontsource/noto-sans-math": "5.2.5", + "@fontsource/noto-sans-symbols": "5.2.5", + "@fontsource/noto-sans-symbols-2": "5.2.5", "@napi-rs/canvas": "1.0.2", "@opencode-ai/ai": "workspace:*", "@opencode-ai/core": "workspace:*", @@ -1795,6 +1798,12 @@ "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], + "@fontsource/noto-sans-math": ["@fontsource/noto-sans-math@5.2.5", "", {}, "sha512-1bxEvVlF51Vfgpju32mRZzI/CHvsfqjXjI2+sAuEyHYvXABUAIyj+93sCO3QZIoMG5drWyrzgoCqRQRaL6wQ8Q=="], + + "@fontsource/noto-sans-symbols": ["@fontsource/noto-sans-symbols@5.2.5", "", {}, "sha512-mxoIRstsmZpZFzd/SRWiD+l6T7TGhpgCrGs7TEnnuGSQIfjVMrQT9Zej2enh9pkfmPNAFyeaGJkHkszJ1hH++w=="], + + "@fontsource/noto-sans-symbols-2": ["@fontsource/noto-sans-symbols-2@5.2.5", "", {}, "sha512-F4O9WLifwoZS1quNzY1ebjMNo2cQPe/UP68Dmud0ONi2lOxaR6xp6fFPO2gG17MI7DwAnfMyQFl64A2tAd28hg=="], + "@fuma-translate/react": ["@fuma-translate/react@1.0.2", "", { "peerDependencies": { "@types/react": "*", "react": "^19.2.0", "react-dom": "^19.2.0" }, "optionalPeers": ["@types/react"] }, "sha512-uOiOtBx3nRXR8Nu1GzBf1tApgF1FErDBTHxRIAQeyQdyOoZbrNRN6H4kDCWObY4qyGeGbHydG0DHzgeUgFDMIw=="], "@fumadocs/tailwind": ["@fumadocs/tailwind@0.1.0", "", { "peerDependencies": { "tailwindcss": "^4.0.0" }, "optionalPeers": ["tailwindcss"] }, "sha512-nF/DCAwOR21HZ4AkjIOv3Iqwyqywzb6pdyeMcoa+aZzirXj5ntvNZbe3jJ0v3ehhtrRfYYeXBezvjn8ZmV+fuQ=="], diff --git a/packages/simulation/package.json b/packages/simulation/package.json index c3baba77e9..8775fc2567 100644 --- a/packages/simulation/package.json +++ b/packages/simulation/package.json @@ -18,6 +18,9 @@ }, "dependencies": { "@fontsource/commit-mono": "5.2.5", + "@fontsource/noto-sans-math": "5.2.5", + "@fontsource/noto-sans-symbols": "5.2.5", + "@fontsource/noto-sans-symbols-2": "5.2.5", "@napi-rs/canvas": "1.0.2", "@opencode-ai/core": "workspace:*", "@opencode-ai/ai": "workspace:*", diff --git a/packages/simulation/src/frontend/png.ts b/packages/simulation/src/frontend/png.ts index 7022ee2272..98fb9f3814 100644 --- a/packages/simulation/src/frontend/png.ts +++ b/packages/simulation/src/frontend/png.ts @@ -5,16 +5,29 @@ import regularFont from "@fontsource/commit-mono/files/commit-mono-latin-400-nor import boldFont from "@fontsource/commit-mono/files/commit-mono-latin-700-normal.woff2" with { type: "file" } import italicFont from "@fontsource/commit-mono/files/commit-mono-latin-400-italic.woff2" with { type: "file" } import boldItalicFont from "@fontsource/commit-mono/files/commit-mono-latin-700-italic.woff2" with { type: "file" } +import symbolFont from "@fontsource/noto-sans-symbols/files/noto-sans-symbols-symbols-400-normal.woff2" with { type: "file" } +import symbolFont2 from "@fontsource/noto-sans-symbols-2/files/noto-sans-symbols-2-symbols-400-normal.woff2" with { type: "file" } +import brailleFont from "@fontsource/noto-sans-symbols-2/files/noto-sans-symbols-2-braille-400-normal.woff2" with { type: "file" } +import mathFont from "@fontsource/noto-sans-math/files/noto-sans-math-math-400-normal.woff2" with { type: "file" } const CellWidth = 10 const CellHeight = 20 const FontSize = 16 const FontFamily = "OpenCode Mono" +const SymbolFontFamily = "OpenCode Symbols" +const SymbolFontFamily2 = "OpenCode Symbols 2" +const MathFontFamily = "OpenCode Math" +const FontStack = `"${FontFamily}", "${SymbolFontFamily}", "${SymbolFontFamily2}", "${MathFontFamily}"` -for (const file of [regularFont, boldFont, italicFont, boldItalicFont]) { +for (const [file, family] of [ + ...[regularFont, boldFont, italicFont, boldItalicFont].map((file) => [file, FontFamily] as const), + [symbolFont, SymbolFontFamily], + [symbolFont2, SymbolFontFamily2], + [brailleFont, SymbolFontFamily2], + [mathFont, MathFontFamily], +] as const) { const font = Buffer.from(await Bun.file(file).arrayBuffer()) - if (!GlobalFonts.register(font, FontFamily)) - throw new Error(`Failed to register screenshot font: ${file}`) + if (!GlobalFonts.register(font, family)) throw new Error(`Failed to register screenshot font: ${file}`) } export function screenshot(renderer: CliRenderer) { @@ -55,7 +68,7 @@ export function screenshotFrame(frame: CapturedFrame) { const x = column * CellWidth const y = row * CellHeight if (!drawBlockElement(context, char, x, y, cells)) { - context.font = `${attributes & TextAttributes.ITALIC ? "italic " : ""}${attributes & TextAttributes.BOLD ? "bold " : ""}${FontSize}px "${FontFamily}"` + context.font = `${attributes & TextAttributes.ITALIC ? "italic " : ""}${attributes & TextAttributes.BOLD ? "bold " : ""}${FontSize}px ${FontStack}` context.fillText(char, x, y + 1) } if (attributes & TextAttributes.UNDERLINE) { diff --git a/packages/simulation/test/png.test.ts b/packages/simulation/test/png.test.ts index af41e355d2..297ca18813 100644 --- a/packages/simulation/test/png.test.ts +++ b/packages/simulation/test/png.test.ts @@ -29,6 +29,38 @@ test("renders captured frames with bundled fonts", () => { expect(image.data.subarray(1, 4).toString()).toBe("PNG") }) +test("renders OpenCode symbols instead of missing-glyph boxes", async () => { + const pixels = async (text: string) => { + const image = SimulationPng.screenshotFrame({ + cols: 1, + rows: 1, + cursor: [0, 0], + lines: [ + { + spans: [ + { + text, + width: 1, + fg: RGBA.fromInts(255, 255, 255), + bg: RGBA.fromInts(0, 0, 0), + attributes: 0, + }, + ], + }, + ], + }) + const canvas = createCanvas(image.width, image.height) + const context = canvas.getContext("2d") + context.drawImage(await loadImage(image.data), 0, 0) + return [...context.getImageData(0, 0, image.width, image.height).data] + } + + const missingGlyph = await pixels("\u{10ffff}") + for (const symbol of ["△", "✱", "⇆", "⠹"]) { + expect(await pixels(symbol)).not.toEqual(missingGlyph) + } +}) + test("fills adjacent block elements without glyph gaps", async () => { const image = SimulationPng.screenshotFrame({ cols: 2, @@ -97,7 +129,5 @@ test("draws heavy vertical box elements on cell boundaries", async () => { expect([...context.getImageData(4, 0, 2, 30).data]).toEqual( Array.from({ length: 60 }, () => [255, 255, 255, 255]).flat(), ) - expect([...context.getImageData(4, 30, 2, 10).data]).toEqual( - Array.from({ length: 20 }, () => [0, 0, 0, 255]).flat(), - ) + expect([...context.getImageData(4, 30, 2, 10).data]).toEqual(Array.from({ length: 20 }, () => [0, 0, 0, 255]).flat()) })