From 7661440ffebdbd5d25914a7f9db3ade25078f45c Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Sun, 17 May 2026 13:08:21 +0200 Subject: [PATCH] fix(assets): sanitize btfont metadata and test partial sprite clips Coerce size, lineHeight, and baseline to positive finite numbers in BitmapFont.load instead of truthy fallbacks. Add tests for invalid metadata defaults and for clipped sprite blits that preserve destination alignment in the software renderer. Co-Authored-By: Claude Signed-off-by: Vaclav Vancura --- src/assets/BitmapFont.test.ts | 22 +++++++++++++++++++++ src/assets/BitmapFont.ts | 30 ++++++++++++++++++++--------- src/render/SoftwareRenderer.test.ts | 24 +++++++++++++++++++++++ 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/src/assets/BitmapFont.test.ts b/src/assets/BitmapFont.test.ts index f617ef1..49413f6 100644 --- a/src/assets/BitmapFont.test.ts +++ b/src/assets/BitmapFont.test.ts @@ -460,6 +460,28 @@ describe('BitmapFont', () => { expect(f.baseline).toBe(12); }); + it('should fall back when size, lineHeight, and baseline are invalid', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + mockFontFetchResponse({ + name: 'BadMetricsFont', + size: -5, + lineHeight: 'not-a-number', + baseline: 0, + texture: 'data:image/png;base64,aGVsbG8=', + glyphs: { A: { x: 0, y: 0, w: 8, h: 12, ox: 0, oy: 0, adv: 9 } }, + }), + ), + ); + + const f = await BitmapFont.load('bad-meta.btfont'); + + expect(f.size).toBe(12); + expect(f.lineHeight).toBe(12); + expect(f.baseline).toBe(12); + }); + it('should use size as a fallback for lineHeight and baseline', async () => { vi.stubGlobal( 'fetch', diff --git a/src/assets/BitmapFont.ts b/src/assets/BitmapFont.ts index c502dc4..36fa1bb 100644 --- a/src/assets/BitmapFont.ts +++ b/src/assets/BitmapFont.ts @@ -271,20 +271,32 @@ export class BitmapFont { const atlasHeight = spriteSheet.height; const { glyphs, asciiGlyphs } = BitmapFont.buildGlyphsFromEntries(glyphEntries, atlasWidth, atlasHeight); + const size = BitmapFont.resolvePositiveFontMetric(data.size, 12); + const lineHeight = BitmapFont.resolvePositiveFontMetric(data.lineHeight, size); + const baseline = BitmapFont.resolvePositiveFontMetric(data.baseline, size); - return new BitmapFont( - spriteSheet, - glyphs, - asciiGlyphs, - data.name || 'Unknown', - data.size || 12, - data.lineHeight || data.size || 12, - data.baseline || data.size || 12, - ); + return new BitmapFont(spriteSheet, glyphs, asciiGlyphs, data.name || 'Unknown', size, lineHeight, baseline); } // #region Loading Helpers + /** + * Coerces a `.btfont` metadata field to a positive finite number. + * + * @param value - Raw JSON value for `size`, `lineHeight`, or `baseline`. + * @param fallback - Value used when `value` is missing or invalid. + * @returns Safe positive metric for {@link BitmapFont} construction. + */ + private static resolvePositiveFontMetric(value: unknown, fallback: number): number { + const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : Number.NaN; + + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + + return fallback; + } + /** * Parses and validates a `.btfont` JSON payload after byte-size checks. * diff --git a/src/render/SoftwareRenderer.test.ts b/src/render/SoftwareRenderer.test.ts index 21f09fe..15574fc 100644 --- a/src/render/SoftwareRenderer.test.ts +++ b/src/render/SoftwareRenderer.test.ts @@ -206,6 +206,30 @@ describe('SoftwareRenderer', () => { expect(getPixel(frame as ImageData, 4, 2, 0)).toEqual([0, 0, 0, 0]); }); + it('clips partial sprite source rectangles while preserving destination alignment', async () => { + const canvas = { + width: 0, + height: 0, + style: { width: '', height: '' }, + getContext: canvasGet2d(context), + toBlob: (_cb: (blob: Blob | null) => void) => {}, + } as unknown as HTMLCanvasElement; + const renderer = new SoftwareRenderer(canvas, new Vector2i(4, 4)); + await renderer.init(); + renderer.setPalette(makePalette()); + + const sheet = SpriteSheet.fromIndexedPixels(2, 2, new Uint8Array([1, 2, 3, 0])); + renderer.beginFrame(); + renderer.drawSprite(sheet, new Rect2i(-1, 0, 2, 2), new Vector2i(0, 0), 0); + renderer.endFrame(); + + const frame = logicalContext.lastImageData; + expect(frame).not.toBeNull(); + expect(getPixel(frame as ImageData, 4, 0, 0)).toEqual([0, 0, 0, 0]); + expect(getPixel(frame as ImageData, 4, 1, 0)).toEqual([255, 0, 0, 255]); + expect(getPixel(frame as ImageData, 4, 1, 1)).toEqual([0, 255, 0, 255]); + }); + it('renders bitmap text through sprite-backed glyphs', async () => { const canvas = { width: 0, -- 2.51.2