All notable changes to @awacloud/fonts are documented here.
Format: Keep a Changelog · this project adheres to Semantic Versioning. Reference specs: OpenType v1.9 + TrueType Reference Manual (Apple).
[Unreleased]
[1.0.0] - 2026-10-07
Added
Initial library surface
Each bullet describes the final state of the surface.
- SFNT container and primitives —
sfnt/sfnt.jsparses and packs the table directory (checksums,head.checksumAdjustmentrecomputation, 4-byte-aligned tables, the search-range / entry-selector / range-shift trio) for the foursfntVersionflavors (TrueType0x00010000, OpenTypeOTTO, Appletrue,typ1).BinaryReader/BinaryWriterbig-endian helpers (int/uint 8/16/24/32, Fixed 16.16, F2Dot14, tag, LONGDATETIME, peek, sub-reader, padding, patching), the table checksum andheadadjustment, UTF-16BE and Mac Roman codecs and 4-character tag pack/unpack. - Required OpenType tables — read:
head,hhea,maxp(v0.5 / v1.0),hmtx(with trailing equal-advance collapse),cmap(formats 0, 2, 4, 6, 12, 13, 14),name(formats 0 and 1; Windows BMP UTF-16BE, Mac Roman, Unicode),OS/2(v0–v5),post(v1, v2, v2.5, v3),loca(short and long) andglyf(simple and composite, with offset/anchor arguments and 2×2 F2Dot14 transforms). Encoders exist forhead,hhea,maxp,hmtx,name,OS/2,postandloca(thelocaformat is chosen from the offset constraints). - Glyph model —
Path(M/L/Q/C/Z commands, affine transform, SVG output), the typedGlyphwrapper (id, name, advanceWidth, lsb, bbox, path, components) andcompositeResolvewith cycle detection and a depth cap. - Top-level orchestrator
fonts.read(bytes)returning a navigableFont: tables,unicodeMap, names,getGlyphByIndex/getGlyphByCodePoint,advanceWidth(gid)andleftSideBearing(gid), plus the idempotent.use(...)hook with its declared surface (hydrateFont,hydrateGlyph,hydrateTable,hydrateName). - Outline and layout tables — CFF Type 2 (INDEX, DICT, Top DICT, String
INDEX, Global/Local Subr INDEX, CharStrings, Private DICT; Type 2 charstring
decoder with subroutine biasing) and CFF2 (header, Top DICT, 32-bit INDEX,
global Subr INDEX, CharStrings INDEX, variation-store offset). GDEF
(header v1.0 / 1.2 / 1.3,
glyphClassDef,markAttachClassDef), GSUB (Script / Feature / Lookup lists; lookup types 1–8 decoded, a lookup of another type is returned as{ type, parsed: false }), GPOS (lookup types 1–9 decoded, ValueRecord decoder) and thelayout/classDefinitions.jscoverage and ClassDef (formats 1 and 2) primitives. GSUB and GPOS are parsed, not applied: the package has no shaping engine. - Containers — WOFF1 (
sfnt/woff.js: per-table compressed / uncompressed payloads, zlib inflation through@awacloud/fw, standalone SFNT output with a tag-sorted directory); WOFF2 envelope read (sfnt/woff2.js: header, 66 known-table index, UIntBase128 lengths, Brotli-decompressed body sliced into per-table views — theglyf/locatransform is not reversed, anddecodeWoff2reports it throughinverseGlyfTransform); TTC / OTC (sfnt/ttc.js:parseTtcandextractFont(bytes, index)). - Secondary, vertical and bitmap tables —
kern,gasp,hdmx,VDMX,LTSH;vhea,vmtx,VORG;EBDT,EBLC,EBSC. - Variable fonts —
fvar(axes and named instances),avar(per-axis segment maps andapplyAvarSegment),STAT(design axes and axis-value records, formats 1–4),gvar(tuple records and packed point/delta),HVAR,MVAR; helpersvariable/coordsConvert.js(normaliseAxisValue,normaliseAxesCoords) andvariable/instance.js(axisScalar,tupleScalar,applyGvarDeltas). There is no one-shot instantiation pipeline. - Colour fonts and
BASE— COLR v0 and v1 (decodeColorLine,decodePaintanddecodePaintGraphwith depth and cycle guards;PAINT_FORMATnames all 32 paint formats (ids 1–32), every one decodes into{ format, name, … }, any other id decodes as{ format, parsed: false }), CPAL v0 and v1,sbix, SVG documents, CBDT / CBLC strikes, andBASEbaseline metadata for the horizontal and vertical axes. - PDF-facing helpers — PDF and legacy encodings (
WinAnsiEncoding,MacRomanEncoding,MacExpertEncoding,StandardEncoding,Symbol,ZapfDingbats, withlookupEncoding(name)/findCode(encoding, glyphName)), the ToUnicode CMap parser and builder (cmap/toUnicode.js), the Standard 14 AFM metrics (standard14/*: Helvetica × 4, Times × 4, Courier × 4, Symbol, ZapfDingbats) and the embed-pdf helpers (subsetForPdf,buildFontDescriptor,buildCidSystemInfo,embedBuildToUnicode), each at its own sub-path (see Changed). - Opt-in extras —
extra/tt-hinting.js, the RM05 bytecode VM (graphics state, zones and the opcode catalogue; most catalogued opcodes are recognized andRM05_DEFERRED_COUNTreports the rest — function-definition opcodes are not implemented);extra/apple-aat/{morx,kerx,ankr,prop,lcar,feat}.jsand a barrel (envelope parsers:featdecodes its settings registry,kerxformat 0 subtables are decoded, otherkerxformats andmorxsubtables return{ parsed: false },ankrandlcarexpose raw lookup bytes);extra/shaper-{arabic,indic,cjk}.js(Arabic joining, Devanagari cluster reorder, CJK vertical forms and IVS);extra/{woff2-write,dsig,math,jstf}.js(WOFF2 encoder, DSIG records, MATH v1.0 and MathConstants, JSTF). The bundlesbundles/fonts-large.js,bundles/fonts-full.jsandbundles/fonts-apple-aat.jslayer them onto the core. fonts/inconsistent-tablescross-table validation inbuildFont(fonts.js) — cmap gids and composite glyph component indices must stay withinmaxp.numGlyphs.fonts/loca-non-monotonic—parseLocanow rejects non-monotone offset arrays which would otherwise produce negative-length glyph slices passed toparseGlyph.fonts/maxp-numglyphs-cap,fonts/sfnt-too-many-tables,fonts/cmap-too-many-subtables,fonts/name-too-many,fonts/gsub-script-count-cap,fonts/gsub-feature-count-cap,fonts/gsub-lookup-count-cap— hard caps on count fields to prevent allocation DoS.fonts/sfnt-emptynow also raised at parse time (previously only atpackSfnt).sliceTable(bytes, offset, length), returned by thefontReaderfactory — a bounds-checked wrapper aroundnew Uint8Array(bytes.buffer, bytes.byteOffset + offset, length).buildKerningTable(gpos), returned by thetableGposfactory — an opt-in memoisedMap<(left<<16)|right, dx>for fast horizontal kerning lookups.glyphIndexForCodePoint(cp, { strict: true })returns-1for unknown code-points instead of0(which is also the valid gid of.notdef).docs/api/errors.md— the security codes, the validation caps, the code families and thecause:convention (not an exhaustive table of every code).- Two-surface
dist/build:tools/generate-bundles.mjs(a thin wrapper over@awacloud/tool-prebuild-generator, shared with@awacloud/ooxml/@awacloud/odf/@awacloud/pdf/@awacloud/md) emitsdist/build/<root>.{js,min.js,meta.json}(fw-mode,dependenciesdeclared) plusdist/standalone/<root>.{js,min.js,meta.json}(framework-free, factories inlined) for each offonts,fonts-large,fonts-full,fonts-apple-aat, plus adist/build/index.jsbarrel.
Font-embedding and colour-font follow-ups
buildToUnicode(map, opts)takesopts.codeBytes(1or2, default2) —codeBytes: 1emits a one-byte codespace (<00> <FF>) and two-hex-digitbfchar/bfrangesource codes, as ISO 32000-1 § 9.10.3 requires for a simple (single-byte) font;2and the default are byte-identical to the previous output. Any other value throwsfonts/tou-bad-code-bytes, and a source code that is not an integer in0..0xFFundercodeBytes: 1throwsfonts/tou-code-out-of-range.parseToUnicodereads the one-byte form back.embedBuildToUnicodeforwardsoptsunchanged, so the option reaches the embed-pdf helper too; the Type0 subset path keeps the two-byte default.- Twelve
PAINT_FORMATkeys —VAR_SCALE(17),VAR_SCALE_AROUND_CENTER(19),SCALE_UNIFORM(20),VAR_SCALE_UNIFORM(21),SCALE_UNIFORM_AROUND_CENTER(22),VAR_SCALE_UNIFORM_AROUND_CENTER(23),VAR_ROTATE(25),ROTATE_AROUND_CENTER(26),VAR_ROTATE_AROUND_CENTER(27),VAR_SKEW(29),SKEW_AROUND_CENTER(30) andVAR_SKEW_AROUND_CENTER(31), with their decoders:decodePaintnow decodes every format 1–32 and each decoded paint carries anamefield (the OpenType paint-table name, for example'PaintScaleAroundCenter'). TheVar*variants decode theirvarIndexBase; the deltas are not applied.
Layout follow-ups
- GSUB lookup type 8 is decoded — Reverse Chaining Contextual Single
Substitution, format 1, decodes to
{ type: 8, format: 1, coverage, backtrackCoverages, lookaheadCoverages, substitutes }, directly and through a type 7 Extension lookup; any other type 8 format throwsfonts/gsub-reverse-format. Lookup types outside 1–8 still return{ type, parsed: false }. The substitutions are decoded, not applied.
Changed
-
subsetForPdfrequires TrueType outlines (documented precondition) — a font withoutglyf/loca, such as a CFF- or CFF2-flavoured font, is rejected withContractErrorfonts/subset-bad-font; there is no CFF subsetting route. The behaviour is unchanged: the source comment now states the precondition and the tests pin the error code. -
package.jsondeclaresengines.node>=18. -
Reference documentation — new API pages for
cff2,vhea,vmtx,vorg,ebdt,eblcandebsc, and for thefontsShared,varCoordsConvert,varInstanceandencodingAglTablehelpers. The API index and themainpage list every exported binding and link the new pages; the manifest header insrc/main.jsdescribes the named re-exports next to the four manifest arrays. The README states that colour and bitmap tables are parsed, not rendered. No code change. -
PAINT_FORMATfollows the OpenType 1.9 COLR numbering — three keys moved to the numbers the specification gives them:SCALE_AROUND_CENTER17 → 18,ROTATE18 → 24 andSKEW19 → 28 (formats 1–16 and 32 keep their numbers). A consumer that compares a decodedformatwith a literal 17, 18 or 19, or that stored those numbers, must switch to the enum keys. The layouts of the three decoded paints did not change; only the format number they answer to did. -
Documentation pass — the README follows the published-package skeleton (install, quick start, one sub-path row per
exportskey, maturity, licence, project) with runnable snippets; the guide and the reference pages were corrected against the live exports (import lines,.use()of an extra, dependency lists, API tables in both directions, the error-class identity rules, theembed-pdfindex turned into a plain section index); internal references were removed from published pages and source comments; this changelog was consolidated into the single[Unreleased]section. No code change. -
Dist — dist regenerated with the licence banner: every committed
dist/**/*.js/.min.jsopens with the row's/*! … */legal block (content from the repository licence matrix), each*.meta.jsonbytesentry is measured on the final bytes, and thebuiltAttimestamp is gone —bun run gen:bundlesis now byte-deterministic. -
Package contents — the npm tarball now ships
NOTICE(dual licence + third-party attributions) and the Adobe Core 14 AFM notice (vendor/afm/NOTICE-adobe-afm.html) the fontsNOTICEpoints at; the six vendored.afmsource files, the eleven_test-runtime.jstest scaffolds and the pre-publication checklist no longer ship. -
Strict factory-only architecture completed across
src/. Every module —primitives/*,sfnt/*,table/*(root +cmap/,cff/,colr/,gsub/,gpos/),extra/*(incl.apple-aat/*andtt-hinting/*),embed-pdf/*,cmap/,encodings/*,standard14/*,variable/*,layout/*,glyph/*— now exports exactly onefwfactory descriptor; no top-level helper/constant/ class export remains outside a descriptor body. The one exception isstandard14/_widths.generated.js, a generated plain-data module that only the tests import. Cross-module wiring is declared inmain.js's 4-array manifest (fw_require/modules/extras/bundle) and resolved through@awacloud/fwModuleRuntimeDI. Sibling tests resolve factories via per-area_test-runtime.jsModuleRuntimebootstraps instead of callingdescriptor.factory()directly or importing deleted top-level shims. -
src/errors.js— thefontErrorsfactory body is strict pure : each invocation declares freshFontError/ParseError/RenderError/ContractErrorclass identities (no module-local memoisation). Cross-call identity stability (required forinstanceofacross consumers) is delegated entirely toModuleRuntime, which caches the resolved instance per(name, version). This matches the@awacloud/ooxml/@awacloud/mdreference pattern. Breaking:src/errors.jsno longer exportsFontError/ParseError/RenderError/ContractError/isFontErroras top-level bindings, and@awacloud/fontsdoes not re-export them either: consumers resolve them from thefontErrorsdescriptor (fw.runtime.resolve('fontErrors'), the same runtime that resolvesfonts). -
Hand-written factory bodies (
primitives/*,sfnt/*,errors.js, and the majority oftable/*/extra/*) are worker-safe: each body inlines the helpers/constants/classes it needs and closes only over DI-injected dependencies, sofactory.toString()is serialisable to a Worker. The frozen 256-entry lookup tables underencodings/*andstandard14/*are declared inside their factory bodies, so they need no DI either. -
primitives/reader.jsandprimitives/writer.jsnow delegate their low-level primitive operations to@awacloud/fwbinaryReader/binaryWriter. The historicalBinaryReader/BinaryWriterclasses remain as thin font-specific facades — same API, same big-endian semantics, OpenType-specific helpers (readFixed,readF2Dot14,readTag,readLongDateTime,writeTag(string|uint32),padTo4,patchUint16/32, windowedsub/constructor(bytes, start, length)) preserved verbatim.primitives/fixed.js(fixedFromInt32,f2dot14FromInt16, …),primitives/checksum.js,primitives/encoding.jsandprimitives/tag.jsremain local (OT-specific, not delegated;fixed.jshas no dependency). -
embed-pdf/index.jsbarrel removed (re-exporting resolved singletons was non-compliant with strict factory-only). Each embed-pdf helper now publishes at its ownpackage.jsonexportssub-path:@awacloud/fonts/embed-pdf/subsetForPdf,.../fontDescriptor,.../cidSystemInfo,.../toUnicodeBuilder(plus thesubsetForPdf/{closure,cmap-builder,glyph-rewriter,hash}internals). -
script-feature-list.js(parseLookupList) no longer swallowsParseError. Only non-ParseErrorexceptions are downgraded to{ parsed: false, error, cause }so a malformed sub-format does not silently produce an opaque subtree. Thecause:chain is preserved. -
writeTag(string)rejects non-ASCII characters withContractError('fonts/bad-tag'). Previously it truncated silently viacharCodeAt() & 0xFF. -
BinaryReaderprimitive reads use an inlinetry/catch(no closure allocation) and a factory-local_translate(e)helper. Reduces per-read overhead on hot paths such asparseGlyfover CJK fonts. -
The fw
binaryReader/binaryWritermodules are injected intofontReader/fontWriterthrough dependency injection instead of being initialised at module load. Improves testability. -
extra/apple-aat/prop.jsreturns{ parsed: false, reason: 'aat-state-machine-deferred' }to signal explicit stub status — the only live AAT deferred-stub marker insrc/. Full AAT state-machine decoding (morxsubtables andkerxformats other than 0,prop) stays out of the 1.0.0 surface, andankr/lcarexpose raw lookup bytes;featis decoded. This is a scope decision: the package ships these tables as envelope-only parsers. -
Module descriptors carry a generated
deps:array (bun run deps:check, backed byfw-codegen deps), mirroring each descriptor'sdependencies:list by binding for static dependency-graph tooling.deps:is inert at runtime — never read byModuleRuntime.resolve()orfw-bundler standalone— and is regenerated together with the full test suite as the correctness gate. -
awa.maturityadvanced from"L3"to"L4"; license (LICENSE,NOTICE) andpackage.jsonmetadata are stamped. -
README translated to English (tagline, coverage summary, security notes, quick-start).
-
API reference describes the current surface only (no maturity-level promises) and the API index is grouped by area; source comments describe each module's contract in plain terms. The message of the
fonts/tt-hinting-unimplementederror now readsTT opcode not implemented: <name> (0x<op>)(the error code and context are unchanged).
Fixed
-
BinaryReaderread errors keep their cause. TheParseErrorafontReaderread throws (for examplefonts/reader-eof) now carries the originating@awacloud/fwContractErrorascause; its code and message are unchanged.fontReaderis in every bundle, so all fourdistroots are regenerated with the fix. -
embedSubsetForPdfis resolvable from the package's own manifest. The four descriptors it depends on (embedClosure,embedCmapBuilder,embedGlyphRewriter,embedHash, theembed-pdf/subsetForPdf/*helpers) were neither inmodulesnor infw_require, so aModuleRuntimeseeded with the package's own arrays threwModule not found: embedClosurethe first time it resolvedembedSubsetForPdf, and a host had to register the four by sub-path. They are now registered inmodules, each beforeembedSubsetForPdf, and re-exported frommain.jsby binding name; thefw_require+modulesself-closure and the uniqueness of themodulesnames are pinned by the wiring test. A composer that already patches these four in by hand (for example@awacloud/pdf) now lists the same names twice, whichModuleRuntimeaccepts because registration is idempotent by name. -
WOFF2 round trip with untransformed
glyf/loca.encodeWoff2wrote version 0 for every table. In WOFF2 version 0 is the null transform for every table exceptglyfandloca, where it means "transformed" (the null transform is version 3) — so a decoder expected atransformLengththe writer never wrote and failed on the first real font (brotli: CL-of-CL Kraft > 1). The writer now flagsglyfandlocawith transform version 3 and every other table with version 0, and writes notransformLength. The mirror bug in the reader (sfnt/woff2.js) is fixed too: it read atransformLengthfor any non-zero transform version, includingglyf/locaversion 3; it now reads one only when the version is not that table's null-transform version. A real font (Liberation Sans Regular, 19 tables) round-trips byte for byte. The writer is part of thefonts-fullandfonts-apple-aatbundles, which are regenerated with the fix; the reader and the other changes of this section reach no bundle. -
COLR v1 paint formats 16–31 decoded as the wrong paint. The paint format numbers after 16 did not follow the OpenType 1.9 table, so a format-18 paint (
PaintScaleAroundCenter) decoded as a rotation and theVar*, uniform-scale and around-centre variants were not decoded at all (see Changed and Added). -
WinAnsiEncodingslots 0x27 and 0x60 — the table namedquoterightat 0x27 andquoteleftat 0x60 (the StandardEncoding ASCII row). ISO 32000-2 Annex D hasquotesingleandgravethere;quoteleft/quoterightstay at 0x91/0x92. Only those two slots changed; no generateddist/bundle carries this table (the regeneration is byte-identical). -
Pack-surface pin for the Adobe Glyph List notice —
third-party/NOTICE-adobe-glyph-list, whichNOTICEpoints at, is now pinned in thenpm packlisting test with a non-vacuity control. -
Documentation links resolve from the npm tarball. Links that pointed outside the package now point at the public repository at this release's tag, so they resolve from the tarball; references to sources that are not published are plain-text citations.
-
fw_requirenot dependency-closed —src/main.js'sfw_requireused to list onlybinaryReader,binaryWriter,zlib,brotli, omitting each provider's own transitive deps:zlibneedsdeflate/adler32(which itself needsbitstream/huffman/lz77), andbrotlineedslz77/brotliDict/brotliDictWords. AModuleRuntimeseeded from this package's own manifest therefore resolvedfontWoff/fontWoff2but threwModule not foundthe first time WOFF/WOFF2 decode actually ran.fw_requirenow lists the full closure, dependencies before dependents;modules,extras,bundleand the named re-export block are unchanged. -
fonts/post-num-mismatchonsubsetForPdfoutput —embedSubsetForPdf'ssubsetForPdfno longer copies a source font'sposttable verbatim. A v1.0/v2.0/v4.0postcarries glyph-name data keyed to the source font's gid count/order, which the subset's renumbered/reduced glyph set invalidates, sofonts.read()on the subset bytes threwfonts/post-num-mismatchfor any real (non-v3.0) source font. The subset'sposttable is now a re-versioned 32-byte v3.0 header (no glyph names) built from the source header's shared fields (italicAngle, underline, isFixedPitch, memory hints), preserved bit-for-bit; an already-v3.0 sourcepostis still copied verbatim, and a sourcepostabsent or shorter than 32 bytes still yields noposttable in the subset. No new dependency added toembedSubsetForPdf— pure byte patch inside the existing factory body.
Security
Parser-bomb and correctness fixes from an internal security audit. Each fix is
locked by a dedicated regression test in tests/fuzz.test.js
(describe block fonts security — P0 fixes).
fonts/cmap-range-bomb(table/cmap/formats.js) — Cmap subtable formats 12 and 13 declaredstartCharCode/endCharCodeasuint32, allowing a malicious font to declare e.g.(start=0, end=0xFFFFFFFF)— a 4-billion-iterationMap.setloop guaranteed to OOM/hang the parser. Each group is now bounded to the legal Unicode range[0..0x10FFFF]and the cumulative materialised entry count is capped at2 × 0x110000.fonts/glyf-too-many-components(table/glyf.js) — Composite glyphs decoded via ado { ... } while (flags & MORE_COMPONENTS)loop with no upper bound. A malicious font could emit millions of component records → OOM. Components per glyph are now capped at 256 (well above any legitimate use; OT validators recommend ≤ 8). The existingMAX_DEPTH=16cap incompositeResolve.jsis unchanged — width and depth are now both bounded.- cmap format 14 (UVS) sub-readers (
table/cmap/formats.js) —parseFormat14usedr.peek(rr => { rr.seek(...); return rr; })to obtain sub-tables, butpeekrestores the parent cursor on return — every subsequentsub.readUint*call was actually reading from the pre-peek position. Default and non-default UVS tables were therefore silently corrupted. Now usesr.sub(rel, length)to obtain an independent sub-reader. Two regression tests (parses non-default UVS mappings correctly,parses default UVS ranges correctly) lock in the corrected behaviour.