Native text layout emulation: offline engine ports, numbers and open decisions
This issue collects the native text layout emulation study so someone can pick it up cold. Nothing is merged, and the research tools aren't in the repo.
The question: instead of adding more hand-written break and width rules, can Pretext reproduce what browsers do by porting their text layout from source? Does that need a shaper, reverse-engineered CoreText or HarfBuzz, or parsed font files?
Measured on the installed wrapping suite run of 2026-09-14 (--suite=full, Chrome 153, Safari 26.5.2 and Firefox 155.0.1 on macOS 26.5.2, DPR 2, both directions), against Pretext at 5dbc9bd (#291). Code locations were checked against main at 5810820 (#311) on 2026-09-15; #319 and #320 have since landed the two small fixes in 4.8. main:path:N is a line at 5810820. Engine references are to Chromium 152.0.7977.83 (79460eb), WebKit's safari-7624.2.5.11-branch (Safari 26.5.2) and Firefox's FIREFOX_155_0_1_RELEASE tag.
Terms
- Break opportunity: a position where a browser may end a line.
- Item: the stretch of text an engine's line breaker measures and places as one unit. In WebKit, a word or a run of white space. In Blink, a run of text split at style and direction changes and at control characters such as tab and LF. Rich-inline items are Pretext's own.
- Break oracle: a program built from one engine's own break code and data that lists that engine's break opportunities for a text.
- Width model: a program that computes text widths the way one engine does, from font files: HarfBuzz with Blink's or Gecko's rounding, and CoreText through
CTFontShapeGlyphs, the private call WebKit uses. - Exact emulator: a port of one engine's line-filling code (where each line ends and how wide it is) over its break oracle and width model, including font fallback.
- Stand-in: an offline program used in place of a browser API. The width models stand in for each browser's Canvas
measureText(). ICU's word iterator with Chrome 153's data, JavaScriptCore over libicucore 78.1, and ICU4X with Firefox 155's data stand in for each browser'sIntl.Segmenter. - Canvas-only emulator: an exact emulator whose widths come only from
measureText()totals a page can get, with the width models as stand-ins. No installed browser ran. - Line-filling port: a proposed port of one engine's line-filling code into Pretext's
prepare()andlayout(), using Canvas widths the way the Canvas-only emulator does (section 6, stages 4-6). - Supported and research scope: the wrapping harness marks each row one or the other. Research rows keep observations for investigation; they require nothing and don't block a change. "Supported scope" means supported rows only.
- Development set: the suite rows above: Chrome 235,410, Safari 236,169, Firefox 235,852. They're mostly stress cases (the maintained accuracy grid and corpora are about 100% today), and the tools were built while reading them.
- Held-out set: 3,000 fresh inputs made from the development corpora and suite texts, with the same fonts: 1,500 corpus slices, 1,200 splices of suite texts and 300 Latin corpus paragraphs, none equal to a suite text. Each was observed in headless Chrome 153 (2,998 rows), Playwright WebKit 26.4 and headless Firefox 155.0.1.
- Fully right: the emulators' own check of a row, not the harness's: the line count is right and every observed line start matches (in Firefox, falls in its observed window).
Summary
- Can Pretext port browsers' text layout from source? Offline, yes: ports of each engine's code match every development row on line count (3.1). In a page, break opportunities port (3.3), but widths are limited to what Canvas reports, so each engine still loses some rows (3.2).
- Do we need a shaper? For exact widths, yes: widths come from shaping plus each engine's own rounding and splitting. But a page can't get system font files. Safari and Firefox expose none, and Chrome only behind a permission prompt. So Canvas stays the shaper for current Pretext.
- Reverse-engineer CoreText or HarfBuzz? Not needed. Blink and Gecko shape with HarfBuzz, and what differs between them is in their source: rounding, where text is split before shaping, and reshaping at line edges. On macOS, CoreText decides Safari's widths and some of Chrome's and Firefox's advances and fallback choices; the offline tools called it directly, including the private call WebKit uses.
- Parse font files? Only in a bring-your-own-font mode (3.4), which would apply to the 13.4% of suite rows set in a web font with no fallback.
- Exact emulators match every development row on line count. In supported scope they fail 0 line counts in all three engines, fix every line count Pretext fails (Safari 55,345, Firefox 46,719, Chrome 61,179), and lose nothing on any replayable metric. The only harness failures left are 21 Chrome widths rows that Pretext also fails, most likely a bad observation (3.1). On the held-out set: WebKit 3,000 of 3,000, Firefox 2,996 of 3,000, Chrome 2,998 of 2,998, but Chrome's font settings were chosen after seeing its misses.
- Canvas alone fixes most of those rows but loses others. The best recipe per engine fixes 52,713 / 46,048 / 55,371 line counts and loses 1,994 / 546 / 2,698 (Safari / Firefox / Chrome, 3.2). Most losses come from CR/FF/VT in Safari's Canvas, emoji size in Firefox, and widths inside words in Chrome. Chrome's recipes need about 4,500 Canvas calls in
layout()and Safari's 32, and Firefox's closest recipe keeps today's DOM read per font. - Break opportunities can come from each engine's own data. TypeScript ports of the three engines' break code match the oracles with 0 differences outside Thai, Lao, Khmer and Myanmar runs. Inside those runs, the
Intl.Segmenterstand-ins match each engine's line breaks on 99.90-100% of breaks, so no dictionaries need to ship (not checked in browsers). - The code wouldn't get much smaller: an estimated 5,800-8,000 lines at the end, against 6,332 at
5dbc9bdwhen the estimate was made (6,306 at main). The gain is rules ported from engine source instead of guessed. - Proposed first shipped step: take break opportunities from engine data for Chrome and Safari (4.1; stage 2 in section 6).
- Open decisions are in section 8.
1. Problem
Pretext splits text with Intl.Segmenter word boundaries, then approximates each engine's line-break rules with hand-written merges. At 5810820, 2,097 of 6,306 runtime lines are break-opportunity rules, and most engine work since June added more of them. Widths come from Canvas totals, fitted with float sums and a small tolerance (lineFitEpsilon: 1/64 in the WebKit profile, 0.005 otherwise).
2. What was built
All offline; none of it ships.
- Break oracles: C++ ports of Blink's break code over Chrome 153's ICU 78.2 data, and of WebKit's over macOS libicucore 78.1. A Rust one for Gecko over icu_segmenter 2.1.2 with Firefox's baked data.
- Width models: HarfBuzz (uharfbuzz 0.56.1) with Blink or Gecko rounding; CoreText through
CTFontShapeGlyphs. - Exact emulators: ports of Blink's LineBreaker and ShapingLineBreaker, WebKit's inline line builders with InlineContentBreaker and
breakWord, and Gecko's BreakAndMeasureText with nsLineLayout. Each includes font fallback, bidi,pre-wrapand letter spacing. - Canvas-only emulators, as defined above.
- TypeScript break scans: runtime-shaped ports of the three engines' break code, plus a 303-line TypeScript ICU rule-based break iterator.
- Replay assessor: scores a file of predictions offline with the wrapping harness's own frozen checks. It reproduces every stored Pretext assessment exactly (707,431 rows, 9 metrics, 0 differences).
- A held-out input generator with observers for the three headless browsers.
How the work went:
- Each engine's source was read at the revisions above before porting, and the ports cite engine file:line for the rules they port.
- Each oracle was checked against the native line starts the suite observed; some emergency starts could only be partly verified (3.3). Each emulator ran over the development set, and misses were traced to engine source, the page setup or the machine's fonts, and fixed in the port. The held-out set was generated and observed once the WebKit and Firefox exact emulators matched the development set; Chrome's complete emulator was finished after that. The three exact emulators and the Safari and Firefox Canvas-only emulators then ran over it. After those runs one Firefox rule was ported, and Chrome's generic font families were chosen after seeing its misses (3.1).
- Rule switches measured what many ported rules decide: turn one rule off, rerun over the rows the emulator handles, and count the rows that go from right to wrong. Structural code had no switch.
- The TypeScript scans were diffed against the oracles over every suite text and corpus, plus 20,000 fuzz inputs for Gecko.
- Harness scores come from the replay assessor, which first had to reproduce every stored Pretext assessment exactly. Rows a Canvas-only emulator loses were attributed by checking whether the exact emulator passes the lost metric.
- Heavy jobs ran serialized under a guard that kills only its own process tree when a memory, CPU, wall-clock or no-progress limit trips. Other jobs sometimes shared the machine, so timings are from a shared machine.
- Every figure quoted here was checked against the raw outputs.
3. Results
3.1 Exact emulators
Development set, supported scope, line count (height behaves the same). "Fixed" and "lost" compare with Pretext at 5dbc9bd.
| Engine | Pretext: pass / fail | Exact emulator: pass / fail | Fixed | Lost on any replayable metric |
|---|---|---|---|---|
| Safari | 163,678 / 55,345 | 219,023 / 0 | 55,345 | 0 |
| Firefox | 171,922 / 46,719 | 218,641 / 0 | 46,719 | 0 |
| Chrome | 157,504 / 61,179 | 218,683 / 0 | 61,179 | 0 |
- Replayable metrics: height, lineCount, breaks, source placement, whitespace, widths, hyphen.
apipasses by construction, since emulators run no API checks.richHeightisn't emulated (24 rows unobserved, 16 of them required). - 0 fails in both scopes, except 21 Chrome widths rows that Pretext also fails. They're one RTL input mixing Latin, Hebrew, brackets and Arabic, with tabs, in 16px Arial, 16px Times New Roman and 24px Amiri, with matching line breaks. On each failing line the last Arabic beh (ب) reports a rectangle as wide as the font's space advance (4.445 / 4.0 / 7.016 px), though the glyph is at least 11.41 px. On 12 of the 21 rows that beh is the line's only character. Most likely the line's Range reports the trimmed collapsible space (inferred), so the observation is wrong there, not the emulator.
- Held-out, by the fully-right check plus every checked character (not harness-scored):
- WebKit: 3,000 of 3,000.
- Firefox: 2,996 of 3,000, after porting one rule the held-out set exposed: white space is transformed per direction run. The other 4 had emoji 17 px wide at both 16 px and 18 px in that headless session; counted as observation noise (inferred).
- Chrome: 2,998 of 2,998 (two inputs crashed the renderer), at zoom 1 with the generic font families of an English Chrome UI. Both conditions are inferred: headless Chrome most likely lays out at zoom 1 while reporting DPR 2 (line widths and emoji advances point that way), and its generic families are the en defaults or none, which these rows can't tell apart. Those families were chosen after 61 rows missed under different generic-family settings, so Chrome's isn't a clean held-out score.
- What this proves: each engine's behaviour is reproducible from its source. What it doesn't: the emulators read font files, call CoreText's private API, and use system ICU and one machine's font list, none of which a page can reach.
3.2 Canvas only
Development set, supported scope, line count, against Pretext at 5dbc9bd. These are specific recipes run over offline Canvas stand-ins, not upper bounds. "Calls" are warm measureText() calls and "units" the UTF-16 code units passed to them, relative to today.
How the recipes get widths:
- A pair probe measures two neighbouring graphemes together and apart.
- A prefix table measures, in
prepare(), the width from the start of the text between two break opportunities (in Chrome, the text between spaces, which Chrome's Canvas shapes separately) to each grapheme. - Complement gives the width up to grapheme k as W(word) − W(rest of the word), with a ZWJ in front of the rest when a join crosses k.
- Whole-string totals (Chrome) measure whole strings with
textRendering = 'optimizeLegibility'for fonts whose GPOS or GSUB tables involve the space glyph, and take in-word positions from prefix tables up to 96 graphemes, with pair probes beyond. Pair context (Chrome) keeps those totals but takes every in-word position from pair probes, with no prefix tables.
| Engine and recipe | Fixed | Lost | Remaining fails | Distinct supported rows pass→fail (any replayable metric) | Canvas cost vs today |
|---|---|---|---|---|---|
| Safari, prefix tables + pair probes | 52,713 | 1,994 | 4,626 | 4,088 | 1.48× calls, 1.55× units, plus 32 calls in layout() in 24 preparations |
| Firefox, pair probes | 45,607 | 1,933 | 3,045 | 5,844 | 1.08× calls, 1.05× units |
| Firefox, complement + today's emoji correction | 46,048 | 546 | 1,217 | 848 | 2.78× calls, 7.29× units; the correction adds one DOM read per font |
| Chrome, pair context | 54,951 | 2,780 | 9,008 | 4,139 | 1.83× calls, 1.43× units, plus 4,506 width-dependent calls in layout() |
| Chrome, whole-string totals | 55,371 | 2,698 | 8,506 | 3,837 | 3.47× calls, 5.22× units, plus 4,541 width-dependent calls in layout() |
Research rows add 41 (each Chrome recipe), 0 (Safari), 153 (Firefox pair probes) and 4 (complement + correction) to the pass→fail column. layout() doesn't allow Canvas calls today.
Every pass-to-fail row is a real loss from what Canvas can tell: the exact emulator passes the lost metric on every lost row and metric (8,595 in Chrome with whole-string totals, 1,796 in Firefox with complement + correction, and all of Safari's).
Main loss causes (rows losing any replayable metric, not only line count):
- Safari (4,088 supported rows): OffscreenCanvas measures CR, FF and VT as spaces, where layout gives CR its glyph's advance and FF and VT the .notdef advance (3,180 rows). Canvas
letterSpacingkeeps ligatures that DOM letter spacing turns off (723). Prefix tables (90) and pair-probe errors (84). - Firefox: Canvas sizes Apple Color Emoji at CSS px, where layout uses device px (4,670 rows without the correction, supported + research, pass or unobserved to fail). In-word advances under legacy kern fonts, Amiri and Nastaliq joins, and words spanning several fonts. Partial ligatures.
- Chrome: in-word positions and totals, mostly Arabic, Hebrew, soft hyphens and substituted characters.
- Separately, fitting at zoom 1 instead of device pixels makes even the exact Chrome emulator wrong on 1,989 of 235,410 rows by the fully-right check (1,264 of them on line count), with the width model at zoom 1, not observed Chrome.
Held-out, by the fully-right check against headless browsers:
- Safari holds: 98.80% with prefix tables + pair probes (the table's recipe) and 97.63% with prefix tables alone, both above development (97.71% and 96.34%).
- Firefox pair probes drop to 94.87% (development 97.86%), losing in-word widths in long paragraphs in narrow boxes. Complement + correction stays closest (98.27% against 98.86%).
None of these Canvas behaviours was observed in installed browsers. They come from engine source and the stand-ins.
3.3 Break opportunities from engine data
- Oracles against native line starts: no native line start skips a usable oracle opportunity. Chrome: 391,545 starts explained, 210 not (probably width effects from joined Arabic and trimmed brackets, inferred). Safari: 36 anomalies in 477,625. Firefox: all 468,971 observed starts allowed, though 1,066 word-splitting starts fail a Range-width check. This can't prove an oracle has no extra opportunities.
- TypeScript scans against oracles: 0 differences outside Thai/Lao/Khmer/Myanmar runs, over 13,108 Blink inputs, 19,393 WebKit inputs and 10,915 Gecko inputs plus 20,000 fuzz inputs. The WebKit figure used the oracle's bidi levels; without a resolver, 15 RTL positions differ.
- Thai, Lao, Khmer, Myanmar: the
Intl.Segmenterstand-ins match each engine's line breaks inside those runs, so no dictionaries need to ship. Not checked in installed browsers:- ICU's word iterator with Chrome 153's ICU data: 99.90% (56,148 of 56,202);
- JavaScriptCore over libicucore 78.1: 99.90% (53,929 of 53,983);
- both exact outside runs that start with a combining mark (none in the suite or corpora);
- ICU4X's word segmenter with Firefox 155's data: 100% (54,588 of 54,588), after dropping breaks inside grapheme clusters.
- What the scans do:
- Blink: SPACE, TAB and LF are breakable, with a break before the first non-space after them; Chromium's U+0021..U+00FF pair bitmap; '-' before a digit; one ICU pass with Chromium's
line_normal.brk(Chrome usesline_normal_cj.brkwhen the language is zh, and text with no language falls back to Chrome's UI language); keep-all by general category. - WebKit: its 223×223 pair table and fast character classes; the ICU fallback with its skip over ASCII letters; 2 characters of prior context at rich-inline item edges; Apple's tables, reproduced as Chromium's
line_normal.brkplus override code points and Apple's curly-quote remap. - Gecko: words end at SPACE, TAB and CR (and LF in 16-bit text); words of only non-breakable ASCII get no internal break; a port of ICU4X 2.1.2's strict line iterator over Firefox's 19,732-byte table; breaks inside grapheme clusters dropped; splits at bidi level runs.
- Blink: SPACE, TAB and LF are breakable, with a break before the first non-space after them; Chromium's U+0021..U+00FF pair bitmap; '-' before a digit; one ICU pass with Chromium's
- Size (the study's scans alone): Blink + WebKit scans 28.0 KB gzipped with data, including both Chromium tables. Gecko 19.4 KB. Today's layout bundle is 20,973 B gzipped. A Chrome and Safari prototype in Pretext has since measured +16 KB on the layout bundle (decision 1).
- Speed (bun, warm, all corpora): the Blink and WebKit scans take 23 ms against
analyzeText()'s 101-102 ms. That compares bare scans with all of today's analysis; in the prototype the wholeanalyzeText()is about 2x faster (decision 1). The WebKit scan was timed without bidi levels. The Gecko scan was slower, 134 ms against 112 ms, almost half of itIntl.Segmentergrapheme calls; a faster copy has since beatenanalyzeText()(section 6, stage 3). - Canvas calls: the study's approximate probe estimated +8.0% warm calls in the Chrome profile and +2.4% in Safari's on suite texts. The prototype measured cold
prepare()calls unchanged in normal mode (decision 1). - Blink restarts ICU at each line start. Running it once per text, as the scans do, changes 86 of 188,274 break decisions.
3.4 Bring your own font
If the page passed font files (#27 asked for opentype.js, #34 asks for a pluggable measure function):
- Reach: about 31,600 rows per browser (13.4% of the suite) use a web font with no fallback. The exact emulators are right on all of them, and Pretext fails line count on 24.9-35.4% there.
- Fidelity: harfbuzzjs 1.6.1 (HarfBuzz 14.4.0) replays 21,622 recorded HarfBuzz calls identically: the Blink (7,435) and Gecko (3,227) width models' calls, plus stock-setup (3,935) and upem-scale (7,025) variants. Safari's widths come from CoreText, which HarfBuzz matches within 1/64 px on 22,960 of 23,374 clean lines.
- Size and speed: WASM plus JS glue is 162 KB brotli. Warm shaping costs 0.49-0.65 ms per 1,000 UTF-16 units on suite texts and 2.1-2.7 ms on the Urdu corpus, measured on a loaded machine, so upper bounds.
- Limits: the stock build l
Source: chenglou/pretext