From d37d7e56e5a65fe5bacdb3a0b4115c20380dd7f5 Mon Sep 17 00:00:00 2001 From: Cody Date: Tue, 5 May 2026 14:46:27 -0400 Subject: [PATCH 1/5] 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 ab54eacb4..bf61e3cb5 100644 --- a/packages/rendering/src/font/font-renderer.ts +++ b/packages/rendering/src/font/font-renderer.ts @@ -243,8 +243,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 a08b0de40ec1da09cda2288a069cf70a21f0e275 Mon Sep 17 00:00:00 2001 From: Cody Date: Thu, 28 May 2026 14:45:36 -0600 Subject: [PATCH 2/5] 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 bf61e3cb5..db9e44351 100644 --- a/packages/rendering/src/font/font-renderer.ts +++ b/packages/rendering/src/font/font-renderer.ts @@ -247,12 +247,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 71d658b19212c8a1670bb39a8648da4984bd815a Mon Sep 17 00:00:00 2001 From: jacobk999 Date: Mon, 8 Jun 2026 17:52:13 -0400 Subject: [PATCH 3/5] chore: rebase --- packages/rendering/src/font/font-renderer.ts | 34 ++++---------- .../rendering/tests/font-renderer.spec.ts | 47 +++---------------- 2 files changed, 15 insertions(+), 66 deletions(-) diff --git a/packages/rendering/src/font/font-renderer.ts b/packages/rendering/src/font/font-renderer.ts index db9e44351..a55278a7b 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) { @@ -61,9 +61,6 @@ export class FontRenderer { ctx.drawImage(image, 0, 0); - this.canvases.set(ctx, canvas); - this.scales.set(ctx, image.width / 256); - this.images.set(id, ctx); } } @@ -197,21 +194,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)); @@ -238,7 +220,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()]; @@ -333,7 +315,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(); }); From cbc5a735a4b1d1079dad69ff43b00312f11fa313 Mon Sep 17 00:00:00 2001 From: jacobk999 Date: Mon, 8 Jun 2026 18:05:51 -0400 Subject: [PATCH 4/5] fix: remove scale property from map --- packages/rendering/src/font/font-renderer.ts | 36 ++++++++-------- .../rendering/tests/font-renderer.spec.ts | 43 ------------------- packages/rendering/vitest.config.ts | 14 +----- 3 files changed, 19 insertions(+), 74 deletions(-) delete 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 a55278a7b..e841519d1 100644 --- a/packages/rendering/src/font/font-renderer.ts +++ b/packages/rendering/src/font/font-renderer.ts @@ -8,11 +8,7 @@ import _positions from "../../positions.json" with { type: "json" }; import _sizes from "../../sizes.json" with { type: "json" }; -import type { - Canvas, - CanvasRenderingContext2D, - ImageData, -} from "skia-canvas"; +import type { Canvas, CanvasRenderingContext2D, ImageData } from "skia-canvas"; import { type TextNode, type Token, tokens } from "./tokens.js"; import { createCanvas } from "../canvas.js"; import { join } from "node:path"; @@ -27,10 +23,6 @@ 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; @@ -38,7 +30,7 @@ interface Sizes { } export class FontRenderer { - private images: Map; + private images: Map; public constructor(private gradient: boolean) { this.images = new Map(); @@ -47,11 +39,15 @@ export class FontRenderer { public async loadImages(fontPath: string) { const files = await readdir(fontPath); - const pictures = await Promise.all(files.filter((file) => file.endsWith(".png")).map(async (file) => { - const image = await loadImage(join(fontPath, file)); - const id = file.replace("unicode_page_", "").replace(".png", ""); - return [id, image] as const; - })); + const pictures = await Promise.all( + files + .filter((file) => file.endsWith(".png")) + .map(async (file) => { + const image = await loadImage(join(fontPath, file)); + const id = file.replace("unicode_page_", "").replace(".png", ""); + return [id, image] as const; + }), + ); for (const [id, image] of pictures) { const canvas = createCanvas(image.width, image.height); @@ -61,7 +57,7 @@ export class FontRenderer { ctx.drawImage(image, 0, 0); - this.images.set(id, ctx); + this.images.set(id, canvas); } } @@ -188,6 +184,10 @@ export class FontRenderer { return unicode.toUpperCase() in sizes.ascii; } + private getTextureScale(canvas: Canvas) { + return canvas.width / 256; + } + private getCharacterImage(unicode: string, isAscii: boolean) { return isAscii ? this.images.get("ascii") : @@ -220,7 +220,7 @@ export class FontRenderer { const { x, y } = this.getCharacterIndexLocation(unicode, isAscii); - const scale = image.scale; + const scale = this.getTextureScale(image); const characterSize = sizes[isAscii ? "ascii" : "unicode"][unicode.toUpperCase()]; @@ -315,7 +315,7 @@ export class FontRenderer { size, } = metadata; - const imageData = image.canvas + const imageData = image .getContext("2d") .getImageData(charX, charY, width, height); diff --git a/packages/rendering/tests/font-renderer.spec.ts b/packages/rendering/tests/font-renderer.spec.ts deleted file mode 100644 index 9e13fb7fe..000000000 --- a/packages/rendering/tests/font-renderer.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * 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("ignores unsupported glyphs with a loaded unicode page", () => { - const page = createCanvas(256, 256); - const target = createCanvas(16, 16).getContext("2d"); - - const renderer = new FontRenderer(false); - 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 page = createCanvas(256, 256); - const target = createCanvas(16, 16).getContext("2d"); - - const renderer = new FontRenderer(false); - 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 page = createCanvas(256, 256); - const target = createCanvas(16, 16).getContext("2d"); - - const renderer = new FontRenderer(false); - renderer["images"] = new Map([["ascii", { canvas: page, scale: 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 073ab261c..19e5e20ae 100644 --- a/packages/rendering/vitest.config.ts +++ b/packages/rendering/vitest.config.ts @@ -7,17 +7,5 @@ */ import { config } from "../../vitest.shared.js"; -import { mergeConfig } from "vitest/config"; -import { resolve } from "node:path"; -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"), - }, - }, -}); +export default await config("./.swcrc"); \ No newline at end of file From ec3247b1e8ed7689ab0893c95e0a463033b805bb Mon Sep 17 00:00:00 2001 From: jacobk999 Date: Mon, 8 Jun 2026 18:07:19 -0400 Subject: [PATCH 5/5] fix: remove unecessary checks --- packages/rendering/src/font/font-renderer.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/rendering/src/font/font-renderer.ts b/packages/rendering/src/font/font-renderer.ts index e841519d1..1dab011b4 100644 --- a/packages/rendering/src/font/font-renderer.ts +++ b/packages/rendering/src/font/font-renderer.ts @@ -229,17 +229,12 @@ 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: scaledWidth, - height, + width: width * scale, + height: 16 * scale, scale, isAscii, image,