Scalable font design
Status
Design started 2026-08-14. Assets are committed, fonts/substitutions.json is
written, and src/10c-truetype.wat parses font tables and derives metrics:
table directory, head/hhea/maxp/OS/2/post, hmtx advances and
bearings, cmap formats 0/4/6 with the symbol-face 0xF000 bias, CP1252, the
full pixel TEXTMETRIC derivation, loca/glyf record bounds in both loca
formats, ABC widths, kern format 0, simple-glyph outline points, and
composite recursion with an explicit depth limit, 26.6 flattening, and nonzero
scan conversion into the FNT column-major bitmap layout.
The public Win98 font surface now exposes that work directly. A native v86
capture pins Arial GGO_BITMAP, quadratic GGO_NATIVE, 4/16/64-level gray
coverage, affine MAT2 behavior, GGO_UNHINTED, and classic kerning pairs.
GGO_BEZIER deliberately returns GDI_ERROR: real Windows 98 rejects it even
for Arial, so implementing the later NT extension would reduce compatibility.
GetCharacterPlacement consumes legacy kern format-0 pairs only under
GCP_USEKERNING, adjusting the left glyph advance as the capture does. The
deployed CP1252 subsets preserve the classic table even when their full source
font also has GPOS. DBCS conversion, contextual shaping/ligation, and TrueType
bytecode hinting remain explicit gaps.
A face registry and glyph cache sit on top: $tt_face_open loads a font from
the same virtual filesystem the .FON strikes come from, keyed by path hash
so a file opens once, and $tt_face_glyph returns a cached bitmap for a guest
ANSI byte. Storage is $heap_alloc, not a fixed region — font files run from
60 KB to 400 KB and the cache grows with what the guest draws, so a fixed
reservation would either waste a megabyte on a guest that never names a
scalable face or run out on one that does.
The GDI seam is now closed, and it turned out not to need a second renderer.
The bitmap-font path already owns everything a text call needs — layout,
alignment, clipping, colours, background modes, paths, TA_UPDATECP,
justification, GetGlyphOutline — and reaches all of it through one parsed
FNT image. So a scalable face is rasterized at one ppem into an FNT 3.00 image
and installed as an ordinary strike, and nothing downstream cares that the
glyphs came from an outline. That the glyph cache already stored bitmaps in
the FNT column-major bit layout is what made this a copy rather than a
conversion.
$gdi_bitmap_font_selected resolves any face to a strike, so Canvas is not
a fallback for text at all any more. Strike records carry a second state: 1 is
an installed bitmap font, 2 is a rasterized substitute, and
face-and-nearest-height matching and font enumeration skip state 2 so a
substitute is reachable only through its exact (face, size, weight, italic)
key.
Face names resolve to the filenames a real C:\WINDOWS\FONTS held —
ARIAL.TTF, TAHOMABD.TTF — from a table in src/10c-truetype.wat. Which
vendored open font is mounted at each of those VFS paths is the host's
decision, recorded in fonts/substitutions.json under win98Files and applied
by lib/font-substitutions.js in both test/run.js and host.js. WAT
therefore carries no licensing knowledge and resolves faces exactly the way
the bundled .FON strikes already do.
A guest can also install a scalable font of its own. AddFontResourceA tries
the bitmap-strike loader first and, when the file carries no strikes, registers
it in a small mutable table beside the substitution one — keyed by the family
name read out of the file's name table, because the guest installs
ARIALBD.TTF by path and then asks for that family in bold, and only the name
table connects the two. Registered entries are consulted before the
substitution table: a guest that ships its own copy of a face means that copy.
Style is scored rather than indexed, since registered files arrive one at a
time in any order — weight is worth more than slant, the way the Windows font
mapper scored it (a weight mismatch cost |requested - actual| / 5, so 60
points from bold to regular, against a flat 4 for the wrong slant).
This is what fontview.exe needs. It tests the AddFontResourceA return
value and destroys its own window without painting when it is zero, so while a
.TTF was rejected by the bitmap loader and the call returned "no fonts
added", the file it was launched on never appeared.
Two limits worth stating rather than discovering later. An FNT cell width is the advance, so ink that overhangs the advance is clipped, the same way a bitmap font clips it. And a face with no italic file falls back to its upright file rather than being synthetically sheared.
The entry points a caller needs, all in src/10c-truetype.wat:
$tt_face_open(path_guest) -> face index, or -1
$tt_face_ppem(face, lfHeight) -> ppem, both lfHeight conventions
$tt_face_metric(face, ppem, field) -> TEXTMETRIC field 0..11
$tt_face_text_width(face, text, n, ppem)
$tt_face_char_width(face, byte, ppem)
$tt_face_glyph(face, byte, ppem) -> cache entry, or 0
$tt_entry_width/height/left/top/pixel(entry, ...)
$tt_family_name(data, size, out, max) -> family name length, or 0
$tt_reg_add(path_guest) -> 1 when installed, else 0
$tt_reg_remove(path_guest)
$tt_reg_path(name, weight, italic) -> registered font path, or 0
$tt_subst_path(name, weight, italic) -> registered or Win98 font path, or 0
$tt_face_for_logfont(name, weight, italic)
$tt_strike_ensure(name, lfHeight, weight, italic)
-> installed strike record, or 0
$tt_strike_ensure is the one the GDI layer actually calls; everything above
it is reachable for tests and for callers that want metrics without a strike.
The end state is no Canvas text at all: WAT parses TrueType
files, owns metrics, and rasterizes glyphs onto the canonical GDI surface, the
same way it already does for .FON strikes. JavaScript keeps only the
canonical-surface-to-screen blit, which is presentation, not logic.
The deterministic Win9x stock font path is complete and pixel-exact: WAT
parses .FON/.FNT resources and blits one-bit glyph pixels directly onto the
canonical GDI surface for System, MS Sans Serif, Fixedsys, Courier, and
Terminal (see bitmap-font-review.md and
../fonts/README.md).
This document covers the faces that path does not cover: the scalable
TrueType faces a Win98 application names explicitly through
CreateFontIndirectA/W, EnumFontFamiliesEx, or a resource-script dialog
font. Every one of them is now rasterized in WAT, including the faces with no
vendored look-alike and the faces no table has ever heard of.
What they used to do instead was fall through to _buildCssFont in
lib/host-imports.js, which emitted a CSS family list naming the host's
fonts:
'arial': 'Arial, sans-serif', 'times new roman': '"Times New Roman", serif',
'verdana': 'Verdana, sans-serif'
That is non-deterministic by construction, and it is why the substituted faces
were moved off it first and why the rest followed. On a machine with
Microsoft's fonts installed the metrics were right by accident; on a bare Linux
CI box or a locked-down browser the fallback was whatever the platform picked,
and GetTextExtentPoint32A returned widths the guest never saw on Win98.
Dialog layout, DrawText wrapping, caret placement, and RichEdit line breaking
all consume those widths, so the failure surfaced as mis-laid-out UI long
before anyone noticed the glyph shapes differed.
Native Windows 98 inventory
Retail Win98/98SE \WINDOWS\FONTS, TrueType only. Raster (.FON) and vector
(MODERN/ROMAN/SCRIPT) faces are out of scope here; see
"Vector fonts" below.
| Face | Files | Notes |
|---|---|---|
| Arial | arial, arialbd, ariali, arialbi |
|
| Times New Roman | times, timesbd, timesi, timesbi |
|
| Courier New | cour, courbd, couri, courbi |
|
| Tahoma | tahoma, tahomabd |
Win98 shell/tooltip font |
| Verdana | 4 styles | bundled IE4 |
| Comic Sans MS | comic, comicbd |
|
| Impact | impact |
|
| Lucida Console | lucon |
|
| Lucida Sans Unicode | l_10646 |
|
| Microsoft Sans Serif | micross |
outline form of MS Sans Serif |
| Symbol | symbol |
|
| Wingdings | wingding |
|
| Webdings | webdings |
bundled IE4 |
| Marlett | marlett |
hidden; title-bar and checkbox glyphs |
Georgia, Trebuchet MS, Arial Black, and Andale Mono were not Win98 defaults. They arrived with Plus!, the Core Fonts for the Web pack, or IE font downloads. Presence of the IE4-bundled rows (Verdana, Webdings, Comic Sans) varies slightly by edition and OSR.
The exact per-edition inventory must be confirmed against a real image before
any of this is asserted in a test; the v86 harness in tools/v86-reference/
already has a font-inventory probe
(tools/v86-reference/font-apps.json) that enumerates installed faces from
inside Win98, and that is the authority.
Substitution policy
Original Microsoft font binaries are not redistributable and are never committed. Every face is served by an open substitute, chosen in this order of preference:
- Metric-compatible substitute — identical advance widths and identical
TEXTMETRICderivation, different outlines. Layout is exactly right; glyph shapes differ. This is the only tier that makes guest layout math correct, and it is what matters. - Wine's own Win9x substitute — already a project dependency, designed for precisely this role, LGPL-2.1-or-later.
- Visual approximation — documented as an approximation, not silently passed off as the real face.
No tier claims to reproduce Microsoft glyph artwork.
Tier 1 — metric-compatible
| Win98 face | Substitute | License | Where |
|---|---|---|---|
| Arial | Liberation Sans | OFL 1.1 | fonts/liberation/ |
| Times New Roman | Liberation Serif | OFL 1.1 | fonts/liberation/ |
| Courier New | Liberation Mono | OFL 1.1 | fonts/liberation/ |
Liberation 2.1.5 is the Croscore-rebased generation, so Arimo/Tinos/Cousine are the same designs under Apache-2.0 if a license change is ever wanted. All four styles (regular, bold, italic, bold-italic) are present for each family, so synthetic obliquing and emboldening are never needed for these faces.
Tier 2 — Wine substitutes
Fetched from the same pinned Wine commit already used for the bitmap stock
fonts (ab0b3e2526827110319ea8d0b8a738e629e9472b).
| Win98 face | Wine file | Glyphs | Embedded strikes |
|---|---|---|---|
| Tahoma | tahoma.ttf |
988 | 8, 9, 10, 11, 12, 13, 15, 16 ppem |
| Tahoma Bold | tahomabd.ttf |
979 | 9, 10, 11, 12, 13, 15, 16 ppem |
| Small Fonts | small_fonts.ttf |
516 | 11 ppem |
| Marlett | marlett.ttf |
39 | outline only |
| Symbol | symbol.ttf |
193 | outline only |
| Wingdings | wingding.ttf |
53 | outline only |
| Webdings | webdings.ttf |
10 | outline only |
Two findings matter here.
Tahoma ships eight monochrome EBDT strikes covering 8–16 ppem, and Tahoma
Bold ships seven covering 9–16 — exactly the range Win98 dialogs and
tooltips use. (An earlier revision of this table said Tahoma Bold was outline
only; that was wrong, and fonts/Tahoma*.fon are built from its strikes.)
Those strikes go through the existing bitmap pipeline:
tools/gen-bitmap-fon.c extracts embedded monochrome strikes with FreeType at
--bitmap-only --raster=exact. MSSansSerif.fon uses the same path for Wine's
native 13/16/20px bitmaps and intentionally omits synthetic 24/29/37px cells;
the fractional enlargements looked thin, while runtime integer magnification
preserves the source pixel weight. Tahoma's listed rungs are all native and
remain pixel-exact.
Tahoma at UI sizes is therefore a bitmap-path face, not a Canvas face, and
needs no new rasterizer.
Wingdings (53 glyphs) and Webdings (10) are heavily partial. Wine only drew
what Wine needed. Treat them as Tier 3 approximations with known holes; a guest
asking for a codepoint Wine never drew gets .notdef. Symbol at 193 glyphs is
effectively complete for the Win98 Symbol repertoire, and Marlett at 39 covers
its full ~30-glyph set.
Tier 3 — approximation, no metric-compatible clone exists
| Win98 face | Closest open option | Caveat |
|---|---|---|
| Verdana | DejaVu Sans | related lineage, different metrics |
| Lucida Console | DejaVu Sans Mono | different metrics |
| Lucida Sans Unicode | DejaVu Sans | different metrics |
| Comic Sans MS | Comic Relief | metric-compatible but GPL+FE — license review needed before vendoring |
| Impact | Anton | visual only |
| Wingdings / Webdings | Wine partials above | incomplete repertoire |
None of these are vendored yet. Each should be added only when a corpus
application actually names the face — the license and size cost is real, and an
unused font in fonts/ is a liability, not a feature.
Font format scope
TrueType glyf outlines only. CFF/Type2 (.otf) is explicitly out of
scope, and this is a faithfulness decision rather than a shortcut: Windows 98
GDI rasterized TrueType natively and had no CFF rasterizer. Type 1 worked only
when Adobe Type Manager was installed; native Type 1 and OpenType/CFF support
arrived in Windows 2000. Supporting exactly glyf is the Win98 behavior.
Every font vendored for this effort is glyf-based. W95FA.otf is CFF, but it
exists only for the emulator shell's own CSS and never enters the GDI path, so
nothing needs converting.
The TrueType bytecode interpreter is a separate question from the format and is addressed under "Hinting" below.
Rendering architecture
Everything converges on one contract that already exists in
src/10b-gdi-font.wat:
$gdi_bitmap_font_glyph_pixel(strike, glyph_offset,
native_w, native_h, x, y) -> 0 | 1
Every consumer above it — layout, measurement, tab stops, ellipsification, path
recording, ROP composite, GetGlyphOutline — reads only that. Any producer of
a one-bit glyph bitmap plus bearings plugs into the entire existing text stack
unchanged. That is what makes removing Canvas tractable: the work is a new
glyph producer, not a new text pipeline.
CreateFontIndirectA(face, height)
│
├─ .FON strike installed at this size?
│ → WAT FNT blit onto canonical GDI surface
│ System, MS Sans Serif, Fixedsys, Courier, Terminal
│
├─ embedded EBDT strike at this ppem?
│ → build-time .FON, same WAT blit
│ Tahoma 8-16, Small Fonts 11
│
├─ outline face at a hinted ladder size (8-20 ppem)?
│ → build-time FreeType-hinted .FON, same WAT blit
│ Liberation Sans / Serif / Mono
│
└─ otherwise: outline, large / rotated / sheared / arbitrary
→ WAT TrueType scan converter -> cached one-bit glyph
-> same WAT blit
Advance widths come from hmtx on every path, never from a strike's own
metrics. If the two disagree, text visibly re-flows as it crosses the ladder
boundary; a single metric source is what prevents that.
Canvas appears nowhere in this diagram. gdi_text_mask in
lib/host-imports.js is deleted at the end of the sequence below.
Font substitution must be data, not a hardcoded CSS string
_buildCssFont's faceMap is replaced by a substitution table driven from a
manifest so that the browser @font-face block, the Node
FontLibrary.use registration, and the WAT-side EnumFontFamiliesEx inventory
cannot drift apart. A single fonts/substitutions.json naming, per Win98 face:
the substitute family, its files by style, its tier, and whether a bitmap
strike path applies.
The registered CSS family names must be private (e.g.
"WA Arial"), never "Arial". Naming the family Arial invites the host's
real Arial to win the cascade on some machines and lose on others — which is
the exact non-determinism being removed. An explicit private name means the
bundled file is the only thing that can ever match.
The WAT TrueType rasterizer
An earlier revision of this document rejected a WAT rasterizer on the grounds that it would still not be pixel-exact against Win98. That criterion does not discriminate: the Canvas mask is not pixel-exact either, so it cannot decide between them. The criteria that do discriminate are determinism and WAT ownership, and the rasterizer wins both. Pixel-exactness against Win98 is unavailable for outline faces regardless of who rasterizes, because Win98 applied Microsoft's own hinting programs from fonts that cannot be shipped.
Structure:
Table parse. SFNT is big-endian, so byte-swapping loads are the base layer; the rest is offset arithmetic.
head unitsPerEm, indexToLocFormat, bounding box
hhea ascender, descender, lineGap, numberOfHMetrics
hmtx advanceWidth[gid], lsb[gid] <- the metric authority
maxp numGlyphs
loca glyph offsets, short or long per head
glyf outlines
cmap format 4 (BMP), format 0/6, (3,0) symbol with the 0xF000 bias
OS/2 sTypoAscender, usWinAscent, sxHeight, sCapHeight, panose, codepage ranges
kern format 0, for GetKerningPairs
Character mapping is the fiddly part, not the table walk. Guest text arrives
as CP1252, OEM, or Symbol bytes and must reach a glyph index through
codepage to Unicode to cmap. CP437 already exists for Terminal; CP1252 adds 27
mappings in 0x80-0x9F. Symbol faces bypass Unicode through the (3,0) cmap.
Outline extraction. Simple glyphs are endPtsOfContours, an instruction
block that is skipped, run-length-encoded flags, and delta-8 / same-as-previous
/ delta-16 coordinates. Composite glyphs recurse over components with
ARGS_ARE_XY_VALUES offsets or point-matching plus an optional 2x2 transform;
the recursion needs an explicit depth limit.
Transform in 26.6 fixed point, with scale = (ppem << 6) / unitsPerEm
folded together with lfEscapement rotation and any synthetic italic shear
into a single 2x2 matrix. Rotated text becomes easier here than under Canvas,
which has no equivalent of GDI's escapement semantics.
Flattening. TrueType quadratics store on- and off-curve points with implied on-curve midpoints between consecutive off-curve points; those must be reconstructed before subdivision. Fixed subdivision is adequate at UI sizes; adaptive subdivision by control-polygon deviation is only needed for large text.
Scan conversion, nonzero winding. Two candidate rules:
- pixel-center sampling with drop-out control, which is what GDI's non-antialiased scan converter does and what the TrueType specification describes; it emits one bit directly but the drop-out rules are intricate;
- signed-area cell accumulation, producing 8-bit coverage that is thresholded at 50 percent, which sidesteps most drop-out handling and leaves real antialiasing available if the Win98 "smooth edges of screen fonts" option is ever emulated.
Prefer the cell accumulator for that second reason.
Cache. Required, not an optimization. Key on face, glyph index, ppem,
matrix, and synthesis flags; store the bitmap in the same column-major layout
$gdi_bitmap_font_glyph_pixel already reads. A cached glyph is then
indistinguishable from an FNT glyph to every caller, and rasterization happens
once per glyph and size rather than per TextOut.
Hinting, without a bytecode interpreter
Unhinted outlines at 8 to 12 ppem look poor in one bit, which is exactly where TrueType hinting earns its keep. Implementing the bytecode interpreter in WAT is still rejected: it is a large virtual machine whose whole purpose is running hinting programs from fonts this project cannot ship.
It is also unnecessary, because tools/gen-bitmap-fon.c already links FreeType
at build time. Extend it to rasterize outlines across a fixed ppem ladder with
FreeType's hinter and emit .FON strikes, exactly as it already emits strikes
extracted from Wine's bitmaps. Small text then gets real hinting through the
existing exact blit, the WAT scan converter only handles sizes where hinting
stops mattering, and FreeType remains a build-time tool that is never linked
into the emulator.
The ladder must be bounded — faces times sizes times styles multiplies quickly. Start at 8-20 ppem for the Tier 1 faces only, driven by what the corpus actually requests.
Vector fonts
MODERN.FON, ROMAN.FON, SCRIPT.FON are stroke fonts — polylines drawn with
the current pen, not filled outlines, sharing the .FON container with the
bitmap faces (dfType bit 0 distinguishes them). They are Windows 1.x-era
holdovers used by plotter and CAD-style code. No corpus application is known to
request one. Out of scope; revisit only if a real binary asks. If one does,
the implementation is a stroke-path walker in WAT feeding the existing path
geometry — closer to the line-drawing code than to the font code.
Metric fidelity and how it gets verified
Metric compatibility is the whole point of Tier 1, so it must be measured,
not assumed. The project already has the instrument: tools/v86-reference/
runs real Windows 98 under v86 and captures probe output, and
probes/font-inventory.c already reports native font data.
Plan:
- Extend the probe (or add a sibling) to emit, for each Tier 1 and Tier 2
face, at each of a fixed size ladder: the full
TEXTMETRIC, per-characterGetCharWidth32for0x20..0x7E, andGetTextExtentPoint32for a fixed set of strings including the ones real dialogs use. - Check the capture in as a pinned reference JSON, as the Paint pixel comparison already does.
- Add a test asserting the emulator's own
GetTextExtentPoint32A/GetTextMetricsAmatch that reference within a stated tolerance — exact for advance widths, and an explicit documented tolerance for anything derived from rasterization.
Done, and it found things. probes/font-metrics.c reads GetTextMetricsA,
GetCharWidthA for 0x20..0x7E and GetTextExtentPoint32A over COM1 for eight
faces across twelve requested heights; font-metrics-to-json.js pins the
capture as test/fixtures/font-metrics.json, and
test/test-wat-font-metrics-reference.js compares ours against it.
What it measured:
- The compatibility claim holds where it is claimed. Courier New matches Windows 98 on all 1140 measured advance widths. Arial matches on 84.5% and Times New Roman on 80.6%; the rest are one pixel out.
- The residual is hinting, as predicted. Win98's Arial
iadvances 3px at 10px, 2px at 11px and 3px at 12px. That non-monotonic sequence is a hinting program snapping a stem, and no unhinted rasterizer reproduces it. It is the strongest argument yet for milestone 2's FreeType-hinted.FONladder at UI sizes, and against a bytecode interpreter. - Vertical metrics are not part of metric compatibility. Liberation keeps
its own OS/2 vertical values, so cell heights differ from Arial's by up to
4px at the same requested size. Win98 honours
tmHeight - tmInternalLeading == |lfHeight|exactly; so do we, with different leading. - Tahoma is substituted by Wine's independently drawn
tahoma.ttf, which is not a metric-compatible design and measures like it (40% of advances). Reported, not gated: a gate there would pin a mismatch in place. - A defect the reference uncovered, since fixed. On one DC with one font
selected,
sum(GetCharWidthA)over a string did not equalGetTextExtentPoint32Aof that string — they disagreed on 20 to 24 of every 24 sentences, by up to 18px, and every dialog that lays out withGetTextExtentPoint32inherited it. Two engines answered one question: the per-character path summed whole-pixel advances in WAT, while the extent path asked Canvas to lay the whole string out and rounded once at the end. Windows 98 has no second engine — GDI kerns nothing, so an extent is the sum of its advances — so$host_measure_textnow measures one glyph at a time and adds in WAT, andgdi_text_maskplaces each glyph at that same whole-pixel advance instead of callingfillTexton the whole string. Courier New's sentence extents went from 8.2% out to exact. Both are gated: theSELFline is now an assertion, andtest/test-wat-text-draw-extent.jsholds the drawn run against the extent the same DC reports, which is what fails if only the measuring half is ever fixed again.
Determinism across hosts
Once text leaves Canvas, host determinism stops being a property that has to be
arranged and becomes structural: the same font bytes go through the same WAT
code and produce the same pixels in the browser, in the Node CLI, and in CI.
Browser and Node use different Canvas rasterizers, so any text that still goes
through gdi_text_mask can differ between them — which is a correctness reason
to finish the removal, not merely a philosophical one.
Until milestone 4 lands, the interim substitution path must still avoid naming host fonts:
- Browser —
@font-faceblocks inindex.htmlalongside the existing W95FA and Fixedsys Excelsior entries;document.fonts.load()must complete before the first guest paint, or earlymeasureTextcalls silently use a fallback. This is a real ordering hazard and needs an explicit await in startup. - Node CLI —
lib/canvas-compat.jsalready exposesregisterFontoverskia-canvas'sFontLibrary.use, so the same files register by private family name.
Both hosts' font registration is deleted along with gdi_text_mask.
Web payload
The vendored TTFs are 4.2 MB (Liberation) + 2.3 MB (Wine). Shipping those raw
to the browser is unacceptable for a page that currently loads a ~200 KB
w95fa.woff2. The build must emit subset TTFs — Win98 apps in the corpus
are CP1252/OEM, so subsetting to the Windows-1252 repertoire plus the box and
symbol ranges each face actually needs cuts this by roughly an order of
magnitude, and the same subsets shrink the resident WASM memory arena.
Deliberately not WOFF2: the emulator loads font bytes into linear memory and parses them in WAT, so a Brotli-compressed container would require a decoder inside the emulator to buy something HTTP transport compression already provides for free. Subset TTF is both the wire format and the in-memory format.
The same reasoning retires the @font-face and FontLibrary.use registrations
entirely once milestone 4 lands — the browser never needs to know these fonts
exist. Only the emulator does.
The full TTFs stay in the repo as the pinned, reproducible source; only subsets
are deployed, generated by a build step next to tools/gen-wine-fonts.sh and
hash-pinned the same way. Do not subset by hand or check in a subset without its
generator — an unreproducible font binary in fonts/ is exactly the provenance
problem the rest of this directory is set up to avoid.
Milestones
Ordered so that each one is independently useful and the Canvas dependency shrinks monotonically.
Assets and manifest — fonts vendored with pinned hashes and licenses (done, commit
3ebdf08);fonts/substitutions.jsonwritten and checked bytest/test-font-substitutions.js(done);fonts/README.mdandfonts/wine/UPSTREAM.mdupdated (done).Metrics in WAT — no rasterizer. Parse
head/hhea/hmtx/maxp/cmap/OS/2, add the codepage tables, and serveGetTextExtentPoint32,GetCharWidth32,GetTextMetrics, andGetTextFacefrom the font file. Parsing, CP1252,TEXTMETRICderivation, face selection, and the strike the public handlers measure from are done insrc/10c-truetype.watandsrc/10b-gdi-font.wat. The handlers reach the font through the installed strike rather than calling$tt_face_*directly, which is why they needed no change. CP437 is deliberately left out — the faces that need it are bitmap strikes on the.FONpath, and no scalable face is requested withOEM_CHARSETyet. Start here regardless of whether the rasterizer is ever built. It is independent of rendering, it is exact rather than approximate, it makes Liberation's metric compatibility real instead of aspirational, and it fixes the layout, wrapping, and caret bugs that actually bite. Canvas keeps producing glyph pixels in the meantime. Roughly 800-1200 lines of WAT.Bitmap strikes —
Tahoma.fon(8-16 ppem),TahomaBold.fon(9-16, withdfWeight700), andSmallFonts.fon(11 ppem) are generated from the embedded EBDT strikes and checked bytest/test-generated-wine-fonts.js(done). Still to do: install the ladder at startup — nothing selects one at runtime yet.The ladder is now measured, not assumed.
tools/gen-bitmap-fon.calready rasterizes outlines with selectable hinting, so a Tier 1 ladder is one command:gen-bitmap-fon fonts/liberation/LiberationSans-Regular.ttf Arial.fon Arial \ --hinting=native --raster=exact 10 11 12 13 14 16 18 20Installed through
AddFontResourceAand measured againsttest/fixtures/font-metrics.json, that ladder reproduces Windows 98's Arial advance widths on 759 of 760 samples (99.9%), against 644/760 (84.7%) for the runtime unhinted rasterization it would replace. The font's own hinting is what does it: FreeType's autohinter scores 474/760 (62.4%), worse than no hinting at all, because it is a different hinter making its own decisions. Use--hinting=native.The hazard to handle when wiring it.
$gdi_bitmap_font_bestmatches an installed strike by face and nearest height, so an installed Arial ladder would also capture Arial at 30px and scale a 20px strike up to it — worse than today's on-demand outline rasterization at that size. A ladder strike must therefore match on exact height only and fall through to$tt_strike_ensureotherwise, which is a third state beside the existing "installed" (nearest height) and "synthesized" (exact key) states rather than a change to either.Interim deterministic substitution —
_buildCssFontreads the manifest and uses private family names, so whatever text still reaches Canvas stops depending on host fonts. Dropped, which is what it was always for: it was insurance against milestone 4 taking a long time, and milestone 4 has landed. There is no text left reaching Canvas to make deterministic.WAT rasterizer —
glyfparse, transform, flatten, scan-convert, cache (done). A substituted face is rasterized into an FNT 3.00 image and installed as an ordinary strike, so it renders through the existing bitmap text path;test/test-wat-gdi-scalable-text.jsgates thatgdi_text_maskis not called at all.Every face now resolves to a file we ship, which is what made the fallback stop being load-bearing. A Win98 face with no vendored look-alike — Verdana, Lucida Console, Comic Sans MS, Impact, Lucida Sans Unicode, Microsoft Sans Serif — has an entry of its own mapping it to its real
C:\WINDOWS\FONTSfilename, mounted from the closest vendored family, so a user who owns the Microsoft font can drop it in at that path and have it answer instead. Those entries live in a second table that resolution reads and enumeration does not: we do not have those fonts, a Win98 machine without them installed did not list them, and advertising them changes which face an application picks. TetriNET makes that concrete — its Delphi runtime enumerates fonts at startup and crashes mid-paint, thousands of batches later, when the list grows by even one entry. A face no table has heard of falls through to the default face rather than to nothing, the way GDI's font mapper always answered with its closest match rather than refusing. A LOGFONT that names no face at all is a different question — the guest is asking GDI to choose — and the selection layer answers it with the bundled MS Sans Serif strike, which needs no font file to load and no host font to exist.Cleanup is done:
gdi_text_mask,gdi_text_bind, and the JavaScript measurement/metric imports are deleted. The$host_measure_textand$host_get_text_metricsnames that remain are internal WAT compatibility wrappers over the strike provider, not host calls. Browser font registration is gone for GDI faces; the remaining CSS fonts belong to the HTML shell.Metric reference — v86 probe, pinned capture, comparison test. Gates any claim of metric correctness; can run against milestone 1 immediately.
API completion —
GetGlyphOutlineAmetrics, bitmap, quadratic native, and gray formats are WAT-owned; the native Win98 contract is pinned bygdi-font-outline.candgdi-font-outline-win98.json.GGO_BEZIERis an intentionalGDI_ERROR, matching Win98 rather than NT.GetCharABCWidthsuseshmtxbearings plus glyph bounds.GetCharacterPlacementnow applies legacykernformat-0 pairs forGCP_USEKERNING, and deployed subsets are tested to preserve them. Still to do: DBCS conversion, contextual shaping/ligation, and any separately demanded publicGetKerningPairsA/Wimport.kernremains the source rather than GPOS on purpose: Win98 GDI had no OpenType layout engine.Enumeration —
EnumFontFamiliesExreports substituted faces under their Win98 names with correctTEXTMETRICand charset, so apps that enumerate and pick by name find what they expect.Tier 3 on demand — add faces only when a corpus binary requests one.
Definition of done
The milestones describe the work; these are the conditions that decide whether it is finished.
gdi_text_maskinlib/host-imports.jsis deleted.- The
@font-faceblocks inindex.htmland theregisterFontcalls inlib/canvas-compat.jsare deleted for every GDI face. The host never learns these fonts exist. _buildCssFontand itsfaceMapare gone. No code path names a host font.- Every text-drawing binary in
test/binaries/still renders, verified by a CLI run and a PNG, with no newcrash_unimplemented. - The v86 metric comparison described above exists and passes.
Milestone 1 is deliberately useful on its own. If the effort stops after metrics land in the public API, the layout, wrapping, and caret bugs are fixed and the remaining gap is appearance rather than correctness.
Verification
Each slice adds its own test and re-runs these:
bash tools/build.sh
node test/test-wat-truetype-metrics.js
node test/test-font-substitutions.js
node test/test-generated-wine-fonts.js
node test/test-wat-gdi-bitmap-text-compat.js
node test/run.js --exe=test/binaries/notepad.exe --max-batches=40
Cost and risk
Milestones 1 and 4 together are roughly 4000 lines of WAT, comparable to the
existing src/10b-gdi-font.wat.
- Memory — Liberation Sans alone is 410 KB and several faces may be resident
alongside a glyph cache. This needs a dedicated arena and a
memory-map.mdentry. Build-time subsetting to the CP1252 repertoire cuts the resident cost substantially and is shared with the WOFF2 subsetting described above. - Performance — bounded by the glyph cache; rasterization is once per glyph
and size, not once per
TextOut. Without the cache this path would be far slower than Canvas. - Unhinted appearance — mitigated by the build-time hinted ladder, not by the rasterizer. If the ladder is ever bypassed, small text quality regresses visibly and that will read as a rasterizer bug when it is not.
- Character mapping breadth — codepages and
cmapsubtable formats are where unbounded scope hides. Implement CP1252, CP437, and Symbol; add others only when a corpus binary needs them.
Coordination
messageboard.txt shows an active GDI-SCALABLE-LAYOUT effort routing scalable
DrawText through WAT layout with TextOut glyph-provider calls, and removing
the semantic gdi_draw_text JS bridge. That work owns the layout half of the
same seam this document's milestone 2 touches on the font selection half.
Sequence them: substitution can land under whatever provider interface that
effort settles on, and should not fork it.
Licensing
| Asset | License | Text |
|---|---|---|
| Liberation 2.1.5 | SIL OFL 1.1 | fonts/liberation/LICENSE |
| Wine fonts | LGPL-2.1-or-later | fonts/Wine-LGPL-2.1.txt |
| ANAKRON v0.3.3 | SIL OFL 1.1 | fonts/ANAKRON-OFL.txt |
| W95FA | SIL OFL 1.1 | fonts/W95FA-OFL.txt |
| Fixedsys Excelsior 3.02 | public domain | — |
Microsoft .FON/.FNT/.TTF binaries must not be committed. Users may supply
their own Win98 font files locally for exact comparison.