From 65630d9b3b9e39aa115c9a8157cfd5fbc0cef237 Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Sun, 17 May 2026 13:02:01 +0200 Subject: [PATCH 1/2] feat(assets): cap sprite and bitmap font asset dimensions Add AssetLimits aligned with render configuration (8192 per side, 16M pixels). Reject oversized images, indexed buffers, and .btfont payloads before canvas readback, buffer retention, or GPU allocation. Validate glyph metrics and clip unsafe software sprite blits. Document limits and throw AssetLimitError with centralized Tier 1 messages. Co-Authored-By: Claude Signed-off-by: Vaclav Vancura --- docs/api-assets.md | 27 +- src/assets/AssetLoader.test.ts | 21 ++ src/assets/AssetLoader.ts | 10 + src/assets/BitmapFont.test.ts | 128 ++++++--- src/assets/BitmapFont.ts | 155 ++++++++-- src/assets/SpriteSheet.test.ts | 27 +- src/assets/SpriteSheet.ts | 15 +- src/render/SoftwareRenderer.test.ts | 24 ++ src/render/SoftwareRenderer.ts | 24 +- src/utils/AssetLimits.test.ts | 129 +++++++++ src/utils/AssetLimits.ts | 421 ++++++++++++++++++++++++++++ src/utils/errorMessages.ts | 266 ++++++++++++++++++ 12 files changed, 1171 insertions(+), 76 deletions(-) create mode 100644 src/utils/AssetLimits.test.ts create mode 100644 src/utils/AssetLimits.ts diff --git a/docs/api-assets.md b/docs/api-assets.md index f421d1d..6b3d477 100644 --- a/docs/api-assets.md +++ b/docs/api-assets.md @@ -4,9 +4,34 @@ Sprite sheets, bitmap fonts, and asset loading. --- +## Asset size limits + +Sprite sheets, font atlases, and raw indexed buffers share the same decoded-size policy as render configuration (`8192` +pixels per side, `16,777,216` total pixels). Limits are enforced before canvas readback, CPU buffer retention, GPU +texture creation, and software sprite loops. + +| Limit | Default | Applies to | +| ----------------------- | ---------------------------- | ---------------------------------------------------------------- | +| Max width / height | `8192` | Decoded PNGs, font atlas textures, `fromIndexedPixels()` | +| Max total pixels | `16,777,216` (`4096 × 4096`) | Same sources | +| Max `.btfont` JSON size | `1,048,576` bytes (`1 MiB`) | `BitmapFont.load()` before `JSON.parse()` | +| Max glyph count | `8192` | Glyph map entries in a `.btfont` file | +| Max software blit area | `16,777,216` pixels | Software renderer source rectangles (clipped to the sheet first) | + +When a limit is exceeded, loading throws an `AssetLimitError` with a beginner-friendly message. The software renderer +skips sprite blits whose source rectangle is empty, non-integer, fully outside the sheet, or still too large after +clipping. + +`.btfont` files may reference either a relative PNG path or an embedded `data:` image URI. Embedded textures use the +same decoded dimension limits as external PNGs. Prefer separate PNG files for large atlases so the JSON payload stays +under the JSON size limit. + +--- + ## Loading Assets -`AssetLoader` caches images by URL so repeated loads share the same `HTMLImageElement`. +`AssetLoader` caches images by URL so repeated loads share the same `HTMLImageElement`. Oversized images are rejected as +soon as the browser reports decoded dimensions. ```ts import { AssetLoader } from 'blit-tech'; diff --git a/src/assets/AssetLoader.test.ts b/src/assets/AssetLoader.test.ts index 5af19e5..93f5745 100644 --- a/src/assets/AssetLoader.test.ts +++ b/src/assets/AssetLoader.test.ts @@ -14,6 +14,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AssetLimitError, MAX_ASSET_DIMENSION } from '../utils/AssetLimits'; import { AssetLoader } from './AssetLoader'; afterEach(() => { @@ -152,6 +153,8 @@ describe('AssetLoader', () => { class { onload: (() => void) | null = null; onerror: (() => void) | null = null; + width = 100; + height = 100; private _src = ''; get src(): string { @@ -205,6 +208,24 @@ describe('AssetLoader', () => { ); }); + it('should reject oversized images after decode', async () => { + vi.stubGlobal( + 'Image', + class { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + width = MAX_ASSET_DIMENSION + 1; + height = 16; + + set src(_: string) { + this.onload?.(); + } + }, + ); + + await expect(AssetLoader.loadImage('huge.png')).rejects.toBeInstanceOf(AssetLimitError); + }); + it('should suggest .png when a font extension is used for an image URL', async () => { await expect(AssetLoader.loadImage('fail/sprites/hero.btfont')).rejects.toThrow( "This looks like a font file. For images, use a file that ends with '.png'.", diff --git a/src/assets/AssetLoader.ts b/src/assets/AssetLoader.ts index 156fd39..3d8ea15 100644 --- a/src/assets/AssetLoader.ts +++ b/src/assets/AssetLoader.ts @@ -1,3 +1,5 @@ +import { assertImageElementWithinLimits } from '../utils/AssetLimits'; + // #region Module State /** @@ -117,6 +119,14 @@ export class AssetLoader { const img = new Image(); img.onload = () => { + try { + assertImageElementWithinLimits('image', img); + } catch (error) { + loadingPromises.delete(url); + reject(error); + return; + } + loadedImages.set(url, img); loadingPromises.delete(url); resolve(img); diff --git a/src/assets/BitmapFont.test.ts b/src/assets/BitmapFont.test.ts index f82ecfa..f617ef1 100644 --- a/src/assets/BitmapFont.test.ts +++ b/src/assets/BitmapFont.test.ts @@ -14,6 +14,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AssetLimitError, MAX_ASSET_DIMENSION, MAX_BTFONT_JSON_BYTES, MAX_GLYPH_COUNT } from '../utils/AssetLimits'; import { Rect2i } from '../utils/Rect2i'; import { BitmapFont } from './BitmapFont'; import { SpriteSheet } from './SpriteSheet'; @@ -81,6 +82,21 @@ function createStubImage({ * Includes ASCII glyphs plus one extended Unicode glyph so the tests can cover * both the fast ASCII lookup table and the fallback Unicode map path. */ +/** + * Builds a mocked fetch response that returns JSON text like {@link BitmapFont.load} expects. + * + * @param data - Font descriptor object to serialize. + * @returns Resolved fetch response stub. + */ +function mockFontFetchResponse(data: unknown) { + return { + ok: true, + status: 200, + statusText: 'OK', + text: vi.fn().mockResolvedValue(JSON.stringify(data)), + }; +} + const MOCK_FONT_DATA: FontData = { name: 'TestFont', size: 12, @@ -106,15 +122,7 @@ describe('BitmapFont', () => { vi.stubGlobal('Image', createStubImage()); // Stub fetch to return the mock font data. - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - status: 200, - statusText: 'OK', - json: vi.fn().mockResolvedValue(MOCK_FONT_DATA), - }), - ); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockFontFetchResponse(MOCK_FONT_DATA))); font = await BitmapFont.load('test.btfont'); }); @@ -283,16 +291,15 @@ describe('BitmapFont', () => { it('should throw when font JSON is missing the texture field', async () => { vi.stubGlobal( 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: vi.fn().mockResolvedValue({ + vi.fn().mockResolvedValue( + mockFontFetchResponse({ name: 'Test', size: 12, lineHeight: 14, baseline: 10, glyphs: {}, }), - }), + ), ); await expect(BitmapFont.load('bad.btfont')).rejects.toThrow( @@ -303,16 +310,15 @@ describe('BitmapFont', () => { it('should throw when font JSON is missing the glyphs field', async () => { vi.stubGlobal( 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: vi.fn().mockResolvedValue({ + vi.fn().mockResolvedValue( + mockFontFetchResponse({ name: 'Test', size: 12, lineHeight: 14, baseline: 10, texture: 'data:image/png;base64,aGVsbG8=', }), - }), + ), ); await expect(BitmapFont.load('bad.btfont')).rejects.toThrow( @@ -322,23 +328,80 @@ describe('BitmapFont', () => { it('should throw when the font texture image fails to load', async () => { vi.stubGlobal('Image', createStubImage({ fireError: true })); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockFontFetchResponse(MOCK_FONT_DATA))); + + await expect(BitmapFont.load('test.btfont')).rejects.toThrow("Can't find the font texture image"); + }); + + it('should suggest .btfont when the font URL extension is wrong', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found' })); + + await expect(BitmapFont.load('missing.json')).rejects.toThrow( + "The extension '.json' looks wrong for this file. Did you mean '.btfont'?", + ); + }); + + it('should reject oversized .btfont JSON before parsing glyphs', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, - json: vi.fn().mockResolvedValue(MOCK_FONT_DATA), + text: vi.fn().mockResolvedValue(' '.repeat(MAX_BTFONT_JSON_BYTES + 1)), }), ); - await expect(BitmapFont.load('test.btfont')).rejects.toThrow("Can't find the font texture image"); + await expect(BitmapFont.load('huge.btfont')).rejects.toBeInstanceOf(AssetLimitError); }); - it('should suggest .btfont when the font URL extension is wrong', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found' })); + it('should reject fonts with too many glyphs', async () => { + const glyphs: Record = {}; - await expect(BitmapFont.load('missing.json')).rejects.toThrow( - "The extension '.json' looks wrong for this file. Did you mean '.btfont'?", + for (let i = 0; i < MAX_GLYPH_COUNT + 1; i++) { + glyphs[`g${i}`] = { x: 0, y: 0, w: 1, h: 1, ox: 0, oy: 0, adv: 1 }; + } + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockFontFetchResponse({ ...MOCK_FONT_DATA, glyphs }))); + + await expect(BitmapFont.load('many-glyphs.btfont')).rejects.toBeInstanceOf(AssetLimitError); + }); + + it('should reject glyph rectangles outside the texture atlas', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + mockFontFetchResponse({ + ...MOCK_FONT_DATA, + glyphs: { + A: { x: 60, y: 0, w: 8, h: 12, ox: 0, oy: 0, adv: 9 }, + }, + }), + ), + ); + + await expect(BitmapFont.load('bad-glyph.btfont')).rejects.toThrow('outside the 64x16 font texture'); + }); + + it('should reject invalid glyph metrics', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + mockFontFetchResponse({ + ...MOCK_FONT_DATA, + glyphs: { + A: { x: 0, y: 0, w: 8.5, h: 12, ox: 0, oy: 0, adv: 9 }, + }, + }), + ), ); + + await expect(BitmapFont.load('bad-metrics.btfont')).rejects.toThrow('invalid width (w)'); + }); + + it('should reject oversized font textures before creating a sprite sheet', async () => { + vi.stubGlobal('Image', createStubImage({ width: MAX_ASSET_DIMENSION + 1, height: 16 })); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockFontFetchResponse(MOCK_FONT_DATA))); + + await expect(BitmapFont.load('huge-texture.btfont')).rejects.toBeInstanceOf(AssetLimitError); }); it('should suggest absolute and relative paths when font URL omits / or ./', async () => { @@ -363,10 +426,7 @@ describe('BitmapFont', () => { vi.stubGlobal( 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: vi.fn().mockResolvedValue({ ...MOCK_FONT_DATA, texture: 'font-atlas.png' }), - }), + vi.fn().mockResolvedValue(mockFontFetchResponse({ ...MOCK_FONT_DATA, texture: 'font-atlas.png' })), ); const loaded = await BitmapFont.load('fonts/test.btfont'); @@ -384,13 +444,12 @@ describe('BitmapFont', () => { it('should use defaults when name, size, lineHeight, baseline are missing', async () => { vi.stubGlobal( 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: vi.fn().mockResolvedValue({ + vi.fn().mockResolvedValue( + mockFontFetchResponse({ 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('minimal.btfont'); @@ -404,15 +463,14 @@ describe('BitmapFont', () => { it('should use size as a fallback for lineHeight and baseline', async () => { vi.stubGlobal( 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: vi.fn().mockResolvedValue({ + vi.fn().mockResolvedValue( + mockFontFetchResponse({ name: 'CustomFont', size: 16, texture: 'data:image/png;base64,aGVsbG8=', glyphs: { A: { x: 0, y: 0, w: 8, h: 16, ox: 0, oy: 0, adv: 9 } }, }), - }), + ), ); const f = await BitmapFont.load('partial.btfont'); diff --git a/src/assets/BitmapFont.ts b/src/assets/BitmapFont.ts index 0393f7f..c502dc4 100644 --- a/src/assets/BitmapFont.ts +++ b/src/assets/BitmapFont.ts @@ -1,3 +1,11 @@ +import { + assertImageElementWithinLimits, + AssetLimitError, + validateBtfontGlyphData, + validateBtfontJsonByteSize, + validateGlyphCount, +} from '../utils/AssetLimits'; +import { btfontGlyphEntryNotObjectError } from '../utils/errorMessages'; import { Rect2i } from '../utils/Rect2i'; import { SpriteSheet } from './SpriteSheet'; @@ -248,32 +256,119 @@ export class BitmapFont { * @throws Error if the font descriptor or texture cannot be loaded. */ static async load(url: string): Promise { - // Fetch and parse the JSON file. const response = await fetch(url); if (!response.ok) { throw new Error(BitmapFont.buildFontLoadErrorMessage(url, response.status)); } - const data: FontFileData = await response.json(); + const { data, glyphEntries } = BitmapFont.parseBtfontFile(url, await response.text()); - // Validate required fields. - if (!data.texture || !data.glyphs) { + // Load texture – embedded `data:` URIs and relative PNG paths are both allowed. + const image = await BitmapFont.loadTexture(data.texture, url); + const spriteSheet = new SpriteSheet(image); + const atlasWidth = spriteSheet.width; + const atlasHeight = spriteSheet.height; + + const { glyphs, asciiGlyphs } = BitmapFont.buildGlyphsFromEntries(glyphEntries, atlasWidth, atlasHeight); + + return new BitmapFont( + spriteSheet, + glyphs, + asciiGlyphs, + data.name || 'Unknown', + data.size || 12, + data.lineHeight || data.size || 12, + data.baseline || data.size || 12, + ); + } + + // #region Loading Helpers + + /** + * Parses and validates a `.btfont` JSON payload after byte-size checks. + * + * @param url - Path to the `.btfont` file (used in error messages). + * @param jsonText - Raw JSON text from the font file. + * @returns Parsed font descriptor and glyph map entries. + */ + private static parseBtfontFile( + url: string, + jsonText: string, + ): { data: FontFileData; glyphEntries: Array<[string, GlyphData]> } { + const jsonByteLength = new TextEncoder().encode(jsonText).length; + const jsonSizeError = validateBtfontJsonByteSize(jsonByteLength); + + if (jsonSizeError) { + throw new AssetLimitError(jsonSizeError); + } + + let data: FontFileData; + + try { + data = JSON.parse(jsonText) as FontFileData; + } catch { throw new Error( `The font file '${url}' is broken or not a valid .btfont file. Check that it's the right file.` + BitmapFont.buildExtensionHint(url, '.btfont'), ); } - // Load texture – either from data URI or relative path. - const image = await BitmapFont.loadTexture(data.texture, url); - const spriteSheet = new SpriteSheet(image); + if ( + typeof data.texture !== 'string' || + data.texture.length === 0 || + data.glyphs === null || + typeof data.glyphs !== 'object' || + Array.isArray(data.glyphs) + ) { + throw new Error( + `The font file '${url}' is broken or not a valid .btfont file. Check that it's the right file.` + + BitmapFont.buildExtensionHint(url, '.btfont'), + ); + } + + const glyphEntries = Object.entries(data.glyphs); + const glyphCountError = validateGlyphCount(glyphEntries.length); + + if (glyphCountError) { + throw new AssetLimitError(glyphCountError); + } + + return { data, glyphEntries }; + } - // Convert glyph data to internal format. + /** + * Converts validated `.btfont` glyph entries into runtime glyph maps. + * + * @param glyphEntries - Glyph map entries from the parsed font file. + * @param atlasWidth - Font texture atlas width in pixels. + * @param atlasHeight - Font texture atlas height in pixels. + * @returns Glyph lookup tables for Unicode and ASCII fast paths. + */ + private static buildGlyphsFromEntries( + glyphEntries: Array<[string, GlyphData]>, + atlasWidth: number, + atlasHeight: number, + ): { glyphs: Map; asciiGlyphs: (Glyph | null)[] } { const glyphs = new Map(); const asciiGlyphs: (Glyph | null)[] = new Array(ASCII_CACHE_SIZE).fill(null); - for (const [char, glyphData] of Object.entries(data.glyphs)) { + for (const [char, glyphData] of glyphEntries) { + if (glyphData === null || typeof glyphData !== 'object' || Array.isArray(glyphData)) { + throw new AssetLimitError(btfontGlyphEntryNotObjectError(BitmapFont.formatGlyphCharLabel(char))); + } + + const glyphError = validateBtfontGlyphData( + glyphData, + atlasWidth, + atlasHeight, + BitmapFont.formatGlyphCharLabel(char), + ); + + if (glyphError) { + throw new AssetLimitError(glyphError); + } + const glyph: Glyph = { rect: new Rect2i(glyphData.x, glyphData.y, glyphData.w, glyphData.h), offsetX: glyphData.ox, @@ -283,7 +378,6 @@ export class BitmapFont { glyphs.set(char, glyph); - // Populate the ASCII fast-lookup array for single-byte characters. if (char.length === 1) { const code = char.charCodeAt(0); @@ -294,19 +388,9 @@ export class BitmapFont { } } - return new BitmapFont( - spriteSheet, - glyphs, - asciiGlyphs, - data.name || 'Unknown', - data.size || 12, - data.lineHeight || data.size || 12, - data.baseline || data.size || 12, - ); + return { glyphs, asciiGlyphs }; } - // #region Loading Helpers - /** * Loads a texture from either a base64 data URI or a relative path. * @@ -395,6 +479,24 @@ export class BitmapFont { return `The extension '${extension}' looks wrong for this file. Did you mean '${expectedExtension}'?`; } + /** + * Formats a glyph character label for user-facing validation errors. + * + * @param char - Glyph map key from the `.btfont` file. + * @returns Printable label or a Unicode code point for control characters. + */ + private static formatGlyphCharLabel(char: string): string { + if (char.length === 1) { + const code = char.charCodeAt(0); + + if (code < 32 || code === 127) { + return `U+${code.toString(16).toUpperCase().padStart(4, '0')}`; + } + } + + return char; + } + /** * Returns whether the URL already has an explicit scheme or protocol. * @@ -425,7 +527,16 @@ export class BitmapFont { return new Promise((resolve, reject) => { const image = new Image(); - image.onload = () => resolve(image); + image.onload = () => { + try { + assertImageElementWithinLimits('font texture', image); + } catch (error) { + reject(error); + return; + } + + resolve(image); + }; image.onerror = () => reject( new Error( diff --git a/src/assets/SpriteSheet.test.ts b/src/assets/SpriteSheet.test.ts index f6917c7..88af14d 100644 --- a/src/assets/SpriteSheet.test.ts +++ b/src/assets/SpriteSheet.test.ts @@ -17,6 +17,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { createMockGPUDevice } from '../__test__/webgpu-mock'; +import { AssetLimitError, MAX_ASSET_DIMENSION } from '../utils/AssetLimits'; import { Color32 } from '../utils/Color32'; import { Rect2i } from '../utils/Rect2i'; import { AssetLoader } from './AssetLoader'; @@ -345,6 +346,15 @@ describe('SpriteSheet', () => { expect(closeSpy).toHaveBeenCalledOnce(); }); + it('rejects oversized images returned from AssetLoader', async () => { + vi.spyOn(AssetLoader, 'loadImage').mockResolvedValue({ + width: MAX_ASSET_DIMENSION + 1, + height: 16, + } as HTMLImageElement); + + await expect(SpriteSheet.load('huge.png')).rejects.toBeInstanceOf(AssetLimitError); + }); + it('should close imageBitmap on destroy when getTexture was never called', async () => { const closeSpy = vi.fn(); vi.stubGlobal( @@ -581,15 +591,28 @@ describe('SpriteSheet', () => { expect(() => sheet.getImage()).toThrow('not available for sheets created from raw indexed data'); }); - it('throws RangeError when pixel array length mismatches dimensions', () => { + it('throws AssetLimitError when pixel array length mismatches dimensions', () => { const pixels = new Uint8Array(10) as Uint8Array; - expect(() => SpriteSheet.fromIndexedPixels(4, 4, pixels)).toThrow(RangeError); + expect(() => SpriteSheet.fromIndexedPixels(4, 4, pixels)).toThrow(AssetLimitError); expect(() => SpriteSheet.fromIndexedPixels(4, 4, pixels)).toThrow( 'The pixel data has 10 values, but a 4x4 sheet needs exactly 16.', ); }); + it('rejects invalid raw dimensions before retaining buffers', () => { + const pixels = new Uint8Array(16) as Uint8Array; + + expect(() => SpriteSheet.fromIndexedPixels(0, 4, pixels)).toThrow(AssetLimitError); + expect(() => SpriteSheet.fromIndexedPixels(Number.NaN, 4, pixels)).toThrow(AssetLimitError); + }); + + it('rejects oversized indexed dimensions before allocation', () => { + const pixels = new Uint8Array(16) as Uint8Array; + + expect(() => SpriteSheet.fromIndexedPixels(MAX_ASSET_DIMENSION + 1, 16, pixels)).toThrow(AssetLimitError); + }); + it('creates r8uint GPU texture via getTexture', () => { const device = createMockGPUDevice(); const createTextureSpy = vi.spyOn(device, 'createTexture'); diff --git a/src/assets/SpriteSheet.ts b/src/assets/SpriteSheet.ts index c4c0ce2..ed2fb54 100644 --- a/src/assets/SpriteSheet.ts +++ b/src/assets/SpriteSheet.ts @@ -1,3 +1,4 @@ +import { assertAssetDimensions, assertImageElementWithinLimits, assertIndexedPixelInput } from '../utils/AssetLimits'; import { Color32 } from '../utils/Color32'; import { spriteColorNotInPaletteError } from '../utils/errorMessages'; import { Rect2i } from '../utils/Rect2i'; @@ -68,8 +69,10 @@ export class SpriteSheet { this.image = image; if (image) { + assertImageElementWithinLimits('sprite sheet', image); this.size = new Vector2i(image.width, image.height); } else if (size) { + assertAssetDimensions('sprite sheet', size.x, size.y); this.size = size; } else { throw new Error('Either an image or explicit size must be provided.'); @@ -188,6 +191,7 @@ export class SpriteSheet { options?: { sort?: 'luminance' | 'none' }, ): Promise { const image = await AssetLoader.loadImage(url); + assertImageElementWithinLimits('sprite sheet', image); const w = image.width; const h = image.height; @@ -276,13 +280,7 @@ export class SpriteSheet { * @returns Sprite sheet ready for rendering. */ static fromIndexedPixels(width: number, height: number, indexedPixels: Uint8Array): SpriteSheet { - const expectedLength = width * height; - - if (indexedPixels.length !== expectedLength) { - throw new RangeError( - `The pixel data has ${indexedPixels.length} values, but a ${width}x${height} sheet needs exactly ${expectedLength}. Make sure indexedPixels has one entry per pixel.`, - ); - } + assertIndexedPixelInput(width, height, indexedPixels.length); const sheet = new SpriteSheet(null, new Vector2i(width, height)); @@ -314,6 +312,7 @@ export class SpriteSheet { throw new Error('indexize: not available for sheets created from raw indexed data.'); } + assertImageElementWithinLimits('sprite sheet', this.image); const w = this.size.x; const h = this.size.y; @@ -623,6 +622,7 @@ export class SpriteSheet { throw new Error('createTexture: no source image available.'); } + assertAssetDimensions('sprite sheet texture', this.size.x, this.size.y); this.texture = device.createTexture({ label: 'Sprite Sheet Texture', size: [this.size.x, this.size.y, 1], @@ -651,6 +651,7 @@ export class SpriteSheet { * @param device - WebGPU device for texture creation. */ private createIndexedTexture(device: GPUDevice): void { + assertAssetDimensions('sprite sheet texture', this.size.x, this.size.y); this.texture = device.createTexture({ label: 'Sprite Sheet Indexed Texture', size: [this.size.x, this.size.y, 1], diff --git a/src/render/SoftwareRenderer.test.ts b/src/render/SoftwareRenderer.test.ts index 6b56686..21f09fe 100644 --- a/src/render/SoftwareRenderer.test.ts +++ b/src/render/SoftwareRenderer.test.ts @@ -182,6 +182,30 @@ describe('SoftwareRenderer', () => { expect(getPixel(frame as ImageData, 4, 1, 0)).toEqual([255, 255, 0, 255]); }); + it('skips invalid sprite source rectangles in software rendering', 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, 4])); + renderer.beginFrame(); + renderer.drawSprite(sheet, new Rect2i(0, 0, 2, 2), new Vector2i(0, 0), 0); + renderer.drawSprite(sheet, new Rect2i(2, 0, -1, 2), new Vector2i(2, 0), 0); + renderer.endFrame(); + + const frame = logicalContext.lastImageData; + expect(frame).not.toBeNull(); + expect(getPixel(frame as ImageData, 4, 0, 0)).toEqual([255, 0, 0, 255]); + expect(getPixel(frame as ImageData, 4, 2, 0)).toEqual([0, 0, 0, 0]); + }); + it('renders bitmap text through sprite-backed glyphs', async () => { const canvas = { width: 0, diff --git a/src/render/SoftwareRenderer.ts b/src/render/SoftwareRenderer.ts index aa7cda4..3175f5d 100644 --- a/src/render/SoftwareRenderer.ts +++ b/src/render/SoftwareRenderer.ts @@ -1,6 +1,7 @@ import type { BitmapFont } from '../assets/BitmapFont'; import type { Palette } from '../assets/Palette'; import type { SpriteSheet } from '../assets/SpriteSheet'; +import { clipSpriteSourceRect } from '../utils/AssetLimits'; import { Color32 } from '../utils/Color32'; import { noActivePaletteError } from '../utils/errorMessages'; import { Rect2i } from '../utils/Rect2i'; @@ -649,24 +650,29 @@ export class SoftwareRenderer implements IRenderer { const sheetHeight = command.spriteSheet.height; const srcRect = command.srcRect; const destPos = command.destPos; + const clipped = clipSpriteSourceRect(srcRect, sheetWidth, sheetHeight); - for (let y = 0; y < srcRect.height; y++) { - for (let x = 0; x < srcRect.width; x++) { - const srcX = srcRect.x + x; - const srcY = srcRect.y + y; - if (srcX < 0 || srcY < 0 || srcX >= sheetWidth || srcY >= sheetHeight) { - continue; - } + if (clipped === null) { + return; + } + + const destOffsetX = clipped.x - srcRect.x; + const destOffsetY = clipped.y - srcRect.y; + for (let y = 0; y < clipped.height; y++) { + for (let x = 0; x < clipped.width; x++) { + const srcX = clipped.x + x; + const srcY = clipped.y + y; const rawIndex = indexedPixels[srcY * sheetWidth + srcX] ?? 0; + if (rawIndex === 0) { continue; } const finalIndex = (rawIndex + command.paletteOffset) >>> 0; const color = this.resolveSpriteColor(finalIndex); - const destX = destPos.x + x - command.cameraX; - const destY = destPos.y + y - command.cameraY; + const destX = destPos.x + destOffsetX + x - command.cameraX; + const destY = destPos.y + destOffsetY + y - command.cameraY; this.writePixel(destX, destY, color.r, color.g, color.b, 255); } } diff --git a/src/utils/AssetLimits.test.ts b/src/utils/AssetLimits.test.ts new file mode 100644 index 0000000..926a596 --- /dev/null +++ b/src/utils/AssetLimits.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; + +import { + ASSET_DIMENSION_LIMITS, + clipSpriteSourceRect, + computeSafePixelArea, + MAX_ASSET_DIMENSION, + MAX_ASSET_PIXELS, + MAX_BTFONT_JSON_BYTES, + MAX_GLYPH_COUNT, + validateAssetDimensions, + validateBtfontGlyphData, + validateBtfontJsonByteSize, + validateGlyphCount, + validateIndexedPixelInput, +} from './AssetLimits'; +import { Rect2i } from './Rect2i'; + +describe('AssetLimits', () => { + describe('validateAssetDimensions', () => { + const cases: Array<{ name: string; width: number; height: number }> = [ + { name: 'zero width', width: 0, height: 16 }, + { name: 'negative height', width: 16, height: -1 }, + { name: 'fractional width', width: 16.5, height: 16 }, + { name: 'NaN height', width: 16, height: Number.NaN }, + { name: 'huge width', width: MAX_ASSET_DIMENSION + 1, height: 16 }, + { name: 'huge height', width: 16, height: MAX_ASSET_DIMENSION + 1 }, + { name: 'huge area', width: 4096, height: 4097 }, + ]; + + for (const testCase of cases) { + it(`rejects ${testCase.name}`, () => { + expect(validateAssetDimensions('sprite sheet', testCase.width, testCase.height)).not.toBeNull(); + }); + } + + it('accepts valid dimensions', () => { + expect(validateAssetDimensions('sprite sheet', 256, 256)).toBeNull(); + }); + + it('rejects total area separately from per-axis limits', () => { + const error = validateAssetDimensions('sprite sheet', 4096, 4097); + + expect(error).toContain(MAX_ASSET_PIXELS.toLocaleString('en-US')); + }); + }); + + describe('validateIndexedPixelInput', () => { + it('rejects length mismatch before allocation', () => { + const error = validateIndexedPixelInput(4, 4, 10); + + expect(error).toContain('10 values'); + expect(error).toContain('16'); + }); + + it('rejects overflowed width*height products', () => { + const error = validateIndexedPixelInput(MAX_ASSET_DIMENSION, MAX_ASSET_DIMENSION); + + expect(error).toContain('too many pixels'); + }); + }); + + describe('computeSafePixelArea', () => { + it('returns null for invalid dimensions', () => { + expect(computeSafePixelArea(0, 16)).toBeNull(); + expect(computeSafePixelArea(16, Number.POSITIVE_INFINITY)).toBeNull(); + }); + + it('returns the pixel count for valid dimensions', () => { + expect(computeSafePixelArea(16, 16)).toBe(256); + }); + }); + + describe('validateBtfontJsonByteSize', () => { + it('rejects oversized JSON payloads', () => { + const error = validateBtfontJsonByteSize(MAX_BTFONT_JSON_BYTES + 1); + + expect(error).toContain(MAX_BTFONT_JSON_BYTES.toLocaleString('en-US')); + }); + }); + + describe('validateGlyphCount', () => { + it('rejects oversized glyph maps', () => { + const error = validateGlyphCount(MAX_GLYPH_COUNT + 1); + + expect(error).toContain(MAX_GLYPH_COUNT.toLocaleString('en-US')); + }); + }); + + describe('validateBtfontGlyphData', () => { + it('rejects glyph rectangles outside the atlas', () => { + const error = validateBtfontGlyphData({ x: 60, y: 0, w: 8, h: 12, ox: 0, oy: 0, adv: 8 }, 64, 16, 'A'); + + expect(error).toContain('outside'); + }); + + it('rejects non-integer metrics', () => { + const error = validateBtfontGlyphData({ x: 0, y: 0, w: 8.5, h: 12, ox: 0, oy: 0, adv: 8 }, 64, 16, 'A'); + + expect(error).toContain('whole number'); + }); + + it('accepts valid glyph metrics', () => { + expect(validateBtfontGlyphData({ x: 0, y: 0, w: 8, h: 12, ox: 0, oy: 0, adv: 8 }, 64, 16, 'A')).toBeNull(); + }); + }); + + describe('clipSpriteSourceRect', () => { + it('clips rectangles to the sheet bounds', () => { + const clipped = clipSpriteSourceRect(new Rect2i(-2, -1, 5, 4), 4, 4); + + expect(clipped).toEqual({ x: 0, y: 0, width: 3, height: 3 }); + }); + + it('returns null for oversized blits after clipping', () => { + const clipped = clipSpriteSourceRect( + new Rect2i(0, 0, ASSET_DIMENSION_LIMITS.maxWidth, ASSET_DIMENSION_LIMITS.maxHeight), + ASSET_DIMENSION_LIMITS.maxWidth, + ASSET_DIMENSION_LIMITS.maxHeight, + ); + + expect(clipped).toBeNull(); + }); + + it('returns null for rectangles with non-positive size', () => { + expect(clipSpriteSourceRect(new Rect2i(0, 0, 0, 2), 4, 4)).toBeNull(); + }); + }); +}); diff --git a/src/utils/AssetLimits.ts b/src/utils/AssetLimits.ts new file mode 100644 index 0000000..aac6b44 --- /dev/null +++ b/src/utils/AssetLimits.ts @@ -0,0 +1,421 @@ +import { + assetDimensionAreaTooLargeError, + assetDimensionInvalidError, + assetDimensionTooLargeError, + assetIndexedPixelLengthError, + assetIndexedPixelOverflowError, + btfontGlyphAreaTooLargeError, + btfontGlyphCountTooLargeError, + btfontGlyphMetricNotIntegerError, + btfontGlyphNegativeAdvanceError, + btfontGlyphNegativePositionError, + btfontGlyphNegativeSizeError, + btfontGlyphOutsideAtlasError, + btfontGlyphSizeTooLargeError, + btfontJsonTooLargeError, +} from './errorMessages'; +import type { Rect2i } from './Rect2i'; +import { MAX_RENDER_DIMENSION, MAX_RENDER_PIXELS } from './RenderLimits'; + +// #region Constants + +/** Maximum decoded asset width or height on either axis before CPU/GPU allocation. */ +export const MAX_ASSET_DIMENSION = MAX_RENDER_DIMENSION; + +/** Maximum total decoded pixels for an image or indexed buffer before allocation. */ +export const MAX_ASSET_PIXELS = MAX_RENDER_PIXELS; + +/** Shared allocation policy for sprite sheets, font atlases, and indexed pixel buffers. */ +export const ASSET_DIMENSION_LIMITS = { + maxWidth: MAX_ASSET_DIMENSION, + maxHeight: MAX_ASSET_DIMENSION, + maxPixels: MAX_ASSET_PIXELS, +} as const; + +/** Maximum `.btfont` JSON payload size in bytes before parsing. */ +export const MAX_BTFONT_JSON_BYTES = 1_048_576; + +/** Maximum number of glyph entries accepted from a `.btfont` file. */ +export const MAX_GLYPH_COUNT = 8192; + +/** Maximum pixels iterated for a single software sprite blit (`width * height`). */ +export const MAX_SPRITE_BLIT_PIXELS = MAX_ASSET_PIXELS; + +// #endregion + +// #region Types + +/** Error type for asset-dimension failures surfaced through public load APIs. */ +export class AssetLimitError extends Error { + /** + * Creates an asset limit error. + * + * @param message - User-facing validation message. + */ + constructor(message: string) { + super(message); + this.name = 'AssetLimitError'; + } +} + +/** Serialized glyph metrics from a `.btfont` file before conversion to {@link Glyph}. */ +export interface BtfontGlyphData { + /** X position in the texture atlas. */ + x: number; + /** Y position in the texture atlas. */ + y: number; + /** Glyph width in pixels. */ + w: number; + /** Glyph height in pixels. */ + h: number; + /** Horizontal offset when rendering. */ + ox: number; + /** Vertical offset when rendering. */ + oy: number; + /** Horizontal advance width. */ + adv: number; +} + +// #endregion + +// #region Helpers + +/** + * Formats width and height for error messages. + * + * @param width - Width in pixels. + * @param height - Height in pixels. + * @returns Size formatted as `WIDTHxHEIGHT`. + */ +export function formatAssetSize(width: number, height: number): string { + return `${width}x${height}`; +} + +/** + * Computes a safe pixel count when width and height are valid asset dimensions. + * + * @param width - Width in pixels. + * @param height - Height in pixels. + * @returns Total pixels when valid, otherwise `null`. + */ +export function computeSafePixelArea(width: number, height: number): number | null { + if (!isPositiveIntegerDimension(width) || !isPositiveIntegerDimension(height)) { + return null; + } + + if (width > ASSET_DIMENSION_LIMITS.maxWidth || height > ASSET_DIMENSION_LIMITS.maxHeight) { + return null; + } + + const area = width * height; + + if (!Number.isSafeInteger(area) || area > ASSET_DIMENSION_LIMITS.maxPixels) { + return null; + } + + return area; +} + +/** + * Returns whether a value is a positive finite integer suitable for pixel sizing. + * + * @param value - Candidate dimension. + * @returns True when the value is a positive whole number. + */ +function isPositiveIntegerDimension(value: number): boolean { + return typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value) && value > 0; +} + +/** + * Returns whether a value is a finite integer suitable for glyph metrics. + * + * @param value - Candidate metric. + * @returns True when the value is a finite whole number. + */ +function isIntegerMetric(value: number): boolean { + return typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value); +} + +// #endregion + +// #region Validation + +/** + * Validates decoded image or indexed-buffer dimensions before allocation. + * + * @param context - Asset label used in error text (for example `'sprite sheet'`). + * @param width - Width in pixels. + * @param height - Height in pixels. + * @returns A user-facing error message when invalid, otherwise `null`. + */ +export function validateAssetDimensions(context: string, width: number, height: number): string | null { + if (!isPositiveIntegerDimension(width) || !isPositiveIntegerDimension(height)) { + return assetDimensionInvalidError(context, formatAssetSize(width, height)); + } + + if (width > ASSET_DIMENSION_LIMITS.maxWidth || height > ASSET_DIMENSION_LIMITS.maxHeight) { + return assetDimensionTooLargeError( + context, + formatAssetSize(width, height), + ASSET_DIMENSION_LIMITS.maxWidth, + ASSET_DIMENSION_LIMITS.maxHeight, + ); + } + + const area = width * height; + + if (!Number.isSafeInteger(area) || area > ASSET_DIMENSION_LIMITS.maxPixels) { + return assetDimensionAreaTooLargeError( + context, + formatAssetSize(width, height), + ASSET_DIMENSION_LIMITS.maxPixels, + ); + } + + return null; +} + +/** + * Validates decoded image dimensions and throws when they exceed engine limits. + * + * @param context - Asset label used in error text. + * @param width - Width in pixels. + * @param height - Height in pixels. + * @throws {@link AssetLimitError} when the dimensions are invalid. + */ +export function assertAssetDimensions(context: string, width: number, height: number): void { + const error = validateAssetDimensions(context, width, height); + + if (error) { + throw new AssetLimitError(error); + } +} + +/** + * Validates an `HTMLImageElement` after decode and before canvas readback or texture upload. + * + * @param context - Asset label used in error text. + * @param image - Loaded image element. + * @throws {@link AssetLimitError} when the image dimensions are invalid. + */ +export function assertImageElementWithinLimits(context: string, image: HTMLImageElement): void { + assertAssetDimensions(context, image.width, image.height); +} + +/** + * Validates raw indexed pixel dimensions and optional buffer length before retention. + * + * @param width - Texture width in pixels. + * @param height - Texture height in pixels. + * @param pixelLength - Optional indexed pixel array length to compare against `width * height`. + * @returns A user-facing error message when invalid, otherwise `null`. + */ +export function validateIndexedPixelInput(width: number, height: number, pixelLength?: number): string | null { + const dimensionError = validateAssetDimensions('indexed sprite sheet', width, height); + + if (dimensionError) { + return dimensionError; + } + + const expectedLength = computeSafePixelArea(width, height); + + if (expectedLength === null) { + return assetIndexedPixelOverflowError(formatAssetSize(width, height)); + } + + if (pixelLength !== undefined && pixelLength !== expectedLength) { + return assetIndexedPixelLengthError(pixelLength, width, height, expectedLength); + } + + return null; +} + +/** + * Validates raw indexed pixel dimensions and throws before buffer allocation. + * + * @param width - Texture width in pixels. + * @param height - Texture height in pixels. + * @param pixelLength - Optional indexed pixel array length to compare against `width * height`. + * @throws {@link AssetLimitError} when the dimensions or length are invalid. + */ +export function assertIndexedPixelInput(width: number, height: number, pixelLength?: number): void { + const error = validateIndexedPixelInput(width, height, pixelLength); + + if (error) { + throw new AssetLimitError(error); + } +} + +/** + * Validates a `.btfont` JSON payload size before parsing. + * + * @param byteLength - UTF-8 byte length of the JSON text. + * @returns A user-facing error message when invalid, otherwise `null`. + */ +export function validateBtfontJsonByteSize(byteLength: number): string | null { + if (!Number.isFinite(byteLength) || byteLength < 0 || !Number.isInteger(byteLength)) { + return btfontJsonTooLargeError(byteLength, MAX_BTFONT_JSON_BYTES); + } + + if (byteLength > MAX_BTFONT_JSON_BYTES) { + return btfontJsonTooLargeError(byteLength, MAX_BTFONT_JSON_BYTES); + } + + return null; +} + +/** + * Validates glyph map size before building lookup tables. + * + * @param count - Number of glyph entries. + * @returns A user-facing error message when invalid, otherwise `null`. + */ +export function validateGlyphCount(count: number): string | null { + if (!Number.isFinite(count) || count < 0 || !Number.isInteger(count)) { + return btfontGlyphCountTooLargeError(count, MAX_GLYPH_COUNT); + } + + if (count > MAX_GLYPH_COUNT) { + return btfontGlyphCountTooLargeError(count, MAX_GLYPH_COUNT); + } + + return null; +} + +/** + * Validates numeric glyph metrics before atlas bounds are checked. + * + * @param glyph - Glyph metrics from the `.btfont` file. + * @param charLabel - Character label used in error text. + * @returns A user-facing error message when invalid, otherwise `null`. + */ +function validateBtfontGlyphMetrics(glyph: BtfontGlyphData, charLabel: string): string | null { + const metrics: Array<[string, number]> = [ + ['x', glyph.x], + ['y', glyph.y], + ['w', glyph.w], + ['h', glyph.h], + ['ox', glyph.ox], + ['oy', glyph.oy], + ['adv', glyph.adv], + ]; + + for (const [name, value] of metrics) { + if (!isIntegerMetric(value)) { + return btfontGlyphMetricNotIntegerError(charLabel, name, value); + } + } + + if (glyph.x < 0 || glyph.y < 0) { + return btfontGlyphNegativePositionError(charLabel); + } + + if (glyph.w < 0 || glyph.h < 0) { + return btfontGlyphNegativeSizeError(charLabel); + } + + if (glyph.adv < 0) { + return btfontGlyphNegativeAdvanceError(charLabel); + } + + return null; +} + +/** + * Validates one serialized glyph entry against atlas bounds and metric rules. + * + * @param glyph - Glyph metrics from the `.btfont` file. + * @param atlasWidth - Font texture atlas width in pixels. + * @param atlasHeight - Font texture atlas height in pixels. + * @param charLabel - Character label used in error text. + * @returns A user-facing error message when invalid, otherwise `null`. + */ +export function validateBtfontGlyphData( + glyph: BtfontGlyphData, + atlasWidth: number, + atlasHeight: number, + charLabel: string, +): string | null { + const metricError = validateBtfontGlyphMetrics(glyph, charLabel); + + if (metricError) { + return metricError; + } + + if (glyph.w > ASSET_DIMENSION_LIMITS.maxWidth || glyph.h > ASSET_DIMENSION_LIMITS.maxHeight) { + return btfontGlyphSizeTooLargeError( + charLabel, + glyph.w, + glyph.h, + ASSET_DIMENSION_LIMITS.maxWidth, + ASSET_DIMENSION_LIMITS.maxHeight, + ); + } + + if (glyph.x + glyph.w > atlasWidth || glyph.y + glyph.h > atlasHeight) { + return btfontGlyphOutsideAtlasError(charLabel, glyph.x, glyph.y, glyph.w, glyph.h, atlasWidth, atlasHeight); + } + + const glyphArea = glyph.w * glyph.h; + + if (glyph.w > 0 && glyph.h > 0 && (!Number.isSafeInteger(glyphArea) || glyphArea > MAX_SPRITE_BLIT_PIXELS)) { + return btfontGlyphAreaTooLargeError(charLabel); + } + + return null; +} + +/** + * Clips a sprite source rectangle to the sheet and software blit limits. + * + * Returns `null` when the rectangle is empty, fully outside the sheet, or still + * too large to iterate safely after clipping. + * + * @param srcRect - Requested source rectangle in sheet space. + * @param sheetWidth - Sprite sheet width in pixels. + * @param sheetHeight - Sprite sheet height in pixels. + * @returns Clipped source bounds, or `null` when the blit should be skipped. + */ +export function clipSpriteSourceRect( + srcRect: Rect2i, + sheetWidth: number, + sheetHeight: number, +): { x: number; y: number; width: number; height: number } | null { + if ( + !Number.isFinite(srcRect.x) || + !Number.isFinite(srcRect.y) || + !Number.isFinite(srcRect.width) || + !Number.isFinite(srcRect.height) || + !Number.isInteger(srcRect.x) || + !Number.isInteger(srcRect.y) || + !Number.isInteger(srcRect.width) || + !Number.isInteger(srcRect.height) + ) { + return null; + } + + if (srcRect.width <= 0 || srcRect.height <= 0) { + return null; + } + + const x0 = Math.max(0, srcRect.x); + const y0 = Math.max(0, srcRect.y); + const x1 = Math.min(sheetWidth, srcRect.x + srcRect.width); + const y1 = Math.min(sheetHeight, srcRect.y + srcRect.height); + const width = x1 - x0; + const height = y1 - y0; + + if (width <= 0 || height <= 0) { + return null; + } + + const area = width * height; + + if (!Number.isSafeInteger(area) || area > MAX_SPRITE_BLIT_PIXELS) { + return null; + } + + return { x: x0, y: y0, width, height }; +} + +// #endregion diff --git a/src/utils/errorMessages.ts b/src/utils/errorMessages.ts index 1371a64..fd9ed4a 100644 --- a/src/utils/errorMessages.ts +++ b/src/utils/errorMessages.ts @@ -104,6 +104,272 @@ export function renderDimensionGpuLimitError(field: string, size: string, maxTex // #endregion +// #region Runtime — Assets + +/** + * Returns the error message for an asset whose width or height is not a positive whole number. + * + * @param context - Asset label (for example `'sprite sheet'`). + * @param size - Invalid size formatted as `WIDTHxHEIGHT`. + * @returns User-facing error string. + */ +export function assetDimensionInvalidError(context: string, size: string): string { + return ( + `This ${context} must use whole-number pixel dimensions greater than 0 (got ${size}). ` + + 'Check the image file or width and height values and try again' + ); +} + +/** + * Returns the error message for an asset whose width or height exceeds engine limits. + * + * @param context - Asset label (for example `'sprite sheet'`). + * @param size - Invalid size formatted as `WIDTHxHEIGHT`. + * @param maxWidth - Maximum accepted width in pixels. + * @param maxHeight - Maximum accepted height in pixels. + * @returns User-facing error string. + */ +export function assetDimensionTooLargeError( + context: string, + size: string, + maxWidth: number, + maxHeight: number, +): string { + return ( + `This ${context} is too large (got ${size}). ` + + `Use an image no larger than ${maxWidth}x${maxHeight} pixels on each side` + ); +} + +/** + * Returns the error message for an asset whose total pixel area exceeds engine limits. + * + * @param context - Asset label (for example `'sprite sheet'`). + * @param size - Invalid size formatted as `WIDTHxHEIGHT`. + * @param maxPixels - Maximum accepted total pixels. + * @returns User-facing error string. + */ +export function assetDimensionAreaTooLargeError(context: string, size: string, maxPixels: number): string { + return ( + `This ${context} uses too many pixels (got ${size}). ` + + `Use an image with ${maxPixels.toLocaleString('en-US')} total pixels or fewer` + ); +} + +/** + * Returns the error message when indexed pixel dimensions overflow safe allocation limits. + * + * @param size - Invalid size formatted as `WIDTHxHEIGHT`. + * @returns User-facing error string. + */ +export function assetIndexedPixelOverflowError(size: string): string { + return `The indexed sprite size ${size} is too large to load safely. Use smaller width and height values`; +} + +/** + * Returns the error message when an indexed pixel buffer length does not match its dimensions. + * + * @param actualLength - Number of values supplied in the buffer. + * @param width - Declared width in pixels. + * @param height - Declared height in pixels. + * @param expectedLength - Required number of values (`width * height`). + * @returns User-facing error string. + */ +export function assetIndexedPixelLengthError( + actualLength: number, + width: number, + height: number, + expectedLength: number, +): string { + return ( + `The pixel data has ${actualLength} values, but a ${width}x${height} sheet needs exactly ${expectedLength}. ` + + 'Make sure indexedPixels has one entry per pixel' + ); +} + +/** + * Returns the error message when a `.btfont` JSON payload is too large to parse safely. + * + * @param byteLength - UTF-8 byte length of the JSON text. + * @param maxBytes - Maximum accepted JSON size in bytes. + * @returns User-facing error string. + */ +export function btfontJsonTooLargeError(byteLength: number, maxBytes: number): string { + return ( + `This font file is too large to load safely (${byteLength.toLocaleString('en-US')} bytes). ` + + `Use a .btfont file of ${maxBytes.toLocaleString('en-US')} bytes or fewer, ` + + 'or move large textures to a separate PNG file' + ); +} + +/** + * Returns the error message when a `.btfont` file defines too many glyphs. + * + * @param count - Number of glyph entries found. + * @param maxGlyphs - Maximum accepted glyph count. + * @returns User-facing error string. + */ +export function btfontGlyphCountTooLargeError(count: number, maxGlyphs: number): string { + return ( + `This font defines too many glyphs (${count.toLocaleString('en-US')}). ` + + `Use ${maxGlyphs.toLocaleString('en-US')} glyphs or fewer` + ); +} + +/** + * Returns a human-readable label for a `.btfont` glyph metric key. + * + * @param metricKey - `.btfont` metric key (for example `w` or `adv`). + * @returns Label text for error messages. + */ +function getBtfontMetricLabel(metricKey: string): string { + switch (metricKey) { + case 'adv': + return 'advance width (adv)'; + case 'h': + return 'height (h)'; + case 'ox': + return 'horizontal offset (ox)'; + case 'oy': + return 'vertical offset (oy)'; + case 'w': + return 'width (w)'; + case 'x': + return 'horizontal position (x)'; + case 'y': + return 'vertical position (y)'; + default: + return metricKey; + } +} + +/** + * Returns the error message when a glyph entry is not a metric object. + * + * @param charLabel - Character label used in the message. + * @returns User-facing error string. + */ +export function btfontGlyphEntryNotObjectError(charLabel: string): string { + return ( + `The '${charLabel}' glyph in this font file is invalid. ` + + 'Each glyph must be an object with x, y, w, h, ox, oy, and adv fields' + ); +} + +/** + * Returns the error message when a glyph metric is not a whole number. + * + * @param charLabel - Character label used in the message. + * @param metricKey - `.btfont` metric key (for example `w` or `adv`). + * @param value - Invalid metric value. + * @returns User-facing error string. + */ +export function btfontGlyphMetricNotIntegerError(charLabel: string, metricKey: string, value: number): string { + const label = getBtfontMetricLabel(metricKey); + + return ( + `The '${charLabel}' glyph has an invalid ${label} (got ${value}). ` + + 'Use a whole number for every glyph metric in the .btfont file' + ); +} + +/** + * Returns the error message when a glyph position is negative. + * + * @param charLabel - Character label used in the message. + * @returns User-facing error string. + */ +export function btfontGlyphNegativePositionError(charLabel: string): string { + return ( + `The '${charLabel}' glyph has a negative position in the texture atlas. ` + + 'Use 0 or greater for x and y in the .btfont file' + ); +} + +/** + * Returns the error message when a glyph width or height is negative. + * + * @param charLabel - Character label used in the message. + * @returns User-facing error string. + */ +export function btfontGlyphNegativeSizeError(charLabel: string): string { + return ( + `The '${charLabel}' glyph has a negative width or height. ` + 'Use 0 or greater for w and h in the .btfont file' + ); +} + +/** + * Returns the error message when a glyph advance width is negative. + * + * @param charLabel - Character label used in the message. + * @returns User-facing error string. + */ +export function btfontGlyphNegativeAdvanceError(charLabel: string): string { + return `The '${charLabel}' glyph has a negative advance width. ` + 'Use 0 or greater for adv in the .btfont file'; +} + +/** + * Returns the error message when a glyph is larger than the engine allows. + * + * @param charLabel - Character label used in the message. + * @param width - Glyph width in pixels. + * @param height - Glyph height in pixels. + * @param maxWidth - Maximum accepted width in pixels. + * @param maxHeight - Maximum accepted height in pixels. + * @returns User-facing error string. + */ +export function btfontGlyphSizeTooLargeError( + charLabel: string, + width: number, + height: number, + maxWidth: number, + maxHeight: number, +): string { + return ( + `The '${charLabel}' glyph is too large (${width}x${height}). ` + + `Use a glyph size no larger than ${maxWidth}x${maxHeight} pixels` + ); +} + +/** + * Returns the error message when a glyph rectangle falls outside the font texture. + * + * @param charLabel - Character label used in the message. + * @param x - Glyph X position in the atlas. + * @param y - Glyph Y position in the atlas. + * @param width - Glyph width in pixels. + * @param height - Glyph height in pixels. + * @param atlasWidth - Texture atlas width in pixels. + * @param atlasHeight - Texture atlas height in pixels. + * @returns User-facing error string. + */ +export function btfontGlyphOutsideAtlasError( + charLabel: string, + x: number, + y: number, + width: number, + height: number, + atlasWidth: number, + atlasHeight: number, +): string { + return ( + `The '${charLabel}' glyph rectangle (${x}, ${y}, ${width}x${height}) is outside the ` + + `${atlasWidth}x${atlasHeight} font texture. Move the glyph inside the atlas image` + ); +} + +/** + * Returns the error message when a glyph area is too large to render safely. + * + * @param charLabel - Character label used in the message. + * @returns User-facing error string. + */ +export function btfontGlyphAreaTooLargeError(charLabel: string): string { + return `The '${charLabel}' glyph covers too many pixels to render safely. Use a smaller glyph rectangle`; +} + +// #endregion + // #region Runtime — Palette /** -- 2.51.2 From 7661440ffebdbd5d25914a7f9db3ade25078f45c Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Sun, 17 May 2026 13:08:21 +0200 Subject: [PATCH 2/2] 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