From f83ab8e9d2a3eea4dd82c6005dcd512a7b382428 Mon Sep 17 00:00:00 2001 From: Cody Date: Tue, 5 May 2026 14:46:27 -0400 Subject: [PATCH 1/3] fix(rendering): ignore unsupported font glyphs --- packages/rendering/src/font/font-renderer.ts | 6 +- .../rendering/tests/font-renderer.spec.ts | 66 +++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 packages/rendering/tests/font-renderer.spec.ts diff --git a/packages/rendering/src/font/font-renderer.ts b/packages/rendering/src/font/font-renderer.ts index bd5d6d775..49d43557d 100644 --- a/packages/rendering/src/font/font-renderer.ts +++ b/packages/rendering/src/font/font-renderer.ts @@ -244,8 +244,10 @@ export class FontRenderer { const characterSize = sizes[isAscii ? "ascii" : "unicode"][unicode.toUpperCase()]; - const startOffset = characterSize?.start ?? 0; - const width = characterSize?.width ?? 0; + if (!characterSize?.width) return null; + + const startOffset = characterSize.start ?? 0; + const width = characterSize.width; return { x: (startOffset + x * 16) * scale, diff --git a/packages/rendering/tests/font-renderer.spec.ts b/packages/rendering/tests/font-renderer.spec.ts new file mode 100644 index 000000000..45aaedc33 --- /dev/null +++ b/packages/rendering/tests/font-renderer.spec.ts @@ -0,0 +1,66 @@ +/** + * Copyright (c) Statsify + * + * This source code is licensed under the GNU GPL v3 license found in the + * LICENSE file in the root directory of this source tree. + * https://github.com/Statsify/statsify/blob/main/LICENSE + */ + +import { FontRenderer } from "../src/font/font-renderer.js"; +import { createCanvas } from "../src/canvas.js"; +import { expect, it, suite } from "vitest"; + +suite("FontRenderer", () => { + it("reads glyph image data from the stored backing canvas", () => { + const page = createCanvas(256, 256); + const pageCtx = { + getImageData() { + throw new Error("stale context"); + }, + } as unknown as ReturnType["getContext"]>; + const target = createCanvas(16, 16).getContext("2d"); + + const renderer = new FontRenderer(false); + renderer["images"] = new Map([["ascii", pageCtx]]); + renderer["canvases"].set(pageCtx, page); + renderer["scales"].set(pageCtx, 1); + + expect(() => renderer.fillText(target, renderer.lex("A"), 0, 0)).not.toThrow(); + }); + + it("uses the stored scale when the context does not expose a canvas", () => { + const pageCtx = createCanvas(256, 256).getContext("2d"); + + Object.defineProperty(pageCtx, "canvas", { value: undefined }); + + const renderer = new FontRenderer(false); + renderer["images"] = new Map([["ascii", pageCtx]]); + renderer["scales"].set(pageCtx, 1); + + expect(() => renderer.measureText(renderer.lex("A"))).not.toThrow(); + }); + + it("ignores unsupported glyphs with a loaded unicode page", () => { + const pageCtx = createCanvas(256, 256).getContext("2d"); + const target = createCanvas(16, 16).getContext("2d"); + + const renderer = new FontRenderer(false); + renderer["images"] = new Map([["1f", pageCtx]]); + renderer["canvases"].set(pageCtx, pageCtx.canvas); + renderer["scales"].set(pageCtx, 1); + + expect(() => renderer.fillText(target, renderer.lex("🌙"), 0, 0)).not.toThrow(); + }); + + it("ignores zero-width glyph metadata", () => { + const pageCtx = createCanvas(256, 256).getContext("2d"); + const target = createCanvas(16, 16).getContext("2d"); + + const renderer = new FontRenderer(false); + renderer["images"] = new Map([["12", pageCtx]]); + renderer["canvases"].set(pageCtx, pageCtx.canvas); + renderer["scales"].set(pageCtx, 1); + + expect(() => renderer.fillText(target, renderer.lex("\u1249"), 0, 0)).not.toThrow(); + }); +}); From 2a08050d0ad69f7e0df054e4c2ef6ed54765d6a6 Mon Sep 17 00:00:00 2001 From: Cody Date: Thu, 28 May 2026 14:45:36 -0600 Subject: [PATCH 2/3] fix(rendering): handle zero scaled dimensions in glyphs * added checks to ignore glyphs with zero scaled width or height * updated tests to verify behavior for zero scaled dimensions --- packages/rendering/src/font/font-renderer.ts | 9 +++++++-- packages/rendering/tests/font-renderer.spec.ts | 12 ++++++++++++ packages/rendering/vitest.config.ts | 14 +++++++++++++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/rendering/src/font/font-renderer.ts b/packages/rendering/src/font/font-renderer.ts index 49d43557d..ca613f63b 100644 --- a/packages/rendering/src/font/font-renderer.ts +++ b/packages/rendering/src/font/font-renderer.ts @@ -248,12 +248,17 @@ export class FontRenderer { const startOffset = characterSize.start ?? 0; const width = characterSize.width; + const scaledWidth = width * scale; + const height = 16 * scale; + + if (!Number.isFinite(scaledWidth) || scaledWidth <= 0) return null; + if (!Number.isFinite(height) || height <= 0) return null; return { x: (startOffset + x * 16) * scale, y: y * 16 * scale, - width: width * scale, - height: 16 * scale, + width: scaledWidth, + height, scale, isAscii, image, diff --git a/packages/rendering/tests/font-renderer.spec.ts b/packages/rendering/tests/font-renderer.spec.ts index 45aaedc33..7136c75dd 100644 --- a/packages/rendering/tests/font-renderer.spec.ts +++ b/packages/rendering/tests/font-renderer.spec.ts @@ -63,4 +63,16 @@ suite("FontRenderer", () => { expect(() => renderer.fillText(target, renderer.lex("\u1249"), 0, 0)).not.toThrow(); }); + + it("ignores glyphs with zero scaled dimensions", () => { + const pageCtx = createCanvas(256, 256).getContext("2d"); + const target = createCanvas(16, 16).getContext("2d"); + + const renderer = new FontRenderer(false); + renderer["images"] = new Map([["ascii", pageCtx]]); + renderer["canvases"].set(pageCtx, pageCtx.canvas); + renderer["scales"].set(pageCtx, 0); + + expect(() => renderer.fillText(target, renderer.lex("A"), 0, 0)).not.toThrow(); + }); }); diff --git a/packages/rendering/vitest.config.ts b/packages/rendering/vitest.config.ts index b0cea40f6..073ab261c 100644 --- a/packages/rendering/vitest.config.ts +++ b/packages/rendering/vitest.config.ts @@ -7,5 +7,17 @@ */ import { config } from "../../vitest.shared.js"; +import { mergeConfig } from "vitest/config"; +import { resolve } from "node:path"; -export default await config("./.swcrc"); +export default mergeConfig(await config("./.swcrc"), { + resolve: { + alias: { + "#colors": resolve(import.meta.dirname, "src/colors/index.ts"), + "#font": resolve(import.meta.dirname, "src/font/index.ts"), + "#hooks": resolve(import.meta.dirname, "src/hooks/index.ts"), + "#intrinsics": resolve(import.meta.dirname, "src/intrinsics/index.ts"), + "#jsx": resolve(import.meta.dirname, "src/jsx/index.ts"), + }, + }, +}); From b2144b94ca5a926dd97feae60e002a0256e898ff Mon Sep 17 00:00:00 2001 From: Cody Date: Thu, 28 May 2026 15:08:12 -0600 Subject: [PATCH 3/3] fix(rendering): refactor glyph handling in FontRenderer * consolidate glyph image data structure * remove unused canvas and scale mappings * ensure zero-width glyphs are properly ignored --- packages/rendering/src/font/font-renderer.ts | 42 +++++------------ .../rendering/tests/font-renderer.spec.ts | 47 +++---------------- 2 files changed, 19 insertions(+), 70 deletions(-) diff --git a/packages/rendering/src/font/font-renderer.ts b/packages/rendering/src/font/font-renderer.ts index ca613f63b..8a3153388 100644 --- a/packages/rendering/src/font/font-renderer.ts +++ b/packages/rendering/src/font/font-renderer.ts @@ -27,6 +27,10 @@ const GRADIENT_TOP_OVERLAY = "rgb(255 255 255 / 0.85)"; const GRADIENT_BOTTOM_OVERLAY = "rgb(0 0 0 / 0.60)"; type CharacterSizes = Record; +type FontImage = { + canvas: Canvas; + scale: number; +}; interface Sizes { ascii: CharacterSizes; @@ -34,14 +38,10 @@ interface Sizes { } export class FontRenderer { - private images: Map; - private canvases: WeakMap; - private scales: WeakMap; + private images: Map; public constructor(private gradient: boolean) { this.images = new Map(); - this.canvases = new WeakMap(); - this.scales = new WeakMap(); } public async loadImages(fontPath: string) { @@ -59,13 +59,10 @@ export class FontRenderer { ctx.drawImage(image, 0, 0); - this.canvases.set(ctx, canvas); - this.scales.set(ctx, image.width / 256); - - this.images.set( - file.replace("unicode_page_", "").replace(".png", ""), - ctx - ); + this.images.set(file.replace("unicode_page_", "").replace(".png", ""), { + canvas, + scale: image.width / 256, + }); } } @@ -198,21 +195,6 @@ export class FontRenderer { this.images.get(`${unicode[0]}${unicode[1]}`); } - private getTextureScale(image: CanvasRenderingContext2D) { - return this.scales.get(image) ?? image.canvas.width / 256; - } - - private getImageData( - image: CanvasRenderingContext2D, - x: number, - y: number, - width: number, - height: number - ) { - const ctx = this.canvases.get(image)?.getContext("2d") ?? image; - return ctx.getImageData(x, y, width, height); - } - private getCharacterIndexLocation(unicode: string, isAscii: boolean) { if (isAscii) { const y = positions.findIndex((row) => row.includes(unicode)); @@ -239,7 +221,7 @@ export class FontRenderer { const { x, y } = this.getCharacterIndexLocation(unicode, isAscii); - const scale = this.getTextureScale(image); + const scale = image.scale; const characterSize = sizes[isAscii ? "ascii" : "unicode"][unicode.toUpperCase()]; @@ -334,7 +316,9 @@ export class FontRenderer { size, } = metadata; - const imageData = this.getImageData(image, charX, charY, width, height); + const imageData = image.canvas + .getContext("2d") + .getImageData(charX, charY, width, height); ctx.filter = this.gradient ? "brightness(15%)" : "brightness(25%)"; diff --git a/packages/rendering/tests/font-renderer.spec.ts b/packages/rendering/tests/font-renderer.spec.ts index 7136c75dd..9e13fb7fe 100644 --- a/packages/rendering/tests/font-renderer.spec.ts +++ b/packages/rendering/tests/font-renderer.spec.ts @@ -11,67 +11,32 @@ import { createCanvas } from "../src/canvas.js"; import { expect, it, suite } from "vitest"; suite("FontRenderer", () => { - it("reads glyph image data from the stored backing canvas", () => { - const page = createCanvas(256, 256); - const pageCtx = { - getImageData() { - throw new Error("stale context"); - }, - } as unknown as ReturnType["getContext"]>; - const target = createCanvas(16, 16).getContext("2d"); - - const renderer = new FontRenderer(false); - renderer["images"] = new Map([["ascii", pageCtx]]); - renderer["canvases"].set(pageCtx, page); - renderer["scales"].set(pageCtx, 1); - - expect(() => renderer.fillText(target, renderer.lex("A"), 0, 0)).not.toThrow(); - }); - - it("uses the stored scale when the context does not expose a canvas", () => { - const pageCtx = createCanvas(256, 256).getContext("2d"); - - Object.defineProperty(pageCtx, "canvas", { value: undefined }); - - const renderer = new FontRenderer(false); - renderer["images"] = new Map([["ascii", pageCtx]]); - renderer["scales"].set(pageCtx, 1); - - expect(() => renderer.measureText(renderer.lex("A"))).not.toThrow(); - }); - it("ignores unsupported glyphs with a loaded unicode page", () => { - const pageCtx = createCanvas(256, 256).getContext("2d"); + const page = createCanvas(256, 256); const target = createCanvas(16, 16).getContext("2d"); const renderer = new FontRenderer(false); - renderer["images"] = new Map([["1f", pageCtx]]); - renderer["canvases"].set(pageCtx, pageCtx.canvas); - renderer["scales"].set(pageCtx, 1); + renderer["images"] = new Map([["1f", { canvas: page, scale: 1 }]]); expect(() => renderer.fillText(target, renderer.lex("🌙"), 0, 0)).not.toThrow(); }); it("ignores zero-width glyph metadata", () => { - const pageCtx = createCanvas(256, 256).getContext("2d"); + const page = createCanvas(256, 256); const target = createCanvas(16, 16).getContext("2d"); const renderer = new FontRenderer(false); - renderer["images"] = new Map([["12", pageCtx]]); - renderer["canvases"].set(pageCtx, pageCtx.canvas); - renderer["scales"].set(pageCtx, 1); + renderer["images"] = new Map([["12", { canvas: page, scale: 1 }]]); expect(() => renderer.fillText(target, renderer.lex("\u1249"), 0, 0)).not.toThrow(); }); it("ignores glyphs with zero scaled dimensions", () => { - const pageCtx = createCanvas(256, 256).getContext("2d"); + const page = createCanvas(256, 256); const target = createCanvas(16, 16).getContext("2d"); const renderer = new FontRenderer(false); - renderer["images"] = new Map([["ascii", pageCtx]]); - renderer["canvases"].set(pageCtx, pageCtx.canvas); - renderer["scales"].set(pageCtx, 0); + renderer["images"] = new Map([["ascii", { canvas: page, scale: 0 }]]); expect(() => renderer.fillText(target, renderer.lex("A"), 0, 0)).not.toThrow(); });