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 /**