Skip to content

lib-font (JavaScript) ​

A query-only OpenType reader built for inspecting fonts in the page that uses them: an Image-like Font element that loads a URL, registers an @font-face, and then exposes each table as a lazily parsed object with enumeration methods over GSUB/GPOS scripts, language systems, features and lookups — and deliberately nothing that draws or shapes.

FieldValue
LanguageJavaScript (ES modules; optional inflate.js/unbrotli.js or Node zlib)
LicenseMIT (LICENSE)
RepositoryPomax/lib-font
DocumentationREADME.md (API section self-described as pending)
Categoryparser
Layer(s) coveredparse
Version at pin3.0.4 (package.json)
Pinned revisionab532bf9b87572a25805ca7b87bb36119cfa2ccf (2026-07-25)

Overview ​

What it solves ​

lib-font answers questions about a font — which scripts and language systems GSUB covers, which features each language system enables, which lookups a feature runs, whether a character or variation sequence is mapped, which axes fvar declares — without a rendering or shaping engine. The README's introduction asks "What if you could actually inspect your fonts? In the same context that you actually use those fonts?" and walks exactly that script → langsys → features → axes sequence (README.md).

Design philosophy ​

Shaping and drawing are explicit non-goals, argued in the README's FAQ:

Proper OpenType text shaping is incredibly complex and requires a lot of specialized code; there is no reason for this library to pretend it supports text shaping when it's guaranteed to do it worse than other technologies you're already using.

and, comparing itself to opentype.js and fontkit:

The reason I needed this is because it doesn't do text shaping: it just lets me query the opentype data to get me the information I need, without being too big of a library.

— README.md (§ "Why can't this draw stuff??", § "Why would I use this instead of OpenType.js or Fontkit or something?")

How it works ​

An element-shaped loader. new Font(name, options) extends an EventManager; assigning font.src (URL, blob or data URL) first injects an @font-face rule for name (unless skipStyleSheet) so the browser can render the font, then fetches the bytes (lib-font.js). fromDataBuffer (buffer, filename) is the bytes entry point. validFontFormat sniffs four lead bytes into SFNT, WOFF or WOFF2 (src/utils/validator.js) and the matching container class is built; completion is a load event, not a return value.

A table factory over dynamic imports. createTable.jsimport()s 41 table classes, maps class name to constructor, and builds a table on demand from { tag, offset, length } and the shared DataView. A tag with no class logs "lib-font has no definition for … The table was skipped." and yields {}.

Laziness by property. src/lazy.js defines a property whose getter runs once and memoises:

js
/**
 * This is a lazy loader but is not optimised for direct record selection,
 * so code will currently load "An entire array" even if it needs only
 * a single element from that array, and the array elements are fixed width.
 */
export default function lazy(object, property, getter) {
  let val;
  Object.defineProperty(object, property, {
    get: () => {
      if (val) return val;
      val = getter();

SFNT reads the directory and calls lazy(this.tables, tag, …) per entry (src/opentype/sfnt.js); inside tables, CommonLayoutTable makes scriptList, featureList, lookupList and featureVariations lazy (common-layout-table.js), and fvar makes axes and instances lazy (fvar.js).

Analysis spine ​

1. Layering and ownership ​

One layer: a parser over a borrowed DataView. Font owns the DataView and font.opentype (the SFNT/WOFF/WOFF2 container); tables are created on first property access and cached on opentype.tables. Sub-records are built by repositioning a shared Parser cursor (this.parser.currentPosition = …), so — as in ./fontkit.md — a table object is not re-entrant. Errors surface as error events from loading and as thrown exceptions (or a {} table) from access.

2. Face loading and table access ​

URL or bytes; single SFNT, WOFF (needs globalThis.pako or Node zlib) or WOFF2 (needs globalThis.unbrotli or zlib.brotliDecompressSync, with the glyf/loca transform-version rule implemented, src/opentype/woff2.js). No collections. Every table is raw and lazy: font.opentype.tables.cmap, .GSUB, .fvar, ['OS/2']. Coverage is uneven by registry, not by file: the repository contains avar, cvar, gvar, HVAR, MVAR, STAT and VVAR modules under src/opentype/tables/simple/variation/, and MATH/JSTF under advanced/, but createTable.js imports only fvar from the variation directory and neither MATH nor JSTF, so all of these read as {}. STAT would be empty anyway — its class body reads the header parser and stops (STAT.js). glyf is "not really a table, but a pure data block" exposing getGlyphData(offset, length) bytes (glyf.js).

3. Shaping ​

Not applicable by design (quoted above). What lib-font offers instead is the enumeration surface an inspector needs, on both GSUB and GPOS via CommonLayoutTable (common-layout-table.js):

js
getSupportedScripts() {
  return this.scriptList.scriptRecords.map((r) => r.scriptTag);
}

followed by getScriptTable(tag), getSupportedLangSys(script) (prepending dflt when a default exists), getDefaultLangSysTable, getLangSysTable(script, tag), getFeatures(langSys), getFeature(indexOrTag), getLookups(feature) and getLookup(index). LookupTable.getSubTable(i) builds the typed subtable (GSUB 1–8, GPOS 1–9). It unwraps lookupType === 7 for both tables — correct for the GSUB extension, wrong for GPOS, where 7 is contextual positioning (whose class has no getSubstTable) and the extension is type 9, left wrapped, and e.g. GSUB type 4 offers getLigatureSet(i).getLigature(j) (lookup.js, lookup-type-4.js). An inspector-grade defect: the LookupTable flag getters are written this.lookupFlag & (0x0002 === 0x0002), which evaluates to lookupFlag & 1, so ignoreBaseGlyphs, ignoreLigatures, ignoreMarks and the rest all report bit 0 (rightToLeft) (lookup.js).

4. Variation and instances ​

fvar only: getSupportedAxes() (tags), getAxis(tag) (minValue, defaultValue, maxValue as 16.16 decoded, flags, axisNameID), and instances (subfamilyNameID, coordinates[], optional postScriptNameID when instanceSize allows) (fvar.js). Names resolve through name.get(id). No normalisation, no avar, no instancing; STAT unavailable (§2). featureVariations is declared as a lazy property on GSUB/GPOS, but its getter names a FeatureVariations class that is never imported — the source flags it, "FIXME: This class doesn't actually exist anywhere in the code..." — so touching it on a font that has the offset throws (common-layout-table.js).

5. Rasterization and outlines ​

Not applicable: no outline decoding, no path, no raster. Drawing is delegated to the browser through the injected @font-face (§ How it works). Colour tables COLR (v0 getBaseGlyphRecord, getLayers), CPAL, CBDT/CBLC, EBDT/EBLC, sbix and SVG are parsed as data for inspection only.

6. Metrics and measurement ​

Raw tables only (head, hhea, hmtx, OS/2, post, vhea, vmtx, VDMX, hdmx). The one derived measurement, measureText(text, size), renders a hidden DOM div in the registered face and returns getBoundingClientRect() augmented with OS/2 sTypoAscender/ sTypoDescender (lib-font.js) — measurement by the browser, metrics by table.

7. Discovery, matching and fallback ​

Not applicable. Coverage queries are per face: font.supports(char), getGlyphId(char), reverse(glyphId), supportsVariation(seq) (cmap format 14), and on the table cmap.getSupportedEncodings() and getSupportedCharCodes(platformID, encodingID) (cmap.js).

What it teaches sparkles:font ​

  • The inspector API is an enumeration ladder: scripts → language systems (with dflt surfaced) → features → lookups → typed subtables. sparkles:font should expose exactly these as ranges over borrowed offsets, independent of the shaper.
  • Tables-as-lazy-properties over one borrowed buffer is the right cost model for a font explorer that opens hundreds of files and reads a few tables from each.
  • Registry coverage must be tested, not assumed. Nine table modules (seven variation tables, MATH, JSTF) exist as source yet are unreachable; a D static foreach over a tag → type table plus a test that every module is registered prevents this.
  • Bit-flag accessors need tests against real fonts — the lookupFlag precedence bug shows how inspector output can be silently wrong.
  • Separating "inspect" from "render" is legitimate: the explorer's table views can rest on the parser alone while glyph views use the shaping and raster layers.

Strengths ​

  • Small, query-shaped API that matches what inspectors display.
  • Lazy at table and sub-list granularity; cheap opens.
  • WOFF/WOFF2 with pluggable decompressors; registered classes cover the bitmap (CBDT, EBDT, sbix) and colour (COLR, CPAL, SVG ) tables.
  • Character, encoding and variation-selector coverage queries.

Weaknesses ​

  • No outlines, metrics derivation, normalisation or shaping (by design).
  • Variation support stops at fvar; STAT and avar unreachable.
  • Event-based loading API, no TTC support, README API docs pending.
  • Shared parser cursor; lookupFlag getters and GPOS extension unwrapping incorrect at the pin.

Key design decisions and trade-offs ​

DecisionRationaleTrade-off
Query-only, no shaping or drawingThe browser already shapes and renders correctlyUseless outside a host renderer; no glyph geometry
Font modelled on Image (src + onload)Familiar web idiom; also registers @font-faceAsynchronous by construction; awkward in tools
Lazy property per table and per listInspectors read few tables from many fontsWhole arrays decoded on first touch (lazy.js note)
Factory over dynamically imported table classesClasses load in parallel; unknown tags degrade to {}Unregistered modules are silently dead code
Pluggable inflate/brotli via globals or zlibKeeps the core smallWOFF/WOFF2 failure depends on page setup

Sources ​

All paths verified at the pinned revision with git cat-file -e.