diff --git a/test/font-generation-helpers.mjs b/test/font-generation-helpers.mjs new file mode 100644 index 00000000..0ac69980 --- /dev/null +++ b/test/font-generation-helpers.mjs @@ -0,0 +1,276 @@ +/** + * Shared utilities for test font generation scripts. + * + * Provides binary encoding helpers, checksum calculation, common OpenType + * table builders, and a font assembly function used by the generate-*.mjs + * scripts in this directory. + */ + +// --- binary helpers (big-endian) --- + +export function u8(v) { return [v & 0xFF]; } +export function u16(v) { return [(v >> 8) & 0xff, v & 0xff]; } +export function u32(v) { return [(v >> 24) & 0xff, (v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff]; } +export function i16(v) { return u16(v < 0 ? v + 0x10000 : v); } +export function i64(v) { return [...u32(0), ...u32(v)]; } // simplified LONGDATETIME +export function tag(s) { return [...s].map(c => c.charCodeAt(0)); } +export function pad(arr) { while (arr.length % 4 !== 0) arr.push(0); return arr; } + +export function calcChecksum(bytes) { + const padded = [...bytes]; + while (padded.length % 4 !== 0) padded.push(0); + let sum = 0; + for (let i = 0; i < padded.length; i += 4) { + sum = (sum + ((padded[i] << 24) | (padded[i+1] << 16) | (padded[i+2] << 8) | padded[i+3])) >>> 0; + } + return sum; +} + +// --- common table builders --- + +export function makeHead({ indexToLocFormat = 0 } = {}) { + return [ + ...u16(1), ...u16(0), // majorVersion, minorVersion + ...u16(1), ...u16(0), // fontRevision (fixed 1.0) + ...u32(0), // checksumAdjustment (filled later) + ...u32(0x5F0F3CF5), // magicNumber + ...u16(0x000B), // flags + ...u16(1000), // unitsPerEm + ...i64(0), // created + ...i64(0), // modified + ...i16(0), ...i16(0), // xMin, yMin + ...i16(1000), ...i16(1000), // xMax, yMax + ...u16(0), // macStyle + ...u16(8), // lowestRecPPEM + ...i16(2), // fontDirectionHint + ...i16(indexToLocFormat), // indexToLocFormat + ...i16(0), // glyphDataFormat + ]; +} + +export function makeHhea(numHMetrics) { + return [ + ...u16(1), ...u16(0), // version 1.0 + ...i16(800), // ascender + ...i16(-200), // descender + ...i16(0), // lineGap + ...u16(1000), // advanceWidthMax + ...i16(0), // minLeftSideBearing + ...i16(0), // minRightSideBearing + ...i16(1000), // xMaxExtent + ...i16(1), ...i16(0), // caretSlopeRise, caretSlopeRun + ...i16(0), // caretOffset + ...i16(0), ...i16(0), ...i16(0), ...i16(0), // reserved + ...i16(0), // metricDataFormat + ...u16(numHMetrics), // numberOfHMetrics + ]; +} + +export function makeMaxp(numGlyphs, { cff = false, ...overrides } = {}) { + if (cff) { + return [ + ...u16(0), ...u16(0x5000), // version 0.5 + ...u16(numGlyphs), + ]; + } + const o = overrides; + return [ + ...u16(1), ...u16(0), // version 1.0 + ...u16(numGlyphs), + ...u16(o.maxPoints ?? 0), + ...u16(o.maxContours ?? 0), + ...u16(o.maxCompositePoints ?? 0), + ...u16(o.maxCompositeContours ?? 0), + ...u16(o.maxZones ?? 1), + ...u16(o.maxTwilightPoints ?? 0), + ...u16(o.maxStorage ?? 0), + ...u16(o.maxFunctionDefs ?? 0), + ...u16(o.maxInstructionDefs ?? 0), + ...u16(o.maxStackElements ?? 0), + ...u16(o.maxSizeOfInstructions ?? 0), + ...u16(o.maxComponentElements ?? 0), + ...u16(o.maxComponentDepth ?? 0), + ]; +} + +export function makeOS2() { + return [ + ...u16(1), // version + ...i16(500), // xAvgCharWidth + ...u16(400), // usWeightClass + ...u16(5), // usWidthClass + ...u16(0), // fsType + ...i16(0), ...i16(0), ...i16(0), ...i16(0), // ySubscript* + ...i16(0), ...i16(0), ...i16(0), ...i16(0), // ySuperscript* + ...i16(0), ...i16(0), // yStrikeout* + ...i16(0), // sFamilyClass + ...Array(10).fill(0), // panose + ...u32(0), ...u32(0), ...u32(0), ...u32(0), // ulUnicodeRange + ...tag(' '), // achVendID + ...u16(0), // fsSelection + ...u16(0), // usFirstCharIndex + ...u16(0), // usLastCharIndex + ...i16(800), // sTypoAscender + ...i16(-200), // sTypoDescender + ...i16(0), // sTypoLineGap + ...u16(800), // usWinAscent + ...u16(200), // usWinDescent + ...u32(0), ...u32(0), // ulCodePageRange 1-2 + ]; +} + +export function makeName(familyName) { + const names = [ + [0, 'Copyright'], + [1, familyName], + [2, 'Regular'], + [4, familyName], + [5, 'Version 1.0'], + [6, familyName], + ]; + const stringData = []; + const records = []; + let offset = 0; + for (const [nameID, str] of names) { + const encoded = [...str].flatMap(c => u16(c.charCodeAt(0))); + records.push([ + ...u16(3), // platformID (Windows) + ...u16(1), // encodingID (Unicode BMP) + ...u16(0x0409), // languageID (English US) + ...u16(nameID), + ...u16(encoded.length), + ...u16(offset), + ]); + stringData.push(...encoded); + offset += encoded.length; + } + const storageOffset = 6 + records.length * 12; + return [ + ...u16(0), // format + ...u16(names.length), // count + ...u16(storageOffset), // stringOffset + ...records.flat(), + ...stringData, + ]; +} + +export function makeHmtx(numGlyphs, advanceWidth = 500) { + const metrics = []; + for (let i = 0; i < numGlyphs; i++) { + metrics.push(...u16(advanceWidth), ...i16(0)); + } + return metrics; +} + +/** + * Build a format-4 cmap table. If charCode is provided, maps that code point + * to glyph index 1. Otherwise creates a sentinel-only table (no mappings). + */ +export function makeCmap(charCode) { + if (charCode === undefined) { + return [ + ...u16(0), ...u16(1), // version, numTables + ...u16(3), ...u16(1), ...u32(12), // platformID=3, encodingID=1, offset + ...u16(4), // format 4 + ...u16(14 + 2 * 5), // length (14-byte header + 5 per-segment u16 fields × 1 segment) + ...u16(0), // language + ...u16(2), // segCountX2 + ...u16(2), ...u16(0), ...u16(0), // searchRange, entrySelector, rangeShift + ...u16(0xFFFF), // endCode sentinel + ...u16(0), // reservedPad + ...u16(0xFFFF), // startCode sentinel + ...u16(1), // idDelta sentinel + ...u16(0), // idRangeOffset sentinel + ]; + } + const segCount = 2; + const searchRange = 2 * Math.pow(2, Math.floor(Math.log2(segCount))); + const entrySelector = Math.floor(Math.log2(segCount)); + const rangeShift = 2 * segCount - searchRange; + + const subtable = [ + ...u16(4), // format + ...u16(0), // length (patched below) + ...u16(0), // language + ...u16(segCount * 2), // segCountX2 + ...u16(searchRange), ...u16(entrySelector), ...u16(rangeShift), + ...u16(charCode), ...u16(0xFFFF), // endCode + ...u16(0), // reservedPad + ...u16(charCode), ...u16(0xFFFF), // startCode + ...i16(1 - charCode), ...i16(1), // idDelta + ...u16(0), ...u16(0), // idRangeOffset + ]; + subtable[2] = (subtable.length >> 8) & 0xff; + subtable[3] = subtable.length & 0xff; + + return [ + ...u16(0), ...u16(1), // version, numTables + ...u16(3), ...u16(1), ...u32(12), // platformID=3, encodingID=1, offset + ...subtable, + ]; +} + +export function makePost({ underlinePosition = -100, underlineThickness = 50 } = {}) { + return [ + ...u16(3), ...u16(0), // version 3.0 (no glyph names) + ...u32(0), // italicAngle + ...i16(underlinePosition), + ...i16(underlineThickness), + ...u32(0), // isFixedPitch + ...u32(0), // minMemType42 + ...u32(0), // maxMemType42 + ...u32(0), // minMemType1 + ...u32(0), // maxMemType1 + ]; +} + +// --- font assembly --- + +/** + * Assembles a complete OpenType/TrueType font file from a table map. + * @param {Object} tables - Map of tag string to byte array, e.g. { 'head': [...], 'cmap': [...] } + * @param {Object} [options] + * @param {string|number} [options.sfVersion] - 'OTTO' for CFF fonts, or a 32-bit number (default 0x00010000 for TrueType) + * @returns {Uint8Array} + */ +export function assembleFont(tables, { sfVersion = 0x00010000 } = {}) { + const tags = Object.keys(tables).sort(); + const numTables = tags.length; + const searchRange = Math.pow(2, Math.floor(Math.log2(numTables))) * 16; + const entrySelector = Math.floor(Math.log2(numTables)); + const rangeShift = numTables * 16 - searchRange; + + const headerSize = 12 + numTables * 16; + let dataOffset = headerSize; + + const tableRecords = []; + const tableData = []; + for (const t of tags) { + const data = tables[t]; + const paddedData = pad([...data]); + tableRecords.push([ + ...tag(t.padEnd(4, ' ')), + ...u32(calcChecksum(data)), + ...u32(dataOffset), + ...u32(data.length), + ]); + tableData.push(...paddedData); + dataOffset += paddedData.length; + } + + const sfVersionBytes = typeof sfVersion === 'string' + ? tag(sfVersion) + : u32(sfVersion); + + const font = [ + ...sfVersionBytes, + ...u16(numTables), + ...u16(searchRange), + ...u16(entrySelector), + ...u16(rangeShift), + ...tableRecords.flat(), + ...tableData, + ]; + + return new Uint8Array(font); +} diff --git a/test/fonts/CFFRecursionTest.otf b/test/fonts/CFFRecursionTest.otf index 28afc200..962a73e0 100644 Binary files a/test/fonts/CFFRecursionTest.otf and b/test/fonts/CFFRecursionTest.otf differ diff --git a/test/fonts/HintingJMPRLoop.ttf b/test/fonts/HintingJMPRLoop.ttf index d51ee862..a8181f68 100644 Binary files a/test/fonts/HintingJMPRLoop.ttf and b/test/fonts/HintingJMPRLoop.ttf differ diff --git a/test/fonts/HintingMutualRecursion.ttf b/test/fonts/HintingMutualRecursion.ttf index bd74459a..740bc6c9 100644 Binary files a/test/fonts/HintingMutualRecursion.ttf and b/test/fonts/HintingMutualRecursion.ttf differ diff --git a/test/fonts/HintingRecursiveCALL.ttf b/test/fonts/HintingRecursiveCALL.ttf index 7c8e65b1..87b92c08 100644 Binary files a/test/fonts/HintingRecursiveCALL.ttf and b/test/fonts/HintingRecursiveCALL.ttf differ diff --git a/test/fonts/circular-composite.ttf b/test/fonts/circular-composite.ttf index 7989491f..19547bc4 100644 Binary files a/test/fonts/circular-composite.ttf and b/test/fonts/circular-composite.ttf differ diff --git a/test/generate-circular-ref-font.mjs b/test/generate-circular-ref-font.mjs index 900d6605..a88b46a8 100644 --- a/test/generate-circular-ref-font.mjs +++ b/test/generate-circular-ref-font.mjs @@ -17,212 +17,15 @@ import { writeFileSync } from 'fs'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; +import { + u16, u32, i16, pad, + makeHead, makeHhea, makeMaxp, makeOS2, makeName, makeHmtx, makeCmap, makePost, + assembleFont, +} from './font-generation-helpers.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); -// --- helpers --- - -function u16(v) { return [(v >> 8) & 0xff, v & 0xff]; } -function u32(v) { return [(v >> 24) & 0xff, (v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff]; } -function i16(v) { return u16(v < 0 ? v + 0x10000 : v); } -function i64(v) { return [...u32(0), ...u32(v)]; } // simplified LONGDATETIME -function tag(s) { return [...s].map(c => c.charCodeAt(0)); } -function pad(arr) { while (arr.length % 4 !== 0) arr.push(0); return arr; } - -function calcChecksum(bytes) { - const padded = [...bytes]; - while (padded.length % 4 !== 0) padded.push(0); - let sum = 0; - for (let i = 0; i < padded.length; i += 4) { - sum = (sum + ((padded[i] << 24) | (padded[i+1] << 16) | (padded[i+2] << 8) | padded[i+3])) >>> 0; - } - return sum; -} - -// --- table builders --- - -function makeHead() { - return [ - ...u16(1), ...u16(0), // majorVersion, minorVersion - ...u16(1), ...u16(0), // fontRevision (fixed 1.0) - ...u32(0), // checksumAdjustment (filled later) - ...u32(0x5F0F3CF5), // magicNumber - ...u16(0x000B), // flags - ...u16(1000), // unitsPerEm - ...i64(0), // created - ...i64(0), // modified - ...i16(0), ...i16(0), // xMin, yMin - ...i16(1000), ...i16(1000), // xMax, yMax - ...u16(0), // macStyle - ...u16(8), // lowestRecPPEM - ...i16(2), // fontDirectionHint - ...i16(1), // indexToLocFormat (long) - ...i16(0), // glyphDataFormat - ]; -} - -function makeHhea(numGlyphs) { - return [ - ...u16(1), ...u16(0), // majorVersion, minorVersion - ...i16(800), // ascender - ...i16(-200), // descender - ...i16(0), // lineGap - ...u16(1000), // advanceWidthMax - ...i16(0), // minLeftSideBearing - ...i16(0), // minRightSideBearing - ...i16(1000), // xMaxExtent - ...i16(1), ...i16(0), // caretSlopeRise, caretSlopeRun - ...i16(0), // caretOffset - ...i16(0), ...i16(0), ...i16(0), ...i16(0), // reserved - ...i16(0), // metricDataFormat - ...u16(numGlyphs), // numberOfHMetrics - ]; -} - -function makeMaxp(numGlyphs) { - return [ - ...u16(1), ...u16(0), // version 1.0 - ...u16(numGlyphs), // numGlyphs - ...u16(0), // maxPoints - ...u16(0), // maxContours - ...u16(0), // maxCompositePoints - ...u16(2), // maxCompositeContours - ...u16(1), // maxZones - ...u16(0), // maxTwilightPoints - ...u16(0), // maxStorage - ...u16(0), // maxFunctionDefs - ...u16(0), // maxInstructionDefs - ...u16(0), // maxStackElements - ...u16(0), // maxSizeOfInstructions - ...u16(2), // maxComponentElements - ...u16(2), // maxComponentDepth - ]; -} - -function makeOs2() { - const os2 = new Array(96).fill(0); - // version - os2[0] = 0; os2[1] = 4; - // xAvgCharWidth - os2[2] = (500 >> 8) & 0xff; os2[3] = 500 & 0xff; - // usWeightClass = 400 - os2[4] = (400 >> 8) & 0xff; os2[5] = 400 & 0xff; - // usWidthClass = 5 - os2[6] = 0; os2[7] = 5; - // sTypoAscender at offset 68 - os2[68] = (800 >> 8) & 0xff; os2[69] = 800 & 0xff; - // sTypoDescender at offset 70 (-200 = 0xFF38) - os2[70] = 0xFF; os2[71] = 0x38; - // sTypoLineGap at offset 72 - os2[72] = 0; os2[73] = 0; - // usWinAscent at offset 74 - os2[74] = (800 >> 8) & 0xff; os2[75] = 800 & 0xff; - // usWinDescent at offset 76 - os2[76] = (200 >> 8) & 0xff; os2[77] = 200 & 0xff; - // ulUnicodeRange1 bit 0 (Basic Latin) at offset 42 - os2[42] = 0; os2[43] = 0; os2[44] = 0; os2[45] = 1; - // sxHeight at offset 86 - os2[86] = (500 >> 8) & 0xff; os2[87] = 500 & 0xff; - // sCapHeight at offset 88 - os2[88] = (700 >> 8) & 0xff; os2[89] = 700 & 0xff; - return os2; -} - -function makeHmtx(numGlyphs) { - const metrics = []; - for (let i = 0; i < numGlyphs; i++) { - metrics.push(...u16(500), ...i16(0)); // advanceWidth, lsb - } - return metrics; -} - -function makeCmap() { - // Format 4 subtable mapping U+0041 ('A') → glyph 1 - const segCount = 2; // 1 segment + sentinel - const searchRange = 2 * Math.pow(2, Math.floor(Math.log2(segCount))); - const entrySelector = Math.floor(Math.log2(segCount)); - const rangeShift = 2 * segCount - searchRange; - - const subtable = [ - ...u16(4), // format - ...u16(24), // length of this subtable - ...u16(0), // language - ...u16(segCount * 2), // segCountX2 - ...u16(searchRange), - ...u16(entrySelector), - ...u16(rangeShift), - ...u16(0x0041), ...u16(0xFFFF), // endCode[]: 'A', sentinel - ...u16(0), // reservedPad - ...u16(0x0041), ...u16(0xFFFF), // startCode[]: 'A', sentinel - ...i16(0), ...i16(1), // idDelta[]: patched below for 'A' and sentinel - ...u16(0), ...u16(0), // idRangeOffset[]: 0, 0 - ]; - // Fix idDelta: to map 0x41 → glyph 1, delta = 1 - 0x41 = -0x40 = 0xFFC0 - subtable[24] = 0xFF; subtable[25] = 0xC0; - // Sentinel delta keeps 0xFFFF mapping to glyph 0 - subtable[26] = 0x00; subtable[27] = 0x01; - // Update length - const len = subtable.length; - subtable[2] = (len >> 8) & 0xff; subtable[3] = len & 0xff; - - return [ - ...u16(0), // version - ...u16(1), // numTables - ...u16(3), // platformID (Windows) - ...u16(1), // encodingID (Unicode BMP) - ...u32(12), // offset to subtable - ...subtable, - ]; -} - -function makePost() { - return [ - ...u16(3), ...u16(0), // version 3.0 (no glyph names) - ...u32(0), // italicAngle - ...i16(-100), // underlinePosition - ...i16(50), // underlineThickness - ...u32(0), // isFixedPitch - ...u32(0), // minMemType42 - ...u32(0), // maxMemType42 - ...u32(0), // minMemType1 - ...u32(0), // maxMemType1 - ]; -} - -function makeName() { - const names = [ - [0, 'Copyright'], - [1, 'CircularTest'], - [2, 'Regular'], - [4, 'CircularTest Regular'], - [5, 'Version 1.0'], - [6, 'CircularTest-Regular'], - ]; - const stringData = []; - const records = []; - let offset = 0; - for (const [nameID, str] of names) { - const encoded = [...str].flatMap(c => u16(c.charCodeAt(0))); - records.push([ - ...u16(3), // platformID (Windows) - ...u16(1), // encodingID (Unicode BMP) - ...u16(0x0409), // languageID (English US) - ...u16(nameID), - ...u16(encoded.length), - ...u16(offset), - ]); - stringData.push(...encoded); - offset += encoded.length; - } - const storageOffset = 6 + records.length * 12; - return [ - ...u16(0), // format - ...u16(names.length), // count - ...u16(storageOffset), // stringOffset - ...records.flat(), - ...stringData, - ]; -} +// --- glyph data (the interesting part of this font) --- function makeGlyf() { // Glyph 0: .notdef — simple empty glyph (0 contours) @@ -253,11 +56,6 @@ function makeGlyf() { return { glyph0, glyph1, glyph2 }; } -function makeLoca(offsets) { - // Long format (indexToLocFormat = 1) - return offsets.flatMap(o => u32(o)); -} - // --- assemble font --- function buildFont() { @@ -269,62 +67,23 @@ function buildFont() { const g2 = pad([...glyph2]); const glyfData = [...g0, ...g1, ...g2]; - const locaData = makeLoca([0, g0.length, g0.length + g1.length, g0.length + g1.length + g2.length]); + // Long loca format (indexToLocFormat = 1) + const locaData = [0, g0.length, g0.length + g1.length, g0.length + g1.length + g2.length] + .flatMap(o => u32(o)); const numGlyphs = 3; - const tables = { - 'head': makeHead(), + return assembleFont({ + 'head': makeHead({ indexToLocFormat: 1 }), 'hhea': makeHhea(numGlyphs), 'maxp': makeMaxp(numGlyphs), - 'OS/2': makeOs2(), + 'OS/2': makeOS2(), 'hmtx': makeHmtx(numGlyphs), - 'cmap': makeCmap(), + 'cmap': makeCmap(0x0041), // 'A' → glyph 1 'loca': locaData, 'glyf': glyfData, - 'name': makeName(), + 'name': makeName('CircularTest'), 'post': makePost(), - }; - - const tags = Object.keys(tables).sort(); - const numTables = tags.length; - const searchRange = Math.pow(2, Math.floor(Math.log2(numTables))) * 16; - const entrySelector = Math.floor(Math.log2(numTables)); - const rangeShift = numTables * 16 - searchRange; - - // Offset table (12 bytes) + table records (numTables * 16 bytes) - const headerSize = 12 + numTables * 16; - let dataOffset = headerSize; - - // Build table records and collect padded table data - const tableRecords = []; - const tableData = []; - for (const t of tags) { - const data = tables[t]; - const paddedData = pad([...data]); - tableRecords.push([ - ...tag(t), - ...u32(calcChecksum(data)), - ...u32(dataOffset), - ...u32(data.length), - ]); - tableData.push(...paddedData); - dataOffset += paddedData.length; - } - - const font = [ - // Offset table - ...u32(0x00010000), // sfVersion (TrueType) - ...u16(numTables), - ...u16(searchRange), - ...u16(entrySelector), - ...u16(rangeShift), - // Table records - ...tableRecords.flat(), - // Table data - ...tableData, - ]; - - return new Uint8Array(font); + }); } const fontBytes = buildFont(); diff --git a/test/generate-hinting-dos-fonts.mjs b/test/generate-hinting-dos-fonts.mjs index 09917765..4daa7325 100644 --- a/test/generate-hinting-dos-fonts.mjs +++ b/test/generate-hinting-dos-fonts.mjs @@ -18,237 +18,28 @@ import { writeFileSync } from 'fs'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; +import { + u16, + makeHead, makeHhea, makeMaxp, makeOS2, makeName, makeHmtx, makeCmap, makePost, + assembleFont, +} from './font-generation-helpers.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); -// --- helpers --- - -function u16(v) { return [(v >> 8) & 0xff, v & 0xff]; } -function u32(v) { return [(v >> 24) & 0xff, (v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff]; } -function i16(v) { return u16(v < 0 ? v + 0x10000 : v); } -function i64(v) { return [...u32(0), ...u32(v)]; } -function tag(s) { return [...s].map(c => c.charCodeAt(0)); } -function pad(arr) { while (arr.length % 4 !== 0) arr.push(0); return arr; } - -function calcChecksum(bytes) { - const padded = [...bytes]; - while (padded.length % 4 !== 0) padded.push(0); - let sum = 0; - for (let i = 0; i < padded.length; i += 4) { - sum = (sum + ((padded[i] << 24) | (padded[i+1] << 16) | (padded[i+2] << 8) | padded[i+3])) >>> 0; - } - return sum; -} - -function encodeUTF16BE(str) { - const result = []; - for (let i = 0; i < str.length; i++) { - result.push((str.charCodeAt(i) >> 8) & 0xFF); - result.push(str.charCodeAt(i) & 0xFF); - } - return result; -} - -// --- table builders --- - -function makeHead() { - return [ - ...u16(1), ...u16(0), // majorVersion, minorVersion - ...u16(1), ...u16(0), // fontRevision (fixed 1.0) - ...u32(0), // checksumAdjustment (filled later) - ...u32(0x5F0F3CF5), // magicNumber - ...u16(0x000B), // flags - ...u16(1000), // unitsPerEm - ...i64(0), // created - ...i64(0), // modified - ...i16(0), ...i16(0), // xMin, yMin - ...i16(1000), ...i16(1000), // xMax, yMax - ...u16(0), // macStyle - ...u16(8), // lowestRecPPEM - ...i16(2), // fontDirectionHint - ...i16(0), // indexToLocFormat (short) - ...i16(0), // glyphDataFormat - ]; -} - -function makeHhea() { - return [ - ...u16(1), ...u16(0), // version - ...i16(800), // ascender - ...i16(-200), // descender - ...i16(0), // lineGap - ...u16(600), // advanceWidthMax - ...i16(0), // minLeftSideBearing - ...i16(0), // minRightSideBearing - ...i16(600), // xMaxExtent - ...i16(1), // caretSlopeRise - ...i16(0), // caretSlopeRun - ...i16(0), // caretOffset - ...i16(0), ...i16(0), ...i16(0), ...i16(0), // reserved - ...i16(0), // metricDataFormat - ...u16(1), // numberOfHMetrics - ]; -} - -function makeMaxp() { - return [ - ...u16(1), ...u16(0), // version 1.0 - ...u16(1), // numGlyphs - ...u16(64), // maxPoints - ...u16(1), // maxContours - ...u16(0), // maxCompositePoints - ...u16(0), // maxCompositeContours - ...u16(1), // maxZones - ...u16(0), // maxTwilightPoints - ...u16(16), // maxStorage - ...u16(16), // maxFunctionDefs - ...u16(0), // maxInstructionDefs - ...u16(64), // maxStackElements - ...u16(0), // maxSizeOfInstructions - ...u16(0), // maxComponentElements - ...u16(0), // maxComponentDepth - ]; -} - -function makeOS2() { - const os2 = new Array(78).fill(0); - // version = 1 - os2[0] = 0x00; os2[1] = 0x01; - // xAvgCharWidth = 600 - os2[2] = 0x02; os2[3] = 0x58; - // usWeightClass = 400 - os2[4] = 0x01; os2[5] = 0x90; - // usWidthClass = 5 (Medium) - os2[6] = 0x00; os2[7] = 0x05; - // sTypoAscender = 800 (offset 68) - os2[68] = 0x03; os2[69] = 0x20; - // sTypoDescender = -200 (offset 70) - os2[70] = 0xFF; os2[71] = 0x38; - // usWinAscent = 1000 (offset 74) - os2[74] = 0x03; os2[75] = 0xE8; - // usWinDescent = 1000 (offset 76) - os2[76] = 0x03; os2[77] = 0xE8; - return os2; -} - -function makeName(familyName) { - const family = encodeUTF16BE(familyName); - const style = encodeUTF16BE('Regular'); - const records = [ - { nameID: 1, data: family }, - { nameID: 2, data: style }, - { nameID: 4, data: family }, - { nameID: 6, data: family }, - ]; - const stringOffset = 6 + records.length * 12; - const result = [...u16(0), ...u16(records.length), ...u16(stringOffset)]; - let strOff = 0; - for (const rec of records) { - result.push(...u16(3), ...u16(1), ...u16(0x0409)); - result.push(...u16(rec.nameID), ...u16(rec.data.length), ...u16(strOff)); - strOff += rec.data.length; - } - for (const rec of records) result.push(...rec.data); - return result; -} - -function makeCmap() { - // Format 4 with just the 0xFFFF sentinel segment - return [ - ...u16(0), // version - ...u16(1), // numTables - ...u16(3), // platformID (Windows) - ...u16(1), // encodingID (Unicode BMP) - ...u32(12), // offset to subtable - // format 4 subtable - ...u16(4), // format - ...u16(22), // length (14 + 1*8) - ...u16(0), // language - ...u16(2), // segCountX2 - ...u16(2), // searchRange - ...u16(0), // entrySelector - ...u16(0), // rangeShift - ...u16(0xFFFF), // endCount sentinel - ...u16(0), // reservedPad - ...u16(0xFFFF), // startCount sentinel - ...u16(1), // idDelta sentinel - ...u16(0), // idRangeOffset sentinel - ]; -} - -function makePost() { - return [ - ...u16(3), ...u16(0), // format 3.0 - ...u32(0), // italicAngle - ...i16(-512), // underlinePosition - ...i16(80), // underlineThickness - ...u32(0), // isFixedPitch - ...u32(0), ...u32(0), // minMemType42, maxMemType42 - ...u32(0), ...u32(0), // minMemType1, maxMemType1 - ]; -} - -function makeLoca() { - // Short format, 1 glyph: offset 0, end 0 (empty) - return [...u16(0), ...u16(0)]; -} - -function makeHmtx() { - return [...u16(600), ...i16(0)]; // advanceWidth, lsb -} - -// --- font assembler --- - function buildFont(familyName, fpgmInstructions) { - const tables = { + return assembleFont({ 'head': makeHead(), - 'hhea': makeHhea(), - 'maxp': makeMaxp(), + 'hhea': makeHhea(1), + 'maxp': makeMaxp(1, { maxFunctionDefs: 16, maxStorage: 16, maxStackElements: 64 }), 'OS/2': makeOS2(), 'name': makeName(familyName), - 'cmap': makeCmap(), + 'cmap': makeCmap(), // no character mappings needed 'post': makePost(), - 'loca': makeLoca(), - 'glyf': [], // empty — glyph 0 has zero length per loca - 'hmtx': makeHmtx(), + 'loca': [...u16(0), ...u16(0)], // short format, 1 empty glyph + 'glyf': [], + 'hmtx': makeHmtx(1), 'fpgm': [...fpgmInstructions], - }; - - const tags = Object.keys(tables).sort(); - const numTables = tags.length; - const searchRange = Math.pow(2, Math.floor(Math.log2(numTables))) * 16; - const entrySelector = Math.floor(Math.log2(numTables)); - const rangeShift = numTables * 16 - searchRange; - - const headerSize = 12 + numTables * 16; - let dataOffset = headerSize; - - const tableRecords = []; - const tableData = []; - for (const t of tags) { - const data = tables[t]; - const paddedData = pad([...data]); - tableRecords.push([ - ...tag(t.padEnd(4, ' ')), - ...u32(calcChecksum(data)), - ...u32(dataOffset), - ...u32(data.length), - ]); - tableData.push(...paddedData); - dataOffset += paddedData.length; - } - - const font = [ - ...u32(0x00010000), // sfVersion (TrueType) - ...u16(numTables), - ...u16(searchRange), - ...u16(entrySelector), - ...u16(rangeShift), - ...tableRecords.flat(), - ...tableData, - ]; - - return new Uint8Array(font); + }); } // --- POC font definitions --- diff --git a/test/generate-recursive-cff-font.mjs b/test/generate-recursive-cff-font.mjs index 673f861d..68a75711 100644 --- a/test/generate-recursive-cff-font.mjs +++ b/test/generate-recursive-cff-font.mjs @@ -10,18 +10,13 @@ */ import { writeFileSync } from 'fs'; +import { + u8, u16, u32, i16, tag, + makeHead, makeHhea, makeMaxp, makeOS2, makeName, makeHmtx, makeCmap, makePost, + assembleFont, +} from './font-generation-helpers.mjs'; -// --- Byte-level helpers (big-endian) --- - -function u8(v) { return [v & 0xFF]; } -function u16(v) { return [(v >> 8) & 0xFF, v & 0xFF]; } -function u32(v) { return [(v >> 24) & 0xFF, (v >> 16) & 0xFF, (v >> 8) & 0xFF, v & 0xFF]; } -function i16(v) { return u16(v < 0 ? v + 0x10000 : v); } -function tag(s) { return [...s].map(c => c.charCodeAt(0)); } -function pad4(bytes) { - while (bytes.length % 4 !== 0) bytes.push(0); - return bytes; -} +// --- CFF-specific helpers --- // CFF number encoding (Type 2 charstring format) function cffInt(v) { @@ -60,61 +55,39 @@ function cffIndex(items) { // --- Build the CFF table --- function buildCFF() { - const fontName = 'RecTest'; // Short to keep things small + const fontName = 'RecTest'; - // --- Header --- const header = [1, 0, 4, 1]; // major=1, minor=0, hdrSize=4, offSize=1 - - // --- Name INDEX --- const nameIndex = cffIndex([[...tag(fontName)]]); - - // --- String INDEX (empty - use only standard strings) --- const stringIndex = cffIndex([]); + const gsubrsIndex = cffIndex([]); - // --- Charstrings --- // Glyph 0 (.notdef): just endchar - const notdefCharstring = [14]; // endchar - - // Glyph 1: pushes biased index for subr 0 then calls callsubr, creating a cycle - // With < 1240 subrs, bias = 107. To call subr 0: push (0 - 107) = -107 then callsubr(10) - const glyph1Charstring = [...cffInt(-107), 10]; // push -107, callsubr - + const notdefCharstring = [14]; + // Glyph 1: calls local subr 0 (bias=107, so push -107 then callsubr) + const glyph1Charstring = [...cffInt(-107), 10]; const charStringIndex = cffIndex([notdefCharstring, glyph1Charstring]); - // --- Local Subrs INDEX --- - // Subr 0: calls itself. Push -107 (= subr 0 with bias 107), callsubr - const subr0 = [...cffInt(-107), 10]; // push -107, callsubr (calls subr 0 again) + // Local subr 0: calls itself recursively + const subr0 = [...cffInt(-107), 10]; const localSubrsIndex = cffIndex([subr0]); - // --- Private DICT --- - // The Subrs operator (19) value is an offset relative to the Private DICT start. + // Private DICT: Subrs offset = its own size (local subrs immediately follow) function buildPrivateDict(subrsOffset) { return [...dictInt(subrsOffset), u8(19)]; } - - // --- Global Subrs INDEX (empty) --- - const gsubrsIndex = cffIndex([]); + const finalPrivateDict = buildPrivateDict(buildPrivateDict(0).length); function buildTopDict(charstringsOffset, privateDictSize, privateDictOffset) { return [ - ...dictInt(charstringsOffset), u8(17), // charStrings offset - ...dictInt(privateDictSize), ...dictInt(privateDictOffset), u8(18), // Private [size, offset] + ...dictInt(charstringsOffset), u8(17), + ...dictInt(privateDictSize), ...dictInt(privateDictOffset), u8(18), ]; } - // CFF layout: header | nameIndex | topDictIndex | stringIndex | gsubrsIndex | charstrings | privateDict | localSubrs - // Two-pass offset calculation: first pass gets approximate offsets, second pass finalizes - // (needed because Top DICT size depends on the offset values it encodes). + // Two-pass offset resolution (Top DICT size depends on the offset values it encodes) const fixedPrefix = header.length + nameIndex.length; const fixedSuffix = stringIndex.length + gsubrsIndex.length; - - // Private DICT: Subrs offset = its own size (local subrs immediately follow) - const finalPrivateDict = buildPrivateDict(buildPrivateDict(0).length); - - // Two-pass offset resolution: Top DICT encodes offsets as variable-length integers, - // so its size depends on the values, which depend on its size. - // Pass 1 uses placeholders, pass 2 uses real offsets (which are close in magnitude, - // so Top DICT size stabilizes). let topDictIndex; for (let pass = 0; pass < 2; pass++) { const csOffset = fixedPrefix + (topDictIndex ? topDictIndex.length : 10) + fixedSuffix; @@ -130,257 +103,30 @@ function buildCFF() { throw new Error('CFF Top DICT offset calculation did not converge'); } - const cff = [ - ...header, - ...nameIndex, - ...topDictIndex, - ...stringIndex, - ...gsubrsIndex, - ...charStringIndex, - ...finalPrivateDict, - ...localSubrsIndex, + return [ + ...header, ...nameIndex, ...topDictIndex, ...stringIndex, + ...gsubrsIndex, ...charStringIndex, ...finalPrivateDict, ...localSubrsIndex, ]; - - return cff; } -// --- Build minimal OTF wrapper --- +// --- Build font --- -function buildOTF() { +function buildFont() { const numGlyphs = 2; - const unitsPerEm = 1000; - - // --- Required tables --- - - // head table (54 bytes) - const headTable = [ - ...u16(1), ...u16(0), // version 1.0 - ...u16(1), ...u16(0), // fontRevision 1.0 - ...u32(0), // checksumAdjustment (filled later) - ...u32(0x5F0F3CF5), // magicNumber - ...u16(0x000B), // flags - ...u16(unitsPerEm), // unitsPerEm - ...u32(0), ...u32(0), // created (longDateTime) - ...u32(0), ...u32(0), // modified (longDateTime) - ...i16(0), ...i16(0), // xMin, yMin - ...i16(1000), ...i16(1000), // xMax, yMax - ...u16(0), // macStyle - ...u16(8), // lowestRecPPEM - ...i16(2), // fontDirectionHint - ...i16(1), // indexToLocFormat - ...i16(0), // glyphDataFormat - ]; - - // hhea table (36 bytes) - const hheaTable = [ - ...u16(1), ...u16(0), // version 1.0 - ...i16(800), // ascender - ...i16(-200), // descender - ...i16(0), // lineGap - ...u16(1000), // advanceWidthMax - ...i16(0), // minLeftSideBearing - ...i16(0), // minRightSideBearing - ...i16(1000), // xMaxExtent - ...i16(1), // caretSlopeRise - ...i16(0), // caretSlopeRun - ...i16(0), // caretOffset - ...i16(0), ...i16(0), ...i16(0), ...i16(0), // reserved - ...i16(0), // metricDataFormat - ...u16(numGlyphs), // numberOfHMetrics - ]; - - // maxp table (6 bytes for CFF) - const maxpTable = [ - ...u16(0), ...u16(0x5000), // version 0.5 - ...u16(numGlyphs), // numGlyphs - ]; - - // OS/2 table (minimal, 78 bytes for version 1) - const os2Table = [ - ...u16(1), // version - ...i16(500), // xAvgCharWidth - ...u16(400), // usWeightClass - ...u16(5), // usWidthClass - ...u16(0), // fsType - ...i16(0), ...i16(0), ...i16(0), ...i16(0), ...i16(0), // subscript/superscript - ...i16(0), ...i16(0), // strikeout - ...i16(0), // sFamilyClass (byte, but i16) - ...Array(10).fill(0), // panose - ...u32(0), ...u32(0), ...u32(0), ...u32(0), // ulUnicodeRange - ...tag(' '), // achVendID - ...u16(0), // fsSelection - ...u16(0x0020), // usFirstCharIndex - ...u16(0x0020), // usLastCharIndex - ...i16(800), // sTypoAscender - ...i16(-200), // sTypoDescender - ...i16(0), // sTypoLineGap - ...u16(800), // usWinAscent - ...u16(200), // usWinDescent - ...u32(0), // ulCodePageRange1 - ...u32(0), // ulCodePageRange2 - ]; - - // name table (minimal - just required nameIDs with short strings) - function buildNameTable() { - const names = [ - [0, 'Copyright'], - [1, 'Test'], - [2, 'Regular'], - [4, 'Test'], - [5, 'Version 1.0'], - [6, 'Test-Regular'], - ]; - const stringData = []; - const records = []; - let offset = 0; - for (const [nameID, str] of names) { - // Platform 3 (Windows), encoding 1 (Unicode BMP), language 0x0409 (English) - const encoded = []; - for (const ch of str) { - encoded.push(0, ch.charCodeAt(0)); - } - records.push([3, 1, 0x0409, nameID, encoded.length, offset]); - stringData.push(...encoded); - offset += encoded.length; - } - const count = records.length; - const storageOffset = 6 + count * 12; - const result = [...u16(0), ...u16(count), ...u16(storageOffset)]; - for (const [platID, encID, langID, nameID, len, off] of records) { - result.push(...u16(platID), ...u16(encID), ...u16(langID), ...u16(nameID), ...u16(len), ...u16(off)); - } - result.push(...stringData); - return result; - } - const nameTable = buildNameTable(); - - // cmap table (format 4, maps space U+0020 to glyph 1) - function buildCmapTable() { - const segCount = 2; // one real segment + sentinel - const searchRange = 2 * Math.pow(2, Math.floor(Math.log2(segCount))); - const entrySelector = Math.floor(Math.log2(segCount)); - const rangeShift = 2 * segCount - searchRange; - - const subtable = [ - ...u16(4), // format - ...u16(0), // length (filled below) - ...u16(0), // language - ...u16(segCount * 2), // segCountX2 - ...u16(searchRange), - ...u16(entrySelector), - ...u16(rangeShift), - // endCode - ...u16(0x0020), ...u16(0xFFFF), - // reservedPad - ...u16(0), - // startCode - ...u16(0x0020), ...u16(0xFFFF), - // idDelta - ...i16(1 - 0x0020), ...i16(1), - // idRangeOffset - ...u16(0), ...u16(0), - ]; - // Fix length - subtable[2] = (subtable.length >> 8) & 0xFF; - subtable[3] = subtable.length & 0xFF; - - return [ - ...u16(0), // version - ...u16(1), // numTables - ...u16(3), ...u16(1), // platformID=3, encodingID=1 - ...u32(12), // offset to subtable - ...subtable, - ]; - } - const cmapTable = buildCmapTable(); - - // post table (format 3 - no glyph names, 32 bytes) - const postTable = [ - ...u16(0x0003), ...u16(0x0000), // format 3.0 - ...u16(0), ...u16(0), // italicAngle - ...i16(-100), // underlinePosition - ...i16(50), // underlineThickness - ...u32(0), // isFixedPitch - ...u32(0), ...u32(0), // minMemType42, maxMemType42 - ...u32(0), ...u32(0), // minMemType1, maxMemType1 - ]; - - // hmtx table - const hmtxTable = [ - ...u16(500), ...i16(0), // glyph 0: advanceWidth=500, lsb=0 - ...u16(500), ...i16(0), // glyph 1: advanceWidth=500, lsb=0 - ]; - - // CFF table - const cffTable = buildCFF(); - - // --- Assemble OTF --- - const tables = [ - ['CFF ', cffTable], - ['OS/2', os2Table], - ['cmap', cmapTable], - ['head', headTable], - ['hhea', hheaTable], - ['hmtx', hmtxTable], - ['maxp', maxpTable], - ['name', nameTable], - ['post', postTable], - ]; - - const numTables = tables.length; - const searchRange2 = Math.pow(2, Math.floor(Math.log2(numTables))) * 16; - const entrySelector2 = Math.floor(Math.log2(numTables)); - const rangeShift2 = numTables * 16 - searchRange2; - - // OTF header - const sfntHeader = [ - ...tag('OTTO'), // sfVersion (CFF) - ...u16(numTables), - ...u16(searchRange2), - ...u16(entrySelector2), - ...u16(rangeShift2), - ]; - - // Calculate table offsets - const tableRecordSize = 16; // tag(4) + checksum(4) + offset(4) + length(4) - let dataOffset = sfntHeader.length + numTables * tableRecordSize; - - const tableEntries = []; - for (const [name, data] of tables) { - const paddedLen = Math.ceil(data.length / 4) * 4; - tableEntries.push({ tag: name, data, offset: dataOffset, length: data.length }); - dataOffset += paddedLen; - } - - // Build table records - function checksum(bytes) { - let sum = 0; - const padded = [...bytes]; - while (padded.length % 4 !== 0) padded.push(0); - for (let i = 0; i < padded.length; i += 4) { - sum = (sum + ((padded[i] << 24) | (padded[i+1] << 16) | (padded[i+2] << 8) | padded[i+3])) >>> 0; - } - return sum; - } - - const records = []; - for (const entry of tableEntries) { - records.push(...tag(entry.tag)); - records.push(...u32(checksum(entry.data))); - records.push(...u32(entry.offset)); - records.push(...u32(entry.length)); - } - - // Assemble final file - const file = [...sfntHeader, ...records]; - for (const entry of tableEntries) { - file.push(...pad4([...entry.data])); - } - - return new Uint8Array(file); + return assembleFont({ + 'CFF ': buildCFF(), + 'OS/2': makeOS2(), + 'cmap': makeCmap(0x0020), // space → glyph 1 + 'head': makeHead({ indexToLocFormat: 1 }), + 'hhea': makeHhea(numGlyphs), + 'hmtx': makeHmtx(numGlyphs), + 'maxp': makeMaxp(numGlyphs, { cff: true }), + 'name': makeName('RecTest'), + 'post': makePost(), + }, { sfVersion: 'OTTO' }); } -const otf = buildOTF(); +const fontBytes = buildFont(); const outputPath = new URL('./fonts/CFFRecursionTest.otf', import.meta.url).pathname; -writeFileSync(outputPath, otf); -console.log(`Written ${otf.length} bytes to ${outputPath}`); +writeFileSync(outputPath, fontBytes); +console.log(`Written ${fontBytes.length} bytes to ${outputPath}`);