import { BinaryFontPacker } from "@suaveplan/font-compress"; import type { TypefaceFont } from "@suaveplan/font-convert"; import { describe, expect, it } from "@suaveplan/testing/runner"; import { FONT_DECOMPRESS_CODES, FontDecompressError, } from "../../error-codes/error-codes.js"; import { BinaryFontUnpacker, createBinaryFontUnpacker, } from "./binary-font-unpacker.js"; function makeFont(overrides: Partial = {}): TypefaceFont { return { familyName: "TestFont", cssFontWeight: "normal", cssFontStyle: "normal", reversed: false, ascender: 800, descender: -200, underlinePosition: -100, underlineThickness: 50, resolution: 1000, boundingBox: { yMin: -200, xMin: 0, yMax: 800, xMax: 1000 }, original_font_information: {}, glyphs: { A: { ha: 600, x_min: 10, x_max: 590, o: "m 0 0 l 100 0 z " }, }, ...overrides, }; } function packFont(font: TypefaceFont): Uint8Array { return new BinaryFontPacker().pack(font); } describe("createBinaryFontUnpacker", () => { it("returns a BinaryFontUnpacker instance", { timeout: 5000 }, () => { expect(createBinaryFontUnpacker()).toBeInstanceOf(BinaryFontUnpacker); }); }); describe("BinaryFontUnpacker.unpack — round-trip pack/unpack preserves font shape", () => { it("round-trips header and metrics", { timeout: 5000 }, () => { const font = makeFont(); const result = new BinaryFontUnpacker().unpack(packFont(font)); expect(result.familyName).toBe(font.familyName); expect(result.cssFontWeight).toBe(font.cssFontWeight); expect(result.cssFontStyle).toBe(font.cssFontStyle); expect(result.reversed).toBe(font.reversed); expect(result.ascender).toBe(font.ascender); expect(result.descender).toBe(font.descender); expect(result.underlinePosition).toBe(font.underlinePosition); expect(result.underlineThickness).toBe(font.underlineThickness); expect(result.resolution).toBe(font.resolution); expect(result.boundingBox).toEqual(font.boundingBox); }); it("round-trips reversed=true", { timeout: 5000 }, () => { const result = new BinaryFontUnpacker().unpack( packFont(makeFont({ reversed: true })) ); expect(result.reversed).toBe(true); }); it("round-trips glyph ha/x_min/x_max", { timeout: 5000 }, () => { const result = new BinaryFontUnpacker().unpack(packFont(makeFont())); expect(result.glyphs["A"]).toBeDefined(); expect(result.glyphs["A"]?.ha).toBe(600); expect(result.glyphs["A"]?.x_min).toBe(10); expect(result.glyphs["A"]?.x_max).toBe(590); }); it("round-trips glyph outline string", { timeout: 5000 }, () => { const result = new BinaryFontUnpacker().unpack(packFont(makeFont())); expect(result.glyphs["A"]?.o).toBe("m 0 0 l 100 0 z "); }); it("round-trips font with no glyphs", { timeout: 5000 }, () => { const result = new BinaryFontUnpacker().unpack( packFont(makeFont({ glyphs: {} })) ); expect(Object.keys(result.glyphs)).toHaveLength(0); }); it("round-trips multiple glyphs and all command types", { timeout: 5000, }, () => { const font = makeFont({ glyphs: { A: { ha: 600, x_min: 0, x_max: 600, o: "m 0 0 l 100 0 b 10 20 30 40 50 60 q 5 10 15 20 z ", }, B: { ha: 500, x_min: 0, x_max: 500, o: "m 0 0 z " }, }, }); const result = new BinaryFontUnpacker().unpack(packFont(font)); expect(result.glyphs["A"]?.o).toBe( "m 0 0 l 100 0 b 10 20 30 40 50 60 q 5 10 15 20 z " ); expect(result.glyphs["B"]?.o).toBe("m 0 0 z "); }); it("round-trips negative coordinate values", { timeout: 5000 }, () => { const font = makeFont({ glyphs: { g: { ha: 600, x_min: -10, x_max: 590, o: "m -5 -200 l 100 -100 z ", }, }, }); const result = new BinaryFontUnpacker().unpack(packFont(font)); expect(result.glyphs["g"]?.o).toBe("m -5 -200 l 100 -100 z "); }); it("round-trips unicode character glyphs", { timeout: 5000 }, () => { const font = makeFont({ glyphs: { "€": { ha: 700, x_min: 50, x_max: 650, o: "m 0 0 z " }, }, }); const result = new BinaryFontUnpacker().unpack(packFont(font)); expect(result.glyphs["€"]).toBeDefined(); expect(result.glyphs["€"]?.ha).toBe(700); }); it("sets original_font_information to empty object", { timeout: 5000, }, () => { const result = new BinaryFontUnpacker().unpack(packFont(makeFont())); expect(result.original_font_information).toEqual({}); }); }); describe("BinaryFontUnpacker.unpack — error handling", () => { it("truncated buffer raises FontDecompressError", { timeout: 5000 }, () => { const unpacker = createBinaryFontUnpacker(); expect(() => unpacker.unpack(new Uint8Array(3))).toThrow( FontDecompressError ); }); it("empty buffer raises FontDecompressError", { timeout: 5000 }, () => { const unpacker = createBinaryFontUnpacker(); expect(() => unpacker.unpack(new Uint8Array(0))).toThrow( FontDecompressError ); }); it("truncated mid-dictionary raises INVALID_DATA", { timeout: 5000, }, () => { const fullBuf = packFont(makeFont()); // Find roughly where the dictionary starts: after 3 length-prefixed strings + reversed byte + u32 dictlen. // Truncate to a length guaranteed to land inside the dictionary. const truncated = fullBuf.subarray( 0, Math.max(40, fullBuf.length - 30) ); try { new BinaryFontUnpacker().unpack(truncated); throw new Error("expected throw"); } catch (err) { expect(err).toBeInstanceOf(FontDecompressError); } }); it("non-Error throws from DataView are wrapped as UNPACK_FAILED", { timeout: 5000, }, () => { const unpacker = new BinaryFontUnpacker(); const data = packFont(makeFont()); // Proxy that makes `buffer` access throw — exercises the catch-all // wrapping path (anything that escapes the inner FontDecompressError // checks is wrapped as UNPACK_FAILED). const proxy = new Proxy(data, { get(target, prop, receiver) { if (prop === "buffer") throw new TypeError("buffer error"); return Reflect.get(target, prop, receiver); }, }); try { unpacker.unpack(proxy as unknown as Uint8Array); throw new Error("expected throw"); } catch (err) { expect(err).toBeInstanceOf(FontDecompressError); expect((err as FontDecompressError).code).toBe( FONT_DECOMPRESS_CODES.UNPACK_FAILED ); } }); it("malformed BTF: glyph indexCount < 3 raises INVALID_DATA", { timeout: 5000, }, () => { const font = makeFont(); const buf = new Uint8Array(packFont(font)); const dv = new DataView(buf.buffer); const charCode = "A".charCodeAt(0); let off = -1; for (let i = 0; i + 4 <= buf.length; i++) { if (dv.getUint32(i, true) === charCode) { off = i + 4; // u32 indexCount lives right after the charCode u32 break; } } expect(off).toBeGreaterThan(0); dv.setUint32(off, 2, true); try { new BinaryFontUnpacker().unpack(buf); throw new Error("expected throw"); } catch (err) { expect(err).toBeInstanceOf(FontDecompressError); expect((err as FontDecompressError).code).toBe( FONT_DECOMPRESS_CODES.INVALID_DATA ); } }); it("malformed BTF: glyph cmd byte out of range raises INVALID_DATA", { timeout: 5000, }, () => { const font = makeFont({ glyphs: { A: { ha: 600, x_min: 0, x_max: 600, o: "z " } }, }); const buf = new Uint8Array(packFont(font)); const dv = new DataView(buf.buffer); const charCode = "A".charCodeAt(0); let off = -1; for (let i = 0; i + 4 <= buf.length; i++) { if (dv.getUint32(i, true) === charCode) { // After charCode (4) + indexCount (4) + ha/x_min/x_max (6 = 3*u16), // the cmd byte u16 starts. off = i + 4 + 4 + 6; break; } } expect(off).toBeGreaterThan(0); dv.setUint16(off, 99, true); try { new BinaryFontUnpacker().unpack(buf); throw new Error("expected throw"); } catch (err) { expect(err).toBeInstanceOf(FontDecompressError); expect((err as FontDecompressError).code).toBe( FONT_DECOMPRESS_CODES.INVALID_DATA ); } }); it("malformed BTF: dictionary index out of range raises INVALID_DATA", { timeout: 5000, }, () => { const buf = new Uint8Array(packFont(makeFont())); const dv = new DataView(buf.buffer); let off = 0; for (let s = 0; s < 3; s++) { const len = dv.getUint32(off, true); off += 4 + len; } off += 1; // reversed byte const dictLen = dv.getUint32(off, true); off += 4; off += dictLen * 2; dv.setUint16(off, 0xffff, true); // ascender metric index try { new BinaryFontUnpacker().unpack(buf); throw new Error("expected throw"); } catch (err) { expect(err).toBeInstanceOf(FontDecompressError); expect((err as FontDecompressError).code).toBe( FONT_DECOMPRESS_CODES.INVALID_DATA ); } }); it("malformed BTF: glyph indexes truncated mid-coords raises INVALID_DATA", { timeout: 5000, }, () => { // Glyph with `o: "m 0 0 z "` packs to indexCount = 7 (ha, x_min, x_max, m, 0idx, 0idx, z). // Truncate to 5: ha, x_min, x_max, m cmd byte, only ONE of the two m coord indexes. const font = makeFont({ glyphs: { A: { ha: 600, x_min: 0, x_max: 600, o: "m 0 0 z " } }, }); const buf = new Uint8Array(packFont(font)); const dv = new DataView(buf.buffer); const charCode = "A".charCodeAt(0); let off = -1; for (let i = 0; i + 4 <= buf.length; i++) { if (dv.getUint32(i, true) === charCode) { off = i + 4; // indexCount break; } } expect(off).toBeGreaterThan(0); dv.setUint32(off, 5, true); // declare 5 indexes; m expects 2 coords after it try { new BinaryFontUnpacker().unpack(buf); throw new Error("expected throw"); } catch (err) { expect(err).toBeInstanceOf(FontDecompressError); expect((err as FontDecompressError).code).toBe( FONT_DECOMPRESS_CODES.INVALID_DATA ); } }); it("tier purity: no node/bun imports", { timeout: 5000 }, () => { // Import-time exercise: the file already loaded above. If a // node:/bun: import had crept in, the universal vitest run // would have failed. The named test exists so the spec's // `Verified by:` line has a concrete anchor. expect(BinaryFontUnpacker).toBeDefined(); }); it("subpath export resolves to unpacker module", { timeout: 5000 }, () => { // Local path mirrors the published subpath at build time. expect(createBinaryFontUnpacker()).toBeInstanceOf(BinaryFontUnpacker); }); });