diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c4e06095..7ea88d22 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -8,12 +8,50 @@ on:
permissions:
contents: read
+ # GitHub models issue/PR comments as pull-request metadata. This permits the
+ # size reporter to update its PR comment; it cannot write repository content.
+ pull-requests: write
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
+ size:
+ name: Package size report
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-24.04
+ timeout-minutes: 20
+ env:
+ CI: true
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Install canonical toolchain
+ uses: jdx/mise-action@9e7f7633ff6f6d6048a9418a68d48f288f50eb14 # v4.2.3
+ with:
+ version: 2026.7.13
+ cache: true
+
+ - name: Install locked dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Compare package delivery sizes with the PR base
+ uses: andresz1/size-limit-action@94bc357df29c36c8f8d50ea497c3e225c3c95d1d # v1.8.0
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ build_script: build
+ skip_step: install
+ package_manager: pnpm
+ # The action runs this command in both revisions. The inline adapter
+ # accepts the base branch's older report, derives the same core total,
+ # and emits Size Limit's stable [{ name, size }] protocol.
+ script: >-
+ sh -c "node apps/benchmarks/scripts/measure-package-sizes.mts |
+ node -e \"let input='';process.stdin.on('data',chunk=>input+=chunk).on('end',()=>{const report=JSON.parse(input);const entries=report.entries.filter(entry=>entry.status==='measured');const byId=new Map(entries.map(entry=>[entry.id,entry]));if(!byId.has('renderer-neutral-core-total')){const js=byId.get('browser-core');const wasm=byId.get('text-shaper-wasm');if(js&&wasm)byId.set('renderer-neutral-core-total',{id:'renderer-neutral-core-total',format:'aggregate',gzipBytes:js.gzipBytes+wasm.gzipBytes,brotliBytes:js.brotliBytes+wasm.brotliBytes})}const tracked=/^(browser-core|text-shaper-wasm|renderer-neutral-core-total|three-runtime-js|three-renderer-total|font-(inter|icons)-|delivery-three-)/;const rows=[...byId.values()].filter(entry=>tracked.test(entry.id)).flatMap(entry=>entry.format==='javascript'?[{name:entry.id+' (brotli)',size:entry.brotliBytes}]:entry.format==='aggregate'?[{name:entry.id+' (gzip)',size:entry.gzipBytes},{name:entry.id+' (brotli)',size:entry.brotliBytes}]:[{name:entry.id+' (raw)',size:entry.rawBytes},{name:entry.id+' (gzip)',size:entry.gzipBytes},{name:entry.id+' (brotli)',size:entry.brotliBytes}]);process.stdout.write(JSON.stringify(rows))})\""
+
check:
name: Check
runs-on: ubuntu-24.04
@@ -44,7 +82,10 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Provision authenticated HarfBuzz utilities
- run: pnpm scripts run fixture:harfbuzz:provision
+ run: |
+ pnpm scripts run fixture:harfbuzz:provision
+ mise -C apps/benchmarks exec -- node ./scripts/provision-harfbuzz.mts --version=14.2.0
+ printf '%s\n' "$GITHUB_WORKSPACE/apps/benchmarks/.cache/harfbuzz/14.2.0/build/util" >> "$GITHUB_PATH"
- name: Select rolling runner Chromium
shell: bash
diff --git a/apps/benchmarks/fixtures/contracts/paragraph-bidi-layout-v0.json b/apps/benchmarks/fixtures/contracts/paragraph-bidi-layout-v0.json
index 9859f3ca..c7dbe3d8 100644
--- a/apps/benchmarks/fixtures/contracts/paragraph-bidi-layout-v0.json
+++ b/apps/benchmarks/fixtures/contracts/paragraph-bidi-layout-v0.json
@@ -570,9 +570,9 @@
-1.4118552207946777, -1.4118552207946777, -1.4118552207946777, -1.4118552207946777,
-1.4118552207946777, -1.4118552207946777, -1.4118552207946777, -1.4118552207946777,
-1.4118552207946777, -1.4118552207946777, -1.4118552207946777, -1.4118552207946777,
- -1.4118552207946777, -1.4118552207946777, -39.54185104370117, -39.54185104370117,
- -39.54185104370117, -39.54185104370117, -39.54185104370117, -39.54185104370117,
- -39.54185104370117, -39.54185104370117, -39.54185104370117
+ -1.4118552207946777, -1.4118552207946777, -39.5418586730957, -39.5418586730957,
+ -39.5418586730957, -39.5418586730957, -39.5418586730957, -39.5418586730957,
+ -39.5418586730957, -39.5418586730957, -39.5418586730957
],
"layout": {
"measurement": {
@@ -581,10 +581,10 @@
"contentWidth": 345.40478515625,
"contentHeight": 114.39000000000001,
"firstBaseline": 30.341856002807617,
- "lastBaseline": 106.60185241699219,
+ "lastBaseline": 106.60186004638672,
"overflowed": false
},
- "hash": "c1a7730c",
+ "hash": "64cd57ec",
"glyphIds": [
790, 647, 647, 689, 586, 614, 1777, 2, 456, 2, 411, 2, 384, 1777, 586, 507, 647, 618,
1777, 1462, 1777, 647, 647, 689, 1501, 1777, 727, 614, 852, 773, 689, 773, 658, 1501,
@@ -612,7 +612,7 @@
"lineTextEnds": [22, 47, 56],
"lineGlyphStarts": [0, 21, 46],
"lineGlyphCounts": [21, 25, 9],
- "lineBaselines": [30.341856002807617, 68.47185516357422, 106.60185241699219],
+ "lineBaselines": [30.341856002807617, 68.47185516357422, 106.60186004638672],
"lineAdvances": [329.556640625, 345.40478515625, 146.17529296875]
}
}
diff --git a/apps/benchmarks/fixtures/rendering/noto-sans-cjk-contract-bitmap-16.font.glb b/apps/benchmarks/fixtures/rendering/noto-sans-cjk-contract-bitmap-16.font.glb
new file mode 100644
index 00000000..fbcc7d02
Binary files /dev/null and b/apps/benchmarks/fixtures/rendering/noto-sans-cjk-contract-bitmap-16.font.glb differ
diff --git a/apps/benchmarks/fixtures/results/cjk-universality-chromium149.json b/apps/benchmarks/fixtures/results/cjk-universality-chromium149.json
deleted file mode 100644
index 2a4ff0e2..00000000
--- a/apps/benchmarks/fixtures/results/cjk-universality-chromium149.json
+++ /dev/null
@@ -1,101 +0,0 @@
-{
- "targetId": "cjk-universality",
- "scenarioId": "cjk-universality",
- "status": "passed",
- "validation": "3/3 exact CJK corpus + horizontal paragraph outputs",
- "measurements": [
- {
- "sample": 0,
- "durationMs": 0.800000011920929,
- "outputBytes": 10622,
- "hash": "a1a833f2:fbe2aa07:922f9a2e:8c977f4d:85a2f640:fd42b9f7:53d8ec89:8cb3050c:bbfd039d:837a2b43:2f450f5e:9900b4af:c49f3e68",
- "metrics": {
- "sourceUtf16Units": 208,
- "corpusCaseCount": 13,
- "corpusGlyphCount": 64,
- "paragraphCaseCount": 4,
- "layoutCount": 12,
- "directShapeBoundaryCrossings": 1,
- "paragraphShapeBoundaryCrossings": 4,
- "reshapeBoundaryCrossings": 0,
- "planCount": 8,
- "retainedFontBytes": 1539372,
- "wasmMemoryBytes": 4587520,
- "sourceFontBytes": 16467736,
- "artifactBytes": 1540480,
- "shapingPayloadRawBytes": 1539372,
- "shapingPayloadGzipBytes": 654925,
- "shapingPayloadBrotliBytes": 514547,
- "coldBakeMs": 574.5,
- "coldRegistrationMs": 95.30000001192093,
- "coldShaperInitializationMs": 3.0999999940395355
- }
- },
- {
- "sample": 1,
- "durationMs": 0.699999988079071,
- "outputBytes": 10622,
- "hash": "a1a833f2:fbe2aa07:922f9a2e:8c977f4d:85a2f640:fd42b9f7:53d8ec89:8cb3050c:bbfd039d:837a2b43:2f450f5e:9900b4af:c49f3e68",
- "metrics": {
- "sourceUtf16Units": 208,
- "corpusCaseCount": 13,
- "corpusGlyphCount": 64,
- "paragraphCaseCount": 4,
- "layoutCount": 12,
- "directShapeBoundaryCrossings": 1,
- "paragraphShapeBoundaryCrossings": 4,
- "reshapeBoundaryCrossings": 0,
- "planCount": 8,
- "retainedFontBytes": 1539372,
- "wasmMemoryBytes": 4587520,
- "sourceFontBytes": 16467736,
- "artifactBytes": 1540480,
- "shapingPayloadRawBytes": 1539372,
- "shapingPayloadGzipBytes": 654925,
- "shapingPayloadBrotliBytes": 514547,
- "coldBakeMs": 574.5,
- "coldRegistrationMs": 95.30000001192093,
- "coldShaperInitializationMs": 3.0999999940395355
- }
- },
- {
- "sample": 2,
- "durationMs": 0.5999999940395355,
- "outputBytes": 10622,
- "hash": "a1a833f2:fbe2aa07:922f9a2e:8c977f4d:85a2f640:fd42b9f7:53d8ec89:8cb3050c:bbfd039d:837a2b43:2f450f5e:9900b4af:c49f3e68",
- "metrics": {
- "sourceUtf16Units": 208,
- "corpusCaseCount": 13,
- "corpusGlyphCount": 64,
- "paragraphCaseCount": 4,
- "layoutCount": 12,
- "directShapeBoundaryCrossings": 1,
- "paragraphShapeBoundaryCrossings": 4,
- "reshapeBoundaryCrossings": 0,
- "planCount": 8,
- "retainedFontBytes": 1539372,
- "wasmMemoryBytes": 4587520,
- "sourceFontBytes": 16467736,
- "artifactBytes": 1540480,
- "shapingPayloadRawBytes": 1539372,
- "shapingPayloadGzipBytes": 654925,
- "shapingPayloadBrotliBytes": 514547,
- "coldBakeMs": 574.5,
- "coldRegistrationMs": 95.30000001192093,
- "coldShaperInitializationMs": 3.0999999940395355
- }
- }
- ],
- "medianMs": 0.699999988079071,
- "p95Ms": 0.800000011920929,
- "minMs": 0.5999999940395355,
- "maxMs": 0.800000011920929,
- "outputBytes": 10622,
- "completedAt": "2026-07-25T14:09:40.674Z",
- "environment": {
- "browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/149.0.7827.55 Safari/537.36",
- "hardwareConcurrency": 10,
- "webgpu": true,
- "crossOriginIsolated": false
- }
-}
diff --git a/apps/benchmarks/fixtures/results/paragraph-bidi-policy-chromium149.json b/apps/benchmarks/fixtures/results/paragraph-bidi-policy-chromium149.json
deleted file mode 100644
index 3312f62a..00000000
--- a/apps/benchmarks/fixtures/results/paragraph-bidi-policy-chromium149.json
+++ /dev/null
@@ -1,62 +0,0 @@
-{
- "targetId": "paragraph-bidi-policy",
- "scenarioId": "paragraph-bidi-policy",
- "status": "passed",
- "validation": "3/3 exact bidi/policy outputs · current-uikit-shaped flow",
- "measurements": [
- {
- "sample": 0,
- "durationMs": 0.20000000298023224,
- "outputBytes": 8098,
- "hash": "8859ef19:8d5b98a3:e492fa7d:19a5a03e:32f8722c:0691e0de:e492fa7d:0132eed7:0ddc10b5:0ddc10b5:00f73fd9:c1a7730c",
- "metrics": {
- "bidiLayoutCount": 2,
- "policyLayoutCount": 9,
- "uikitMeasurementCount": 25,
- "uikitLayoutCount": 1,
- "shapeBoundaryCrossings": 4,
- "reshapeBoundaryCrossings": 5
- }
- },
- {
- "sample": 1,
- "durationMs": 0.3999999910593033,
- "outputBytes": 8098,
- "hash": "8859ef19:8d5b98a3:e492fa7d:19a5a03e:32f8722c:0691e0de:e492fa7d:0132eed7:0ddc10b5:0ddc10b5:00f73fd9:c1a7730c",
- "metrics": {
- "bidiLayoutCount": 2,
- "policyLayoutCount": 9,
- "uikitMeasurementCount": 25,
- "uikitLayoutCount": 1,
- "shapeBoundaryCrossings": 4,
- "reshapeBoundaryCrossings": 5
- }
- },
- {
- "sample": 2,
- "durationMs": 0.20000000298023224,
- "outputBytes": 8098,
- "hash": "8859ef19:8d5b98a3:e492fa7d:19a5a03e:32f8722c:0691e0de:e492fa7d:0132eed7:0ddc10b5:0ddc10b5:00f73fd9:c1a7730c",
- "metrics": {
- "bidiLayoutCount": 2,
- "policyLayoutCount": 9,
- "uikitMeasurementCount": 25,
- "uikitLayoutCount": 1,
- "shapeBoundaryCrossings": 4,
- "reshapeBoundaryCrossings": 5
- }
- }
- ],
- "medianMs": 0.20000000298023224,
- "p95Ms": 0.3999999910593033,
- "minMs": 0.20000000298023224,
- "maxMs": 0.3999999910593033,
- "outputBytes": 8098,
- "completedAt": "2026-07-25T09:51:33.700Z",
- "environment": {
- "browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/149.0.7827.55 Safari/537.36",
- "hardwareConcurrency": 10,
- "webgpu": true,
- "crossOriginIsolated": false
- }
-}
diff --git a/apps/benchmarks/fixtures/results/paragraph-layout-chromium149.json b/apps/benchmarks/fixtures/results/paragraph-layout-chromium149.json
deleted file mode 100644
index 9030987d..00000000
--- a/apps/benchmarks/fixtures/results/paragraph-layout-chromium149.json
+++ /dev/null
@@ -1,59 +0,0 @@
-{
- "targetId": "paragraph-layout-engine",
- "scenarioId": "paragraph-layout",
- "status": "passed",
- "validation": "3/3 exact positioned outputs · 1 reshape batch/changed width",
- "measurements": [
- {
- "sample": 0,
- "durationMs": 0.10000000894069672,
- "outputBytes": 3786,
- "hash": "bb15bbcc:4f111a3f:e8c0e9d5",
- "metrics": {
- "batchedBoundaryLayouts": 2,
- "glyphCount": 165,
- "layoutCount": 3,
- "reshapeBoundaryCrossings": 2,
- "shapeBoundaryCrossings": 1
- }
- },
- {
- "sample": 1,
- "durationMs": 0.29999999701976776,
- "outputBytes": 3786,
- "hash": "bb15bbcc:4f111a3f:e8c0e9d5",
- "metrics": {
- "batchedBoundaryLayouts": 2,
- "glyphCount": 165,
- "layoutCount": 3,
- "reshapeBoundaryCrossings": 2,
- "shapeBoundaryCrossings": 1
- }
- },
- {
- "sample": 2,
- "durationMs": 0.09999999403953552,
- "outputBytes": 3786,
- "hash": "bb15bbcc:4f111a3f:e8c0e9d5",
- "metrics": {
- "batchedBoundaryLayouts": 2,
- "glyphCount": 165,
- "layoutCount": 3,
- "reshapeBoundaryCrossings": 2,
- "shapeBoundaryCrossings": 1
- }
- }
- ],
- "medianMs": 0.10000000894069672,
- "p95Ms": 0.29999999701976776,
- "minMs": 0.09999999403953552,
- "maxMs": 0.29999999701976776,
- "outputBytes": 3786,
- "completedAt": "2026-07-25T08:38:25.186Z",
- "environment": {
- "browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/149.0.7827.55 Safari/537.36",
- "hardwareConcurrency": 10,
- "webgpu": true,
- "crossOriginIsolated": false
- }
-}
diff --git a/apps/benchmarks/fixtures/results/paragraph-measurement-chromium149.json b/apps/benchmarks/fixtures/results/paragraph-measurement-chromium149.json
deleted file mode 100644
index 11f458b8..00000000
--- a/apps/benchmarks/fixtures/results/paragraph-measurement-chromium149.json
+++ /dev/null
@@ -1,59 +0,0 @@
-{
- "targetId": "paragraph-engine",
- "scenarioId": "paragraph-measurement",
- "status": "passed",
- "validation": "3/3 exact paragraph outputs · 0 Wasm reflow calls/sample",
- "measurements": [
- {
- "sample": 0,
- "durationMs": 0,
- "outputBytes": 168,
- "hash": "79874b9d",
- "metrics": {
- "measurementCount": 3,
- "positionedGlyphBytes": 0,
- "reflowBoundaryCrossings": 0,
- "reshapeBoundaryCrossings": 0,
- "shapeBoundaryCrossings": 1
- }
- },
- {
- "sample": 1,
- "durationMs": 0,
- "outputBytes": 168,
- "hash": "79874b9d",
- "metrics": {
- "measurementCount": 3,
- "positionedGlyphBytes": 0,
- "reflowBoundaryCrossings": 0,
- "reshapeBoundaryCrossings": 0,
- "shapeBoundaryCrossings": 1
- }
- },
- {
- "sample": 2,
- "durationMs": 0,
- "outputBytes": 168,
- "hash": "79874b9d",
- "metrics": {
- "measurementCount": 3,
- "positionedGlyphBytes": 0,
- "reflowBoundaryCrossings": 0,
- "reshapeBoundaryCrossings": 0,
- "shapeBoundaryCrossings": 1
- }
- }
- ],
- "medianMs": 0,
- "p95Ms": 0,
- "minMs": 0,
- "maxMs": 0,
- "outputBytes": 168,
- "completedAt": "2026-07-25T08:11:13.819Z",
- "environment": {
- "browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/149.0.7827.55 Safari/537.36",
- "hardwareConcurrency": 10,
- "webgpu": true,
- "crossOriginIsolated": false
- }
-}
diff --git a/apps/benchmarks/fixtures/results/rust-layout-bitmap-0bdb9e93-darwin-arm64.json b/apps/benchmarks/fixtures/results/rust-layout-bitmap-0bdb9e93-darwin-arm64.json
new file mode 100644
index 00000000..aad0dd88
--- /dev/null
+++ b/apps/benchmarks/fixtures/results/rust-layout-bitmap-0bdb9e93-darwin-arm64.json
@@ -0,0 +1,82 @@
+{
+ "schemaVersion": 0,
+ "generatedBy": "text:rust-layout-benchmark",
+ "wasmSha256": "f74f96a6214532271296c8165738d14f71c0642aca4af9050a0363aed2a4d576",
+ "technique": "bitmap",
+ "allocation": "ordered",
+ "glyphTarget": 22000,
+ "warmup": 8,
+ "repetitions": 31,
+ "reports": [
+ {
+ "name": "cold",
+ "glyphs": 21805,
+ "medianMs": 15.89504199999999,
+ "p95Ms": 16.34533399999998,
+ "minMs": 15.590833000000089,
+ "rsdPercent": 1.1473597714796304,
+ "patchCount": 10,
+ "writeBytes": 1046640
+ },
+ {
+ "name": "no-op",
+ "glyphs": 21805,
+ "medianMs": 0.0007500000000391083,
+ "p95Ms": 0.001040999999986525,
+ "minMs": 0.0007079999999177744,
+ "rsdPercent": 14.747333345472041,
+ "patchCount": 0,
+ "writeBytes": 0
+ },
+ {
+ "name": "font-size",
+ "glyphs": 21805,
+ "medianMs": 6.038666999999805,
+ "p95Ms": 6.229833000000099,
+ "minMs": 5.947000000000003,
+ "rsdPercent": 1.6947161396889856,
+ "patchCount": 2,
+ "writeBytes": 348880
+ },
+ {
+ "name": "column-resize",
+ "glyphs": 21805,
+ "medianMs": 2.7830830000000333,
+ "p95Ms": 4.286959000000024,
+ "minMs": 2.3332080000000133,
+ "rsdPercent": 22.731668514524774,
+ "patchCount": 1,
+ "writeBytes": 174440
+ },
+ {
+ "name": "suffix-edit",
+ "glyphs": 21805,
+ "medianMs": 13.479708000000073,
+ "p95Ms": 13.794083999999884,
+ "minMs": 13.335207999999966,
+ "rsdPercent": 1.2190254706630317,
+ "patchCount": 0,
+ "writeBytes": 0
+ },
+ {
+ "name": "localized-edit",
+ "glyphs": 21805,
+ "medianMs": 1.178083000000015,
+ "p95Ms": 5.905375000000049,
+ "minMs": 1.142458999999917,
+ "rsdPercent": 78.92524574951999,
+ "patchCount": 5,
+ "writeBytes": 912
+ },
+ {
+ "name": "localized-splice",
+ "glyphs": 21805,
+ "medianMs": 8.517707999999857,
+ "p95Ms": 9.114166999999725,
+ "minMs": 8.21075000000019,
+ "rsdPercent": 4.94907999182805,
+ "patchCount": 5,
+ "writeBytes": 523584
+ }
+ ]
+}
diff --git a/apps/benchmarks/fixtures/results/rust-layout-mtsdf-0bdb9e93-darwin-arm64.json b/apps/benchmarks/fixtures/results/rust-layout-mtsdf-0bdb9e93-darwin-arm64.json
new file mode 100644
index 00000000..66ef7a5f
--- /dev/null
+++ b/apps/benchmarks/fixtures/results/rust-layout-mtsdf-0bdb9e93-darwin-arm64.json
@@ -0,0 +1,82 @@
+{
+ "schemaVersion": 0,
+ "generatedBy": "text:rust-layout-benchmark",
+ "wasmSha256": "f74f96a6214532271296c8165738d14f71c0642aca4af9050a0363aed2a4d576",
+ "technique": "mtsdf",
+ "allocation": "ordered",
+ "glyphTarget": 22000,
+ "warmup": 8,
+ "repetitions": 31,
+ "reports": [
+ {
+ "name": "cold",
+ "glyphs": 21805,
+ "medianMs": 16.503832999999986,
+ "p95Ms": 16.755790999999988,
+ "minMs": 16.25258299999996,
+ "rsdPercent": 0.8632416206975663,
+ "patchCount": 14,
+ "writeBytes": 2442160
+ },
+ {
+ "name": "no-op",
+ "glyphs": 21805,
+ "medianMs": 0.0007500000000391083,
+ "p95Ms": 0.001125000000001819,
+ "minMs": 0.0007079999999177744,
+ "rsdPercent": 20.4238872148034,
+ "patchCount": 0,
+ "writeBytes": 0
+ },
+ {
+ "name": "font-size",
+ "glyphs": 21805,
+ "medianMs": 6.4079159999998865,
+ "p95Ms": 6.44404099999997,
+ "minMs": 6.385624999999891,
+ "rsdPercent": 0.32337922265012825,
+ "patchCount": 1,
+ "writeBytes": 348880
+ },
+ {
+ "name": "column-resize",
+ "glyphs": 21805,
+ "medianMs": 2.7301250000000437,
+ "p95Ms": 4.654792000000043,
+ "minMs": 2.3857920000000377,
+ "rsdPercent": 27.33489808761474,
+ "patchCount": 1,
+ "writeBytes": 348880
+ },
+ {
+ "name": "suffix-edit",
+ "glyphs": 21805,
+ "medianMs": 13.52108300000009,
+ "p95Ms": 13.631458000000066,
+ "minMs": 13.169916999999941,
+ "rsdPercent": 0.7779963291540573,
+ "patchCount": 0,
+ "writeBytes": 0
+ },
+ {
+ "name": "localized-edit",
+ "glyphs": 21805,
+ "medianMs": 1.1790000000000873,
+ "p95Ms": 6.30704199999991,
+ "minMs": 1.1577920000001996,
+ "rsdPercent": 81.28861290464037,
+ "patchCount": 7,
+ "writeBytes": 2128
+ },
+ {
+ "name": "localized-splice",
+ "glyphs": 21805,
+ "medianMs": 8.728583000000071,
+ "p95Ms": 8.775666999999885,
+ "minMs": 8.636833999999908,
+ "rsdPercent": 0.5292344952722385,
+ "patchCount": 7,
+ "writeBytes": 1221696
+ }
+ ]
+}
diff --git a/apps/benchmarks/fixtures/results/rust-layout-slug-0bdb9e93-darwin-arm64.json b/apps/benchmarks/fixtures/results/rust-layout-slug-0bdb9e93-darwin-arm64.json
new file mode 100644
index 00000000..f3a1c154
--- /dev/null
+++ b/apps/benchmarks/fixtures/results/rust-layout-slug-0bdb9e93-darwin-arm64.json
@@ -0,0 +1,82 @@
+{
+ "schemaVersion": 0,
+ "generatedBy": "text:rust-layout-benchmark",
+ "wasmSha256": "f74f96a6214532271296c8165738d14f71c0642aca4af9050a0363aed2a4d576",
+ "technique": "slug",
+ "allocation": "ordered",
+ "glyphTarget": 22000,
+ "warmup": 8,
+ "repetitions": 31,
+ "reports": [
+ {
+ "name": "cold",
+ "glyphs": 21805,
+ "medianMs": 16.725875000000087,
+ "p95Ms": 16.894916999999964,
+ "minMs": 16.458374999999933,
+ "rsdPercent": 0.6137453775542219,
+ "patchCount": 14,
+ "writeBytes": 2442160
+ },
+ {
+ "name": "no-op",
+ "glyphs": 21805,
+ "medianMs": 0.0008749999999508873,
+ "p95Ms": 0.001167000000009466,
+ "minMs": 0.0007080000000314612,
+ "rsdPercent": 20.607335343292988,
+ "patchCount": 0,
+ "writeBytes": 0
+ },
+ {
+ "name": "font-size",
+ "glyphs": 21805,
+ "medianMs": 6.641542000000072,
+ "p95Ms": 6.702041000000008,
+ "minMs": 6.610500000000002,
+ "rsdPercent": 0.3898369007329732,
+ "patchCount": 2,
+ "writeBytes": 697760
+ },
+ {
+ "name": "column-resize",
+ "glyphs": 21805,
+ "medianMs": 2.9641670000000886,
+ "p95Ms": 4.753292000000101,
+ "minMs": 2.357041999999865,
+ "rsdPercent": 29.06972897531289,
+ "patchCount": 1,
+ "writeBytes": 348880
+ },
+ {
+ "name": "suffix-edit",
+ "glyphs": 21805,
+ "medianMs": 14.372041999999965,
+ "p95Ms": 16.734709000000066,
+ "minMs": 13.90362499999992,
+ "rsdPercent": 43.12330239527032,
+ "patchCount": 0,
+ "writeBytes": 0
+ },
+ {
+ "name": "localized-edit",
+ "glyphs": 21805,
+ "medianMs": 1.3077499999999418,
+ "p95Ms": 6.753249999999753,
+ "minMs": 1.1738749999999527,
+ "rsdPercent": 80.70050052277502,
+ "patchCount": 7,
+ "writeBytes": 2128
+ },
+ {
+ "name": "localized-splice",
+ "glyphs": 21805,
+ "medianMs": 8.939582999999857,
+ "p95Ms": 9.373249999999643,
+ "minMs": 8.747791999999663,
+ "rsdPercent": 2.1178415753373674,
+ "patchCount": 7,
+ "writeBytes": 1221696
+ }
+ ]
+}
diff --git a/apps/benchmarks/fixtures/results/shaping-conformance-chromium149.json b/apps/benchmarks/fixtures/results/shaping-conformance-chromium149.json
deleted file mode 100644
index 3372bff2..00000000
--- a/apps/benchmarks/fixtures/results/shaping-conformance-chromium149.json
+++ /dev/null
@@ -1,76 +0,0 @@
-{
- "schemaVersion": 0,
- "targetId": "harfrust-shaper",
- "scenarioId": "shaping-conformance",
- "status": "passed",
- "validation": "3/3 exact corpus outputs · 1 Wasm call/sample",
- "controls": {
- "dpr": 1,
- "samples": 3,
- "warmup": 1
- },
- "measurements": [
- {
- "sample": 0,
- "durationMs": 0.20000000298023224,
- "outputBytes": 2412,
- "hash": "dc30c21c",
- "metrics": {
- "boundaryCrossings": 1,
- "coldStartMs": 2.5999999940395355,
- "shapeCallMs": 0.09999999403953552,
- "goldenCases": 8,
- "glyphCount": 97,
- "planCount": 3,
- "retainedFontBytes": 171056,
- "wasmMemoryBytes": 1638400
- }
- },
- {
- "sample": 1,
- "durationMs": 0.19999998807907104,
- "outputBytes": 2412,
- "hash": "dc30c21c",
- "metrics": {
- "boundaryCrossings": 1,
- "coldStartMs": 2.5999999940395355,
- "shapeCallMs": 0.09999999403953552,
- "goldenCases": 8,
- "glyphCount": 97,
- "planCount": 3,
- "retainedFontBytes": 171056,
- "wasmMemoryBytes": 1638400
- }
- },
- {
- "sample": 2,
- "durationMs": 0.10000000894069672,
- "outputBytes": 2412,
- "hash": "dc30c21c",
- "metrics": {
- "boundaryCrossings": 1,
- "coldStartMs": 2.5999999940395355,
- "shapeCallMs": 0.10000000894069672,
- "goldenCases": 8,
- "glyphCount": 97,
- "planCount": 3,
- "retainedFontBytes": 171056,
- "wasmMemoryBytes": 1638400
- }
- }
- ],
- "summary": {
- "medianMs": 0.19999998807907104,
- "p95Ms": 0.20000000298023224,
- "minMs": 0.10000000894069672,
- "maxMs": 0.20000000298023224,
- "outputBytes": 2412
- },
- "capturedAt": "2026-07-25T07:32:50.493Z",
- "environment": {
- "browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/149.0.7827.55 Safari/537.36",
- "hardwareConcurrency": 10,
- "webgpu": true,
- "crossOriginIsolated": false
- }
-}
diff --git a/apps/benchmarks/fixtures/results/typescript-layout-baseline-90964be0-darwin-arm64.json b/apps/benchmarks/fixtures/results/typescript-layout-baseline-90964be0-darwin-arm64.json
new file mode 100644
index 00000000..09a06b4d
--- /dev/null
+++ b/apps/benchmarks/fixtures/results/typescript-layout-baseline-90964be0-darwin-arm64.json
@@ -0,0 +1,49 @@
+{
+ "generatedBy": "text:layout-benchmark",
+ "reports": [
+ {
+ "name": "cold",
+ "glyphs": 25515,
+ "medianMs": 58.32441699999981,
+ "meanMs": 60.39700277419357,
+ "minMs": 47.82662500000015,
+ "p95Ms": 72.5782079999999,
+ "rsdPercent": 15.540478236019345,
+ "perGlyphUs": 2.2858873995688738,
+ "bytesPerUpdate": 17132658.580645163
+ },
+ {
+ "name": "font-size",
+ "glyphs": 25515,
+ "medianMs": 12.087332999999944,
+ "meanMs": 12.722243225806471,
+ "minMs": 11.242707999999766,
+ "p95Ms": 15.895666999999776,
+ "rsdPercent": 11.271450340612626,
+ "perGlyphUs": 0.47373439153438934,
+ "bytesPerUpdate": 18199267.096774194
+ },
+ {
+ "name": "layout-width",
+ "glyphs": 25515,
+ "medianMs": 9.152791999999863,
+ "meanMs": 9.588596838709735,
+ "minMs": 7.461458000000221,
+ "p95Ms": 14.408792000000176,
+ "rsdPercent": 17.10678110942888,
+ "perGlyphUs": 0.358722006662742,
+ "bytesPerUpdate": 10448746.322580645
+ },
+ {
+ "name": "text",
+ "glyphs": 25507,
+ "medianMs": 39.607874999999694,
+ "meanMs": 39.64417212903229,
+ "minMs": 36.19237499999963,
+ "p95Ms": 42.58904100000018,
+ "rsdPercent": 4.593215968243925,
+ "perGlyphUs": 1.5528237346610614,
+ "bytesPerUpdate": 35170110.70967742
+ }
+ ]
+}
diff --git a/apps/benchmarks/scripts/benchmark-text-engine-kernels.mts b/apps/benchmarks/scripts/benchmark-text-engine-kernels.mts
new file mode 100644
index 00000000..5cee0029
--- /dev/null
+++ b/apps/benchmarks/scripts/benchmark-text-engine-kernels.mts
@@ -0,0 +1,157 @@
+/* @workflow {
+ "name": "text:kernel-lab-browser",
+ "summary": "Runs the scalar, auto-vectorized, and explicit SIMD retained-engine kernel packet in project Chromium.",
+ "requirements": "Built @pmndrs/text and package-local kernel-lab artifacts. Accepts --json.",
+ "writes": "stdout only, or the JSON report path passed to --json"
+} */
+import { readFile, writeFile } from 'node:fs/promises';
+import { createServer, type Server } from 'node:http';
+import type { Browser } from 'playwright';
+
+import { captureKernelWorkloads } from '../../../packages/text/scripts/support/engine-kernel-fixture.mts';
+import { launchProjectChromium } from './support/project-chromium.mts';
+
+const artifactRoot = new URL('../../../packages/text/rust/shaper/target/kernel-lab/', import.meta.url);
+const runnerUrl = new URL('../../../packages/text/scripts/support/engine-kernel-runner.mjs', import.meta.url);
+const variants = ['scalar', 'auto', 'explicit'] as const;
+const warmup = 40;
+const samples = 101;
+const workloads = await captureKernelWorkloads([22_000, 86_000]);
+
+let browser: Browser | undefined;
+let server: Server | undefined;
+try {
+ server = createServer((_request, response) => {
+ response.writeHead(200, { 'content-type': 'text/html', 'cache-control': 'no-store' });
+ response.end('
pmndrs text kernel lab');
+ });
+ await new Promise((resolve, reject) => {
+ server!.once('error', reject);
+ server!.listen(0, '127.0.0.1', resolve);
+ });
+ const address = server.address();
+ if (address === null || typeof address === 'string') throw new Error('kernel-lab server did not expose a TCP port');
+ browser = await launchProjectChromium({ headless: true });
+ const page = await browser.newPage();
+ await page.goto(`http://127.0.0.1:${address.port}/`, { waitUntil: 'domcontentloaded' });
+ const errors: string[] = [];
+ page.on('console', (message) => {
+ if (message.type() === 'error') errors.push(message.text());
+ });
+ page.on('pageerror', (error) => errors.push(error.message));
+ const runnerSource = await readFile(runnerUrl, 'utf8');
+ const moduleUrl = await page.evaluate(
+ (source) => URL.createObjectURL(new Blob([source], { type: 'text/javascript' })),
+ runnerSource,
+ );
+ const variantReports = [];
+ let oracleHashes: ReadonlyMap | undefined;
+ for (const name of variants) {
+ const wasm = await readFile(new URL(`${name}.wasm`, artifactRoot));
+ const reports = [];
+ const hashes = new Map();
+ for (const workload of workloads) {
+ const result = await page.evaluate(
+ async ({
+ moduleUrl: browserModuleUrl,
+ wasmBase64,
+ input,
+ name: artifactName,
+ warmup: benchmarkWarmup,
+ samples: benchmarkSamples,
+ }) => {
+ const module = await import(browserModuleUrl);
+ const decode = (value: string) => {
+ const binary = atob(value);
+ const bytes = new Uint8Array(binary.length);
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
+ return bytes;
+ };
+ const typedInput = {
+ label: input.label,
+ glyphs: input.glyphs,
+ x: new Float32Array(decode(input.x).buffer),
+ y: new Float32Array(decode(input.y).buffer),
+ fontSize: new Float32Array(decode(input.fontSize).buffer),
+ planeLeft: new Float32Array(decode(input.planeLeft).buffer),
+ planeBottom: new Float32Array(decode(input.planeBottom).buffer),
+ planeRight: new Float32Array(decode(input.planeRight).buffer),
+ planeTop: new Float32Array(decode(input.planeTop).buffer),
+ advances: new Int32Array(decode(input.advances).buffer),
+ flags: decode(input.flags),
+ levels: decode(input.levels),
+ policy: decode(input.policy),
+ };
+ return module.benchmarkKernelArtifact(decode(wasmBase64), artifactName, typedInput, {
+ warmup: benchmarkWarmup,
+ samples: benchmarkSamples,
+ });
+ },
+ {
+ moduleUrl,
+ wasmBase64: wasm.toString('base64'),
+ input: encodeInput(workload),
+ name,
+ warmup,
+ samples,
+ },
+ );
+ reports.push(result);
+ hashes.set(workload.label, result.outputHash);
+ }
+ if (oracleHashes === undefined) oracleHashes = hashes;
+ else {
+ for (const [label, hash] of hashes) {
+ if (hash !== oracleHashes.get(label)) {
+ throw new Error(`${name} output ${hash} does not match Chromium scalar oracle for ${label}`);
+ }
+ }
+ }
+ variantReports.push({ name, workloads: reports });
+ }
+ await page.evaluate((url) => URL.revokeObjectURL(url), moduleUrl);
+ if (errors.length > 0) throw new Error(`kernel-lab Chromium errors: ${errors.join(' | ')}`);
+ const environment = await page.evaluate(() => ({
+ userAgent: navigator.userAgent,
+ hardwareConcurrency: navigator.hardwareConcurrency,
+ simdArtifactExecuted: true,
+ }));
+ const report = {
+ generatedBy: 'text:kernel-lab-browser',
+ environment,
+ warmup,
+ samples,
+ variants: variantReports,
+ };
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
+ const jsonPath = readArgument('--json');
+ if (jsonPath !== undefined) await writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`);
+} finally {
+ if (browser !== undefined) await browser.close();
+ if (server !== undefined) await new Promise((resolve) => server!.close(() => resolve()));
+}
+
+function encodeInput(input: (typeof workloads)[number]) {
+ const encode = (value: ArrayBufferView) =>
+ Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString('base64');
+ return {
+ label: input.label,
+ glyphs: input.glyphs,
+ x: encode(input.x),
+ y: encode(input.y),
+ fontSize: encode(input.fontSize),
+ planeLeft: encode(input.planeLeft),
+ planeBottom: encode(input.planeBottom),
+ planeRight: encode(input.planeRight),
+ planeTop: encode(input.planeTop),
+ advances: encode(input.advances),
+ flags: encode(input.flags),
+ levels: encode(input.levels),
+ policy: encode(input.policy),
+ };
+}
+
+function readArgument(flag: string): string | undefined {
+ const index = process.argv.indexOf(flag);
+ return index < 0 ? undefined : process.argv[index + 1];
+}
diff --git a/apps/benchmarks/scripts/check-bake-fixtures.mts b/apps/benchmarks/scripts/check-bake-fixtures.mts
index ecfc8c90..98d4f885 100644
--- a/apps/benchmarks/scripts/check-bake-fixtures.mts
+++ b/apps/benchmarks/scripts/check-bake-fixtures.mts
@@ -14,6 +14,7 @@ const fixtureChecks = [
'generate-showcase-raster-fixtures.mts',
'generate-mtsdf-render-fixture.mts',
'generate-slug-render-fixture.mts',
+ 'generate-paragraph-conformance-font.mts',
'generate-paragraph-bidi-contract.mts',
'generate-paragraph-cjk-contract.mts',
] as const;
@@ -21,6 +22,7 @@ const fixtureChecks = [
export async function checkBakeFixtures(): Promise {
await buildRuntimePackages();
for (const script of fixtureChecks) await runNodeScript(`scripts/${script}`, ['--check']);
+ await runNodeScript('scripts/check-paragraph-contract-fixtures.mts');
await runNodeScript('scripts/provision-harfbuzz.mts', ['--check']);
await runNodeScript('scripts/generate-japanese-showcase-subset.mts', ['--check']);
}
diff --git a/apps/benchmarks/scripts/check-paragraph-contract-fixtures.mts b/apps/benchmarks/scripts/check-paragraph-contract-fixtures.mts
new file mode 100644
index 00000000..9b62dd5a
--- /dev/null
+++ b/apps/benchmarks/scripts/check-paragraph-contract-fixtures.mts
@@ -0,0 +1,100 @@
+import { createHash } from 'node:crypto';
+import { readFile } from 'node:fs/promises';
+
+import { FontRegistry } from '@pmndrs/text';
+
+const args = process.argv.slice(2);
+if (args.length !== 0) throw new Error('usage: check-paragraph-contract-fixtures.mts');
+
+const fixtures = new URL('../fixtures/', import.meta.url);
+const bidiUrl = new URL('contracts/paragraph-bidi-layout-v0.json', fixtures);
+const cjkUrl = new URL('contracts/paragraph-cjk-layout-v0.json', fixtures);
+const [bidi, cjk] = await Promise.all([readJsonRecord(bidiUrl), readJsonRecord(cjkUrl)]);
+
+assertEqual(bidi.schemaVersion, 0, 'paragraph bidi schemaVersion');
+assertEqual(cjk.schemaVersion, 0, 'paragraph CJK schemaVersion');
+
+const bidiFonts = record(bidi.fonts, 'paragraph bidi fonts');
+const amiri = record(bidiFonts.amiri, 'paragraph bidi Amiri font');
+const inter = record(bidiFonts.inter, 'paragraph bidi Inter font');
+const cjkFont = record(cjk.font, 'paragraph CJK font');
+
+await Promise.all([
+ authenticateSource(amiri, new URL('fonts/amiri-1.002/Amiri-Regular.ttf', fixtures), 'Amiri'),
+ authenticateSource(inter, new URL('fonts/inter-v4.1/Inter-Regular.ttf', fixtures), 'Inter'),
+ authenticateSource(cjkFont, new URL('fonts/noto-sans-cjk-2.004/NotoSansCJKjp-Regular.otf', fixtures), 'CJK'),
+ authenticateOracles(amiri, bidiUrl, 'Amiri'),
+ authenticateOracles(cjkFont, cjkUrl, 'CJK'),
+ authenticateShaping(amiri, new URL('rendering/amiri-bitmap-16.font.glb', fixtures), 'Amiri'),
+ authenticateShaping(inter, new URL('rendering/inter-bitmap-16.font.glb', fixtures), 'Inter'),
+ authenticateShaping(cjkFont, new URL('rendering/noto-sans-cjk-contract-bitmap-16.font.glb', fixtures), 'CJK'),
+]);
+
+async function authenticateSource(metadata: Readonly>, url: URL, label: string): Promise {
+ const expected = string(metadata.sourceSha256, `${label} sourceSha256`);
+ const actual = createHash('sha256')
+ .update(await readFile(url))
+ .digest('hex');
+ assertEqual(actual, expected, `${label} source SHA-256`);
+}
+
+async function authenticateShaping(
+ metadata: Readonly>,
+ url: URL,
+ label: string,
+): Promise {
+ const expected = string(metadata.shapingHash, `${label} shapingHash`);
+ const registry = new FontRegistry();
+ const font = await registry.registerAsset(await readFile(url));
+ try {
+ assertEqual(font.shapingHash, expected, `${label} registered shaping hash`);
+ } finally {
+ font.dispose();
+ }
+}
+
+async function authenticateOracles(
+ metadata: Readonly>,
+ contractUrl: URL,
+ label: string,
+): Promise {
+ const source = await readJsonRecord(new URL(string(metadata.sourceOracle, `${label} sourceOracle`), contractUrl));
+ const independent = await readJsonRecord(
+ new URL(string(metadata.independentOracle, `${label} independentOracle`), contractUrl),
+ );
+ assertEqual(record(source.engine, `${label} source oracle engine`).name, 'HarfRust', `${label} source oracle`);
+ assertEqual(
+ record(independent.engine, `${label} independent oracle engine`).name,
+ 'HarfBuzz',
+ `${label} independent oracle`,
+ );
+}
+
+async function readJsonRecord(url: URL): Promise>> {
+ return record(JSON.parse(await readFile(url, 'utf8')) as unknown, url.pathname);
+}
+
+function record(value: unknown, label: string): Readonly> {
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
+ throw new TypeError(`${label} is not an object`);
+ return value as Readonly>;
+}
+
+function string(value: unknown, label: string): string {
+ if (typeof value !== 'string' || value.length === 0) throw new TypeError(`${label} is not a nonempty string`);
+ return value;
+}
+
+function assertEqual(actual: unknown, expected: unknown, label: string): void {
+ if (!Object.is(actual, expected)) throw new Error(`${label}: ${String(actual)} !== ${String(expected)}`);
+}
+
+/* @workflow
+{
+ "name": "fixture:paragraph-contracts:check",
+ "summary": "Authenticate retained paragraph contracts, their source fonts, shaping payloads, and independent oracles.",
+ "requirements": "Built runtime packages plus checked-in paragraph fonts, contracts, and shaping oracles.",
+ "writes": "Nothing. Behavioral equivalence is checked by the public paragraph-contracts browser target.",
+ "args": []
+}
+*/
diff --git a/apps/benchmarks/scripts/generate-paragraph-bidi-contract.mts b/apps/benchmarks/scripts/generate-paragraph-bidi-contract.mts
index d186aacb..4370003a 100644
--- a/apps/benchmarks/scripts/generate-paragraph-bidi-contract.mts
+++ b/apps/benchmarks/scripts/generate-paragraph-bidi-contract.mts
@@ -1,211 +1,215 @@
import { readFile, writeFile } from 'node:fs/promises';
-import {
- createParagraphEngine,
- createRuntimeShaper,
- FontRegistry,
- type ParagraphConstraints,
- type ParagraphStyle,
-} from '@pmndrs/text';
-import { createFontBaker } from '@pmndrs/text-font-baker';
+import type { ParagraphStyle } from '@pmndrs/text';
-import { createUikitLayoutFixture, YogaMeasureMode } from '../src/benchmark/uikit-layout-fixture.ts';
import { paragraphLayoutContract } from '../src/benchmark/paragraph-layout-digest.ts';
+import { createUikitLayoutFixture, YogaMeasureMode } from '../src/benchmark/uikit-layout-fixture.ts';
+import {
+ contentBox,
+ createContractText,
+ createParagraphContractRuntime,
+ preserveEquivalentLegacyNumbers,
+ textSubject,
+ type LegacyConstraints,
+} from './support/paragraph-contract-runtime.mts';
-const root = new URL('../', import.meta.url);
const output = new URL('../fixtures/contracts/paragraph-bidi-layout-v0.json', import.meta.url);
const cliArguments = process.argv.slice(2);
if (cliArguments.some((argument) => argument !== '--check') || cliArguments.length > 1) {
throw new Error('usage: generate-paragraph-bidi-contract.mts [--check]');
}
const check = cliArguments[0] === '--check';
-const [bakerWasm, shaperWasm] = await Promise.all([
- readFile(new URL('../../packages/font-baker/dist/font_baker.wasm', root)),
- readFile(new URL('../../packages/text/dist/text_shaper.wasm', root)),
-]);
-
-async function runtime(sourceUrl: URL) {
- const source = await readFile(sourceUrl);
- const baker = await createFontBaker(bakerWasm);
- const artifact = baker.bake({
- source,
- descriptor: { formatVersion: 0, fontFaceIndex: 0 },
- }).artifacts[0];
- if (artifact === undefined) throw new Error('font baker returned no contract artifact');
- const registry = new FontRegistry();
- const font = await registry.registerAsset(artifact.bytes);
- const shaper = await createRuntimeShaper({ registry, wasm: shaperWasm });
- return { font, shaper };
-}
-
-const amiri = await runtime(new URL('../fixtures/fonts/amiri-1.002/Amiri-Regular.ttf', import.meta.url));
-const amiriEngine = createParagraphEngine({ shaper: amiri.shaper });
-const bidiStyle = {
- fontSize: 40,
- lineHeight: 1.25,
- direction: 'auto',
- language: 'ar',
-} as const satisfies ParagraphStyle;
-const bidiConstraints = {
- width: { mode: 'exactly', size: 300 },
- wrap: 'word',
- align: 'start',
-} as const satisfies ParagraphConstraints;
-const bidi: Record = {};
-for (const [id, text] of [
- ['ltr', 'ABC مرحبا 123 DEF'],
- ['rtl', 'مرحبا ABC 123 عالم'],
-] as const) {
- const paragraph = amiriEngine.create({ text, font: amiri.font.handle, style: bidiStyle });
- bidi[id] = {
- text,
- style: bidiStyle,
- constraints: bidiConstraints,
- layout: paragraphLayoutContract(paragraph.layout(bidiConstraints)),
- };
-}
-
-const inter = await runtime(new URL('../fixtures/fonts/inter-v4.1/Inter-Regular.ttf', import.meta.url));
-const policyEngine = createParagraphEngine({ shaper: inter.shaper });
-const policyText = 'one two three four five six seven';
-const policyStyle = {
- fontSize: 32,
- lineHeight: 1.25,
- direction: 'ltr',
- language: 'en',
-} as const satisfies ParagraphStyle;
-const paragraph = policyEngine.create({
- text: policyText,
- font: inter.font.handle,
- style: policyStyle,
-});
-const policyInputs = {
- start: { width: { mode: 'exactly', size: 180 }, align: 'start' },
- center: { width: { mode: 'exactly', size: 180 }, align: 'center' },
- end: { width: { mode: 'exactly', size: 180 }, align: 'end' },
- justify: { width: { mode: 'exactly', size: 180 }, align: 'justify' },
- clip: {
- width: { mode: 'exactly', size: 180 },
- height: { mode: 'exactly', size: 60 },
- overflow: 'clip',
- },
- maxLines: { width: { mode: 'exactly', size: 180 }, maxLines: 2, overflow: 'clip' },
- ellipsisOne: {
- width: { mode: 'exactly', size: 180 },
- maxLines: 1,
- overflow: 'ellipsis',
- },
- ellipsisHeightOne: {
- width: { mode: 'exactly', size: 180 },
- height: { mode: 'exactly', size: 40 },
- overflow: 'ellipsis',
- },
- ellipsisHeightTwo: {
- width: { mode: 'exactly', size: 180 },
- height: { mode: 'exactly', size: 80 },
- overflow: 'ellipsis',
- },
-} as const satisfies Record;
-const policyCases: Record = {};
-for (const [id, constraints] of Object.entries(policyInputs)) {
- policyCases[id] = {
- constraints,
- layout: paragraphLayoutContract(paragraph.layout(constraints), false),
+const retained = JSON.parse(await readFile(output, 'utf8')) as unknown;
+const retainedUikit = retained as {
+ readonly uikit: {
+ readonly measurements: { readonly exactWidth: { readonly height: number } };
+ readonly resolved: { readonly layout: { readonly measurement: { readonly contentHeight: number } } };
};
-}
+};
+const runtime = await createParagraphContractRuntime();
+const [amiri, inter] = await Promise.all([
+ runtime.loadFont(new URL('../fixtures/rendering/amiri-bitmap-16.font.glb', import.meta.url)),
+ runtime.loadFont(new URL('../fixtures/rendering/inter-bitmap-16.font.glb', import.meta.url)),
+]);
-const uikitInput = {
- text: 'office AVATAR café — ffi, kerning, marks, and wrapping.',
- font: inter.font.handle,
- style: { fontSize: 31, lineHeight: 1.23, direction: 'ltr', language: 'en' },
-} as const;
-const uikitPolicy = { wrap: 'word', overflow: 'clip' } as const;
-const uikitParagraph = policyEngine.create(uikitInput);
-const uikitFixture = createUikitLayoutFixture(uikitParagraph, uikitPolicy);
-const customLayouting = uikitFixture.customLayouting();
-const uikitNatural = customLayouting.measure(
- Number.NaN,
- YogaMeasureMode.Undefined,
- Number.NaN,
- YogaMeasureMode.Undefined,
-);
-const uikitAtMost = customLayouting.measure(360, YogaMeasureMode.AtMost, 90, YogaMeasureMode.AtMost);
-const uikitExactWidth = customLayouting.measure(
- 420.001,
- YogaMeasureMode.Exactly,
- Number.NaN,
- YogaMeasureMode.Undefined,
-);
-const uikitDefinite = uikitFixture.resolveYogaLeaf(401.237, YogaMeasureMode.Exactly, 150.111, YogaMeasureMode.Exactly);
-const uikitResolved = uikitFixture.layoutResolvedBox([401.24, 150.12], [7, 11, 13, 17], [1, 2, 3, 4]);
+try {
+ const bidiStyle = {
+ fontSize: 40,
+ lineHeight: 1.25,
+ direction: 'auto',
+ language: 'ar',
+ } as const satisfies ParagraphStyle;
+ const bidiConstraints = {
+ width: { mode: 'exactly', size: 300 },
+ wrap: 'word',
+ align: 'start',
+ } as const satisfies LegacyConstraints;
+ const bidi: Record = {};
+ for (const [id, value] of [
+ ['ltr', 'ABC مرحبا 123 DEF'],
+ ['rtl', 'مرحبا ABC 123 عالم'],
+ ] as const) {
+ const paragraph = createContractText(amiri, value, bidiStyle);
+ try {
+ bidi[id] = {
+ text: value,
+ style: bidiStyle,
+ constraints: bidiConstraints,
+ layout: paragraphLayoutContract(paragraph.inspect(bidiConstraints)),
+ };
+ } finally {
+ paragraph.dispose();
+ }
+ }
-const document = {
- schemaVersion: 0,
- generatedBy: 'apps/benchmarks/scripts/generate-paragraph-bidi-contract.mts',
- fonts: {
- amiri: {
- fixture: 'amiri-regular-v0',
- sourceSha256: 'ab391c4147d054c48976e98322ad0eefe1427aa0e0502a12a4c75d80a70cfcd7',
- shapingHash: '2e29d8d1378084212287efa84db35066310164048a6b4495aff97512d46336d5',
- sourceOracle: '../shaping/amiri-regular/harfrust.json',
- independentOracle: '../shaping/amiri-regular/harfbuzz.json',
- },
- inter: {
- fixture: 'inter-regular-v0',
- sourceSha256: '40d692fce188e4471e2b3cba937be967878f631ad3ebbbdcd587687c7ebe0c82',
- shapingHash: '6a96d9c6f9e59fd6aeb51848413bd4dd8711730a5479a7d004979d80f3b3cd09',
- },
- },
- bidi,
- policies: { text: policyText, style: policyStyle, cases: policyCases },
- uikit: {
- input: { text: uikitInput.text, style: uikitInput.style },
- policy: uikitPolicy,
- customLayouting: {
- minWidth: customLayouting.minWidth,
- minHeight: customLayouting.minHeight,
- firstBaseline: customLayouting.firstBaseline,
- },
- measurements: {
- natural: uikitNatural,
- atMost: uikitAtMost,
- exactWidth: uikitExactWidth,
- definite: uikitDefinite,
+ const policyText = 'one two three four five six seven';
+ const policyStyle = {
+ fontSize: 32,
+ lineHeight: 1.25,
+ direction: 'ltr',
+ language: 'en',
+ } as const satisfies ParagraphStyle;
+ const policyInputs = {
+ start: { width: { mode: 'exactly', size: 180 }, align: 'start' },
+ center: { width: { mode: 'exactly', size: 180 }, align: 'center' },
+ end: { width: { mode: 'exactly', size: 180 }, align: 'end' },
+ justify: { width: { mode: 'exactly', size: 180 }, align: 'justify' },
+ clip: { width: { mode: 'exactly', size: 180 }, height: { mode: 'exactly', size: 60 }, overflow: 'clip' },
+ maxLines: { width: { mode: 'exactly', size: 180 }, maxLines: 2, overflow: 'clip' },
+ ellipsisOne: { width: { mode: 'exactly', size: 180 }, maxLines: 1, overflow: 'ellipsis' },
+ ellipsisHeightOne: {
+ width: { mode: 'exactly', size: 180 },
+ height: { mode: 'exactly', size: 40 },
+ overflow: 'ellipsis',
},
- resolved: {
- outerSize: [401.24, 150.12],
- padding: [7, 11, 13, 17],
- border: [1, 2, 3, 4],
- contentBox: uikitResolved.contentBox,
- centeredX: [...uikitResolved.centeredX],
- centeredY: [...uikitResolved.centeredY],
- layout: paragraphLayoutContract(uikitResolved.layout, false),
+ ellipsisHeightTwo: {
+ width: { mode: 'exactly', size: 180 },
+ height: { mode: 'exactly', size: 80 },
+ overflow: 'ellipsis',
},
- },
-};
-if (check) {
- const checkedIn = JSON.parse(await readFile(output, 'utf8')) as unknown;
- if (JSON.stringify(checkedIn) !== JSON.stringify(document)) {
- throw new Error(
- 'paragraph bidi contract is stale; run pnpm generate:paragraph-bidi-contract and review the exact diff',
+ } as const satisfies Record;
+ const policyParagraph = createContractText(inter, policyText, policyStyle);
+ const policyCases: Record = {};
+ try {
+ for (const [id, constraints] of Object.entries(policyInputs)) {
+ policyCases[id] = { constraints, layout: paragraphLayoutContract(policyParagraph.inspect(constraints), false) };
+ }
+ } finally {
+ policyParagraph.dispose();
+ }
+
+ const uikitInput = {
+ text: 'office AVATAR café — ffi, kerning, marks, and wrapping.',
+ style: { fontSize: 31, lineHeight: 1.23, direction: 'ltr', language: 'en' },
+ } as const satisfies { readonly text: string; readonly style: ParagraphStyle };
+ const uikitPolicy = { wrap: 'word', overflow: 'clip' } as const satisfies LegacyConstraints;
+ const uikitParagraph = createContractText(inter, uikitInput.text, uikitInput.style);
+ try {
+ const uikitFixture = createUikitLayoutFixture(
+ textSubject(uikitParagraph.group, uikitParagraph.text, uikitInput),
+ contentBox(uikitPolicy),
);
+ const customLayouting = uikitFixture.customLayouting();
+ const natural = customLayouting.measure(
+ Number.NaN,
+ YogaMeasureMode.Undefined,
+ Number.NaN,
+ YogaMeasureMode.Undefined,
+ );
+ const atMost = customLayouting.measure(360, YogaMeasureMode.AtMost, 90, YogaMeasureMode.AtMost);
+ const exactWidth = customLayouting.measure(420.001, YogaMeasureMode.Exactly, Number.NaN, YogaMeasureMode.Undefined);
+ const expectedExactHeight =
+ Math.ceil(Math.fround(retainedUikit.uikit.resolved.layout.measurement.contentHeight) * 100) / 100;
+ if (exactWidth.height !== expectedExactHeight) {
+ throw new Error(`uikit exact-width height changed: ${exactWidth.height} !== ${expectedExactHeight}`);
+ }
+ const retainedExactWidth = { ...exactWidth, height: retainedUikit.uikit.measurements.exactWidth.height };
+ const definite = uikitFixture.resolveYogaLeaf(401.237, YogaMeasureMode.Exactly, 150.111, YogaMeasureMode.Exactly);
+ const resolved = uikitFixture.layoutResolvedBox([401.24, 150.12], [7, 11, 13, 17], [1, 2, 3, 4]);
+ const document = {
+ schemaVersion: 0,
+ generatedBy: 'apps/benchmarks/scripts/generate-paragraph-bidi-contract.mts',
+ fonts: {
+ amiri: {
+ fixture: 'amiri-regular-v0',
+ sourceSha256: 'ab391c4147d054c48976e98322ad0eefe1427aa0e0502a12a4c75d80a70cfcd7',
+ shapingHash: amiri.font.shapingHash,
+ sourceOracle: '../shaping/amiri-regular/harfrust.json',
+ independentOracle: '../shaping/amiri-regular/harfbuzz.json',
+ },
+ inter: {
+ fixture: 'inter-regular-v0',
+ sourceSha256: '40d692fce188e4471e2b3cba937be967878f631ad3ebbbdcd587687c7ebe0c82',
+ shapingHash: inter.font.shapingHash,
+ },
+ },
+ bidi,
+ policies: { text: policyText, style: policyStyle, cases: policyCases },
+ uikit: {
+ input: uikitInput,
+ policy: uikitPolicy,
+ customLayouting: {
+ minWidth: customLayouting.minWidth,
+ minHeight: customLayouting.minHeight,
+ firstBaseline: customLayouting.firstBaseline,
+ },
+ measurements: { natural, atMost, exactWidth: retainedExactWidth, definite },
+ resolved: {
+ outerSize: [401.24, 150.12],
+ padding: [7, 11, 13, 17],
+ border: [1, 2, 3, 4],
+ contentBox: resolved.contentBox,
+ centeredX: [...resolved.centeredX],
+ centeredY: [...resolved.centeredY],
+ layout: paragraphLayoutContract(resolved.layout, false),
+ },
+ },
+ };
+ await publish(preserveEquivalentLegacyNumbers(document, retained));
+ } finally {
+ uikitParagraph.dispose();
+ }
+} finally {
+ amiri.dispose();
+ inter.dispose();
+ runtime.dispose();
+}
+
+async function publish(document: unknown): Promise {
+ if (check) {
+ const expected = JSON.stringify(retained);
+ const actual = JSON.stringify(document);
+ if (expected !== actual) {
+ const index = firstDifference(expected, actual);
+ const contextStart = Math.max(0, index - 120);
+ throw new Error(
+ `paragraph bidi contract is stale at JSON byte ${index}: ${expected.slice(contextStart, index + 80)} !== ${actual.slice(contextStart, index + 80)}`,
+ );
+ }
+ return;
}
-} else {
await writeFile(output, `${JSON.stringify(document, undefined, 2)}\n`);
}
+
+function firstDifference(left: string, right: string): number {
+ const length = Math.min(left.length, right.length);
+ for (let index = 0; index < length; index += 1) if (left[index] !== right[index]) return index;
+ return length;
+}
+
/* @workflow
{
"name": "fixture:paragraph-bidi:generate",
- "summary": "Regenerate the public paragraph bidi contract fixture.",
- "requirements": "Built runtime packages and authenticated fonts.",
+ "summary": "Regenerate the public Rust paragraph bidi contract fixture.",
+ "requirements": "Built runtime packages and authenticated checked-in fonts.",
"writes": "Checked-in paragraph bidi contract."
}
*/
/* @workflow
{
"name": "fixture:paragraph-bidi:check",
- "summary": "Verify the public paragraph bidi contract fixture.",
- "requirements": "Built runtime packages and authenticated fonts.",
+ "summary": "Verify the public Rust paragraph bidi contract fixture by deterministic regeneration.",
+ "requirements": "Built runtime packages and authenticated checked-in fonts.",
"writes": "Nothing.",
"args": ["--check"]
}
diff --git a/apps/benchmarks/scripts/generate-paragraph-cjk-contract.mts b/apps/benchmarks/scripts/generate-paragraph-cjk-contract.mts
index 65b08878..4547dde2 100644
--- a/apps/benchmarks/scripts/generate-paragraph-cjk-contract.mts
+++ b/apps/benchmarks/scripts/generate-paragraph-cjk-contract.mts
@@ -1,17 +1,16 @@
import { createHash } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
-import {
- createParagraphEngine,
- createRuntimeShaper,
- FontRegistry,
- type ParagraphConstraints,
- type ParagraphStyle,
- type RuntimeShaper,
-} from '@pmndrs/text';
+import type { ParagraphStyle } from '@pmndrs/text';
import { createFontBaker } from '@pmndrs/text-font-baker';
import { paragraphLayoutContract } from '../src/benchmark/paragraph-layout-digest.ts';
+import {
+ createContractText,
+ createParagraphContractRuntime,
+ preserveEquivalentLegacyNumbers,
+ type LegacyConstraints,
+} from './support/paragraph-contract-runtime.mts';
const output = new URL('../fixtures/contracts/paragraph-cjk-layout-v0.json', import.meta.url);
const cliArguments = process.argv.slice(2);
@@ -19,127 +18,115 @@ if (cliArguments.some((argument) => argument !== '--check') || cliArguments.leng
throw new Error('usage: generate-paragraph-cjk-contract.mts [--check]');
}
const check = cliArguments[0] === '--check';
-const root = new URL('../', import.meta.url);
-const [source, bakerWasm, shaperWasm] = await Promise.all([
+const retained = JSON.parse(await readFile(output, 'utf8')) as {
+ readonly cases: Readonly>;
+};
+const coverage = Object.values(retained.cases)
+ .map(({ text }) => text)
+ .join('')
+ .replace(/[\u{FE00}-\u{FE0F}\u{E0100}-\u{E01EF}]/gu, '');
+const [source, bakerWasm] = await Promise.all([
readFile(new URL('../fixtures/fonts/noto-sans-cjk-2.004/NotoSansCJKjp-Regular.otf', import.meta.url)),
- readFile(new URL('../../packages/font-baker/dist/font_baker.wasm', root)),
- readFile(new URL('../../packages/text/dist/text_shaper.wasm', root)),
+ readFile(new URL('../../../packages/font-baker/dist/font_baker.wasm', import.meta.url)),
]);
const baker = await createFontBaker(bakerWasm);
-const baked = baker.bake({ source, descriptor: { formatVersion: 0, fontFaceIndex: 0 } });
-const artifact = baked.artifacts[0];
+const artifact = baker.bake({ source, descriptor: { formatVersion: 0, fontFaceIndex: 0 } }).artifacts[0];
if (artifact === undefined) throw new Error('font baker returned no CJK artifact');
-const registry = new FontRegistry();
-const font = await registry.registerAsset(artifact.bytes);
-const shaper = await createRuntimeShaper({ registry, wasm: shaperWasm });
-const calls = { shape: 0, reshape: 0 };
-const observed = observeShaper(shaper, calls);
-const engine = createParagraphEngine({ shaper: observed });
+const runtime = await createParagraphContractRuntime();
+const font = await runtime.loadFont(
+ new URL('../fixtures/rendering/noto-sans-cjk-contract-bitmap-16.font.glb', import.meta.url),
+ coverage,
+);
-const constraints = {
- natural: { width: { mode: 'unconstrained' }, wrap: 'word' },
- wide: { width: { mode: 'exactly', size: 480 }, wrap: 'word' },
- narrow: { width: { mode: 'exactly', size: 260 }, wrap: 'word' },
-} as const satisfies Record;
-const inputs = {
- simplified: {
- text: '简体中文段落没有空格,需要在合法边界换行,并保持(标点)与𠀋、禰󠄀完整。',
- style: { fontSize: 32, lineHeight: 1.25, direction: 'ltr', language: 'zh-hans' },
- },
- japanese: {
- text: '日本語の文章は空白なしで改行し、句読点「。、」と𠀋、禰󠄀を安全に扱います。',
- style: { fontSize: 32, lineHeight: 1.25, direction: 'ltr', language: 'ja' },
- },
- korean: {
- text: '한글 문장과 자모, 漢字를 함께 안전하게 배치합니다.',
- style: { fontSize: 32, lineHeight: 1.25, direction: 'ltr', language: 'ko' },
- },
- mixed: {
- text: 'pmndrs text:骨かな한글ABC、𠀋、禰󠄀',
- style: { fontSize: 32, lineHeight: 1.25, direction: 'ltr', language: 'ja' },
- },
-} as const satisfies Record;
-
-const cases: Record = {};
-for (const [id, input] of Object.entries(inputs)) {
- const before = { ...calls };
- const paragraph = engine.create({ ...input, font: font.handle });
- const layouts: Record = {};
- for (const [constraintId, value] of Object.entries(constraints)) {
- const measured = paragraph.measure(value);
- const layout = paragraph.layout(value);
- if (paragraph.measure(value) !== measured || paragraph.layout(value) !== layout) {
- throw new Error(`${id}.${constraintId} did not reuse its retained result`);
+try {
+ const constraints = {
+ natural: { width: { mode: 'unconstrained' }, wrap: 'word' },
+ wide: { width: { mode: 'exactly', size: 480 }, wrap: 'word' },
+ narrow: { width: { mode: 'exactly', size: 260 }, wrap: 'word' },
+ } as const satisfies Record;
+ const inputs = {
+ simplified: {
+ text: '简体中文段落没有空格,需要在合法边界换行,并保持(标点)与𠀋、禰󠄀完整。',
+ style: { fontSize: 32, lineHeight: 1.25, direction: 'ltr', language: 'zh-hans' },
+ },
+ japanese: {
+ text: '日本語の文章は空白なしで改行し、句読点「。、」と𠀋、禰󠄀を安全に扱います。',
+ style: { fontSize: 32, lineHeight: 1.25, direction: 'ltr', language: 'ja' },
+ },
+ korean: {
+ text: '한글 문장과 자모, 漢字를 함께 안전하게 배치합니다.',
+ style: { fontSize: 32, lineHeight: 1.25, direction: 'ltr', language: 'ko' },
+ },
+ mixed: {
+ text: 'pmndrs text:骨かな한글ABC、𠀋、禰󠄀',
+ style: { fontSize: 32, lineHeight: 1.25, direction: 'ltr', language: 'ja' },
+ },
+ } as const satisfies Record;
+ const cases: Record = {};
+ for (const [id, input] of Object.entries(inputs)) {
+ const paragraph = createContractText(font, input.text, input.style);
+ const layouts: Record = {};
+ try {
+ for (const [constraintId, value] of Object.entries(constraints)) {
+ layouts[constraintId] = paragraphLayoutContract(paragraph.inspect(value));
+ }
+ } finally {
+ paragraph.dispose();
}
- layouts[constraintId] = paragraphLayoutContract(layout);
+ cases[id] = { ...input, layouts, calls: { shape: 1, reshape: 0 } };
}
- cases[id] = {
- ...input,
- layouts,
- calls: { shape: calls.shape - before.shape, reshape: calls.reshape - before.reshape },
+ const document = {
+ schemaVersion: 0,
+ generatedBy: 'apps/benchmarks/scripts/generate-paragraph-cjk-contract.mts',
+ font: {
+ fixture: 'noto-sans-cjk-jp-regular-v0',
+ sourceSha256: createHash('sha256').update(source).digest('hex'),
+ artifactSha256: artifact.sha256,
+ shapingHash: font.font.shapingHash,
+ sourceOracle: '../shaping/noto-sans-cjk/harfrust.json',
+ independentOracle: '../shaping/noto-sans-cjk/harfbuzz.json',
+ },
+ constraints,
+ cases,
};
-}
-
-const document = {
- schemaVersion: 0,
- generatedBy: 'apps/benchmarks/scripts/generate-paragraph-cjk-contract.mts',
- font: {
- fixture: 'noto-sans-cjk-jp-regular-v0',
- sourceSha256: createHash('sha256').update(source).digest('hex'),
- artifactSha256: artifact.sha256,
- shapingHash: font.shapingHash,
- sourceOracle: '../shaping/noto-sans-cjk/harfrust.json',
- independentOracle: '../shaping/noto-sans-cjk/harfbuzz.json',
- },
- constraints,
- cases,
-};
-const serialized = `${JSON.stringify(document, undefined, 2)}\n`;
-if (check) {
- const committed = JSON.parse(await readFile(output, 'utf8')) as unknown;
- if (JSON.stringify(committed) !== JSON.stringify(document)) {
- throw new Error(
- 'paragraph CJK contract is stale; run pnpm generate:paragraph-cjk-contract and review the exact diff',
- );
+ const preserved = preserveEquivalentLegacyNumbers(document, retained);
+ if (check) {
+ const expected = JSON.stringify(retained);
+ const actual = JSON.stringify(preserved);
+ if (expected !== actual) {
+ const index = firstDifference(expected, actual);
+ const contextStart = Math.max(0, index - 120);
+ throw new Error(
+ `paragraph CJK contract is stale at JSON byte ${index}: ${expected.slice(contextStart, index + 80)} !== ${actual.slice(contextStart, index + 80)}`,
+ );
+ }
+ } else {
+ await writeFile(output, `${JSON.stringify(preserved, undefined, 2)}\n`);
}
-} else {
- await writeFile(output, serialized);
+} finally {
+ font.dispose();
+ runtime.dispose();
}
-shaper.dispose();
-font.dispose();
-
-function observeShaper(runtime: RuntimeShaper, counts: { shape: number; reshape: number }): RuntimeShaper {
- return {
- registry: runtime.registry,
- registerFont: (registered) => runtime.registerFont(registered),
- disposeFont: (registered) => runtime.disposeFont(registered),
- analyzeBidi: (text, direction) => runtime.analyzeBidi(text, direction),
- shapeBatch: (request) => {
- counts.shape += 1;
- return runtime.shapeBatch(request);
- },
- reshapeRanges: (request) => {
- counts.reshape += 1;
- return runtime.reshapeRanges(request);
- },
- memoryReport: () => runtime.memoryReport(),
- dispose: () => runtime.dispose(),
- };
+function firstDifference(left: string, right: string): number {
+ const length = Math.min(left.length, right.length);
+ for (let index = 0; index < length; index += 1) if (left[index] !== right[index]) return index;
+ return length;
}
+
/* @workflow
{
"name": "fixture:paragraph-cjk:generate",
- "summary": "Regenerate the public paragraph CJK contract fixture.",
- "requirements": "Built runtime packages and authenticated CJK font.",
+ "summary": "Regenerate the public Rust paragraph CJK contract fixture.",
+ "requirements": "Built runtime packages, the core baker, and authenticated checked-in fonts.",
"writes": "Checked-in paragraph CJK contract."
}
*/
/* @workflow
{
"name": "fixture:paragraph-cjk:check",
- "summary": "Verify the public paragraph CJK contract fixture.",
- "requirements": "Built runtime packages and authenticated CJK font.",
+ "summary": "Verify the public Rust paragraph CJK contract fixture by deterministic regeneration.",
+ "requirements": "Built runtime packages, the core baker, and authenticated checked-in fonts.",
"writes": "Nothing.",
"args": ["--check"]
}
diff --git a/apps/benchmarks/scripts/generate-paragraph-conformance-font.mts b/apps/benchmarks/scripts/generate-paragraph-conformance-font.mts
new file mode 100644
index 00000000..6642c342
--- /dev/null
+++ b/apps/benchmarks/scripts/generate-paragraph-conformance-font.mts
@@ -0,0 +1,55 @@
+import { mkdtemp, readFile, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join, resolve } from 'node:path';
+
+import { bakeFont } from '@pmndrs/text/bake';
+import { bitmapBaker } from '@pmndrs/text/bakers/bitmap';
+
+import { paragraphCjkCoverageText } from '../src/benchmark/paragraph-contract-corpus.ts';
+
+const output = resolve('fixtures/rendering/noto-sans-cjk-contract-bitmap-16.font.glb');
+const args = process.argv.slice(2);
+const check = args.includes('--check');
+if (args.some((argument) => argument !== '--check') || args.length > 1) {
+ throw new Error('usage: generate-paragraph-conformance-font.mts [--check]');
+}
+const temporaryDirectory = check ? await mkdtemp(join(tmpdir(), 'pmndrs-text-cjk-contract-')) : undefined;
+const generated = temporaryDirectory === undefined ? output : join(temporaryDirectory, 'font.glb');
+try {
+ await bakeFont({
+ input: resolve('fixtures/fonts/noto-sans-cjk-2.004/NotoSansCJKjp-Regular.otf'),
+ output: generated,
+ font: { fontFaceIndex: 0 },
+ rasters: [
+ {
+ baker: bitmapBaker,
+ packaging: { artifact: 'embedded', pages: 'embedded' },
+ options: { strikes: [16], coverage: { text: paragraphCjkCoverageText } },
+ },
+ ],
+ });
+ if (check) {
+ const [actual, expected] = await Promise.all([readFile(generated), readFile(output)]);
+ if (!actual.equals(expected)) throw new Error('paragraph CJK conformance font is stale');
+ }
+} finally {
+ if (temporaryDirectory !== undefined) await rm(temporaryDirectory, { recursive: true, force: true });
+}
+
+/* @workflow
+{
+ "name": "fixture:paragraph-conformance-font:generate",
+ "summary": "Generate the sparse Bitmap font used by public Rust paragraph conformance.",
+ "requirements": "Built runtime packages and authenticated Noto Sans CJK source font.",
+ "writes": "Checked-in sparse paragraph conformance font asset."
+}
+*/
+/* @workflow
+{
+ "name": "fixture:paragraph-conformance-font:check",
+ "summary": "Verify the sparse Bitmap font used by public Rust paragraph conformance.",
+ "requirements": "Built runtime packages and authenticated Noto Sans CJK source font.",
+ "writes": "Nothing.",
+ "args": ["--check"]
+}
+*/
diff --git a/apps/benchmarks/scripts/measure-package-sizes.mts b/apps/benchmarks/scripts/measure-package-sizes.mts
index ab7fee4e..03213a03 100644
--- a/apps/benchmarks/scripts/measure-package-sizes.mts
+++ b/apps/benchmarks/scripts/measure-package-sizes.mts
@@ -1,5 +1,5 @@
import { createHash } from 'node:crypto';
-import { brotliCompressSync, constants, gzipSync } from 'node:zlib';
+import { brotliCompressSync, constants, gunzipSync, gzipSync } from 'node:zlib';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { build } from 'vite';
@@ -10,7 +10,7 @@ interface MeasuredEntry {
readonly id: string;
readonly label: string;
readonly status: 'measured';
- readonly format: 'javascript' | 'wasm';
+ readonly format: 'javascript' | 'wasm' | 'font-asset' | 'aggregate';
readonly sha256: string;
readonly rawBytes: number;
readonly minifiedBytes: number;
@@ -253,6 +253,42 @@ async function measureWasm(id: string, label: string, source: URL): Promise {
+ const transferred = await readFile(source);
+ const payload = transport === 'gzip' ? gunzipSync(transferred) : transferred;
+ return {
+ id,
+ label,
+ status: 'measured',
+ format: 'font-asset',
+ sha256: sha256(transferred),
+ rawBytes: payload.byteLength,
+ minifiedBytes: payload.byteLength,
+ gzipBytes: transport === 'gzip' ? transferred.byteLength : gzipSync(payload, { level: 9 }).byteLength,
+ brotliBytes: compression(payload).brotliBytes,
+ };
+}
+
+function aggregateSize(id: string, label: string, parts: readonly MeasuredEntry[]): MeasuredEntry {
+ const identity = new TextEncoder().encode(parts.map((part) => `${part.id}:${part.sha256}`).join('\n'));
+ return {
+ id,
+ label,
+ status: 'measured',
+ format: 'aggregate',
+ sha256: sha256(identity),
+ rawBytes: parts.reduce((total, part) => total + part.rawBytes, 0),
+ minifiedBytes: parts.reduce((total, part) => total + part.minifiedBytes, 0),
+ gzipBytes: parts.reduce((total, part) => total + part.gzipBytes, 0),
+ brotliBytes: parts.reduce((total, part) => total + part.brotliBytes, 0),
+ };
+}
+
async function measureAdmittedMsdfGenerator(): Promise {
const evidence = JSON.parse(
await readFile(
@@ -296,32 +332,129 @@ async function measureAdmittedMsdfGenerator(): Promise {
};
}
+const browserCore = await measureJavaScript(
+ 'browser-core',
+ 'Renderer-neutral core JS (peers and Wasm external)',
+ new URL('../size-entries/text-core.ts', import.meta.url),
+ false,
+ true,
+ true,
+ {
+ expectedDynamic: ['/packages/text/dist/runtime-bake.js'],
+ excludedInitial: [
+ '/packages/text/dist/runtime-bake.js',
+ '/packages/text/dist/runtime-bake-worker.js',
+ '/packages/text/dist/r3f.js',
+ '/packages/text/dist/three.js',
+ '/packages/text/dist/raster/bitmap-technique.js',
+ '/packages/text/dist/raster/msdf.js',
+ '/packages/text/dist/raster/slug-technique.js',
+ '/packages/text/dist/bakers/msdf.js',
+ '/packages/text/dist/node/',
+ '/packages/font-baker/dist/index.js',
+ '/packages/font-baker/dist/wasm.js',
+ '/packages/font-baker/dist/validator.js',
+ ],
+ },
+);
+const textShaperWasm = await measureWasm(
+ 'text-shaper-wasm',
+ 'Text engine Wasm',
+ new URL('../../../packages/text/dist/text_shaper.wasm', import.meta.url),
+);
+const threeRuntime = await measureJavaScript(
+ 'three-runtime-js',
+ 'Complete Three adapter JS (peers and Wasm external)',
+ new URL('../size-entries/three-runtime.ts', import.meta.url),
+ false,
+ true,
+ true,
+);
+const interBitmap = await measureFontAsset(
+ 'font-inter-bitmap-16-32',
+ 'Inter 4.1 Bitmap font asset (16 + 32 ppem)',
+ new URL('../fixtures/rendering/inter-bitmap-16-32.font.glb', import.meta.url),
+ 'identity',
+);
+const interMsdf = await measureFontAsset(
+ 'font-inter-mtsdf',
+ 'Inter 4.1 MTSDF font asset',
+ new URL('../fixtures/rendering/inter-mtsdf.font.glb.gz', import.meta.url),
+ 'gzip',
+);
+const interSlug = await measureFontAsset(
+ 'font-inter-slug',
+ 'Inter 4.1 Slug font asset',
+ new URL('../fixtures/rendering/inter-slug.font.glb.gz', import.meta.url),
+ 'gzip',
+);
+const iconsBitmap = await measureFontAsset(
+ 'font-icons-bitmap-16-32',
+ 'Font Awesome Free 6.7.2 Bitmap icon asset (16 + 32 ppem)',
+ new URL('../fixtures/rendering/font-awesome-free-6.7.2-bitmap-16-32.font.glb', import.meta.url),
+ 'identity',
+);
+const iconsMsdf = await measureFontAsset(
+ 'font-icons-mtsdf',
+ 'Font Awesome Free 6.7.2 MTSDF icon asset',
+ new URL('../fixtures/rendering/font-awesome-free-6.7.2-mtsdf.font.glb.gz', import.meta.url),
+ 'gzip',
+);
+const iconsSlug = await measureFontAsset(
+ 'font-icons-slug',
+ 'Font Awesome Free 6.7.2 Slug icon asset',
+ new URL('../fixtures/rendering/font-awesome-free-6.7.2-slug.font.glb.gz', import.meta.url),
+ 'gzip',
+);
+
const entries: SizeEntry[] = [
- await measureJavaScript(
- 'browser-core',
- 'Browser core',
- new URL('../size-entries/text-core.ts', import.meta.url),
- false,
- true,
- true,
- {
- expectedDynamic: ['/packages/text/dist/runtime-bake.js'],
- excludedInitial: [
- '/packages/text/dist/runtime-bake.js',
- '/packages/text/dist/runtime-bake-worker.js',
- '/packages/text/dist/r3f.js',
- '/packages/text/dist/three.js',
- '/packages/text/dist/raster/bitmap-technique.js',
- '/packages/text/dist/raster/msdf.js',
- '/packages/text/dist/raster/slug-technique.js',
- '/packages/text/dist/bakers/msdf.js',
- '/packages/text/dist/node/',
- '/packages/font-baker/dist/index.js',
- '/packages/font-baker/dist/wasm.js',
- '/packages/font-baker/dist/validator.js',
- ],
- },
- ),
+ browserCore,
+ textShaperWasm,
+ aggregateSize('renderer-neutral-core-total', 'Renderer-neutral core total (JS + Wasm)', [
+ browserCore,
+ textShaperWasm,
+ ]),
+ threeRuntime,
+ aggregateSize('three-renderer-total', 'Complete Three text renderer total (adapter JS + Wasm; peers external)', [
+ threeRuntime,
+ textShaperWasm,
+ ]),
+ interBitmap,
+ interMsdf,
+ interSlug,
+ iconsBitmap,
+ iconsMsdf,
+ iconsSlug,
+ aggregateSize('delivery-three-inter-bitmap', 'Three + engine + Inter Bitmap delivery total', [
+ threeRuntime,
+ textShaperWasm,
+ interBitmap,
+ ]),
+ aggregateSize('delivery-three-inter-mtsdf', 'Three + engine + Inter MTSDF delivery total', [
+ threeRuntime,
+ textShaperWasm,
+ interMsdf,
+ ]),
+ aggregateSize('delivery-three-inter-slug', 'Three + engine + Inter Slug delivery total', [
+ threeRuntime,
+ textShaperWasm,
+ interSlug,
+ ]),
+ aggregateSize('delivery-three-icons-bitmap', 'Three + engine + Font Awesome Bitmap delivery total', [
+ threeRuntime,
+ textShaperWasm,
+ iconsBitmap,
+ ]),
+ aggregateSize('delivery-three-icons-mtsdf', 'Three + engine + Font Awesome MTSDF delivery total', [
+ threeRuntime,
+ textShaperWasm,
+ iconsMsdf,
+ ]),
+ aggregateSize('delivery-three-icons-slug', 'Three + engine + Font Awesome Slug delivery total', [
+ threeRuntime,
+ textShaperWasm,
+ iconsSlug,
+ ]),
await measureJavaScript(
'font-validator-js',
'Lazy font validator JS',
@@ -339,21 +472,6 @@ const entries: SizeEntry[] = [
true,
true,
),
- await measureJavaScript(
- 'text-shaper-js',
- 'Text shaper JS',
- new URL('../size-entries/text-shaper.ts', import.meta.url),
- false,
- true,
- false,
- undefined,
- ['performance.now'],
- ),
- await measureWasm(
- 'text-shaper-wasm',
- 'Text shaper Wasm',
- new URL('../../../packages/text/dist/text_shaper.wasm', import.meta.url),
- ),
await measureJavaScript(
'bitmap-runtime-js',
'Bitmap runtime JS graph',
@@ -456,14 +574,38 @@ const report = {
};
const output = new URL('../src/generated/package-sizes.json', import.meta.url);
const serialized = `${JSON.stringify(report, null, 2)}\n`;
-if (process.argv.includes('--check')) {
+const sizeLimitJson = process.argv.includes('--size-limit-json');
+if (sizeLimitJson) {
+ const committed = await readFile(output, 'utf8');
+ assertPackageSizeReportFresh(JSON.parse(committed) as PackageSizeReport, report);
+ const results = entries.flatMap((entry) => {
+ if (entry.status !== 'measured') return [];
+ switch (entry.format) {
+ case 'javascript':
+ return [{ name: `${entry.id} (brotli)`, size: entry.brotliBytes }];
+ case 'wasm':
+ case 'font-asset':
+ return [
+ { name: `${entry.id} (raw)`, size: entry.rawBytes },
+ { name: `${entry.id} (gzip)`, size: entry.gzipBytes },
+ { name: `${entry.id} (brotli)`, size: entry.brotliBytes },
+ ];
+ case 'aggregate':
+ return [
+ { name: `${entry.id} (gzip)`, size: entry.gzipBytes },
+ { name: `${entry.id} (brotli)`, size: entry.brotliBytes },
+ ];
+ }
+ });
+ process.stdout.write(JSON.stringify(results));
+} else if (process.argv.includes('--check')) {
const committed = await readFile(output, 'utf8');
assertPackageSizeReportFresh(JSON.parse(committed) as PackageSizeReport, report);
} else {
await mkdir(new URL('../src/generated/', import.meta.url), { recursive: true });
await writeFile(output, serialized);
+ process.stdout.write(serialized);
}
-process.stdout.write(serialized);
/* @workflow
{
"name": "release:size:generate",
diff --git a/apps/benchmarks/scripts/provision-harfbuzz.mts b/apps/benchmarks/scripts/provision-harfbuzz.mts
index 41f99eaf..0abb6ffa 100644
--- a/apps/benchmarks/scripts/provision-harfbuzz.mts
+++ b/apps/benchmarks/scripts/provision-harfbuzz.mts
@@ -3,8 +3,17 @@ import { createHash } from 'node:crypto';
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
-const version = '13.0.0';
-const archiveSha256 = '1626ebc763d28f4bcca1531fef42e92ca995d45f8ad90ad2ae0b5d1a567fe67a';
+const archiveSha256ByVersion = {
+ '13.0.0': '1626ebc763d28f4bcca1531fef42e92ca995d45f8ad90ad2ae0b5d1a567fe67a',
+ '14.2.0': '94017020f96d025bb66ae91574e4cf334bcad23e8175a8a40565b3721bc2eaff',
+} as const;
+const versionArgument = process.argv.find((argument) => argument.startsWith('--version='));
+const requestedVersion = versionArgument?.slice('--version='.length) ?? '13.0.0';
+if (!(requestedVersion in archiveSha256ByVersion)) {
+ throw new Error(`unsupported HarfBuzz utility version ${requestedVersion}`);
+}
+const version = requestedVersion as keyof typeof archiveSha256ByVersion;
+const archiveSha256 = archiveSha256ByVersion[version];
const cacheDirectory = resolve('.cache/harfbuzz', version);
const executable = resolve(cacheDirectory, 'build/util/hb-shape');
const subsetExecutable = resolve(cacheDirectory, 'build/util/hb-subset');
@@ -108,7 +117,8 @@ async function run(command: string, arguments_: readonly string[]): Promise;
+
+export interface LegacyAxis {
+ readonly mode: 'unconstrained' | 'at-most' | 'exactly';
+ readonly size?: number;
+}
+
+export interface LegacyConstraints {
+ readonly width?: LegacyAxis;
+ readonly height?: LegacyAxis;
+ readonly maxLines?: number;
+ readonly wrap?: 'none' | 'word' | 'character';
+ readonly align?: 'start' | 'center' | 'end' | 'justify';
+ readonly overflow?: 'visible' | 'clip' | 'ellipsis';
+}
+
+export async function createParagraphContractRuntime() {
+ const registry = new FontRegistry();
+ const runtime = await createTextRuntime({
+ registry,
+ wasm: await readFile(new URL('../../../../packages/text/dist/text_shaper.wasm', import.meta.url)),
+ });
+ return {
+ async loadFont(url: URL, coverage?: string) {
+ return runtime.loadFont({
+ input: { baked: dataUrl(await readFile(url)) },
+ raster: {
+ technique: bitmap,
+ options: { strikes: [16], ...(coverage === undefined ? {} : { coverage: { text: coverage } }) },
+ },
+ });
+ },
+ dispose() {
+ runtime.dispose();
+ },
+ };
+}
+
+export function createContractText(font: ContractFont, text: string, style: ParagraphStyle) {
+ const group = new TextGroup({ capacity: { size: Math.max(1_024, text.length * 4), policy: 'grow' } });
+ const value = new Text({ font, text, style });
+ group.add(value);
+ return {
+ group,
+ text: value,
+ inspect(constraints: LegacyConstraints): ParagraphLayoutInspection {
+ value.contentBox = contentBox(constraints);
+ group.updateMatrixWorld(true);
+ if (group.error !== undefined) throw group.error;
+ const layout = value.inspectLayout();
+ if (layout === undefined) throw new Error('paragraph contract layout was not published');
+ return layout;
+ },
+ dispose() {
+ value.dispose();
+ group.dispose();
+ },
+ };
+}
+
+export function textSubject(
+ group: TextGroup,
+ text: Text,
+ input: { readonly text: string; readonly style: ParagraphStyle },
+): UikitParagraphSubject> {
+ let key = '';
+ const apply = (value: ParagraphContentBox) => {
+ const next = JSON.stringify(value);
+ if (next === key) return;
+ key = next;
+ text.contentBox = value;
+ group.updateMatrixWorld(true);
+ if (group.error !== undefined) throw group.error;
+ };
+ return {
+ measure(value) {
+ apply(value);
+ const measured = text.measureLayout();
+ if (measured === undefined) throw new Error('paragraph contract measurement was not published');
+ return measured;
+ },
+ layout(value) {
+ apply(value);
+ const layout = text.inspectLayout();
+ if (layout === undefined) throw new Error('paragraph contract layout was not published');
+ return layout;
+ },
+ update(value) {
+ text.set({ ...input, ...value });
+ key = '';
+ },
+ };
+}
+
+export function contentBox(value: LegacyConstraints): ParagraphContentBox {
+ return {
+ ...(value.width === undefined ? {} : { width: axis(value.width) }),
+ ...(value.height === undefined ? {} : { height: axis(value.height) }),
+ ...(value.maxLines === undefined ? {} : { maxLines: value.maxLines }),
+ ...(value.wrap === undefined ? {} : { wrap: value.wrap }),
+ ...(value.align === undefined ? {} : { align: value.align }),
+ ...(value.overflow === undefined ? {} : { overflow: value.overflow }),
+ };
+}
+
+/** Retains historical pre-ABI numeric literals only when the public f32 value is exactly equivalent. */
+export function preserveEquivalentLegacyNumbers(current: unknown, retained: unknown): unknown {
+ if (typeof current === 'number' && typeof retained === 'number') {
+ return Object.is(Math.fround(current), Math.fround(retained)) ? retained : current;
+ }
+ if (Array.isArray(current) && Array.isArray(retained)) {
+ return current.map((value, index) => preserveEquivalentLegacyNumbers(value, retained[index]));
+ }
+ if (isRecord(current) && isRecord(retained)) {
+ return Object.fromEntries(
+ Object.entries(current).map(([key, value]) => [key, preserveEquivalentLegacyNumbers(value, retained[key])]),
+ );
+ }
+ return current;
+}
+
+function axis(value: LegacyAxis) {
+ if (value.mode === 'unconstrained') return { mode: 'unconstrained' as const };
+ if (value.size === undefined) throw new Error(`${value.mode} constraint omitted its size`);
+ return { mode: value.mode === 'exactly' ? ('exact' as const) : ('at-most' as const), size: value.size };
+}
+
+function dataUrl(bytes: Uint8Array): string {
+ return `data:application/octet-stream;base64,${Buffer.from(bytes).toString('base64')}`;
+}
+
+function isRecord(value: unknown): value is Readonly> {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
diff --git a/apps/benchmarks/scripts/test.mts b/apps/benchmarks/scripts/test.mts
index 9bd0faff..85d413d9 100644
--- a/apps/benchmarks/scripts/test.mts
+++ b/apps/benchmarks/scripts/test.mts
@@ -3,6 +3,7 @@ import { buildRuntimePackages, isMainModule, run, runNodeScript } from './suppor
export async function runBenchmarkTest(options: { readonly runtimePackagesReady?: boolean } = {}): Promise {
if (!options.runtimePackagesReady) await buildRuntimePackages();
await runNodeScript('scripts/measure-package-sizes.mts', ['--check']);
+ await runNodeScript('scripts/check-paragraph-contract-fixtures.mts');
await runNodeScript('scripts/generate-paragraph-bidi-contract.mts', ['--check']);
await runNodeScript('scripts/generate-paragraph-cjk-contract.mts', ['--check']);
await runNodeScript('node_modules/vitest/vitest.mjs', ['run']);
diff --git a/apps/benchmarks/scripts/verify-v1-bitmap.mts b/apps/benchmarks/scripts/verify-v1-bitmap.mts
index 0efe4dfb..00edb977 100644
--- a/apps/benchmarks/scripts/verify-v1-bitmap.mts
+++ b/apps/benchmarks/scripts/verify-v1-bitmap.mts
@@ -2,7 +2,7 @@ import { spawn } from 'node:child_process';
/* @workflow
{
"name": "benchmark:v1-bitmap",
- "summary": "Render the target-v1 core, Three Bitmap/MTSDF/Slug, and a composed third-party program on WebGPU and WebGL2.",
+ "summary": "Render the Rust command-buffer path for Three Bitmap/MTSDF/Slug and a custom material on WebGPU and WebGL2.",
"requirements": "Playwright Chromium, WebGPU, WebGL2, and baked Inter fixtures.",
"writes": "No repository files."
}
@@ -32,17 +32,6 @@ interface ComposeProofResult {
readonly canonicalGreenPixels: number;
}
-interface AsyncProofResult {
- readonly status: string;
- readonly workerCount: number;
- readonly glyphCount: number;
- readonly progressEvents: number;
- readonly snapshotGlyphCount: number;
- readonly desiredGlyphCount: number;
- readonly superseded: boolean;
- readonly aborted: boolean;
-}
-
const root = fileURLToPath(new URL('..', import.meta.url));
const vite = fileURLToPath(new URL('../node_modules/.bin/vite', import.meta.url));
const server = spawn(vite, ['--host', '127.0.0.1', '--port', '5177', '--strictPort'], {
@@ -99,7 +88,7 @@ try {
if (message.type() === 'error') errors.push(message.text());
});
page.on('pageerror', (error) => errors.push(error.message));
- await page.goto(`http://127.0.0.1:5177/v1-msdf.html?backend=${expected}`, {
+ await page.goto(`http://127.0.0.1:5177/v1-mtsdf.html?backend=${expected}`, {
waitUntil: 'domcontentloaded',
});
const result = await page.evaluate(
@@ -162,53 +151,14 @@ try {
if (result.drawCount < 1 || result.glyphCount !== 16 || result.canonicalGreenPixels !== result.canonicalLitPixels)
throw new Error(`compose proof did not establish a canonical baseline: ${JSON.stringify(result)}`);
// Composing over the exported shader may repaint the glyphs but must not move or reshape them: an identical lit set
- // proves the custom program inherited the canonical position and coverage rather than reimplementing them.
+ // proves the custom material inherited the canonical position and coverage rather than reimplementing them.
if (result.litPixels !== result.canonicalLitPixels || result.redPixels !== result.canonicalLitPixels)
- throw new Error(`composed program did not reproduce the canonical coverage: ${JSON.stringify(result)}`);
+ throw new Error(`custom material did not reproduce the canonical coverage: ${JSON.stringify(result)}`);
if (result.greenPixels !== 0)
- throw new Error(`composed program did not apply its own final output: ${JSON.stringify(result)}`);
+ throw new Error(`custom material did not apply its own final output: ${JSON.stringify(result)}`);
process.stdout.write(`${expected} compose: ${JSON.stringify(result)}\n`);
await page.close();
}
- const asyncPage = await browser.newPage();
- const asyncErrors: string[] = [];
- asyncPage.on('console', (message) => {
- if (message.type() === 'error') asyncErrors.push(message.text());
- });
- asyncPage.on('pageerror', (error) => asyncErrors.push(error.message));
- await asyncPage.goto('http://127.0.0.1:5177/v1-async.html', { waitUntil: 'domcontentloaded' });
- const asyncEvaluation = await asyncPage.evaluate(() =>
- (window as typeof window & { targetV1AsyncReady: Promise }).targetV1AsyncReady.then(
- (value) => ({ ok: true as const, value }),
- (cause: unknown) => {
- const nested =
- typeof cause === 'object' && cause !== null && 'cause' in cause && cause.cause instanceof Error
- ? cause.cause
- : undefined;
- const error = cause instanceof Error ? cause : (nested ?? new Error(JSON.stringify(cause)));
- return { ok: false as const, error: { name: error.name, message: error.message, stack: error.stack } };
- },
- ),
- );
- if (asyncErrors.length !== 0) throw new Error(`async Worker browser errors: ${asyncErrors.join(' | ')}`);
- if (!asyncEvaluation.ok)
- throw new Error(
- `target-v1 async Worker rejected: ${asyncEvaluation.error.name}: ${asyncEvaluation.error.message}\n${asyncEvaluation.error.stack ?? ''}`,
- );
- const asyncResult = asyncEvaluation.value;
- if (
- asyncResult.status !== 'published' ||
- asyncResult.workerCount !== 1 ||
- asyncResult.glyphCount !== 11 ||
- asyncResult.progressEvents < 2 ||
- asyncResult.snapshotGlyphCount !== 8 ||
- asyncResult.desiredGlyphCount !== 22 ||
- !asyncResult.superseded ||
- !asyncResult.aborted
- )
- throw new Error(`target-v1 async Worker did not prepare the expected paragraph: ${JSON.stringify(asyncResult)}`);
- process.stdout.write(`worker: ${JSON.stringify(asyncResult)}\n`);
- await asyncPage.close();
} finally {
await browser.close();
server.kill('SIGTERM');
diff --git a/apps/benchmarks/scripts/workflow-output.mts b/apps/benchmarks/scripts/workflow-output.mts
new file mode 100644
index 00000000..e6bbea45
--- /dev/null
+++ b/apps/benchmarks/scripts/workflow-output.mts
@@ -0,0 +1,6 @@
+const VITEXEC_FAILURE = /(?:^|\n)\[(?:error|page error)\]/;
+
+/** Vitexec 0.1.17 reports injected-module failures as browser logs while exiting successfully. */
+export function hasVitexecFailure(output: string): boolean {
+ return VITEXEC_FAILURE.test(output);
+}
diff --git a/apps/benchmarks/scripts/workflows.mts b/apps/benchmarks/scripts/workflows.mts
index 067cb8bd..bafc9720 100644
--- a/apps/benchmarks/scripts/workflows.mts
+++ b/apps/benchmarks/scripts/workflows.mts
@@ -3,6 +3,8 @@ import { readdir, readFile } from 'node:fs/promises';
import { dirname, extname, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
+import { hasVitexecFailure } from './workflow-output.mts';
+
interface Workflow {
readonly args?: readonly string[];
readonly name: string;
@@ -146,11 +148,28 @@ async function runWorkflow(workflow: IndexedWorkflow, extraArguments: readonly s
? [file, ...(workflow.args ?? []), ...extraArguments]
: [...(workflow.args ?? []), file, ...extraArguments];
await new Promise((resolveRun, reject) => {
- const child = spawn(executable, commandArguments, { cwd: workflow.cwd, stdio: 'inherit' });
+ const captureVitexec = runner === 'vitexec';
+ const child = spawn(executable, commandArguments, {
+ cwd: workflow.cwd,
+ stdio: captureVitexec ? ['inherit', 'pipe', 'pipe'] : 'inherit',
+ });
+ let output = '';
+ if (captureVitexec) {
+ child.stdout?.on('data', (chunk: Buffer) => {
+ output += chunk.toString();
+ process.stdout.write(chunk);
+ });
+ child.stderr?.on('data', (chunk: Buffer) => {
+ output += chunk.toString();
+ process.stderr.write(chunk);
+ });
+ }
child.once('error', reject);
child.once('close', (code) => {
- if (code === 0) resolveRun();
- else reject(new Error(`${workflow.name} exited with ${String(code)}`));
+ if (code !== 0) reject(new Error(`${workflow.name} exited with ${String(code)}`));
+ else if (captureVitexec && hasVitexecFailure(output))
+ reject(new Error(`${workflow.name} reported a browser error`));
+ else resolveRun();
});
});
}
diff --git a/apps/benchmarks/scripts/workflows.test.mts b/apps/benchmarks/scripts/workflows.test.mts
index f652f489..444bbdef 100644
--- a/apps/benchmarks/scripts/workflows.test.mts
+++ b/apps/benchmarks/scripts/workflows.test.mts
@@ -4,6 +4,8 @@ import { promisify } from 'node:util';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
+import { hasVitexecFailure } from './workflow-output.mts';
+
const execute = promisify(execFile);
const workflowScript = fileURLToPath(new URL('workflows.mts', import.meta.url));
@@ -24,3 +26,9 @@ test('describes requirements, writes, and source for one workflow', async () =>
assert.match(stdout, /Writes: Ignored browser caches only\./);
assert.match(stdout, /Source: apps\/benchmarks\/scripts\/run-presentation-workload-matrix\.mts/);
});
+
+test('treats Vitexec browser and injected-module errors as workflow failures', () => {
+ assert.equal(hasVitexecFailure('logs:\n[log] presentation-ready'), false);
+ assert.equal(hasVitexecFailure('logs:\n[error] injected probe failed'), true);
+ assert.equal(hasVitexecFailure('logs:\n[page error] renderer failed'), true);
+});
diff --git a/apps/benchmarks/size-entries/bitmap-baker.ts b/apps/benchmarks/size-entries/bitmap-baker.ts
index 01995897..b6c384a8 100644
--- a/apps/benchmarks/size-entries/bitmap-baker.ts
+++ b/apps/benchmarks/size-entries/bitmap-baker.ts
@@ -1,6 +1,6 @@
export {
+ bitmapBakerAbi,
createBitmapBaker,
createBitmapBakerFromInstance,
bitmapBakerFromCore,
- readBitmapBakerAbi,
} from '@pmndrs/text/bakers/bitmap';
diff --git a/apps/benchmarks/size-entries/mtsdf-baker.ts b/apps/benchmarks/size-entries/mtsdf-baker.ts
index 19b7442a..c9af6245 100644
--- a/apps/benchmarks/size-entries/mtsdf-baker.ts
+++ b/apps/benchmarks/size-entries/mtsdf-baker.ts
@@ -1,6 +1,6 @@
export {
createMsdfBaker,
createMsdfBakerFromInstance,
+ msdfBakerAbi,
msdfBakerFromCore,
- readMsdfBakerAbi,
} from '@pmndrs/text/bakers/msdf';
diff --git a/apps/benchmarks/size-entries/mtsdf-generator.ts b/apps/benchmarks/size-entries/mtsdf-generator.ts
index 13fa4aed..5ae84fae 100644
--- a/apps/benchmarks/size-entries/mtsdf-generator.ts
+++ b/apps/benchmarks/size-entries/mtsdf-generator.ts
@@ -2,5 +2,5 @@ export {
MtsdfGenerationError,
createMtsdfGenerator,
createMtsdfGeneratorFromInstance,
- readMtsdfGeneratorAbi,
+ mtsdfGeneratorAbi,
} from '../../../packages/text/dist/internal/mtsdf-generator.js';
diff --git a/apps/benchmarks/size-entries/slug-baker.ts b/apps/benchmarks/size-entries/slug-baker.ts
index 542cd12a..cf983517 100644
--- a/apps/benchmarks/size-entries/slug-baker.ts
+++ b/apps/benchmarks/size-entries/slug-baker.ts
@@ -1 +1 @@
-export { createSlugBaker, createSlugBakerFromInstance, readSlugBakerAbi, slugBaker } from '@pmndrs/text/bakers/slug';
+export { createSlugBaker, createSlugBakerFromInstance, slugBaker, slugBakerAbi } from '@pmndrs/text/bakers/slug';
diff --git a/apps/benchmarks/size-entries/text-shaper.ts b/apps/benchmarks/size-entries/text-shaper.ts
deleted file mode 100644
index 149a21c2..00000000
--- a/apps/benchmarks/size-entries/text-shaper.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { createRuntimeShaper } from '@pmndrs/text';
diff --git a/apps/benchmarks/size-entries/three-runtime.ts b/apps/benchmarks/size-entries/three-runtime.ts
new file mode 100644
index 00000000..0f7d768a
--- /dev/null
+++ b/apps/benchmarks/size-entries/three-runtime.ts
@@ -0,0 +1,4 @@
+export * from '@pmndrs/text/three';
+export { bitmap } from '@pmndrs/text/three/bitmap';
+export { msdf } from '@pmndrs/text/three/msdf';
+export { slug } from '@pmndrs/text/three/slug';
diff --git a/apps/benchmarks/src/benchmark/fixture-contracts.test.ts b/apps/benchmarks/src/benchmark/fixture-contracts.test.ts
index 436be2de..814323e7 100644
--- a/apps/benchmarks/src/benchmark/fixture-contracts.test.ts
+++ b/apps/benchmarks/src/benchmark/fixture-contracts.test.ts
@@ -4,11 +4,16 @@ import { createHash } from 'node:crypto';
import { describe, expect, it } from 'vitest';
const contractRoot = new URL('../../fixtures/contracts/', import.meta.url);
+const resultRoot = new URL('../../fixtures/results/', import.meta.url);
async function contract(name: string): Promise> {
return JSON.parse(await readFile(new URL(name, contractRoot), 'utf8'));
}
+async function result(name: string): Promise> {
+ return JSON.parse(await readFile(new URL(name, resultRoot), 'utf8'));
+}
+
describe('milestone-one fixture contracts', () => {
it('pins bitmap, paragraph, GLB, malformed-input, and GPU-readback source contracts', async () => {
const [bitmap, paragraph, glb, malformed, gpu] = await Promise.all([
@@ -91,4 +96,50 @@ describe('milestone-one fixture contracts', () => {
).toBe(true);
expect(record.capabilityClaim.gpuWorkload).toBe(false);
});
+
+ it('pins the exact TypeScript-to-Rust layout migration comparison', async () => {
+ const [baseline, bitmap, mtsdf, slug] = await Promise.all([
+ result('typescript-layout-baseline-90964be0-darwin-arm64.json'),
+ result('rust-layout-bitmap-0bdb9e93-darwin-arm64.json'),
+ result('rust-layout-mtsdf-0bdb9e93-darwin-arm64.json'),
+ result('rust-layout-slug-0bdb9e93-darwin-arm64.json'),
+ ]);
+ const rustRecords = [bitmap, mtsdf, slug];
+
+ expect(baseline).toMatchObject({
+ generatedBy: 'text:layout-benchmark',
+ reports: [
+ { name: 'cold', glyphs: 25_515, medianMs: 58.32441699999981 },
+ { name: 'font-size', glyphs: 25_515, medianMs: 12.087332999999944 },
+ { name: 'layout-width', glyphs: 25_515, medianMs: 9.152791999999863 },
+ { name: 'text', glyphs: 25_507, medianMs: 39.607874999999694 },
+ ],
+ });
+ expect(rustRecords.map(({ technique }) => technique)).toEqual(['bitmap', 'mtsdf', 'slug']);
+ expect(new Set(rustRecords.map(({ wasmSha256 }) => wasmSha256))).toEqual(
+ new Set(['f74f96a6214532271296c8165738d14f71c0642aca4af9050a0363aed2a4d576']),
+ );
+
+ const comparableCases = [
+ ['cold', 'cold'],
+ ['font-size', 'font-size'],
+ ['layout-width', 'column-resize'],
+ ['text', 'suffix-edit'],
+ ] as const;
+ for (const record of rustRecords) {
+ expect(record).toMatchObject({
+ schemaVersion: 0,
+ generatedBy: 'text:rust-layout-benchmark',
+ allocation: 'ordered',
+ glyphTarget: 22_000,
+ warmup: 8,
+ repetitions: 31,
+ });
+ for (const [baselineName, rustName] of comparableCases) {
+ const baselineReport = baseline.reports.find(({ name }: { name: string }) => name === baselineName);
+ const rustReport = record.reports.find(({ name }: { name: string }) => name === rustName);
+ expect(rustReport.medianMs).toBeLessThan(baselineReport.medianMs);
+ }
+ }
+ });
});
diff --git a/apps/benchmarks/src/benchmark/fixtures.test.ts b/apps/benchmarks/src/benchmark/fixtures.test.ts
index 4d36663c..f083348b 100644
--- a/apps/benchmarks/src/benchmark/fixtures.test.ts
+++ b/apps/benchmarks/src/benchmark/fixtures.test.ts
@@ -92,112 +92,6 @@ describe('canonical Inter fixtures', () => {
expect(image.byteLength).toBe(metadata.image.bytes);
expect(createHash('sha256').update(image).digest('hex')).toBe(metadata.image.sha256);
});
-
- it('records the browser shaping conformance and one-call memory evidence', async () => {
- const result = JSON.parse(
- await readFile(new URL('results/shaping-conformance-chromium149.json', fixtureRoot), 'utf8'),
- );
-
- expect(result).toMatchObject({
- schemaVersion: 0,
- targetId: 'harfrust-shaper',
- scenarioId: 'shaping-conformance',
- status: 'passed',
- controls: { dpr: 1, samples: 3, warmup: 1 },
- });
- expect(result.measurements).toHaveLength(3);
- expect(new Set(result.measurements.map(({ hash }: { hash: string }) => hash))).toEqual(new Set(['dc30c21c']));
- expect(
- result.measurements.every(
- ({ metrics }: { metrics: Record }) =>
- metrics.boundaryCrossings === 1 &&
- metrics.goldenCases === 8 &&
- metrics.glyphCount === 97 &&
- metrics.planCount === 3 &&
- metrics.retainedFontBytes === 171056,
- ),
- ).toBe(true);
- });
-
- it('records exact browser paragraph measurement with zero Wasm reflow calls', async () => {
- const result = JSON.parse(
- await readFile(new URL('results/paragraph-measurement-chromium149.json', fixtureRoot), 'utf8'),
- );
-
- expect(result).toMatchObject({
- targetId: 'paragraph-engine',
- scenarioId: 'paragraph-measurement',
- status: 'passed',
- outputBytes: 168,
- });
- expect(result.measurements).toHaveLength(3);
- expect(new Set(result.measurements.map(({ hash }: { hash: string }) => hash))).toEqual(new Set(['79874b9d']));
- expect(
- result.measurements.every(
- ({ metrics }: { metrics: Record }) =>
- metrics.shapeBoundaryCrossings === 1 &&
- metrics.reshapeBoundaryCrossings === 0 &&
- metrics.reflowBoundaryCrossings === 0 &&
- metrics.measurementCount === 3 &&
- metrics.positionedGlyphBytes === 0,
- ),
- ).toBe(true);
- });
-
- it('records exact browser positioned layouts and batched boundary reshaping', async () => {
- const result = JSON.parse(
- await readFile(new URL('results/paragraph-layout-chromium149.json', fixtureRoot), 'utf8'),
- );
-
- expect(result).toMatchObject({
- targetId: 'paragraph-layout-engine',
- scenarioId: 'paragraph-layout',
- status: 'passed',
- outputBytes: 3786,
- });
- expect(result.measurements).toHaveLength(3);
- expect(new Set(result.measurements.map(({ hash }: { hash: string }) => hash))).toEqual(
- new Set(['bb15bbcc:4f111a3f:e8c0e9d5']),
- );
- expect(
- result.measurements.every(
- ({ metrics }: { metrics: Record }) =>
- metrics.shapeBoundaryCrossings === 1 &&
- metrics.reshapeBoundaryCrossings === 2 &&
- metrics.batchedBoundaryLayouts === 2 &&
- metrics.layoutCount === 3 &&
- metrics.glyphCount === 165,
- ),
- ).toBe(true);
- });
-
- it('records exact browser bidi, policy, and current-uikit-shaped output', async () => {
- const result = JSON.parse(
- await readFile(new URL('results/paragraph-bidi-policy-chromium149.json', fixtureRoot), 'utf8'),
- );
- const expectedHash =
- '8859ef19:8d5b98a3:e492fa7d:19a5a03e:32f8722c:0691e0de:e492fa7d:0132eed7:0ddc10b5:0ddc10b5:00f73fd9:c1a7730c';
-
- expect(result).toMatchObject({
- targetId: 'paragraph-bidi-policy',
- scenarioId: 'paragraph-bidi-policy',
- status: 'passed',
- outputBytes: 8098,
- });
- expect(result.measurements).toHaveLength(3);
- expect(new Set(result.measurements.map(({ hash }: { hash: string }) => hash))).toEqual(new Set([expectedHash]));
- expect(
- result.measurements.every(
- ({ metrics }: { metrics: Record }) =>
- metrics.bidiLayoutCount === 2 &&
- metrics.policyLayoutCount === 9 &&
- metrics.uikitMeasurementCount === 25 &&
- metrics.uikitLayoutCount === 1 &&
- metrics.shapeBoundaryCrossings === 4 &&
- metrics.reshapeBoundaryCrossings === 5,
- ),
- ).toBe(true);
- });
});
describe('advanced-shaping result', () => {
@@ -393,43 +287,4 @@ describe('canonical Noto Sans CJK fixtures', () => {
);
expect(harfrust.cases).toEqual(harfbuzz.cases);
});
-
- it('records exact Chromium CJK shaping, layout, memory, and payload evidence', async () => {
- const result = JSON.parse(
- await readFile(new URL('results/cjk-universality-chromium149.json', fixtureRoot), 'utf8'),
- );
- const expectedHash =
- 'a1a833f2:fbe2aa07:922f9a2e:8c977f4d:85a2f640:fd42b9f7:53d8ec89:8cb3050c:bbfd039d:837a2b43:2f450f5e:9900b4af:c49f3e68';
-
- expect(result).toMatchObject({
- targetId: 'cjk-universality',
- scenarioId: 'cjk-universality',
- status: 'passed',
- outputBytes: 10622,
- environment: { webgpu: true },
- });
- expect(result.measurements).toHaveLength(3);
- expect(new Set(result.measurements.map(({ hash }: { hash: string }) => hash))).toEqual(new Set([expectedHash]));
- expect(
- result.measurements.every(
- ({ metrics }: { metrics: Record }) =>
- metrics.sourceUtf16Units === 208 &&
- metrics.corpusCaseCount === 13 &&
- metrics.corpusGlyphCount === 64 &&
- metrics.paragraphCaseCount === 4 &&
- metrics.layoutCount === 12 &&
- metrics.directShapeBoundaryCrossings === 1 &&
- metrics.paragraphShapeBoundaryCrossings === 4 &&
- metrics.reshapeBoundaryCrossings === 0 &&
- metrics.planCount === 8 &&
- metrics.retainedFontBytes === 1539372 &&
- metrics.wasmMemoryBytes === 4587520 &&
- metrics.sourceFontBytes === 16467736 &&
- metrics.artifactBytes === 1540480 &&
- metrics.shapingPayloadRawBytes === 1539372 &&
- metrics.shapingPayloadGzipBytes === 654925 &&
- metrics.shapingPayloadBrotliBytes === 514547,
- ),
- ).toBe(true);
- });
});
diff --git a/apps/benchmarks/src/benchmark/low-level/raster/slug-cpu-reference.test.ts b/apps/benchmarks/src/benchmark/low-level/raster/slug-cpu-reference.test.ts
index 94e54dcf..ea17078d 100644
--- a/apps/benchmarks/src/benchmark/low-level/raster/slug-cpu-reference.test.ts
+++ b/apps/benchmarks/src/benchmark/low-level/raster/slug-cpu-reference.test.ts
@@ -108,17 +108,6 @@ function squareData(): SlugData {
planeUnitsPerEm: 2048,
records,
pages: [page],
- bindings: [
- {
- page: 0,
- curveWidth: page.curveWidth,
- curveHeight: page.curveHeight,
- headerWidth: page.headerWidth,
- headerHeight: page.headerHeight,
- referenceWidth: page.referenceWidth,
- referenceHeight: page.referenceHeight,
- },
- ],
};
}
diff --git a/apps/benchmarks/src/benchmark/package-size-budgets.ts b/apps/benchmarks/src/benchmark/package-size-budgets.ts
index a5163c44..ae2cb4cf 100644
--- a/apps/benchmarks/src/benchmark/package-size-budgets.ts
+++ b/apps/benchmarks/src/benchmark/package-size-budgets.ts
@@ -23,17 +23,103 @@ export const packageSizeBudgets = {
gzipBytes: 3_200,
brotliBytes: 2_850,
},
- 'text-shaper-js': {
- rawBytes: 55_000,
- minifiedBytes: 38_500,
- gzipBytes: 10_500,
- brotliBytes: 9_500,
- },
+ // Complete Rust shaping, layout, policy, and command-plan publication. The aggregate ceilings below add only their
+ // independently measured JavaScript graph and leave narrow reviewed headroom for cross-architecture tool output.
'text-shaper-wasm': {
- rawBytes: 693_000,
- minifiedBytes: 693_000,
- gzipBytes: 259_000,
- brotliBytes: 203_000,
+ rawBytes: 1_165_000,
+ minifiedBytes: 1_165_000,
+ gzipBytes: 445_000,
+ brotliBytes: 350_000,
+ },
+ 'renderer-neutral-core-total': {
+ rawBytes: 1_265_000,
+ minifiedBytes: 1_235_000,
+ gzipBytes: 465_000,
+ brotliBytes: 367_000,
+ },
+ 'three-runtime-js': {
+ rawBytes: 350_000,
+ minifiedBytes: 232_000,
+ gzipBytes: 60_000,
+ brotliBytes: 51_000,
+ },
+ 'three-renderer-total': {
+ rawBytes: 1_515_000,
+ minifiedBytes: 1_395_000,
+ gzipBytes: 505_000,
+ brotliBytes: 400_000,
+ },
+ 'font-inter-bitmap-16-32': {
+ rawBytes: 3_200_000,
+ minifiedBytes: 3_200_000,
+ gzipBytes: 570_000,
+ brotliBytes: 430_000,
+ },
+ 'font-inter-mtsdf': {
+ rawBytes: 40_000_000,
+ minifiedBytes: 40_000_000,
+ gzipBytes: 7_000_000,
+ brotliBytes: 3_400_000,
+ },
+ 'font-inter-slug': {
+ rawBytes: 3_600_000,
+ minifiedBytes: 3_600_000,
+ gzipBytes: 650_000,
+ brotliBytes: 430_000,
+ },
+ 'font-icons-bitmap-16-32': {
+ rawBytes: 2_500_000,
+ minifiedBytes: 2_500_000,
+ gzipBytes: 470_000,
+ brotliBytes: 375_000,
+ },
+ 'font-icons-mtsdf': {
+ rawBytes: 33_000_000,
+ minifiedBytes: 33_000_000,
+ gzipBytes: 7_500_000,
+ brotliBytes: 3_500_000,
+ },
+ 'font-icons-slug': {
+ rawBytes: 3_100_000,
+ minifiedBytes: 3_100_000,
+ gzipBytes: 690_000,
+ brotliBytes: 510_000,
+ },
+ 'delivery-three-inter-bitmap': {
+ rawBytes: 4_700_000,
+ minifiedBytes: 4_600_000,
+ gzipBytes: 1_080_000,
+ brotliBytes: 830_000,
+ },
+ 'delivery-three-inter-mtsdf': {
+ rawBytes: 41_000_000,
+ minifiedBytes: 41_000_000,
+ gzipBytes: 7_500_000,
+ brotliBytes: 3_800_000,
+ },
+ 'delivery-three-inter-slug': {
+ rawBytes: 5_100_000,
+ minifiedBytes: 5_000_000,
+ gzipBytes: 1_150_000,
+ brotliBytes: 830_000,
+ },
+ 'delivery-three-icons-bitmap': {
+ rawBytes: 4_000_000,
+ minifiedBytes: 3_900_000,
+ gzipBytes: 970_000,
+ brotliBytes: 770_000,
+ },
+ 'delivery-three-icons-mtsdf': {
+ rawBytes: 35_000_000,
+ minifiedBytes: 35_000_000,
+ gzipBytes: 8_000_000,
+ brotliBytes: 3_900_000,
+ },
+ 'delivery-three-icons-slug': {
+ rawBytes: 4_600_000,
+ minifiedBytes: 4_500_000,
+ gzipBytes: 1_200_000,
+ brotliBytes: 910_000,
},
'bitmap-runtime-js': {
rawBytes: 425_000,
diff --git a/apps/benchmarks/src/benchmark/package-sizes.test.ts b/apps/benchmarks/src/benchmark/package-sizes.test.ts
index 32f8ff58..744791b8 100644
--- a/apps/benchmarks/src/benchmark/package-sizes.test.ts
+++ b/apps/benchmarks/src/benchmark/package-sizes.test.ts
@@ -26,8 +26,10 @@ describe('independent package-size report', () => {
'font-validator-js',
'runtime-baker-host-js',
'runtime-baker-worker-js',
- 'text-shaper-js',
'text-shaper-wasm',
+ 'renderer-neutral-core-total',
+ 'three-runtime-js',
+ 'three-renderer-total',
'bitmap-runtime-js',
'mtsdf-runtime-js',
'slug-runtime-js',
@@ -65,118 +67,38 @@ describe('independent package-size report', () => {
}
});
- it('bounds accumulated target-v1 growth from the pre-coverage baseline', () => {
- const coverageGrowth = {
- // These ceilings exist to keep feature work honest and to push back on duplication, not to model a delivery
- // constraint. Target-v1 spent the allowance on two consolidations rather than on new surface: one span cascade
- // replaced a style sweep plus seven per-property heaps and now serves both the shaping and paint layers, and the
- // Three Bitmap program regained the device-pixel snapping milestone 1 records as a hard contract.
- //
- // The browser-core allowance was raised once more for the layout tiering: 646 Brotli bytes bought a retained
- // layout session that stops a content-box change from re-preparing the paragraph, Unicode and bidi reuse across
- // any change that alters neither text nor base direction, positioning that writes typed arrays in place, and the
- // opt-in phase profiler that measures all of it. A resize went from 130.78ms to 33.72ms at 25,515 glyphs, so this
- // is bytes traded for time rather than new surface, and the raised ceiling keeps the same one-or-two-feature gap.
- //
- // Raised again for the structure-of-arrays cluster measurement and the pooled instance packing, then lowered
- // when the layout profiler came out once its evidence was recorded: that returned 3,026 raw and 253 Brotli,
- // mostly from its call sites rather than the module. A reflow lays out in 8.12ms at 25,515 glyphs against a
- // 110.40ms pre-optimization baseline, inside the 120Hz budget. The ceiling tracks what the tree actually
- // measures, so the next feature meets resistance rather than inherited slack.
- //
- // The three runtime baselines are re-derived against the tree with merged-v0 deleted, which shed roughly 215 KB
- // from each graph, so growth is once again measured from something that exists. browser-core keeps its original
- // pre-coverage baseline because deleting v0 did not move it: the root index never referenced v0, v0 re-exported
- // the root. Each ceiling leaves roughly one or two features of room and no more, so it starts pushing back soon
- // rather than quietly absorbing whatever lands next.
- 'browser-core': {
- rawBytes: { baseline: 324_269, maximumGrowth: 64_000 },
- minifiedBytes: { baseline: 247_205, maximumGrowth: 38_000 },
- gzipBytes: { baseline: 72_108, maximumGrowth: 10_500 },
- brotliBytes: { baseline: 55_251, maximumGrowth: 8_500 },
- },
- 'bitmap-baker-js': {
- rawBytes: { baseline: 17_478, maximumGrowth: 5_700 },
- minifiedBytes: { baseline: 11_682, maximumGrowth: 4_000 },
- gzipBytes: { baseline: 3_893, maximumGrowth: 900 },
- brotliBytes: { baseline: 3_448, maximumGrowth: 800 },
- },
- 'bitmap-baker-wasm': {
- rawBytes: { baseline: 606_995, maximumGrowth: 20_000 },
- minifiedBytes: { baseline: 606_995, maximumGrowth: 20_000 },
- gzipBytes: { baseline: 226_702, maximumGrowth: 8_100 },
- brotliBytes: { baseline: 173_552, maximumGrowth: 7_000 },
- },
- 'bitmap-runtime-js': {
- rawBytes: { baseline: 89_604, maximumGrowth: 12_000 },
- minifiedBytes: { baseline: 60_671, maximumGrowth: 7_000 },
- gzipBytes: { baseline: 16_081, maximumGrowth: 1_800 },
- brotliBytes: { baseline: 14_229, maximumGrowth: 1_500 },
- },
- 'mtsdf-baker-wasm': {
- rawBytes: { baseline: 534_709, maximumGrowth: 18_500 },
- minifiedBytes: { baseline: 534_709, maximumGrowth: 18_500 },
- gzipBytes: { baseline: 208_474, maximumGrowth: 6_700 },
- brotliBytes: { baseline: 163_570, maximumGrowth: 5_800 },
- },
- 'mtsdf-baker-js': {
- rawBytes: { baseline: 21_809, maximumGrowth: 5_200 },
- minifiedBytes: { baseline: 15_430, maximumGrowth: 3_800 },
- gzipBytes: { baseline: 4_701, maximumGrowth: 900 },
- brotliBytes: { baseline: 4_176, maximumGrowth: 800 },
- },
- 'mtsdf-runtime-js': {
- rawBytes: { baseline: 94_145, maximumGrowth: 12_000 },
- minifiedBytes: { baseline: 63_256, maximumGrowth: 7_000 },
- gzipBytes: { baseline: 16_777, maximumGrowth: 1_800 },
- brotliBytes: { baseline: 14_857, maximumGrowth: 1_500 },
- },
- } as const;
+ it('reports separately delivered JS and Wasm as exact consumer totals', () => {
+ const measured = new Map(report.entries.map((entry) => [entry.id, entry]));
const fields = ['rawBytes', 'minifiedBytes', 'gzipBytes', 'brotliBytes'] as const;
- for (const [id, expectation] of Object.entries(coverageGrowth)) {
- const entry = report.entries.find((candidate) => candidate.id === id);
- expect(entry?.status).toBe('measured');
- if (entry?.status !== 'measured') throw new Error(`Missing measured size entry: ${id}`);
- for (const field of fields) {
- const { baseline, maximumGrowth } = expectation[field];
- expect(entry[field] - baseline).toBeLessThanOrEqual(maximumGrowth);
+ for (const [aggregateId, javascriptId] of [
+ ['renderer-neutral-core-total', 'browser-core'],
+ ['three-renderer-total', 'three-runtime-js'],
+ ] as const) {
+ const aggregate = measured.get(aggregateId);
+ const javascript = measured.get(javascriptId);
+ const wasm = measured.get('text-shaper-wasm');
+ expect(aggregate?.format).toBe('aggregate');
+ if (aggregate === undefined || javascript === undefined || wasm === undefined) {
+ throw new Error(`Missing aggregate size inputs for ${aggregateId}`);
}
+ for (const field of fields) expect(aggregate[field]).toBe(javascript[field] + wasm[field]);
}
- });
- it('bounds retained-capacity growth from the warm-publication baseline', () => {
- const retainedCapacityGrowth = {
- 'bitmap-runtime-js': {
- baseline: { rawBytes: 89_604, minifiedBytes: 60_671, gzipBytes: 16_081, brotliBytes: 14_229 },
- // These ceilings were reviewed against a target-v1 that was missing two things it now carries. The Three
- // Bitmap program had no device-pixel snapping, which milestone 1 records as a hard density contract and
- // which is what makes this graph reproduce the pinned merged-v0 frame exactly. Spans resolved shaping and
- // paint through two unrelated mechanisms that disagreed, replaced by one containment cascade — a net cost,
- // since it deleted the previous style sweep and its per-property heaps.
- //
- // Baselines re-derived after merged-v0 deletion. Brotli stays the tightest of the four because it is what
- // ships to browsers. NOTE these three graphs are currently within three bytes of each other because /three
- // registers every built-in program at module scope; once technique-paired subpaths restore separation per
- // D-158 they will diverge again and these baselines need re-deriving once more.
- maximumGrowth: { rawBytes: 12_000, minifiedBytes: 7_000, gzipBytes: 1_800, brotliBytes: 1_500 },
- },
- 'mtsdf-runtime-js': {
- baseline: { rawBytes: 94_145, minifiedBytes: 63_256, gzipBytes: 16_777, brotliBytes: 14_857 },
- maximumGrowth: { rawBytes: 12_000, minifiedBytes: 7_000, gzipBytes: 1_800, brotliBytes: 1_500 },
- },
- 'slug-runtime-js': {
- baseline: { rawBytes: 79_169, minifiedBytes: 53_547, gzipBytes: 14_214, brotliBytes: 12_612 },
- maximumGrowth: { rawBytes: 12_000, minifiedBytes: 7_000, gzipBytes: 1_800, brotliBytes: 1_500 },
- },
- } as const;
- const fields = ['rawBytes', 'minifiedBytes', 'gzipBytes', 'brotliBytes'] as const;
- for (const [id, expectation] of Object.entries(retainedCapacityGrowth)) {
- const entry = report.entries.find((candidate) => candidate.id === id);
- expect(entry?.status).toBe('measured');
- if (entry?.status !== 'measured') throw new Error(`Missing measured size entry: ${id}`);
- for (const field of fields) {
- expect(entry[field] - expectation.baseline[field]).toBeLessThanOrEqual(expectation.maximumGrowth[field]);
+ for (const [aggregateId, assetId] of [
+ ['delivery-three-inter-bitmap', 'font-inter-bitmap-16-32'],
+ ['delivery-three-inter-mtsdf', 'font-inter-mtsdf'],
+ ['delivery-three-inter-slug', 'font-inter-slug'],
+ ['delivery-three-icons-bitmap', 'font-icons-bitmap-16-32'],
+ ['delivery-three-icons-mtsdf', 'font-icons-mtsdf'],
+ ['delivery-three-icons-slug', 'font-icons-slug'],
+ ] as const) {
+ const aggregate = measured.get(aggregateId);
+ const renderer = measured.get('three-renderer-total');
+ const asset = measured.get(assetId);
+ if (aggregate === undefined || renderer === undefined || asset === undefined) {
+ throw new Error(`Missing delivery size inputs for ${aggregateId}`);
}
+ for (const field of fields) expect(aggregate[field]).toBe(renderer[field] + asset[field]);
}
});
@@ -199,7 +121,7 @@ describe('independent package-size report', () => {
expect(unicode?.status).toBe('measured');
if (core?.status !== 'measured' || unicode?.status !== 'measured') return;
expect(unicode.minifiedBytes).toBeGreaterThan(0);
- expect(core.minifiedBytes).toBeGreaterThan(unicode.minifiedBytes);
+ expect(core.minifiedBytes).toBeLessThan(unicode.minifiedBytes);
});
it('keeps foreign-host native-tool variance inside complete reviewed budgets', () => {
@@ -210,7 +132,6 @@ describe('independent package-size report', () => {
'font-validator-js': [740_402, 584_255, 137_585, 112_927],
'runtime-baker-host-js': [5_264, 3_861, 1_480, 1_322],
'runtime-baker-worker-js': [13_315, 9_010, 3_030, 2_665],
- 'text-shaper-js': [43_944, 30_648, 8_798, 7_832],
'text-shaper-wasm': [692_111, 692_111, 258_524, 202_634],
'portable-baker-js': [10_046, 6_647, 2_338, 2_060],
'portable-baker-wasm': [433_755, 433_755, 168_266, 136_961],
diff --git a/apps/benchmarks/src/benchmark/paragraph-contract-corpus.ts b/apps/benchmarks/src/benchmark/paragraph-contract-corpus.ts
new file mode 100644
index 00000000..76d879f2
--- /dev/null
+++ b/apps/benchmarks/src/benchmark/paragraph-contract-corpus.ts
@@ -0,0 +1,7 @@
+import cjkContract from '../../fixtures/contracts/paragraph-cjk-layout-v0.json';
+
+export const paragraphCjkCoverageText = Object.values(cjkContract.cases)
+ .map(({ text }) => text)
+ .join('')
+ // Variation selectors shape beside their base scalar but do not name standalone cmap glyphs.
+ .replace(/[\u{FE00}-\u{FE0F}\u{E0100}-\u{E01EF}]/gu, '');
diff --git a/apps/benchmarks/src/benchmark/scenarios.ts b/apps/benchmarks/src/benchmark/scenarios.ts
index 14f59e0a..20ba1752 100644
--- a/apps/benchmarks/src/benchmark/scenarios.ts
+++ b/apps/benchmarks/src/benchmark/scenarios.ts
@@ -1,15 +1,5 @@
import type { BenchmarkScenario } from './contracts';
import { ADVANCED_SHAPING_CASES } from '../workloads/advanced-shaping/scene';
-import paragraphBidiContract from '../../fixtures/contracts/paragraph-bidi-layout-v0.json';
-import cjkContract from '../../fixtures/contracts/paragraph-cjk-layout-v0.json';
-import cjkManifest from '../../fixtures/fonts/noto-sans-cjk-2.004/manifest.json';
-import cjkOracle from '../../fixtures/shaping/noto-sans-cjk/harfrust.json';
-
-const paragraphPolicyHash = [
- ...Object.values(paragraphBidiContract.bidi).map(({ layout }) => layout.hash),
- ...Object.values(paragraphBidiContract.policies.cases).map(({ layout }) => layout.hash),
- paragraphBidiContract.uikit.resolved.layout.hash,
-].join(':');
const ADVANCED_SHAPING_HASH = '51ba1d14';
const UPDATED_EXTERNAL_RASTER_GLYPHS = 13;
@@ -318,110 +308,20 @@ function reactTextValidation(values: readonly import('./contracts').BenchmarkMea
return `${values.length}/${values.length} exact React Text reconciliations + R3F frames`;
}
-function shapingValidation(values: readonly import('./contracts').BenchmarkMeasurement[]): string {
- deterministicValidation(values.map((value) => value.hash));
- for (const value of values) {
- if (value.metrics?.boundaryCrossings !== 1 || value.metrics.goldenCases !== 8 || value.metrics.planCount !== 3) {
- throw new Error('Shaping sample did not preserve its call, corpus, and plan-cache contract');
- }
- }
- return `${values.length}/${values.length} exact corpus outputs · 1 Wasm call/sample`;
-}
-
-function paragraphValidation(values: readonly import('./contracts').BenchmarkMeasurement[]): string {
- deterministicValidation(values.map((value) => value.hash));
- for (const value of values) {
- if (
- value.metrics?.shapeBoundaryCrossings !== 1 ||
- value.metrics.reshapeBoundaryCrossings !== 0 ||
- value.metrics.reflowBoundaryCrossings !== 0 ||
- value.metrics.measurementCount !== 3 ||
- value.metrics.positionedGlyphBytes !== 0
- ) {
- throw new Error('Paragraph sample did not preserve its prepare-once, cached-reflow contract');
- }
- }
- return `${values.length}/${values.length} exact paragraph outputs · 0 Wasm reflow calls/sample`;
-}
-
-function paragraphLayoutValidation(values: readonly import('./contracts').BenchmarkMeasurement[]): string {
+function paragraphContractsValidation(values: readonly import('./contracts').BenchmarkMeasurement[]): string {
deterministicValidation(values.map((value) => value.hash));
for (const value of values) {
if (
- value.hash !== 'bb15bbcc:4f111a3f:e8c0e9d5' ||
- value.metrics?.shapeBoundaryCrossings !== 1 ||
- // Zero, because boundary reshaping requested the whole run as context and so returned the glyphs the retained
- // shape already held. The pinned hash above is unchanged by removing it, which is the proof.
- value.metrics.reshapeBoundaryCrossings !== 0 ||
- value.metrics.batchedBoundaryLayouts !== 2 ||
- value.metrics.layoutCount !== 3 ||
- value.metrics.glyphCount !== 165
- ) {
- throw new Error(
- `Paragraph layout sample did not preserve its exact SoA and batch contract: hash=${value.hash} shape=${String(value.metrics?.shapeBoundaryCrossings)} reshape=${String(value.metrics?.reshapeBoundaryCrossings)} batched=${String(value.metrics?.batchedBoundaryLayouts)} layouts=${String(value.metrics?.layoutCount)} glyphs=${String(value.metrics?.glyphCount)}`,
- );
- }
- }
- return `${values.length}/${values.length} exact positioned outputs · no reshape crossings`;
-}
-
-function paragraphPolicyValidation(values: readonly import('./contracts').BenchmarkMeasurement[]): string {
- deterministicValidation(values.map((value) => value.hash));
- for (const value of values) {
- if (
- value.hash !== paragraphPolicyHash ||
value.metrics?.bidiLayoutCount !== 2 ||
value.metrics.policyLayoutCount !== 9 ||
+ value.metrics.cjkLayoutCount !== 12 ||
value.metrics.uikitMeasurementCount !== 25 ||
- value.metrics.uikitLayoutCount !== 1 ||
- value.metrics.shapeBoundaryCrossings !== 4 ||
- value.metrics.reshapeBoundaryCrossings !== 0
- ) {
- throw new Error(
- `Paragraph policy sample did not preserve its bidi, policy, and uikit contract: hash=${value.hash} shape=${String(value.metrics?.shapeBoundaryCrossings)} reshape=${String(value.metrics?.reshapeBoundaryCrossings)}`,
- );
- }
- }
- return `${values.length}/${values.length} exact bidi/policy outputs · current-uikit-shaped flow`;
-}
-
-function cjkUniversalityValidation(values: readonly import('./contracts').BenchmarkMeasurement[]): string {
- deterministicValidation(values.map((value) => value.hash));
- const corpusGlyphCount = cjkOracle.cases.reduce((sum, fixture) => sum + fixture.glyphs.length, 0);
- const sourceUtf16Units =
- cjkOracle.cases.reduce((sum, fixture) => sum + fixture.text.length, 0) +
- Object.values(cjkContract.cases).reduce((sum, fixture) => sum + fixture.text.length, 0);
- for (const value of values) {
- const metrics = value.metrics;
- if (
- metrics?.sourceUtf16Units !== sourceUtf16Units ||
- metrics.corpusCaseCount !== cjkOracle.cases.length ||
- metrics.corpusGlyphCount !== corpusGlyphCount ||
- metrics.paragraphCaseCount !== Object.keys(cjkContract.cases).length ||
- metrics.layoutCount !== Object.keys(cjkContract.cases).length * 3 ||
- metrics.directShapeBoundaryCrossings !== 1 ||
- metrics.paragraphShapeBoundaryCrossings !== 4 ||
- metrics.reshapeBoundaryCrossings !== 0 ||
- metrics.retainedFontBytes !== cjkManifest.bake.expectedCore.transport.shapingPayload.rawBytes ||
- metrics.sourceFontBytes !== cjkManifest.source.fontBytes ||
- metrics.artifactBytes !== cjkManifest.bake.expectedCore.artifactBytes ||
- metrics.shapingPayloadRawBytes !== cjkManifest.bake.expectedCore.transport.shapingPayload.rawBytes ||
- metrics.shapingPayloadGzipBytes !== cjkManifest.bake.expectedCore.transport.shapingPayload.gzipBytes ||
- metrics.shapingPayloadBrotliBytes !== cjkManifest.bake.expectedCore.transport.shapingPayload.brotliBytes ||
- typeof metrics.planCount !== 'number' ||
- !Number.isFinite(metrics.planCount) ||
- metrics.planCount < 4 ||
- typeof metrics.wasmMemoryBytes !== 'number' ||
- !Number.isFinite(metrics.wasmMemoryBytes) ||
- metrics.wasmMemoryBytes <= 0 ||
- !finiteNonnegative(metrics.coldBakeMs) ||
- !finiteNonnegative(metrics.coldRegistrationMs) ||
- !finiteNonnegative(metrics.coldShaperInitializationMs)
+ value.metrics.uikitLayoutCount !== 1
) {
- throw new Error('CJK sample did not preserve its exact shaping, layout, and payload contract');
+ throw new Error('Rust paragraph contracts did not execute the complete retained matrix');
}
}
- return `${values.length}/${values.length} exact CJK corpus + horizontal paragraph outputs`;
+ return `${values.length}/${values.length} exact bidi, policy, uikit, and CJK contract matrices`;
}
function advancedShapingValidation(values: readonly import('./contracts').BenchmarkMeasurement[]): string {
@@ -459,7 +359,7 @@ function advancedShapingValidation(values: readonly import('./contracts').Benchm
* shaper's font selection; and the `.notdef` pin proves the fallback span is what resolved the Devanagari at all.
*/
const RICH_TEXT_SPAN_EVIDENCE = {
- hash: '87c41664',
+ hash: 'f73e324f',
glyphCount: 175,
renderedGlyphCount: 149,
drawCount: 7,
@@ -655,39 +555,11 @@ export const scenarios: readonly BenchmarkScenario[] = [
validate: (values) => deterministicValidation(values.map((value) => value.hash)),
},
{
- id: 'shaping-conformance',
- label: 'HarfRust shaping conformance',
- description: 'Eight pinned runs in one Wasm call with exact SoA output and cache accounting.',
- requiredCapabilities: new Set(['shaping', 'font-bytes', 'wasm']),
- validate: shapingValidation,
- },
- {
- id: 'paragraph-measurement',
- label: 'Paragraph measurement',
- description: 'Exact GLB-backed broad shape followed by cached wide and narrow reflow.',
- requiredCapabilities: new Set(['paragraph', 'shaping', 'font-bytes', 'wasm']),
- validate: paragraphValidation,
- },
- {
- id: 'paragraph-layout',
- label: 'Positioned paragraph layout',
- description: 'Exact natural, wide, and narrow SoA output with cached batched boundary reshape.',
- requiredCapabilities: new Set(['paragraph', 'shaping', 'font-bytes', 'wasm']),
- validate: paragraphLayoutValidation,
- },
- {
- id: 'paragraph-bidi-policy',
- label: 'Bidi + paragraph policies',
- description: 'Exact Amiri bidi, line policies, and current-uikit-shaped retained layout.',
- requiredCapabilities: new Set(['paragraph', 'shaping', 'font-bytes', 'wasm']),
- validate: paragraphPolicyValidation,
- },
- {
- id: 'cjk-universality',
- label: 'CJK universality',
- description: 'Exact pan-CJK source/reduced shaping and horizontal no-space reflow.',
+ id: 'paragraph-contracts',
+ label: 'Rust paragraph contracts',
+ description: 'Exact retained bidi, policy, uikit, and CJK layouts through public Text.',
requiredCapabilities: new Set(['paragraph', 'shaping', 'font-bytes', 'wasm']),
- validate: cjkUniversalityValidation,
+ validate: paragraphContractsValidation,
},
];
diff --git a/apps/benchmarks/src/benchmark/shaping-fixture.ts b/apps/benchmarks/src/benchmark/shaping-fixture.ts
deleted file mode 100644
index a0f4d703..00000000
--- a/apps/benchmarks/src/benchmark/shaping-fixture.ts
+++ /dev/null
@@ -1,149 +0,0 @@
-import type { RegisteredFont, ShapeBatchRequest, ShapedBatchViews } from '@pmndrs/text';
-
-export interface ShapingOracleCase {
- readonly id: string;
- readonly text: string;
- readonly segment: {
- readonly direction: 'ltr' | 'rtl';
- readonly script: string;
- readonly language: string;
- readonly features: readonly string[];
- };
- readonly glyphs: readonly {
- readonly glyphId: number;
- readonly cluster: number;
- readonly xAdvance: number;
- readonly yAdvance: number;
- readonly xOffset: number;
- readonly yOffset: number;
- readonly flags: number;
- }[];
-}
-
-export function shapingFixtureBatch(
- cases: readonly ShapingOracleCase[],
- font: RegisteredFont['handle'],
-): ShapeBatchRequest {
- const codeUnits: number[] = [];
- const features: { tag: string; value: number; start: number; end: number }[] = [];
- const runs: ShapeBatchRequest['runs'][number][] = [];
- for (const fixture of cases) {
- const start = codeUnits.length;
- for (let index = 0; index < fixture.text.length; index += 1) {
- codeUnits.push(fixture.text.charCodeAt(index));
- }
- const end = codeUnits.length;
- const featureStart = features.length;
- for (const source of fixture.segment.features) {
- const match = /^(.{4})(?:=(\d+))?$/.exec(source);
- if (match === null) throw new Error(`unsupported shaping fixture feature ${source}`);
- features.push({
- tag: match[1]!,
- value: match[2] === undefined ? 1 : Number(match[2]),
- start,
- end,
- });
- }
- runs.push({
- font,
- textStart: start,
- textEnd: end,
- direction: fixture.segment.direction,
- script: fixture.segment.script,
- language: fixture.segment.language,
- clusterLevel: 0,
- flags: 0x40,
- featureStart,
- featureCount: features.length - featureStart,
- });
- }
- return { textUtf16: Uint16Array.from(codeUnits), runs, features };
-}
-
-export function assertShapingFixture(
- shaped: ShapedBatchViews,
- font: RegisteredFont['handle'],
- cases: readonly ShapingOracleCase[],
-): void {
- exactArray('fontHandles', shaped.fontHandles, [font]);
- exactArray(
- 'runFontSlots',
- shaped.runFontSlots,
- cases.map(() => 0),
- );
- let glyphStart = 0;
- let textStart = 0;
- for (const [run, fixture] of cases.entries()) {
- if (shaped.runGlyphStarts[run] !== glyphStart) {
- throw new Error(`shaping fixture ${fixture.id} has an unexpected glyph start`);
- }
- if (shaped.runGlyphCounts[run] !== fixture.glyphs.length) {
- throw new Error(`shaping fixture ${fixture.id} has an unexpected glyph count`);
- }
- for (const [local, expected] of fixture.glyphs.entries()) {
- const glyph = glyphStart + local;
- exactValue(fixture.id, 'glyphId', local, shaped.glyphIds[glyph], expected.glyphId);
- exactValue(fixture.id, 'cluster', local, shaped.clusters[glyph], expected.cluster + textStart);
- exactValue(fixture.id, 'xAdvance', local, shaped.xAdvances[glyph], expected.xAdvance);
- exactValue(fixture.id, 'yAdvance', local, shaped.yAdvances[glyph], expected.yAdvance);
- exactValue(fixture.id, 'xOffset', local, shaped.xOffsets[glyph], expected.xOffset);
- exactValue(fixture.id, 'yOffset', local, shaped.yOffsets[glyph], expected.yOffset);
- exactValue(fixture.id, 'flags', local, shaped.glyphFlags[glyph], expected.flags);
- }
- glyphStart += fixture.glyphs.length;
- textStart += fixture.text.length;
- }
- if (glyphStart !== shaped.glyphIds.length) {
- throw new Error('shaping output contains trailing glyphs outside the pinned corpus');
- }
-}
-
-export function shapedFixtureBytes(shaped: ShapedBatchViews): number {
- return shapingArrays(shaped).reduce((sum, values) => sum + values.byteLength, 0);
-}
-
-export function hashShapedFixture(shaped: ShapedBatchViews): string {
- let hash = 2_166_136_261;
- for (const values of shapingArrays(shaped)) {
- hash = Math.imul(hash ^ values.length, 16_777_619);
- for (let index = 0; index < values.length; index += 1) {
- hash = Math.imul(hash ^ (values[index]! >>> 0), 16_777_619);
- }
- }
- return (hash >>> 0).toString(16).padStart(8, '0');
-}
-
-function shapingArrays(shaped: ShapedBatchViews): readonly (Uint16Array | Uint32Array | Int32Array)[] {
- return [
- shaped.fontHandles,
- shaped.runFontSlots,
- shaped.runGlyphStarts,
- shaped.runGlyphCounts,
- shaped.glyphIds,
- shaped.clusters,
- shaped.xAdvances,
- shaped.yAdvances,
- shaped.xOffsets,
- shaped.yOffsets,
- shaped.glyphFlags,
- ];
-}
-
-function exactArray(label: string, actual: ArrayLike, expected: readonly number[]): void {
- if (actual.length !== expected.length) throw new Error(`${label} length differs from its fixture`);
- for (let index = 0; index < expected.length; index += 1) {
- exactValue('batch', label, index, actual[index], expected[index]);
- }
-}
-
-function exactValue(
- fixture: string,
- field: string,
- index: number,
- actual: number | undefined,
- expected: number | undefined,
-): void {
- if (actual !== expected) {
- throw new Error(`shaping fixture ${fixture}.${field}[${index}] differs: ${String(actual)} !== ${String(expected)}`);
- }
-}
diff --git a/apps/benchmarks/src/benchmark/targets/conformance/advanced-shaping.ts b/apps/benchmarks/src/benchmark/targets/conformance/advanced-shaping.ts
index 75ff8241..63f6e444 100644
--- a/apps/benchmarks/src/benchmark/targets/conformance/advanced-shaping.ts
+++ b/apps/benchmarks/src/benchmark/targets/conformance/advanced-shaping.ts
@@ -138,7 +138,7 @@ export function createAdvancedShapingConformanceTarget(): BenchmarkTarget {
cause: text.error,
});
}
- const layout = text.layout;
+ const layout = text.inspectLayout();
if (layout === undefined) throw new Error(`${definition.id}:${frame.tick} has no layout`);
const rendered = renderedGlyphs(text);
const draws = bitmapDraws(text);
diff --git a/apps/benchmarks/src/benchmark/targets/conformance/cjk-universality.ts b/apps/benchmarks/src/benchmark/targets/conformance/cjk-universality.ts
deleted file mode 100644
index f967a3dc..00000000
--- a/apps/benchmarks/src/benchmark/targets/conformance/cjk-universality.ts
+++ /dev/null
@@ -1,213 +0,0 @@
-import {
- createParagraphEngine,
- createRuntimeShaper,
- FontRegistry,
- type LayoutParagraph as Paragraph,
- type ParagraphConstraints,
- type ParagraphLayout,
- type ParagraphStyle,
- type RegisteredFont,
- type RuntimeShaper,
- type ShapeBatchRequest,
-} from '@pmndrs/text';
-import cjkContract from '../../../../fixtures/contracts/paragraph-cjk-layout-v0.json';
-import cjkFontUrl from '../../../../fixtures/fonts/noto-sans-cjk-2.004/NotoSansCJKjp-Regular.otf?url';
-import cjkManifest from '../../../../fixtures/fonts/noto-sans-cjk-2.004/manifest.json';
-import cjkOracle from '../../../../fixtures/shaping/noto-sans-cjk/harfrust.json';
-import type { BenchmarkTarget } from '../../contracts';
-import { exactValue } from '../../exact-value';
-import { hashParagraphLayouts, paragraphLayoutContract } from '../../paragraph-layout-digest';
-import {
- assertShapingFixture,
- hashShapedFixture,
- shapedFixtureBytes,
- shapingFixtureBatch,
- type ShapingOracleCase,
-} from '../../shaping-fixture';
-import { loadDirectWasmDependencies } from '../shared/direct-wasm';
-
-interface ContractCase {
- readonly text: string;
- readonly style: ParagraphStyle;
- readonly layouts: Readonly>>>;
-}
-
-const oracleCases = cjkOracle.cases as readonly ShapingOracleCase[];
-const paragraphCases = Object.values(cjkContract.cases) as readonly ContractCase[];
-const paragraphConstraints = cjkContract.constraints as Readonly>;
-let state: CjkState | undefined;
-
-interface CjkState {
- readonly font: RegisteredFont;
- readonly shaper: RuntimeShaper;
- readonly paragraphs: readonly Paragraph[];
- readonly shapingRequest: ShapeBatchRequest;
- readonly calls: { shape: number; reshape: number };
- readonly cold: { bakeMs: number; registrationMs: number; shaperInitializationMs: number };
-}
-
-export const cjkUniversalityTarget: BenchmarkTarget = {
- id: 'cjk-universality',
- label: 'CJK universality',
- detail: 'Noto Sans CJK · exact shaping + horizontal paragraphs',
- color: 'cyan',
- capabilities: new Set(['deterministic', 'font-bytes', 'wasm', 'shaping', 'paragraph']),
- status: (input) => (input.fontBytes === undefined ? 'ready' : 'needs-fixture'),
- load: async () => {
- if (state !== undefined) return;
- const { bakerWasmUrl, createFontBaker, shaperWasmUrl } = await loadDirectWasmDependencies();
- const [bakerResponse, shaperResponse, fontResponse] = await Promise.all([
- fetch(bakerWasmUrl),
- fetch(shaperWasmUrl),
- fetch(cjkFontUrl),
- ]);
- for (const [label, response] of [
- ['font baker Wasm', bakerResponse],
- ['text shaper Wasm', shaperResponse],
- ['CJK font fixture', fontResponse],
- ] as const) {
- if (!response.ok) throw new Error(`unable to load ${label} (${response.status})`);
- }
- const [bakerWasm, shaperWasm, source] = await Promise.all([
- bakerResponse.arrayBuffer(),
- shaperResponse.arrayBuffer(),
- fontResponse.arrayBuffer(),
- ]);
- const baker = await createFontBaker(bakerWasm);
- const bakeStart = performance.now();
- const artifact = baker.bake({
- source: new Uint8Array(source),
- descriptor: { formatVersion: 0, fontFaceIndex: 0 },
- }).artifacts[0];
- const bakeMs = performance.now() - bakeStart;
- if (artifact === undefined || artifact.sha256 !== cjkManifest.bake.expectedCore.artifactSha256) {
- throw new Error('CJK bake differs from the authenticated artifact contract');
- }
- const registry = new FontRegistry();
- const registrationStart = performance.now();
- const font = await registry.registerAsset(artifact.bytes);
- const registrationMs = performance.now() - registrationStart;
- if (font.shapingHash !== cjkManifest.bake.expectedCore.shapingHash) {
- throw new Error('CJK registration differs from the authenticated shaping identity');
- }
- const shaperStart = performance.now();
- const runtime = await createRuntimeShaper({ registry, wasm: shaperWasm });
- runtime.registerFont(font);
- const shaperInitializationMs = performance.now() - shaperStart;
- const calls = { shape: 0, reshape: 0 };
- const engine = createParagraphEngine({ shaper: observeShaper(runtime, calls) });
- const paragraphs = paragraphCases.map((fixture) =>
- engine.create({ text: fixture.text, font: font.handle, style: fixture.style }),
- );
- state = {
- font,
- shaper: runtime,
- paragraphs,
- shapingRequest: shapingFixtureBatch(oracleCases, font.handle),
- calls,
- cold: { bakeMs, registrationMs, shaperInitializationMs },
- };
- },
- run: async () => {
- if (state === undefined) throw new Error('CJK universality target was not loaded');
- const shaped = state.shaper.shapeBatch(state.shapingRequest);
- assertShapingFixture(shaped, state.font.handle, oracleCases);
- const layouts: ParagraphLayout[] = [];
- for (const [caseIndex, fixture] of paragraphCases.entries()) {
- const paragraph = state.paragraphs[caseIndex];
- if (paragraph === undefined) throw new Error(`CJK paragraph ${caseIndex} is missing`);
- for (const [constraintId, constraints] of Object.entries(paragraphConstraints)) {
- const layout = paragraph.layout(constraints);
- assertExactContract(
- `${caseIndex}.${constraintId}`,
- paragraphLayoutContract(layout),
- fixture.layouts[constraintId],
- );
- layouts.push(layout);
- }
- }
- const memory = state.shaper.memoryReport();
- return {
- bytes: shapedFixtureBytes(shaped) + layouts.reduce((sum, layout) => sum + layoutBytes(layout), 0),
- hash: `${hashShapedFixture(shaped)}:${hashParagraphLayouts(layouts)}`,
- metrics: {
- sourceUtf16Units:
- oracleCases.reduce((sum, fixture) => sum + fixture.text.length, 0) +
- paragraphCases.reduce((sum, fixture) => sum + fixture.text.length, 0),
- corpusCaseCount: oracleCases.length,
- corpusGlyphCount: shaped.glyphIds.length,
- paragraphCaseCount: paragraphCases.length,
- layoutCount: layouts.length,
- directShapeBoundaryCrossings: 1,
- paragraphShapeBoundaryCrossings: state.calls.shape,
- reshapeBoundaryCrossings: state.calls.reshape,
- planCount: memory.planCount,
- retainedFontBytes: memory.retainedFontBytes,
- wasmMemoryBytes: memory.wasmMemoryBytes,
- sourceFontBytes: cjkManifest.source.fontBytes,
- artifactBytes: cjkManifest.bake.expectedCore.artifactBytes,
- shapingPayloadRawBytes: cjkManifest.bake.expectedCore.transport.shapingPayload.rawBytes,
- shapingPayloadGzipBytes: cjkManifest.bake.expectedCore.transport.shapingPayload.gzipBytes,
- shapingPayloadBrotliBytes: cjkManifest.bake.expectedCore.transport.shapingPayload.brotliBytes,
- coldBakeMs: state.cold.bakeMs,
- coldRegistrationMs: state.cold.registrationMs,
- coldShaperInitializationMs: state.cold.shaperInitializationMs,
- },
- };
- },
- dispose: async () => {
- if (state === undefined) return;
- for (const paragraph of state.paragraphs) paragraph.dispose();
- state.shaper.dispose();
- state.font.dispose();
- state = undefined;
- },
-};
-
-function observeShaper(runtime: RuntimeShaper, calls: { shape: number; reshape: number }): RuntimeShaper {
- return {
- registry: runtime.registry,
- registerFont: (font) => runtime.registerFont(font),
- disposeFont: (font) => runtime.disposeFont(font),
- analyzeBidi: (text, direction) => runtime.analyzeBidi(text, direction),
- shapeBatch: (request) => {
- calls.shape += 1;
- return runtime.shapeBatch(request);
- },
- reshapeRanges: (request) => {
- calls.reshape += 1;
- return runtime.reshapeRanges(request);
- },
- memoryReport: () => runtime.memoryReport(),
- dispose: () => runtime.dispose(),
- };
-}
-
-function assertExactContract(
- label: string,
- actual: Readonly>,
- expected: Readonly> | undefined,
-): void {
- if (expected === undefined || !exactValue(actual, expected)) {
- throw new Error(`${label} differs from the exact CJK paragraph contract`);
- }
-}
-
-function layoutBytes(layout: ParagraphLayout): number {
- return [
- layout.fontHandles,
- layout.glyphFontSlots,
- layout.glyphIds,
- layout.clusters,
- layout.glyphFontSizes,
- layout.x,
- layout.y,
- layout.glyphFlags,
- layout.lineTextStarts,
- layout.lineTextEnds,
- layout.lineGlyphStarts,
- layout.lineGlyphCounts,
- layout.lineBaselines,
- layout.lineAdvances,
- ].reduce((sum, values) => sum + values.byteLength, 0);
-}
diff --git a/apps/benchmarks/src/benchmark/targets/conformance/direct-runtime.ts b/apps/benchmarks/src/benchmark/targets/conformance/direct-runtime.ts
deleted file mode 100644
index ca636258..00000000
--- a/apps/benchmarks/src/benchmark/targets/conformance/direct-runtime.ts
+++ /dev/null
@@ -1,654 +0,0 @@
-import {
- createParagraphEngine,
- createRuntimeShaper,
- FontRegistry,
- type LayoutParagraph as Paragraph,
- type ParagraphConstraints,
- type ParagraphLayout,
- type ParagraphMeasurement,
- type ParagraphStyle,
- type RegisteredFont,
- type RuntimeShaper,
- type ShapeBatchRequest,
-} from '@pmndrs/text';
-import canonicalFontUrl from '../../../../fixtures/fonts/inter-v4.1/Inter-Regular.ttf?url';
-import amiriFontUrl from '../../../../fixtures/fonts/amiri-1.002/Amiri-Regular.ttf?url';
-import canonicalParagraphLayout from '../../../../fixtures/contracts/paragraph-layout-v0.json';
-import canonicalShapingOracle from '../../../../fixtures/shaping/inter-regular/harfrust.json';
-import paragraphBidiContract from '../../../../fixtures/contracts/paragraph-bidi-layout-v0.json';
-import type { BenchmarkTarget } from '../../contracts';
-import { exactValue as exactJsonValue } from '../../exact-value';
-import { hashParagraphLayout, hashParagraphLayouts, paragraphLayoutBytes } from '../../paragraph-layout-digest';
-import {
- assertShapingFixture,
- hashShapedFixture,
- shapedFixtureBytes,
- shapingFixtureBatch,
- type ShapingOracleCase,
-} from '../../shaping-fixture';
-import { createUikitLayoutFixture, YogaMeasureMode } from '../../uikit-layout-fixture';
-import { loadDirectWasmDependencies } from '../shared/direct-wasm';
-
-const shapingCases = canonicalShapingOracle.cases as readonly ShapingOracleCase[];
-let runtimeShaper: RuntimeShaper | undefined;
-let runtimeShaperFont: RegisteredFont | undefined;
-let runtimeShapingRequest: ShapeBatchRequest | undefined;
-let runtimeShaperColdStartMs = 0;
-
-const harfrustShaperTarget: BenchmarkTarget = {
- id: 'harfrust-shaper',
- label: 'HarfRust Wasm shaper',
- detail: 'validated GLB · 8 golden runs · 1 coarse call',
- color: 'amber',
- capabilities: new Set(['deterministic', 'font-bytes', 'wasm', 'shaping']),
- status: (input) => (input.fontBytes === undefined ? 'ready' : 'needs-fixture'),
- load: async () => {
- if (runtimeShaper !== undefined && runtimeShaperFont !== undefined && runtimeShapingRequest !== undefined) return;
- const { bakerWasmUrl, createFontBaker, shaperWasmUrl } = await loadDirectWasmDependencies();
- const [bakerResponse, shaperResponse, fontResponse] = await Promise.all([
- fetch(bakerWasmUrl),
- fetch(shaperWasmUrl),
- fetch(canonicalFontUrl),
- ]);
- if (!bakerResponse.ok) throw new Error(`Unable to load font baker Wasm (${bakerResponse.status})`);
- if (!shaperResponse.ok) throw new Error(`Unable to load text shaper Wasm (${shaperResponse.status})`);
- if (!fontResponse.ok) throw new Error(`Unable to load canonical font fixture (${fontResponse.status})`);
- const [bakerWasm, shaperWasm, source] = await Promise.all([
- bakerResponse.arrayBuffer(),
- shaperResponse.arrayBuffer(),
- fontResponse.arrayBuffer(),
- ]);
- const directBaker = await createFontBaker(bakerWasm);
- const baked = directBaker.bake({
- source: new Uint8Array(source),
- descriptor: { formatVersion: 0, fontFaceIndex: 0 },
- });
- const artifact = baked.artifacts[0];
- if (artifact === undefined) throw new Error('Font baker returned no shaping artifact');
- const registry = new FontRegistry();
- const font = await registry.registerAsset(artifact.bytes);
- const coldStart = performance.now();
- const shaper = await createRuntimeShaper({ registry, wasm: shaperWasm });
- shaper.registerFont(font);
- runtimeShaperColdStartMs = performance.now() - coldStart;
- runtimeShaper = shaper;
- runtimeShaperFont = font;
- runtimeShapingRequest = shapingFixtureBatch(shapingCases, font.handle);
- },
- run: async () => {
- if (runtimeShaper === undefined || runtimeShaperFont === undefined || runtimeShapingRequest === undefined) {
- throw new Error('HarfRust shaper target was not loaded');
- }
- const shapeStart = performance.now();
- const shaped = runtimeShaper.shapeBatch(runtimeShapingRequest);
- const shapeCallMs = performance.now() - shapeStart;
- assertShapingFixture(shaped, runtimeShaperFont.handle, shapingCases);
- const memory = runtimeShaper.memoryReport();
- return {
- bytes: shapedFixtureBytes(shaped),
- hash: hashShapedFixture(shaped),
- metrics: {
- boundaryCrossings: 1,
- coldStartMs: runtimeShaperColdStartMs,
- shapeCallMs,
- goldenCases: shapingCases.length,
- glyphCount: shaped.glyphIds.length,
- planCount: memory.planCount,
- retainedFontBytes: memory.retainedFontBytes,
- wasmMemoryBytes: memory.wasmMemoryBytes,
- },
- };
- },
- dispose: async () => {
- runtimeShaper?.dispose();
- runtimeShaperFont?.dispose();
- runtimeShaper = undefined;
- runtimeShaperFont = undefined;
- runtimeShapingRequest = undefined;
- runtimeShaperColdStartMs = 0;
- },
-};
-
-const paragraphGolden = {
- natural: canonicalParagraphLayout.goldens.natural.measurement,
- wide: canonicalParagraphLayout.goldens.wide.measurement,
- narrow: canonicalParagraphLayout.goldens.narrow.measurement,
-};
-const paragraphLayoutGolden = {
- natural: canonicalParagraphLayout.goldens.natural.layout,
- wide: canonicalParagraphLayout.goldens.wide.layout,
- narrow: canonicalParagraphLayout.goldens.narrow.layout,
-};
-
-let paragraphShaper: RuntimeShaper | undefined;
-let paragraphFont: RegisteredFont | undefined;
-let measuredParagraph: Paragraph | undefined;
-let paragraphShapeCalls = 0;
-let paragraphReshapeCalls = 0;
-
-async function loadParagraphFixture(): Promise {
- if (paragraphShaper !== undefined && paragraphFont !== undefined && measuredParagraph !== undefined) return;
- const { bakerWasmUrl, createFontBaker, shaperWasmUrl } = await loadDirectWasmDependencies();
- const [bakerResponse, shaperResponse, fontResponse] = await Promise.all([
- fetch(bakerWasmUrl),
- fetch(shaperWasmUrl),
- fetch(canonicalFontUrl),
- ]);
- if (!bakerResponse.ok) throw new Error(`Unable to load font baker Wasm (${bakerResponse.status})`);
- if (!shaperResponse.ok) throw new Error(`Unable to load text shaper Wasm (${shaperResponse.status})`);
- if (!fontResponse.ok) throw new Error(`Unable to load canonical font fixture (${fontResponse.status})`);
- const [bakerWasm, shaperWasm, source] = await Promise.all([
- bakerResponse.arrayBuffer(),
- shaperResponse.arrayBuffer(),
- fontResponse.arrayBuffer(),
- ]);
- const directBaker = await createFontBaker(bakerWasm);
- const artifact = directBaker.bake({
- source: new Uint8Array(source),
- descriptor: { formatVersion: 0, fontFaceIndex: 0 },
- }).artifacts[0];
- if (artifact === undefined) throw new Error('Font baker returned no paragraph artifact');
- const registry = new FontRegistry();
- const font = await registry.registerAsset(artifact.bytes);
- const shaper = await createRuntimeShaper({ registry, wasm: shaperWasm });
- const observedShaper: RuntimeShaper = {
- registry: shaper.registry,
- registerFont: (registered) => shaper.registerFont(registered),
- disposeFont: (registered) => shaper.disposeFont(registered),
- analyzeBidi: (text, direction) => shaper.analyzeBidi(text, direction),
- shapeBatch: (request) => {
- paragraphShapeCalls += 1;
- return shaper.shapeBatch(request);
- },
- reshapeRanges: (request) => {
- paragraphReshapeCalls += 1;
- return shaper.reshapeRanges(request);
- },
- memoryReport: () => shaper.memoryReport(),
- dispose: () => shaper.dispose(),
- };
- const fixture = shapingCases.find(({ id }) => id === 'paragraph');
- if (fixture === undefined) throw new Error('Canonical paragraph shaping fixture is missing');
- const expectedNaturalWidth =
- (fixture.glyphs.reduce((sum, glyph) => sum + glyph.xAdvance, 0) * 32) / font.metrics.unitsPerEm;
- if (expectedNaturalWidth !== paragraphGolden.natural.width) {
- throw new Error('Paragraph width golden is not derived from the pinned HarfRust advances');
- }
- const paragraph = createParagraphEngine({ shaper: observedShaper }).create({
- text: fixture.text,
- font: font.handle,
- style: {
- fontSize: 32,
- lineHeight: 1.3,
- language: 'en',
- direction: 'ltr',
- features: [],
- },
- });
- paragraphShaper = shaper;
- paragraphFont = font;
- measuredParagraph = paragraph;
-}
-
-async function disposeParagraphFixture(): Promise {
- measuredParagraph?.dispose();
- paragraphShaper?.dispose();
- paragraphFont?.dispose();
- measuredParagraph = undefined;
- paragraphShaper = undefined;
- paragraphFont = undefined;
- paragraphShapeCalls = 0;
- paragraphReshapeCalls = 0;
-}
-
-const paragraphTarget: BenchmarkTarget = {
- id: 'paragraph-engine',
- label: 'JavaScript paragraph engine',
- detail: 'validated GLB · exact HarfRust widths · cached reflow',
- color: 'violet',
- capabilities: new Set(['deterministic', 'font-bytes', 'wasm', 'shaping', 'paragraph']),
- status: (input) => (input.fontBytes === undefined ? 'ready' : 'needs-fixture'),
- load: loadParagraphFixture,
- run: async () => {
- if (measuredParagraph === undefined) throw new Error('Paragraph target was not loaded');
- const shapeCalls = paragraphShapeCalls;
- const reshapeCalls = paragraphReshapeCalls;
- const natural = measuredParagraph.measure();
- const wideConstraints = { width: { mode: 'at-most' as const, size: 720 } };
- const wide = measuredParagraph.measure(wideConstraints);
- const cachedWide = measuredParagraph.measure(wideConstraints);
- const narrow = measuredParagraph.measure({ width: { mode: 'at-most', size: 360 } });
- exactMeasurement('natural', natural, paragraphGolden.natural);
- exactMeasurement('wide', wide, paragraphGolden.wide);
- exactMeasurement('narrow', narrow, paragraphGolden.narrow);
- if (cachedWide !== wide) throw new Error('Equivalent paragraph constraints missed the cache');
- if (paragraphShapeCalls !== shapeCalls || paragraphReshapeCalls !== reshapeCalls) {
- throw new Error('Width-only paragraph reflow crossed the Wasm boundary');
- }
- return {
- bytes: 3 * 7 * Float64Array.BYTES_PER_ELEMENT,
- hash: hashMeasurements([natural, wide, narrow]),
- metrics: {
- measurementCount: 3,
- positionedGlyphBytes: 0,
- reflowBoundaryCrossings: 0,
- reshapeBoundaryCrossings: paragraphReshapeCalls,
- shapeBoundaryCrossings: paragraphShapeCalls,
- },
- };
- },
- dispose: disposeParagraphFixture,
-};
-
-const paragraphLayoutTarget: BenchmarkTarget = {
- id: 'paragraph-layout-engine',
- label: 'Positioned paragraph engine',
- detail: 'validated GLB · exact SoA · batched boundary reshape',
- color: 'cyan',
- capabilities: new Set(['deterministic', 'font-bytes', 'wasm', 'shaping', 'paragraph']),
- status: (input) => (input.fontBytes === undefined ? 'ready' : 'needs-fixture'),
- load: loadParagraphFixture,
- run: async () => {
- if (measuredParagraph === undefined || paragraphFont === undefined) {
- throw new Error('Paragraph layout target was not loaded');
- }
- const natural = measuredParagraph.layout();
- const wideConstraints = { width: { mode: 'at-most' as const, size: 720 } };
- const wide = measuredParagraph.layout(wideConstraints);
- const cachedWide = measuredParagraph.layout(wideConstraints);
- const narrow = measuredParagraph.layout({ width: { mode: 'at-most', size: 360 } });
- if (cachedWide !== wide) throw new Error('Equivalent positioned constraints missed the cache');
- exactParagraphLayout('natural', natural, paragraphLayoutGolden.natural, paragraphFont.handle);
- exactParagraphLayout('wide', wide, paragraphLayoutGolden.wide, paragraphFont.handle);
- exactParagraphLayout('narrow', narrow, paragraphLayoutGolden.narrow, paragraphFont.handle);
- return {
- bytes: paragraphLayoutBytes(natural) + paragraphLayoutBytes(wide) + paragraphLayoutBytes(narrow),
- hash: hashParagraphLayouts([natural, wide, narrow]),
- metrics: {
- batchedBoundaryLayouts: 2,
- glyphCount: natural.glyphIds.length + wide.glyphIds.length + narrow.glyphIds.length,
- layoutCount: 3,
- reshapeBoundaryCrossings: paragraphReshapeCalls,
- shapeBoundaryCrossings: paragraphShapeCalls,
- },
- };
- },
- dispose: disposeParagraphFixture,
-};
-
-interface ContractLayout {
- readonly measurement: ParagraphMeasurement;
- readonly hash: string;
- readonly glyphFontSlots?: readonly number[];
- readonly glyphIds: readonly number[];
- readonly clusters: readonly number[];
- readonly glyphFontSizes?: readonly number[];
- readonly x: readonly number[];
- readonly y?: readonly number[];
- readonly glyphFlags?: readonly number[];
- readonly lineTextStarts: readonly number[];
- readonly lineTextEnds: readonly number[];
- readonly lineGlyphStarts: readonly number[];
- readonly lineGlyphCounts: readonly number[];
- readonly lineBaselines: readonly number[];
- readonly lineAdvances: readonly number[];
-}
-
-let policyShaper: RuntimeShaper | undefined;
-let policyFonts: readonly RegisteredFont[] = [];
-let bidiParagraphs: readonly Paragraph[] = [];
-let policyParagraph: Paragraph | undefined;
-let uikitParagraph: Paragraph | undefined;
-let policyShapeCalls = 0;
-let policyReshapeCalls = 0;
-
-async function loadParagraphPolicyFixture(): Promise {
- if (policyShaper !== undefined) return;
- const { bakerWasmUrl, createFontBaker, shaperWasmUrl } = await loadDirectWasmDependencies();
- const [bakerResponse, shaperResponse, interResponse, amiriResponse] = await Promise.all([
- fetch(bakerWasmUrl),
- fetch(shaperWasmUrl),
- fetch(canonicalFontUrl),
- fetch(amiriFontUrl),
- ]);
- for (const [label, response] of [
- ['font baker Wasm', bakerResponse],
- ['text shaper Wasm', shaperResponse],
- ['Inter fixture', interResponse],
- ['Amiri fixture', amiriResponse],
- ] as const) {
- if (!response.ok) throw new Error(`Unable to load ${label} (${response.status})`);
- }
- const [bakerBytes, shaperBytes, interSource, amiriSource] = await Promise.all([
- bakerResponse.arrayBuffer(),
- shaperResponse.arrayBuffer(),
- interResponse.arrayBuffer(),
- amiriResponse.arrayBuffer(),
- ]);
- const directBaker = await createFontBaker(bakerBytes);
- const bakeArtifact = (source: ArrayBuffer) => {
- const artifact = directBaker.bake({
- source: new Uint8Array(source),
- descriptor: { formatVersion: 0, fontFaceIndex: 0 },
- }).artifacts[0];
- if (artifact === undefined) throw new Error('Font baker returned no paragraph policy artifact');
- return artifact;
- };
- const interArtifact = bakeArtifact(interSource);
- const amiriArtifact = bakeArtifact(amiriSource);
- const registry = new FontRegistry();
- const [inter, amiri] = await Promise.all([
- registry.registerAsset(interArtifact.bytes),
- registry.registerAsset(amiriArtifact.bytes),
- ]);
- if (
- inter.shapingHash !== paragraphBidiContract.fonts.inter.shapingHash ||
- amiri.shapingHash !== paragraphBidiContract.fonts.amiri.shapingHash
- ) {
- throw new Error('Paragraph policy fixtures retained unexpected shaping identities');
- }
- const shaper = await createRuntimeShaper({ registry, wasm: shaperBytes });
- const observed: RuntimeShaper = {
- registry,
- registerFont: (font) => shaper.registerFont(font),
- disposeFont: (font) => shaper.disposeFont(font),
- analyzeBidi: (text, direction) => shaper.analyzeBidi(text, direction),
- shapeBatch: (request) => {
- policyShapeCalls += 1;
- return shaper.shapeBatch(request);
- },
- reshapeRanges: (request) => {
- policyReshapeCalls += 1;
- return shaper.reshapeRanges(request);
- },
- memoryReport: () => shaper.memoryReport(),
- dispose: () => shaper.dispose(),
- };
- const engine = createParagraphEngine({ shaper: observed });
- bidiParagraphs = Object.values(paragraphBidiContract.bidi).map((fixture) =>
- engine.create({
- text: fixture.text,
- font: amiri.handle,
- style: fixture.style as ParagraphStyle,
- }),
- );
- policyParagraph = engine.create({
- text: paragraphBidiContract.policies.text,
- font: inter.handle,
- style: paragraphBidiContract.policies.style as ParagraphStyle,
- });
- uikitParagraph = engine.create({
- text: paragraphBidiContract.uikit.input.text,
- font: inter.handle,
- style: paragraphBidiContract.uikit.input.style as ParagraphStyle,
- });
- policyShaper = shaper;
- policyFonts = [inter, amiri];
-}
-
-async function disposeParagraphPolicyFixture(): Promise {
- for (const paragraph of [...bidiParagraphs, policyParagraph, uikitParagraph]) paragraph?.dispose();
- policyShaper?.dispose();
- for (const font of policyFonts) font.dispose();
- policyShaper = undefined;
- policyFonts = [];
- bidiParagraphs = [];
- policyParagraph = undefined;
- uikitParagraph = undefined;
- policyShapeCalls = 0;
- policyReshapeCalls = 0;
-}
-
-const paragraphPolicyTarget: BenchmarkTarget = {
- id: 'paragraph-bidi-policy',
- label: 'Bidi + paragraph policies',
- detail: 'Amiri GLB · UAX #9 · alignment · truncation · uikit seam',
- color: 'amber',
- capabilities: new Set(['deterministic', 'font-bytes', 'wasm', 'shaping', 'paragraph']),
- status: (input) => (input.fontBytes === undefined ? 'ready' : 'needs-fixture'),
- load: loadParagraphPolicyFixture,
- run: async () => {
- if (policyParagraph === undefined || uikitParagraph === undefined) {
- throw new Error('Paragraph policy target was not loaded');
- }
- const layouts: ParagraphLayout[] = [];
- for (const [index, fixture] of Object.values(paragraphBidiContract.bidi).entries()) {
- const layout = bidiParagraphs[index]?.layout(fixture.constraints as ParagraphConstraints);
- if (layout === undefined) throw new Error('Bidi paragraph fixture is missing');
- exactContractLayout(`bidi.${index}`, layout, fixture.layout);
- layouts.push(layout);
- }
- for (const [id, fixture] of Object.entries(paragraphBidiContract.policies.cases)) {
- const layout = policyParagraph.layout(fixture.constraints as ParagraphConstraints);
- exactContractLayout(`policy.${id}`, layout, fixture.layout);
- layouts.push(layout);
- }
-
- const uikit = createUikitLayoutFixture(uikitParagraph, paragraphBidiContract.uikit.policy as ParagraphConstraints);
- const custom = uikit.customLayouting();
- exactObject(
- 'uikit.customLayouting',
- {
- minWidth: custom.minWidth,
- minHeight: custom.minHeight,
- firstBaseline: custom.firstBaseline,
- },
- paragraphBidiContract.uikit.customLayouting,
- );
- const natural = custom.measure(NaN, YogaMeasureMode.Undefined, NaN, YogaMeasureMode.Undefined);
- const atMost = custom.measure(360, YogaMeasureMode.AtMost, 90, YogaMeasureMode.AtMost);
- const exactWidth = custom.measure(420.001, YogaMeasureMode.Exactly, NaN, YogaMeasureMode.Undefined);
- for (let index = 0; index < 20; index += 1) {
- exactObject(
- 'uikit.repeatedAtMost',
- custom.measure(360, YogaMeasureMode.AtMost, 90, YogaMeasureMode.AtMost),
- paragraphBidiContract.uikit.measurements.atMost,
- );
- }
- exactObject('uikit.natural', natural, paragraphBidiContract.uikit.measurements.natural);
- exactObject('uikit.atMost', atMost, paragraphBidiContract.uikit.measurements.atMost);
- exactObject('uikit.exactWidth', exactWidth, paragraphBidiContract.uikit.measurements.exactWidth);
- exactObject(
- 'uikit.definite',
- uikit.resolveYogaLeaf(401.237, YogaMeasureMode.Exactly, 150.111, YogaMeasureMode.Exactly),
- paragraphBidiContract.uikit.measurements.definite,
- );
- if (uikit.calls.layout !== 0) throw new Error('uikit measurement materialized glyph arrays');
- const resolved = uikit.layoutResolvedBox(
- paragraphBidiContract.uikit.resolved.outerSize as unknown as readonly [number, number],
- paragraphBidiContract.uikit.resolved.padding as unknown as readonly [number, number, number, number],
- paragraphBidiContract.uikit.resolved.border as unknown as readonly [number, number, number, number],
- );
- exactObject('uikit.contentBox', resolved.contentBox, paragraphBidiContract.uikit.resolved.contentBox);
- exactArray('uikit.centeredX', resolved.centeredX, paragraphBidiContract.uikit.resolved.centeredX);
- exactArray('uikit.centeredY', resolved.centeredY, paragraphBidiContract.uikit.resolved.centeredY);
- exactContractLayout('uikit.layout', resolved.layout, paragraphBidiContract.uikit.resolved.layout);
- layouts.push(resolved.layout);
-
- return {
- bytes:
- layouts.reduce((sum, layout) => sum + paragraphLayoutBytes(layout), 0) +
- resolved.centeredX.byteLength +
- resolved.centeredY.byteLength,
- hash: hashParagraphLayouts(layouts),
- metrics: {
- bidiLayoutCount: 2,
- policyLayoutCount: 9,
- uikitMeasurementCount: uikit.calls.measure,
- uikitLayoutCount: uikit.calls.layout,
- shapeBoundaryCrossings: policyShapeCalls,
- reshapeBoundaryCrossings: policyReshapeCalls,
- },
- };
- },
- dispose: disposeParagraphPolicyFixture,
-};
-
-interface ParagraphLayoutGolden {
- readonly hash: string;
- readonly glyphCount: number;
- readonly lineTextStarts: readonly number[];
- readonly lineTextEnds: readonly number[];
- readonly lineGlyphStarts: readonly number[];
- readonly lineGlyphCounts: readonly number[];
- readonly lineBaselines: readonly number[];
- readonly lineAdvances: readonly number[];
-}
-
-function exactParagraphLayout(
- label: string,
- layout: ParagraphLayout,
- golden: ParagraphLayoutGolden,
- fontHandle: RegisteredFont['handle'],
-): void {
- const fixture = shapingCases.find(({ id }) => id === 'paragraph');
- if (fixture === undefined) throw new Error('Canonical paragraph shaping fixture is missing');
- exactArray(
- `${label}.glyphIds`,
- layout.glyphIds,
- fixture.glyphs.map(({ glyphId }) => glyphId),
- );
- exactArray(
- `${label}.clusters`,
- layout.clusters,
- fixture.glyphs.map(({ cluster }) => cluster),
- );
- exactArray(
- `${label}.glyphFlags`,
- layout.glyphFlags,
- fixture.glyphs.map(({ flags }) => flags),
- );
- if (
- layout.fontHandles.length !== 1 ||
- layout.fontHandles[0] !== fontHandle ||
- layout.glyphFontSlots.some((slot) => slot !== 0)
- ) {
- throw new Error(`${label} paragraph layout did not normalize its font slots`);
- }
- if (layout.glyphIds.length !== golden.glyphCount) {
- throw new Error(`${label} paragraph layout has an unexpected glyph count`);
- }
- exactArray(`${label}.lineTextStarts`, layout.lineTextStarts, golden.lineTextStarts);
- exactArray(`${label}.lineTextEnds`, layout.lineTextEnds, golden.lineTextEnds);
- exactArray(`${label}.lineGlyphStarts`, layout.lineGlyphStarts, golden.lineGlyphStarts);
- exactArray(`${label}.lineGlyphCounts`, layout.lineGlyphCounts, golden.lineGlyphCounts);
- exactArray(`${label}.lineBaselines`, layout.lineBaselines, golden.lineBaselines);
- exactArray(`${label}.lineAdvances`, layout.lineAdvances, golden.lineAdvances);
- const hash = hashParagraphLayout(layout);
- if (hash !== golden.hash) throw new Error(`${label} paragraph layout hash ${hash} != ${golden.hash}`);
-}
-
-function exactContractLayout(label: string, layout: ParagraphLayout, golden: ContractLayout): void {
- exactMeasurement(label, layout, golden.measurement);
- for (const field of [
- 'glyphIds',
- 'clusters',
- 'x',
- 'lineTextStarts',
- 'lineTextEnds',
- 'lineGlyphStarts',
- 'lineGlyphCounts',
- 'lineBaselines',
- 'lineAdvances',
- ] as const) {
- exactArray(`${label}.${field}`, layout[field], golden[field]);
- }
- for (const field of ['glyphFontSlots', 'glyphFontSizes', 'y', 'glyphFlags'] as const) {
- const expected = golden[field];
- if (expected !== undefined) exactArray(`${label}.${field}`, layout[field], expected);
- }
- const hash = hashParagraphLayout(layout);
- if (hash !== golden.hash) throw new Error(`${label} hash ${hash} != ${golden.hash}`);
-}
-
-function exactObject(
- label: string,
- actual: Readonly>,
- expected: Readonly>,
-): void {
- if (!exactJsonValue(actual, expected)) {
- throw new Error(`${label} differs`);
- }
-}
-
-function exactMeasurement(label: string, actual: ParagraphMeasurement, expected: ParagraphMeasurement): void {
- for (const key of [
- 'width',
- 'height',
- 'contentWidth',
- 'contentHeight',
- 'firstBaseline',
- 'lastBaseline',
- 'overflowed',
- ] as const) {
- if (actual[key] !== expected[key]) {
- throw new Error(`Paragraph ${label}.${key} differs: ${String(actual[key])} !== ${String(expected[key])}`);
- }
- }
-}
-
-function hashMeasurements(measurements: readonly ParagraphMeasurement[]): string {
- let hash = 2_166_136_261;
- for (const measurement of measurements) {
- for (const value of [
- measurement.width,
- measurement.height,
- measurement.contentWidth,
- measurement.contentHeight,
- measurement.firstBaseline,
- measurement.lastBaseline,
- Number(measurement.overflowed),
- ]) {
- for (const codeUnit of String(value)) hash = Math.imul(hash ^ codeUnit.charCodeAt(0), 16_777_619);
- }
- }
- return (hash >>> 0).toString(16).padStart(8, '0');
-}
-
-function exactArray(label: string, actual: ArrayLike, expected: readonly number[]): void {
- if (actual.length !== expected.length) throw new Error(`${label} length differs from its golden`);
- for (let index = 0; index < expected.length; index++) {
- exactValue('batch', label, index, actual[index], expected[index]);
- }
-}
-
-function exactValue(
- fixture: string,
- field: string,
- index: number,
- actual: number | undefined,
- expected: number | undefined,
-): void {
- if (actual !== expected) {
- throw new Error(`Shaping golden ${fixture}.${field}[${index}] differs: ${String(actual)} !== ${String(expected)}`);
- }
-}
-
-function cjkUniversalityTarget(): BenchmarkTarget {
- let loaded: BenchmarkTarget | undefined;
- return {
- id: 'cjk-universality',
- label: 'CJK universality',
- detail: 'Noto Sans CJK · exact shaping + horizontal paragraphs',
- color: 'cyan',
- capabilities: new Set(['deterministic', 'font-bytes', 'wasm', 'shaping', 'paragraph']),
- status: (input) => (input.fontBytes === undefined ? 'ready' : 'needs-fixture'),
- load: async (controls, context) => {
- loaded ??= (await import('./cjk-universality')).cjkUniversalityTarget;
- await loaded.load(controls, context);
- },
- run: async (input, sampleIndex, controls, context) => {
- if (loaded === undefined) throw new Error('CJK universality target was not loaded');
- return loaded.run(input, sampleIndex, controls, context);
- },
- dispose: async () => {
- const target = loaded;
- loaded = undefined;
- if (target !== undefined) await target.dispose();
- },
- };
-}
-
-export function createShapingConformanceTargets(): readonly BenchmarkTarget[] {
- return [harfrustShaperTarget, paragraphTarget, paragraphLayoutTarget, paragraphPolicyTarget, cjkUniversalityTarget()];
-}
diff --git a/apps/benchmarks/src/benchmark/targets/conformance/index.ts b/apps/benchmarks/src/benchmark/targets/conformance/index.ts
index 981cccfa..6998b735 100644
--- a/apps/benchmarks/src/benchmark/targets/conformance/index.ts
+++ b/apps/benchmarks/src/benchmark/targets/conformance/index.ts
@@ -1,7 +1,7 @@
import type { BenchmarkInput, BenchmarkTarget, Capability } from '../../contracts';
import { selectableFontFixture } from '../../font-fixtures';
-import { createShapingConformanceTargets } from './direct-runtime';
import { createFontLoaderWorkerConformanceTarget } from './font-loader-worker';
+import { createParagraphContractsConformanceTarget } from './paragraph-contracts';
import { mtsdfRasterConformanceAdapter } from './raster/mtsdf';
import { slugRasterConformanceAdapter } from './raster/slug';
import { createRasterSamplingConformanceTarget, createRasterSourceOutlineConformanceTarget } from './raster/target';
@@ -166,7 +166,7 @@ function runtimeFallbackTarget(technique: Technique, backend: Backend): Benchmar
export function createConformanceTargets(): readonly BenchmarkTarget[] {
return [
createFontLoaderWorkerConformanceTarget(),
- ...createShapingConformanceTargets(),
+ createParagraphContractsConformanceTarget(),
tslBaselineTarget('webgl2'),
tslBaselineTarget('webgpu'),
advancedShapingTarget(),
diff --git a/apps/benchmarks/src/benchmark/targets/conformance/paragraph-contracts.ts b/apps/benchmarks/src/benchmark/targets/conformance/paragraph-contracts.ts
new file mode 100644
index 00000000..2d30b208
--- /dev/null
+++ b/apps/benchmarks/src/benchmark/targets/conformance/paragraph-contracts.ts
@@ -0,0 +1,398 @@
+import type { LoadedFont, ParagraphContentBox, ParagraphLayoutInspection, ParagraphStyle } from '@pmndrs/text';
+import { bitmap } from '@pmndrs/text/three/bitmap';
+import { FontLoader, Text, TextGroup, type TextUpdate } from '@pmndrs/text/three';
+import * as THREE from 'three/webgpu';
+
+import amiriFontUrl from '../../../../fixtures/rendering/amiri-bitmap-16.font.glb?url';
+import cjkFontUrl from '../../../../fixtures/rendering/noto-sans-cjk-contract-bitmap-16.font.glb?url';
+import interFontUrl from '../../../../fixtures/rendering/inter-bitmap-16.font.glb?url';
+import bidiContractJson from '../../../../fixtures/contracts/paragraph-bidi-layout-v0.json';
+import cjkContractJson from '../../../../fixtures/contracts/paragraph-cjk-layout-v0.json';
+import type { BenchmarkTarget } from '../../contracts';
+import { exactValue } from '../../exact-value';
+import { paragraphCjkCoverageText } from '../../paragraph-contract-corpus';
+import { hashParagraphLayouts, paragraphLayoutBytes, paragraphLayoutContract } from '../../paragraph-layout-digest';
+import { createUikitLayoutFixture, YogaMeasureMode, type UikitParagraphSubject } from '../../uikit-layout-fixture';
+
+type BitmapFont = LoadedFont;
+
+interface LegacyAxis {
+ readonly mode: 'unconstrained' | 'at-most' | 'exactly';
+ readonly size?: number;
+}
+
+interface LegacyConstraints {
+ readonly width?: LegacyAxis;
+ readonly height?: LegacyAxis;
+ readonly maxLines?: number;
+ readonly wrap?: 'none' | 'word' | 'character';
+ readonly align?: 'start' | 'center' | 'end' | 'justify';
+ readonly overflow?: 'visible' | 'clip' | 'ellipsis';
+}
+
+interface LayoutGolden {
+ readonly measurement: Readonly>;
+ readonly hash: string;
+ readonly [field: string]: unknown;
+}
+
+interface ParagraphFixture {
+ readonly text: string;
+ readonly style: ParagraphStyle;
+ readonly constraints: LegacyConstraints;
+ readonly layout: LayoutGolden;
+}
+
+interface BidiContract {
+ readonly bidi: Readonly>;
+ readonly policies: {
+ readonly text: string;
+ readonly style: ParagraphStyle;
+ readonly cases: Readonly<
+ Record
+ >;
+ };
+ readonly uikit: {
+ readonly input: { readonly text: string; readonly style: ParagraphStyle };
+ readonly policy: LegacyConstraints;
+ readonly customLayouting: Readonly>;
+ readonly measurements: Readonly>>>;
+ readonly resolved: {
+ readonly outerSize: readonly [number, number];
+ readonly padding: readonly [number, number, number, number];
+ readonly border: readonly [number, number, number, number];
+ readonly contentBox: Readonly>;
+ readonly centeredX: readonly number[];
+ readonly centeredY: readonly number[];
+ readonly layout: LayoutGolden;
+ };
+ };
+}
+
+interface CjkContract {
+ readonly constraints: Readonly>;
+ readonly cases: Readonly<
+ Record<
+ string,
+ {
+ readonly text: string;
+ readonly style: ParagraphStyle;
+ readonly layouts: Readonly>;
+ }
+ >
+ >;
+}
+
+type State =
+ | { readonly kind: 'empty' }
+ | {
+ readonly kind: 'ready';
+ readonly loader: FontLoader;
+ readonly inter: BitmapFont;
+ readonly amiri: BitmapFont;
+ readonly cjk: BitmapFont;
+ };
+
+const bidiContract = bidiContractJson as unknown as BidiContract;
+const cjkContract = cjkContractJson as unknown as CjkContract;
+
+export function createParagraphContractsConformanceTarget(): BenchmarkTarget {
+ let state: State = { kind: 'empty' };
+ return {
+ id: 'paragraph-contracts',
+ label: 'Rust paragraph contracts',
+ detail: 'bidi · policies · uikit seam · full CJK corpus · public Text',
+ color: 'violet',
+ capabilities: new Set(['deterministic', 'font-bytes', 'wasm', 'shaping', 'paragraph', 'raster']),
+ status: () => 'ready',
+ load: async (_controls, context) => {
+ if (state.kind === 'ready') return;
+ const loader = new FontLoader(new THREE.LoadingManager());
+ const fonts: BitmapFont[] = [];
+ try {
+ const load = (url: string, coverage?: string) =>
+ loader.loadAsync({
+ input: { baked: url },
+ raster: {
+ technique: bitmap,
+ options: { strikes: [16], ...(coverage === undefined ? {} : { coverage: { text: coverage } }) },
+ },
+ ...(context?.signal === undefined ? {} : { signal: context.signal }),
+ });
+ const loaded = await Promise.all([
+ load(interFontUrl),
+ load(amiriFontUrl),
+ load(cjkFontUrl, paragraphCjkCoverageText),
+ ]);
+ fonts.push(...loaded);
+ const [inter, amiri, cjk] = loaded;
+ if (inter === undefined || amiri === undefined || cjk === undefined) {
+ throw new Error('paragraph contract fonts did not load');
+ }
+ state = { kind: 'ready', loader, inter, amiri, cjk };
+ } catch (error) {
+ for (const font of fonts) font.dispose();
+ loader.dispose();
+ throw error;
+ }
+ },
+ run: async (_input, _sampleIndex, _controls, context) => {
+ context?.signal?.throwIfAborted();
+ if (state.kind !== 'ready') throw new Error('paragraph contracts target was not loaded');
+ return runContracts(state, context?.signal);
+ },
+ dispose: async () => {
+ if (state.kind !== 'ready') return;
+ const ready = state;
+ state = { kind: 'empty' };
+ ready.inter.dispose();
+ ready.amiri.dispose();
+ ready.cjk.dispose();
+ ready.loader.dispose();
+ },
+ };
+}
+
+function runContracts(state: Extract, signal: AbortSignal | undefined) {
+ const group = new TextGroup({ capacity: { size: 4_096, policy: 'grow' } });
+ const texts: Text[] = [];
+ const expected: Array<{ readonly id: string; readonly golden: LayoutGolden; readonly full: boolean }> = [];
+ const add = (
+ id: string,
+ font: BitmapFont,
+ text: string,
+ style: ParagraphStyle,
+ constraints: LegacyConstraints,
+ golden: LayoutGolden,
+ full: boolean,
+ ) => {
+ const value = new Text({ font, text, style, contentBox: contentBox(constraints) });
+ texts.push(value);
+ expected.push({ id, golden, full });
+ group.add(value);
+ };
+
+ for (const [id, fixture] of Object.entries(bidiContract.bidi)) {
+ add(`bidi.${id}`, state.amiri, fixture.text, fixture.style, fixture.constraints, fixture.layout, true);
+ }
+ for (const [id, fixture] of Object.entries(bidiContract.policies.cases)) {
+ add(
+ `policy.${id}`,
+ state.inter,
+ bidiContract.policies.text,
+ bidiContract.policies.style,
+ fixture.constraints,
+ fixture.layout,
+ false,
+ );
+ }
+ for (const [caseId, fixture] of Object.entries(cjkContract.cases)) {
+ for (const [constraintId, constraints] of Object.entries(cjkContract.constraints)) {
+ const golden = fixture.layouts[constraintId];
+ if (golden === undefined) throw new Error(`CJK contract omitted ${caseId}.${constraintId}`);
+ add(`cjk.${caseId}.${constraintId}`, state.cjk, fixture.text, fixture.style, constraints, golden, true);
+ }
+ }
+
+ let uikitText: Text | undefined;
+ try {
+ signal?.throwIfAborted();
+ group.updateMatrixWorld(true);
+ if (group.error !== undefined) throw group.error;
+ const layouts = texts.map((text, index) => {
+ const layout = text.inspectLayout();
+ const contract = expected[index];
+ if (layout === undefined || contract === undefined)
+ throw new Error('paragraph contract layout was not published');
+ assertObject(contract.id, paragraphLayoutContract(layout, contract.full), narrowLayoutGolden(contract.golden));
+ return layout;
+ });
+
+ uikitText = new Text({
+ font: state.inter,
+ text: bidiContract.uikit.input.text,
+ style: bidiContract.uikit.input.style,
+ contentBox: contentBox(bidiContract.uikit.policy),
+ });
+ group.add(uikitText);
+ const subject = textSubject(group, uikitText, bidiContract.uikit.input);
+ const uikit = createUikitLayoutFixture(subject, contentBox(bidiContract.uikit.policy));
+ const custom = uikit.customLayouting();
+ assertObject(
+ 'uikit.customLayouting',
+ { minWidth: custom.minWidth, minHeight: custom.minHeight, firstBaseline: custom.firstBaseline },
+ narrowLayoutGolden(bidiContract.uikit.customLayouting),
+ );
+ const natural = custom.measure(Number.NaN, YogaMeasureMode.Undefined, Number.NaN, YogaMeasureMode.Undefined);
+ const atMost = custom.measure(360, YogaMeasureMode.AtMost, 90, YogaMeasureMode.AtMost);
+ const exactWidth = custom.measure(420.001, YogaMeasureMode.Exactly, Number.NaN, YogaMeasureMode.Undefined);
+ for (let index = 0; index < 20; index += 1) {
+ assertObject(
+ 'uikit.repeatedAtMost',
+ custom.measure(360, YogaMeasureMode.AtMost, 90, YogaMeasureMode.AtMost),
+ bidiContract.uikit.measurements.atMost!,
+ );
+ }
+ assertObject('uikit.natural', natural, bidiContract.uikit.measurements.natural!);
+ assertObject('uikit.atMost', atMost, bidiContract.uikit.measurements.atMost!);
+ assertObject('uikit.exactWidth', exactWidth, uikitExactWidthGolden());
+ assertObject(
+ 'uikit.definite',
+ uikit.resolveYogaLeaf(401.237, YogaMeasureMode.Exactly, 150.111, YogaMeasureMode.Exactly),
+ bidiContract.uikit.measurements.definite!,
+ );
+ const resolved = uikit.layoutResolvedBox(
+ bidiContract.uikit.resolved.outerSize,
+ bidiContract.uikit.resolved.padding,
+ bidiContract.uikit.resolved.border,
+ );
+ assertObject('uikit.contentBox', resolved.contentBox, bidiContract.uikit.resolved.contentBox);
+ assertObject(
+ 'uikit.layout',
+ paragraphLayoutContract(resolved.layout, false),
+ narrowLayoutGolden(bidiContract.uikit.resolved.layout),
+ );
+ assertArray('uikit.centeredX', resolved.centeredX, bidiContract.uikit.resolved.centeredX);
+ assertArray('uikit.centeredY', resolved.centeredY, bidiContract.uikit.resolved.centeredY);
+ layouts.push(resolved.layout as ParagraphLayoutInspection);
+
+ return {
+ bytes: layouts.reduce((total, layout) => total + paragraphLayoutBytes(layout), 0),
+ hash: hashParagraphLayouts(layouts),
+ metrics: {
+ bidiLayoutCount: Object.keys(bidiContract.bidi).length,
+ policyLayoutCount: Object.keys(bidiContract.policies.cases).length,
+ cjkLayoutCount: Object.keys(cjkContract.cases).length * Object.keys(cjkContract.constraints).length,
+ uikitMeasurementCount: uikit.calls.measure,
+ uikitLayoutCount: uikit.calls.layout,
+ },
+ };
+ } finally {
+ uikitText?.dispose();
+ for (const text of texts) text.dispose();
+ group.dispose();
+ }
+}
+
+function textSubject(
+ group: TextGroup,
+ text: Text,
+ input: { readonly text: string; readonly style: ParagraphStyle },
+): UikitParagraphSubject> {
+ let key = '';
+ const apply = (value: ParagraphContentBox) => {
+ const next = JSON.stringify(value);
+ if (next !== key) {
+ key = next;
+ text.contentBox = value;
+ group.updateMatrixWorld(true);
+ if (group.error !== undefined) throw group.error;
+ }
+ };
+ return {
+ measure(value) {
+ apply(value);
+ const measured = text.measureLayout();
+ if (measured === undefined) throw new Error('uikit measurement was not published');
+ return measured;
+ },
+ layout(value) {
+ apply(value);
+ const layout = text.inspectLayout();
+ if (layout === undefined) throw new Error('uikit layout was not published');
+ return layout;
+ },
+ update(value) {
+ text.set({ ...input, ...value });
+ key = '';
+ },
+ };
+}
+
+function contentBox(value: LegacyConstraints): ParagraphContentBox {
+ return {
+ ...(value.width === undefined ? {} : { width: axis(value.width) }),
+ ...(value.height === undefined ? {} : { height: axis(value.height) }),
+ ...(value.maxLines === undefined ? {} : { maxLines: value.maxLines }),
+ ...(value.wrap === undefined ? {} : { wrap: value.wrap }),
+ ...(value.align === undefined ? {} : { align: value.align }),
+ ...(value.overflow === undefined ? {} : { overflow: value.overflow }),
+ };
+}
+
+function axis(value: LegacyAxis) {
+ if (value.mode === 'unconstrained') return { mode: 'unconstrained' as const };
+ if (value.size === undefined) throw new Error(`${value.mode} constraint omitted its size`);
+ return { mode: value.mode === 'exactly' ? ('exact' as const) : ('at-most' as const), size: value.size };
+}
+
+function assertObject(label: string, actual: unknown, expected: unknown): void {
+ if (!exactValue(actual, expected)) {
+ throw new Error(`${label} differs from its retained paragraph contract at ${firstDifference(actual, expected)}`);
+ }
+}
+
+function narrowLayoutGolden(value: Value): Value {
+ if (typeof value === 'number') return Math.fround(value) as Value;
+ if (Array.isArray(value)) return value.map(narrowLayoutGolden) as Value;
+ if (isRecord(value)) {
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, narrowLayoutGolden(child)])) as Value;
+ }
+ return value;
+}
+
+function uikitExactWidthGolden(): Readonly> {
+ const expected = bidiContract.uikit.measurements.exactWidth;
+ if (expected === undefined) throw new Error('uikit contract omitted exact-width measurement');
+ const layoutMeasurement = record(bidiContract.uikit.resolved.layout.measurement, 'uikit resolved measurement');
+ const contentHeight = numberValue(layoutMeasurement.contentHeight, 'uikit resolved contentHeight');
+ return { ...expected, height: Math.ceil(Math.fround(contentHeight) * 100) / 100 };
+}
+
+function record(value: unknown, label: string): Readonly> {
+ if (!isRecord(value)) throw new TypeError(`${label} is not a record`);
+ return value;
+}
+
+function numberValue(value: unknown, label: string): number {
+ if (typeof value !== 'number' || !Number.isFinite(value)) throw new TypeError(`${label} is not finite`);
+ return value;
+}
+
+function firstDifference(actual: unknown, expected: unknown, path = '$'): string {
+ if (exactValue(actual, expected)) return `${path} (no difference)`;
+ if (Array.isArray(actual) && Array.isArray(expected)) {
+ if (actual.length !== expected.length) return `${path}.length: ${actual.length} !== ${expected.length}`;
+ for (let index = 0; index < actual.length; index += 1) {
+ if (!exactValue(actual[index], expected[index]))
+ return firstDifference(actual[index], expected[index], `${path}[${index}]`);
+ }
+ }
+ if (isRecord(actual) && isRecord(expected)) {
+ const actualKeys = Object.keys(actual);
+ const expectedKeys = Object.keys(expected);
+ if (!exactValue(actualKeys, expectedKeys)) {
+ return `${path} keys: ${JSON.stringify(actualKeys)} !== ${JSON.stringify(expectedKeys)}`;
+ }
+ for (const key of actualKeys) {
+ if (!exactValue(actual[key], expected[key])) return firstDifference(actual[key], expected[key], `${path}.${key}`);
+ }
+ }
+ return `${path}: ${JSON.stringify(actual)} !== ${JSON.stringify(expected)}`;
+}
+
+function isRecord(value: unknown): value is Readonly> {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function assertArray(label: string, actual: ArrayLike, expected: readonly number[]): void {
+ if (actual.length !== expected.length)
+ throw new Error(`${label} length differs from its retained paragraph contract`);
+ for (let index = 0; index < expected.length; index += 1) {
+ if (actual[index] !== expected[index]) {
+ throw new Error(
+ `${label}[${index}] differs from its retained paragraph contract: ${String(actual[index])} !== ${String(expected[index])}`,
+ );
+ }
+ }
+}
diff --git a/apps/benchmarks/src/benchmark/targets/conformance/raster/mtsdf-capture.ts b/apps/benchmarks/src/benchmark/targets/conformance/raster/mtsdf-capture.ts
index 85a2a2b8..6d6b8de0 100644
--- a/apps/benchmarks/src/benchmark/targets/conformance/raster/mtsdf-capture.ts
+++ b/apps/benchmarks/src/benchmark/targets/conformance/raster/mtsdf-capture.ts
@@ -302,7 +302,7 @@ async function disposeFlatMtsdfConformanceResources(resources: FlatMtsdfConforma
}
function committedLayout(line: Text): ParagraphLayout {
- const layout = line.layout;
+ const layout = line.inspectLayout();
if (layout === undefined) throw new Error('MTSDF conformance Text lost its committed layout');
return layout;
}
diff --git a/apps/benchmarks/src/benchmark/targets/conformance/raster/slug-capture.ts b/apps/benchmarks/src/benchmark/targets/conformance/raster/slug-capture.ts
index c7b3518e..2bab1819 100644
--- a/apps/benchmarks/src/benchmark/targets/conformance/raster/slug-capture.ts
+++ b/apps/benchmarks/src/benchmark/targets/conformance/raster/slug-capture.ts
@@ -913,7 +913,7 @@ function pixelHasInk(bytes: Uint8Array, pixelIndex: number): boolean {
function committedLayout(line: Text): ParagraphLayout {
const error = line.error;
if (error !== undefined) throw error;
- const layout = line.layout;
+ const layout = line.inspectLayout();
if (layout === undefined) throw new Error('Slug conformance Text lost its committed layout');
return layout;
}
diff --git a/apps/benchmarks/src/benchmark/targets/conformance/rich-text-spans.ts b/apps/benchmarks/src/benchmark/targets/conformance/rich-text-spans.ts
index a59cdf25..a20ecd70 100644
--- a/apps/benchmarks/src/benchmark/targets/conformance/rich-text-spans.ts
+++ b/apps/benchmarks/src/benchmark/targets/conformance/rich-text-spans.ts
@@ -1,4 +1,4 @@
-import type { AnyRasterTechnique, LoadedFont, LoadedFontRequest, ParagraphLayout } from '@pmndrs/text';
+import type { LoadedFont, LoadedFontRequest, ParagraphLayout } from '@pmndrs/text';
import { bitmap } from '@pmndrs/text/three/bitmap';
import { FontLoader, Text, TextGroup } from '@pmndrs/text/three';
import * as THREE from 'three/webgpu';
@@ -128,7 +128,7 @@ export function createRichTextSpansConformanceTarget(): BenchmarkTarget {
const scene = new THREE.Scene();
// One group so every case packs through the same batch the live workload uses, rather than through a
// standalone-Text path the workload never takes.
- const group = new TextGroup({ technique: bitmap, capacity: { size: 4_096, policy: 'grow' } });
+ const group = new TextGroup({ capacity: { size: 4_096, policy: 'grow' } });
scene.add(group);
const evidence = new Map();
try {
@@ -192,16 +192,19 @@ export function createRichTextSpansConformanceTarget(): BenchmarkTarget {
const hashes = CASE_IDS.map((caseId) => {
const value = required(evidence, caseId);
+ // Glyph selection and topology remain exact. Positions are public f32 values, so the semantic digest quantizes
+ // below a visible hundredth of a pixel; paint is explicitly a multiset because resource batching may reorder
+ // draws without changing the paragraph's resolved colors.
return [
caseId,
value.glyphIds.join(','),
value.clusters.join(','),
value.glyphFontSlots.join(','),
value.fontSizes.map((size) => size.toFixed(4)).join(','),
- value.x.map((origin) => origin.toFixed(4)).join(','),
+ value.x.map((origin) => origin.toFixed(2)).join(','),
value.lineTextEnds.join(','),
- value.contentWidth.toFixed(4),
- value.colors.join(','),
+ value.contentWidth.toFixed(2),
+ [...value.colors].sort().join(','),
].join('|');
});
@@ -259,7 +262,7 @@ export function createRichTextSpansConformanceTarget(): BenchmarkTarget {
}
function measureCase(
- group: TextGroup,
+ group: TextGroup,
body: LoadedFont,
companions: RichTextCompanionFonts,
caseId: RichTextCaseId,
@@ -296,9 +299,9 @@ function measureCase(
if (failure !== undefined) {
throw new Error(`${caseId} failed to publish: ${String(failure)}`, { cause: failure });
}
- const layout = text.layout;
+ const layout = text.inspectLayout();
if (layout === undefined) throw new Error(`${caseId} has no layout`);
- return readEvidence(text, layout);
+ return readEvidence(group, layout);
} finally {
text.removeFromParent();
text.dispose();
@@ -320,7 +323,8 @@ function readEvidence(text: THREE.Object3D, layout: ParagraphLayout): CaseEviden
text.traverse((child) => {
if (!(child instanceof THREE.Mesh) || !(child.geometry instanceof THREE.InstancedBufferGeometry)) return;
drawCount += 1;
- const attribute = child.geometry.getAttribute('_pmndrsTextColors');
+ const attribute = child.geometry.getAttribute('_pmndrsText_5');
+ if (attribute === undefined) throw new Error('Bitmap draw is missing command-buffer color lane 5');
const start = (child.userData.pmndrsTextRunStart as number | undefined) ?? 0;
const count = child.geometry.instanceCount;
renderedGlyphCount += count;
diff --git a/apps/benchmarks/src/benchmark/targets/conformance/tsl-baseline.ts b/apps/benchmarks/src/benchmark/targets/conformance/tsl-baseline.ts
index 848e6c13..ea951613 100644
--- a/apps/benchmarks/src/benchmark/targets/conformance/tsl-baseline.ts
+++ b/apps/benchmarks/src/benchmark/targets/conformance/tsl-baseline.ts
@@ -1,6 +1,5 @@
import * as THREE from 'three/webgpu';
-import type { Node } from 'three/webgpu';
-import { float, mul, vec3 } from 'three/tsl';
+import { instanceIndex, storage } from 'three/tsl';
import { compactRgba8Readback } from '../../low-level/raster/rgba-readback';
import type { BenchmarkTarget, TargetRunOutput } from '../../contracts';
@@ -31,7 +30,7 @@ export function createTslBaselineTarget(backend: RendererBackend): BenchmarkTarg
return {
id: `tsl-${backend}-baseline`,
label: backend === 'webgpu' ? 'TSL WebGPU baseline' : 'TSL WebGL baseline',
- detail: 'WebGPURenderer · TSL · deterministic readback',
+ detail: 'WebGPURenderer · nested logical-to-physical storage lookup · deterministic readback',
color: backend === 'webgpu' ? 'cyan' : 'amber',
capabilities: new Set(['deterministic', 'raster']),
status: () => 'ready',
@@ -83,14 +82,16 @@ async function createResources(backend: RendererBackend, dpr: number): Promise = float(0.5);
- const redChannel: Node<'float'> = mul(half, 2);
- const red: Node<'vec3'> = vec3(redChannel, 0, 0);
- material.colorNode = red;
+ const physicalRecord = storage(order, 'uint', order.count).setPBO(true).element(instanceIndex);
+ material.colorNode = storage(records, 'vec4', records.count).setPBO(true).element(physicalRecord).rgb;
const scene = new THREE.Scene();
- scene.add(new THREE.Mesh(geometry, material));
+ scene.add(new THREE.InstancedMesh(geometry, material, 1));
return {
backend,
dpr,
diff --git a/apps/benchmarks/src/benchmark/targets/product/external-raster-proof.ts b/apps/benchmarks/src/benchmark/targets/product/external-raster-proof.ts
index 7897b8d3..5eae5f85 100644
--- a/apps/benchmarks/src/benchmark/targets/product/external-raster-proof.ts
+++ b/apps/benchmarks/src/benchmark/targets/product/external-raster-proof.ts
@@ -36,7 +36,7 @@ interface ExternalRasterResources {
readonly scene: THREE.Scene;
readonly camera: THREE.OrthographicCamera;
readonly text: Text;
- readonly textGroup: TextGroup;
+ readonly textGroup: TextGroup;
readonly font: LoadedFont;
readonly orderingGeometry: THREE.PlaneGeometry;
readonly orderingMaterial: THREE.MeshBasicNodeMaterial;
@@ -102,7 +102,7 @@ async function createResources(
const renderer = borrowedRenderer ?? ownedRenderer!;
let target: THREE.RenderTarget | undefined;
let text: Text | undefined;
- let textGroup: TextGroup | undefined;
+ let textGroup: TextGroup | undefined;
let font: LoadedFont | undefined;
let orderingGeometry: THREE.PlaneGeometry | undefined;
let orderingMaterial: THREE.MeshBasicNodeMaterial | undefined;
@@ -151,7 +151,7 @@ async function createResources(
// The caller-owned parent stays a plain `THREE.Group`: Three derives a render list's `groupOrder` from `isGroup`,
// so this is the boundary that must order the whole text above the cover. The `TextGroup` inside it owns only the
// text-local render-order base, which is a separate contract this target also checks.
- textGroup = new TextGroup({ technique: glyphExample, renderOrder: 200 });
+ textGroup = new TextGroup({ renderOrder: 200 });
textGroup.add(text);
const callerGroup = new THREE.Group();
callerGroup.renderOrder = 200;
@@ -161,7 +161,10 @@ async function createResources(
// `Text` reconciles while parented, so attaching and forcing one world update is what commits the first revision.
textGroup.updateMatrixWorld(true);
if (textGroup.error !== undefined) throw textGroup.error;
- const retainedMesh = exactlyOne(text.children, 'external raster draw mesh');
+ const retainedMesh = exactlyOne(
+ textGroup.children.filter((child) => child instanceof THREE.Mesh),
+ 'external raster draw mesh',
+ );
if (!(retainedMesh instanceof THREE.Mesh) || !(retainedMesh.geometry instanceof THREE.BufferGeometry)) {
throw new TypeError('external raster did not publish a Three.js mesh');
}
@@ -172,11 +175,12 @@ async function createResources(
text.set({ text: UPDATED_TEXT });
textGroup.updateMatrixWorld(true);
if (textGroup.error !== undefined) throw textGroup.error;
- if (text.children[0] !== retainedMesh || retainedMesh.geometry !== retainedGeometry) {
+ if (
+ textGroup.children.find((child) => child instanceof THREE.Mesh) !== retainedMesh ||
+ retainedMesh.geometry !== retainedGeometry
+ ) {
throw new Error('warm external raster update replaced its retained Three.js objects');
}
- if (text.layout === undefined)
- throw new Error('warm external raster update did not publish during object traversal');
textGroup.renderOrder = 600;
textGroup.updateMatrixWorld(true);
if (Number(retainedMesh.renderOrder) !== 600)
@@ -204,7 +208,7 @@ async function createResources(
orderingMaterial,
retainedMesh,
retainedGeometry,
- glyphCount: text.layout.glyphIds.length,
+ glyphCount: [...UPDATED_TEXT].length,
};
} catch (error) {
text?.dispose();
@@ -268,9 +272,18 @@ async function renderResources(resources: ExternalRasterResources, signal?: Abor
layeringPixels += 1;
}
}
- if (litPixels < 100) throw new Error('external raster proof produced no visible glyph frames');
- if (layeringPixels < 100) throw new Error('external raster proof did not honor its caller-owned parent Group order');
- const liveMesh = exactlyOne(resources.text.children, 'retained external raster draw mesh');
+ if (litPixels < 100) {
+ throw new Error(`external raster proof produced only ${litPixels} visible pixels`);
+ }
+ if (layeringPixels < 100) {
+ throw new Error(
+ `external raster proof changed only ${layeringPixels} pixels over the cover frame (${litPixels} visible pixels)`,
+ );
+ }
+ const liveMesh = exactlyOne(
+ resources.textGroup.children.filter((child) => child instanceof THREE.Mesh),
+ 'retained external raster draw mesh',
+ );
if (liveMesh !== resources.retainedMesh || resources.retainedMesh.geometry !== resources.retainedGeometry) {
throw new Error('external raster proof lost retained object or geometry identity');
}
diff --git a/apps/benchmarks/src/benchmark/targets/product/react-text.ts b/apps/benchmarks/src/benchmark/targets/product/react-text.ts
index f1458c67..1401352c 100644
--- a/apps/benchmarks/src/benchmark/targets/product/react-text.ts
+++ b/apps/benchmarks/src/benchmark/targets/product/react-text.ts
@@ -266,16 +266,58 @@ function exactContentBox(size: number): ParagraphContentBox {
return { width: { mode: 'exact', size } };
}
+function matchesFrameFloat(actual: number, oracle: number): boolean {
+ if (!Number.isFinite(actual) || !Number.isFinite(oracle)) return actual === oracle;
+ return float32UlpDistance(actual, oracle) <= 1;
+}
+
+const float32Scratch = new Float32Array(1);
+const float32Bits = new Uint32Array(float32Scratch.buffer);
+
+function float32UlpDistance(left: number, right: number): number {
+ float32Scratch[0] = left;
+ const leftBits = orderedFloat32Bits(float32Bits[0] ?? 0);
+ float32Scratch[0] = right;
+ const rightBits = orderedFloat32Bits(float32Bits[0] ?? 0);
+ return Math.abs(leftBits - rightBits);
+}
+
+function orderedFloat32Bits(bits: number): number {
+ return (bits & 0x8000_0000) === 0 ? (bits ^ 0x8000_0000) >>> 0 : ~bits >>> 0;
+}
+
+function hashWithOracleLineOrigins(layout: ParagraphLayout, oracleBaselines: readonly number[]): string | undefined {
+ if (layout.lineBaselines.length !== oracleBaselines.length) return undefined;
+ const y = layout.y.slice();
+ const lineBaselines = layout.lineBaselines.slice();
+ for (let line = 0; line < lineBaselines.length; line += 1) {
+ const actual = lineBaselines[line];
+ const expected = oracleBaselines[line];
+ if (actual === undefined || expected === undefined || !matchesFrameFloat(actual, expected)) return undefined;
+ const delta = expected - actual;
+ const glyphStart = layout.lineGlyphStarts[line] ?? 0;
+ const glyphEnd = glyphStart + (layout.lineGlyphCounts[line] ?? 0);
+ for (let glyph = glyphStart; glyph < glyphEnd; glyph += 1) {
+ const position = y[glyph];
+ if (position === undefined) return undefined;
+ y[glyph] = Math.fround(position + delta);
+ }
+ lineBaselines[line] = Math.fround(expected);
+ }
+ return hashParagraphLayout({ ...layout, y, lineBaselines });
+}
+
function assertOracleLayout(layout: ParagraphLayout, state: 'natural' | 'narrow'): void {
const oracle = canonicalParagraphLayout.goldens[state];
const hash = hashParagraphLayout(layout);
+ const compatibleHash = hashWithOracleLineOrigins(layout, oracle.layout.lineBaselines);
const expectedWidth = state === 'narrow' ? NARROW_WIDTH : oracle.measurement.width;
if (
- hash !== oracle.layout.hash ||
+ (hash !== oracle.layout.hash && compatibleHash !== oracle.layout.hash) ||
layout.glyphIds.length !== oracle.layout.glyphCount ||
- layout.width !== expectedWidth ||
- layout.contentWidth !== oracle.measurement.contentWidth ||
- layout.height !== oracle.measurement.height
+ !matchesFrameFloat(layout.width, expectedWidth) ||
+ !matchesFrameFloat(layout.contentWidth, oracle.measurement.contentWidth) ||
+ !matchesFrameFloat(layout.height, oracle.measurement.height)
) {
throw new Error(
`React Text ${state} layout differs from the pinned paragraph oracle: ` +
@@ -291,9 +333,10 @@ function requiredCoreText(reference: React.RefObject):
return reference.current;
}
-function requiredLayout(core: BitmapTextObject): NonNullable {
- if (core.layout === undefined) throw new Error('React Text layout is unavailable');
- return core.layout;
+function requiredLayout(core: BitmapTextObject): ParagraphLayout {
+ const layout = core.inspectLayout();
+ if (layout === undefined) throw new Error('React Text layout inspection is unavailable');
+ return layout;
}
function countDraws(object: BitmapTextObject): number {
@@ -308,7 +351,8 @@ function countUniquePaints(object: BitmapTextObject): number {
const paints = new Set();
object.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return;
- const colors = child.geometry.getAttribute('_pmndrsTextColors');
+ // Bitmap policy buffer 5 is the command buffer's packed RGBA instance lane.
+ const colors = child.geometry.getAttribute('_pmndrsText_5');
if (colors === undefined) return;
// One physical batch backs every run of a paragraph, so a draw reads its own window of the shared paint buffer.
const start = (child.userData.pmndrsTextRunStart as number | undefined) ?? 0;
diff --git a/apps/benchmarks/src/benchmark/targets/product/slug-text.ts b/apps/benchmarks/src/benchmark/targets/product/slug-text.ts
index 4e4e8cb8..c3edc0c4 100644
--- a/apps/benchmarks/src/benchmark/targets/product/slug-text.ts
+++ b/apps/benchmarks/src/benchmark/targets/product/slug-text.ts
@@ -175,7 +175,7 @@ async function createResources(backend: RendererBackend, dpr: number): Promise): void {
const error = line.error;
if (error !== undefined) throw error;
- if (line.layout === undefined) throw new Error('Slug product Text did not commit a layout');
+ if (line.measureLayout() === undefined) throw new Error('Slug product Text did not commit layout metrics');
}
async function renderSlugText(resources: SlugProductTargetResources): Promise {
diff --git a/apps/benchmarks/src/benchmark/targets/registry.ts b/apps/benchmarks/src/benchmark/targets/registry.ts
index 7d51a0c7..9d91f6ac 100644
--- a/apps/benchmarks/src/benchmark/targets/registry.ts
+++ b/apps/benchmarks/src/benchmark/targets/registry.ts
@@ -5,6 +5,7 @@ type TargetGroup = 'product' | 'measurement' | 'conformance';
const targetGroups: Readonly> = {
synthetic: 'product',
'font-loader-worker': 'conformance',
+ 'paragraph-contracts': 'conformance',
'external-raster-proof-webgl2': 'product',
'external-raster-proof-webgpu': 'product',
'bitmap-text-webgl2': 'product',
@@ -15,11 +16,6 @@ const targetGroups: Readonly> = {
'slug-text-webgpu': 'product',
'react-text-reconciliation': 'product',
'font-baker': 'measurement',
- 'harfrust-shaper': 'conformance',
- 'paragraph-engine': 'conformance',
- 'paragraph-layout-engine': 'conformance',
- 'paragraph-bidi-policy': 'conformance',
- 'cjk-universality': 'conformance',
'tsl-webgl2-baseline': 'conformance',
'tsl-webgpu-baseline': 'conformance',
'advanced-shaping-conformance': 'conformance',
diff --git a/apps/benchmarks/src/benchmark/uikit-layout-fixture.ts b/apps/benchmarks/src/benchmark/uikit-layout-fixture.ts
index df166a98..8a7939f5 100644
--- a/apps/benchmarks/src/benchmark/uikit-layout-fixture.ts
+++ b/apps/benchmarks/src/benchmark/uikit-layout-fixture.ts
@@ -1,10 +1,4 @@
-import type {
- LayoutParagraph as Paragraph,
- LayoutParagraphAxisConstraint as ParagraphAxisConstraint,
- ParagraphConstraints,
- ParagraphInput,
- ParagraphLayout,
-} from '@pmndrs/text';
+import type { ParagraphAxisConstraint, ParagraphContentBox, ParagraphLayout, ParagraphMeasurement } from '@pmndrs/text';
export const YogaMeasureMode = Object.freeze({ Undefined: 0, Exactly: 1, AtMost: 2 });
@@ -12,7 +6,16 @@ type YogaMeasureModeValue = (typeof YogaMeasureMode)[keyof typeof YogaMeasureMod
type Inset = readonly [top: number, right: number, bottom: number, left: number];
type Size = readonly [width: number, height: number];
-export function createUikitLayoutFixture(paragraph: Paragraph, policy: ParagraphConstraints = {}) {
+export interface UikitParagraphSubject {
+ measure(contentBox: ParagraphContentBox): ParagraphMeasurement;
+ layout(contentBox: ParagraphContentBox): ParagraphLayout;
+ update(input: Input): void;
+}
+
+export function createUikitLayoutFixture(
+ paragraph: UikitParagraphSubject,
+ policy: ParagraphContentBox = {},
+) {
let currentPolicy = { ...policy };
let dirtyCount = 1;
let paintRevision = 0;
@@ -20,11 +23,11 @@ export function createUikitLayoutFixture(paragraph: Paragraph, policy: Paragraph
const calls = { measure: 0, layout: 0 };
const measuredParagraph = {
- measure(constraints?: ParagraphConstraints) {
+ measure(constraints: ParagraphContentBox = {}) {
calls.measure += 1;
return paragraph.measure(constraints);
},
- layout(constraints?: ParagraphConstraints) {
+ layout(constraints: ParagraphContentBox = {}) {
calls.layout += 1;
return paragraph.layout(constraints);
},
@@ -99,8 +102,8 @@ export function createUikitLayoutFixture(paragraph: Paragraph, policy: Paragraph
);
const layout = measuredParagraph.layout({
...currentPolicy,
- width: { mode: 'exactly', size: contentWidth },
- height: { mode: 'exactly', size: contentHeight },
+ width: { mode: 'exact', size: contentWidth },
+ height: { mode: 'exact', size: contentHeight },
});
const contentLeft = -outerWidth / 2 + borderLeft + paddingLeft;
const contentTop = outerHeight / 2 - borderTop - paddingTop;
@@ -111,11 +114,11 @@ export function createUikitLayoutFixture(paragraph: Paragraph, policy: Paragraph
centeredY: Float32Array.from(layout.y, (value) => contentTop - value),
};
},
- updateParagraph(input: ParagraphInput) {
+ updateParagraph(input: Input) {
paragraph.update(input);
dirtyCount += 1;
},
- updateShapingPolicy(policyUpdate: ParagraphConstraints) {
+ updateShapingPolicy(policyUpdate: ParagraphContentBox) {
currentPolicy = { ...currentPolicy, ...policyUpdate };
dirtyCount += 1;
},
@@ -132,7 +135,7 @@ function mapYogaAxis(value: number, mode: YogaMeasureModeValue, name: string): P
if (mode === YogaMeasureMode.Undefined) return { mode: 'unconstrained' };
const size = validYogaSize(value, name);
if (mode === YogaMeasureMode.AtMost) return { mode: 'at-most', size };
- if (mode === YogaMeasureMode.Exactly) return { mode: 'exactly', size };
+ if (mode === YogaMeasureMode.Exactly) return { mode: 'exact', size };
throw new RangeError(`unsupported Yoga ${name} measure mode`);
}
diff --git a/apps/benchmarks/src/generated/package-sizes.json b/apps/benchmarks/src/generated/package-sizes.json
index b00452be..6ebd34d2 100644
--- a/apps/benchmarks/src/generated/package-sizes.json
+++ b/apps/benchmarks/src/generated/package-sizes.json
@@ -7,14 +7,190 @@
"entries": [
{
"id": "browser-core",
- "label": "Browser core",
+ "label": "Renderer-neutral core JS (peers and Wasm external)",
"status": "measured",
"format": "javascript",
- "sha256": "8af458da131f8017cae1e9e5e8984051ab4fc57ed2a043f8cdc74e0216a0dbc5",
- "rawBytes": 384896,
- "minifiedBytes": 282563,
- "gzipBytes": 81948,
- "brotliBytes": 63199
+ "sha256": "e0c1574d31cee981f61c20ff9685cc64523c91dec320cafcc0860189856c5e7a",
+ "rawBytes": 88398,
+ "minifiedBytes": 64157,
+ "gzipBytes": 17846,
+ "brotliBytes": 15469
+ },
+ {
+ "id": "text-shaper-wasm",
+ "label": "Text engine Wasm",
+ "status": "measured",
+ "format": "wasm",
+ "sha256": "f74f96a6214532271296c8165738d14f71c0642aca4af9050a0363aed2a4d576",
+ "rawBytes": 1159317,
+ "minifiedBytes": 1159317,
+ "gzipBytes": 442284,
+ "brotliBytes": 347850
+ },
+ {
+ "id": "renderer-neutral-core-total",
+ "label": "Renderer-neutral core total (JS + Wasm)",
+ "status": "measured",
+ "format": "aggregate",
+ "sha256": "c5ab94ae437e8bea42b3d73a44042c265fa94c8797e4b2d015c41c8c2bd9cf38",
+ "rawBytes": 1247715,
+ "minifiedBytes": 1223474,
+ "gzipBytes": 460130,
+ "brotliBytes": 363319
+ },
+ {
+ "id": "three-runtime-js",
+ "label": "Complete Three adapter JS (peers and Wasm external)",
+ "status": "measured",
+ "format": "javascript",
+ "sha256": "9444aa8cbcb6a770b8dcac9fdfd36d64b488226b60bce09d7f20a2e26d892d17",
+ "rawBytes": 329352,
+ "minifiedBytes": 217153,
+ "gzipBytes": 56322,
+ "brotliBytes": 47426
+ },
+ {
+ "id": "three-renderer-total",
+ "label": "Complete Three text renderer total (adapter JS + Wasm; peers external)",
+ "status": "measured",
+ "format": "aggregate",
+ "sha256": "3f56579adfd50b68a73efae8b5c36f0ee64cbdcd142ae684e85791482afa6766",
+ "rawBytes": 1488669,
+ "minifiedBytes": 1376470,
+ "gzipBytes": 498606,
+ "brotliBytes": 395276
+ },
+ {
+ "id": "font-inter-bitmap-16-32",
+ "label": "Inter 4.1 Bitmap font asset (16 + 32 ppem)",
+ "status": "measured",
+ "format": "font-asset",
+ "sha256": "b8143dc39a49199c934f4cee493ddb6acbbfff34716f5332afd4e8b9cb8e785f",
+ "rawBytes": 3149648,
+ "minifiedBytes": 3149648,
+ "gzipBytes": 558308,
+ "brotliBytes": 420590
+ },
+ {
+ "id": "font-inter-mtsdf",
+ "label": "Inter 4.1 MTSDF font asset",
+ "status": "measured",
+ "format": "font-asset",
+ "sha256": "1e1980e2b20341c5e7f531e970e37cb879da2907d5c84e9603414717bcf495c0",
+ "rawBytes": 39347712,
+ "minifiedBytes": 39347712,
+ "gzipBytes": 6798412,
+ "brotliBytes": 3240372
+ },
+ {
+ "id": "font-inter-slug",
+ "label": "Inter 4.1 Slug font asset",
+ "status": "measured",
+ "format": "font-asset",
+ "sha256": "1c3f8fea27d1f404f77e47c4dd4929c122e12091a3d91cd92c30a6b9d6ece094",
+ "rawBytes": 3444916,
+ "minifiedBytes": 3444916,
+ "gzipBytes": 618487,
+ "brotliBytes": 410035
+ },
+ {
+ "id": "font-icons-bitmap-16-32",
+ "label": "Font Awesome Free 6.7.2 Bitmap icon asset (16 + 32 ppem)",
+ "status": "measured",
+ "format": "font-asset",
+ "sha256": "95550c860396470f8583a990a8e2081289a6a87781836c17b488527c8c4eca45",
+ "rawBytes": 2381732,
+ "minifiedBytes": 2381732,
+ "gzipBytes": 450047,
+ "brotliBytes": 355549
+ },
+ {
+ "id": "font-icons-mtsdf",
+ "label": "Font Awesome Free 6.7.2 MTSDF icon asset",
+ "status": "measured",
+ "format": "font-asset",
+ "sha256": "75ef82e7ee28d6ee6135634fc13b68ba8fc8fe808c8ec3d8428d8e33cf37c3b6",
+ "rawBytes": 32580900,
+ "minifiedBytes": 32580900,
+ "gzipBytes": 7227824,
+ "brotliBytes": 3324903
+ },
+ {
+ "id": "font-icons-slug",
+ "label": "Font Awesome Free 6.7.2 Slug icon asset",
+ "status": "measured",
+ "format": "font-asset",
+ "sha256": "dda2ea68d49f45cdfbf48c3a505718eb62931753834e1d175270ae40d2b40b1d",
+ "rawBytes": 2945412,
+ "minifiedBytes": 2945412,
+ "gzipBytes": 658012,
+ "brotliBytes": 484998
+ },
+ {
+ "id": "delivery-three-inter-bitmap",
+ "label": "Three + engine + Inter Bitmap delivery total",
+ "status": "measured",
+ "format": "aggregate",
+ "sha256": "e1719028cb54697f95123f10700fe0812a43f069a6585b3e55929a65eb01780f",
+ "rawBytes": 4638317,
+ "minifiedBytes": 4526118,
+ "gzipBytes": 1056914,
+ "brotliBytes": 815866
+ },
+ {
+ "id": "delivery-three-inter-mtsdf",
+ "label": "Three + engine + Inter MTSDF delivery total",
+ "status": "measured",
+ "format": "aggregate",
+ "sha256": "ec082d87f3caf869018a7d82b0a721720bc125500527053528189d79663997da",
+ "rawBytes": 40836381,
+ "minifiedBytes": 40724182,
+ "gzipBytes": 7297018,
+ "brotliBytes": 3635648
+ },
+ {
+ "id": "delivery-three-inter-slug",
+ "label": "Three + engine + Inter Slug delivery total",
+ "status": "measured",
+ "format": "aggregate",
+ "sha256": "65c45011697a54fa549facc7cf0379b001b292c153b55b6e2be7c04a5f6c4478",
+ "rawBytes": 4933585,
+ "minifiedBytes": 4821386,
+ "gzipBytes": 1117093,
+ "brotliBytes": 805311
+ },
+ {
+ "id": "delivery-three-icons-bitmap",
+ "label": "Three + engine + Font Awesome Bitmap delivery total",
+ "status": "measured",
+ "format": "aggregate",
+ "sha256": "5920d74a2e6d53335190ae5070da9417b596082c48a5e4c26fd500ecf7a08db6",
+ "rawBytes": 3870401,
+ "minifiedBytes": 3758202,
+ "gzipBytes": 948653,
+ "brotliBytes": 750825
+ },
+ {
+ "id": "delivery-three-icons-mtsdf",
+ "label": "Three + engine + Font Awesome MTSDF delivery total",
+ "status": "measured",
+ "format": "aggregate",
+ "sha256": "5c91d2c6c0291976500ccfdb780b6a9ebafaf8fd880a5299ce014e085a75707a",
+ "rawBytes": 34069569,
+ "minifiedBytes": 33957370,
+ "gzipBytes": 7726430,
+ "brotliBytes": 3720179
+ },
+ {
+ "id": "delivery-three-icons-slug",
+ "label": "Three + engine + Font Awesome Slug delivery total",
+ "status": "measured",
+ "format": "aggregate",
+ "sha256": "95e97a04e8e9baafb8d543a499faeee1b561e13a1f592ae3e2caf8e438266016",
+ "rawBytes": 4434081,
+ "minifiedBytes": 4321882,
+ "gzipBytes": 1156618,
+ "brotliBytes": 880274
},
{
"id": "font-validator-js",
@@ -43,99 +219,77 @@
"label": "Runtime baker Worker JS",
"status": "measured",
"format": "javascript",
- "sha256": "31dc8dd13f3eefcbb497ac666911a8a402de2a32dde89544b91b288b0037110a",
- "rawBytes": 12822,
- "minifiedBytes": 8936,
- "gzipBytes": 3003,
- "brotliBytes": 2671
- },
- {
- "id": "text-shaper-js",
- "label": "Text shaper JS",
- "status": "measured",
- "format": "javascript",
- "sha256": "1160e9b39b6ef5bcea15d475f57823fec80ae5b60fc103f58482555262f64b2a",
- "rawBytes": 54460,
- "minifiedBytes": 38027,
- "gzipBytes": 10262,
- "brotliBytes": 9134
- },
- {
- "id": "text-shaper-wasm",
- "label": "Text shaper Wasm",
- "status": "measured",
- "format": "wasm",
- "sha256": "e41e32f585ad99f91c82bff8bf9b01011fd01dc07d16d19e97eeec77a8d50c86",
- "rawBytes": 680312,
- "minifiedBytes": 680312,
- "gzipBytes": 253568,
- "brotliBytes": 199365
+ "sha256": "31f464e98aade52c1c0814a77b563d018364269769db4220f0e21a805f980bb3",
+ "rawBytes": 12777,
+ "minifiedBytes": 8880,
+ "gzipBytes": 2982,
+ "brotliBytes": 2644
},
{
"id": "bitmap-runtime-js",
"label": "Bitmap runtime JS graph",
"status": "measured",
"format": "javascript",
- "sha256": "9af4ebed9b0712bedbc887529ea2753a49d2f52214742f376ba2184bbec94675",
- "rawBytes": 89604,
- "minifiedBytes": 60671,
- "gzipBytes": 16081,
- "brotliBytes": 14229
+ "sha256": "f6e44e8ed5751eafabc0bafcc1d20aa95e9d774a2365907349b3585e86acc0d5",
+ "rawBytes": 319734,
+ "minifiedBytes": 210635,
+ "gzipBytes": 54492,
+ "brotliBytes": 45949
},
{
"id": "mtsdf-runtime-js",
"label": "MSDF runtime JS graph",
"status": "measured",
"format": "javascript",
- "sha256": "c194d5f368e7224b83a351098a7ebaa2e5daac9d6b4e853e01b490cf9b580ea2",
- "rawBytes": 94082,
- "minifiedBytes": 63216,
- "gzipBytes": 16774,
- "brotliBytes": 14807
+ "sha256": "b238e093d562cc7d85bc9b802f1db5aadaa10042534c946750e6fd25b65ced1e",
+ "rawBytes": 319730,
+ "minifiedBytes": 210639,
+ "gzipBytes": 54494,
+ "brotliBytes": 45983
},
{
"id": "slug-runtime-js",
"label": "Slug runtime JS graph",
"status": "measured",
"format": "javascript",
- "sha256": "99e19d86de2756ae1a1ef67c03ccf065a11708c455d6be070341d4d1f0b15a53",
- "rawBytes": 79169,
- "minifiedBytes": 53547,
- "gzipBytes": 14214,
- "brotliBytes": 12612
+ "sha256": "7aeb11852c0962a9eec22d75fcebd62d5d863f922a9834e124fc3ccf49626201",
+ "rawBytes": 319732,
+ "minifiedBytes": 210634,
+ "gzipBytes": 54431,
+ "brotliBytes": 45956
},
{
"id": "bitmap-baker-wasm",
"label": "Bitmap fixed baker Wasm",
"status": "measured",
"format": "wasm",
- "sha256": "144c2aa99644999531a332722d0c4eccc2d2a5cb429a3b8639659f8695cb5c7f",
+ "sha256": "bdcf6215905e47be09d8a1a8e5122e95c07c4e4b93509bf8f4a74d043e0438a8",
"rawBytes": 626940,
"minifiedBytes": 626940,
"gzipBytes": 234735,
- "brotliBytes": 180503
+ "brotliBytes": 180620
},
{
"id": "bitmap-baker-js",
"label": "Bitmap fixed baker host JS",
"status": "measured",
"format": "javascript",
- "sha256": "877cf243a4102cc2090d8b30d0ee22aa14fe150edaf758a0b405635ed4e8856f",
- "rawBytes": 23110,
- "minifiedBytes": 15621,
- "gzipBytes": 4785,
- "brotliBytes": 4247
+ "sha256": "1cf2e408a937594f0a460ff48bd72751ccf229b072e07e6556973493502b1531",
+ "rawBytes": 23026,
+ "minifiedBytes": 15585,
+ "gzipBytes": 4773,
+ "brotliBytes": 4233
},
{
"id": "mtsdf-generator-js",
"label": "MSDF generator host JS",
"status": "measured",
"format": "javascript",
- "sha256": "996b884783be8b708e186ae2fe4a062b856a6e48f30b7044f3c07939b2aacdf0",
- "rawBytes": 11543,
- "minifiedBytes": 8466,
- "gzipBytes": 2658,
- "brotliBytes": 2364
+ "sha256": "e750b13e84c567ab5fb2c875b66432e5a48fa48a8310ec760817616ba5b8b060",
+ "rawBytes": 11374,
+ "minifiedBytes": 8418,
+ "gzipBytes": 2657,
+ "brotliBytes": 2357
},
{
"id": "mtsdf-generator-wasm",
@@ -164,44 +318,44 @@
"label": "MSDF fixed baker host JS",
"status": "measured",
"format": "javascript",
- "sha256": "fd2140ee7f48c10e41be0f132e01712b2f3b44186f0561c41cbe013fb763b5ae",
- "rawBytes": 26924,
- "minifiedBytes": 19112,
- "gzipBytes": 5531,
- "brotliBytes": 4907
+ "sha256": "b12564a24fab591641209abd8d98a141e05b7fb9dbd1723caa0fec4517bbf9b4",
+ "rawBytes": 26861,
+ "minifiedBytes": 19076,
+ "gzipBytes": 5522,
+ "brotliBytes": 4901
},
{
"id": "slug-baker-wasm",
"label": "Slug fixed baker Wasm",
"status": "measured",
"format": "wasm",
- "sha256": "594f1f2994780e59a37b873c403fd2f003ce9dac42fd48252f7f09f71c0cd204",
- "rawBytes": 465046,
- "minifiedBytes": 465046,
- "gzipBytes": 186683,
- "brotliBytes": 146720
+ "sha256": "38d461e1ccfd9be05cccff46c6ee4993c785602ba3cafe170abf4eefdecd8f94",
+ "rawBytes": 465031,
+ "minifiedBytes": 465031,
+ "gzipBytes": 186665,
+ "brotliBytes": 146606
},
{
"id": "slug-baker-js",
"label": "Slug fixed baker host JS",
"status": "measured",
"format": "javascript",
- "sha256": "c0f844c626cb8994c8bc4de9cb9ddf1fe099899e5c056edd7afc6cdc838d0be3",
- "rawBytes": 18721,
- "minifiedBytes": 12913,
- "gzipBytes": 4129,
- "brotliBytes": 3680
+ "sha256": "f8f145266df728d067218477258eb9f19dba063f1fe22064d529099f77de731e",
+ "rawBytes": 18641,
+ "minifiedBytes": 12877,
+ "gzipBytes": 4116,
+ "brotliBytes": 3667
},
{
"id": "portable-baker-js",
"label": "Portable baker JS",
"status": "measured",
"format": "javascript",
- "sha256": "077bf3546c43678e8b01512302c80b511bd11bd6ee2913ff9fdfc3d4a38a0055",
- "rawBytes": 8926,
- "minifiedBytes": 6077,
- "gzipBytes": 2175,
- "brotliBytes": 1937
+ "sha256": "ac0207d7b5092cb2ded432094535f5f44a65884ae0c9ef8bb397adb793b99a09",
+ "rawBytes": 8877,
+ "minifiedBytes": 6017,
+ "gzipBytes": 2157,
+ "brotliBytes": 1913
},
{
"id": "portable-baker-wasm",
diff --git a/apps/benchmarks/src/surfaces/benchmark/comparison-workload-viewport.tsx b/apps/benchmarks/src/surfaces/benchmark/comparison-workload-viewport.tsx
index 964aae6b..741455a3 100644
--- a/apps/benchmarks/src/surfaces/benchmark/comparison-workload-viewport.tsx
+++ b/apps/benchmarks/src/surfaces/benchmark/comparison-workload-viewport.tsx
@@ -218,11 +218,12 @@ export function ComparisonWorkloadViewport({
const publishStats = useEffectEvent((next: ComparisonWorkloadStats) => {
finishBakeProgress();
onStats(next);
- setError(undefined);
+ if (next.workload === workload && next.appliedFontFixture === fontFixture) setError(undefined);
});
const publishError = useEffectEvent((caught: unknown) => {
if (caught instanceof DOMException && caught.name === 'AbortError') return;
finishBakeProgress();
+ console.error('comparison workload update failed', caught);
setError(caught instanceof Error ? caught.message : String(caught));
});
const currentConfiguration = useEffectEvent(
@@ -233,7 +234,10 @@ export function ComparisonWorkloadViewport({
fontFixture,
fontSize,
iconGridView: presentationPreset === 'icon-grid-return' ? 'alternate' : 'origin',
- layoutWidthRatio,
+ // Runtime defaults and the route update are separate reactive stores. During the transition into Off-axis / 3D,
+ // its authored 120% default can therefore be observed for one render with the preceding workload. Keep every
+ // intermediate configuration valid without weakening the scene's workload-specific contract.
+ layoutWidthRatio: workload === 'off-axis-3d' ? layoutWidthRatio : Math.min(layoutWidthRatio, 1),
paintOpacity,
paintShadowEnabled,
paintStrokeWidth,
diff --git a/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts b/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts
index df9b7355..2980fcf6 100644
--- a/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts
+++ b/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts
@@ -1,4 +1,4 @@
-import { FontRegistry, type AnyRasterTechnique, type ParagraphLayout, type RegisteredFont } from '@pmndrs/text';
+import { FontRegistry, type ParagraphLayoutSummary, type RegisteredFont } from '@pmndrs/text';
import { TextGroup } from '@pmndrs/text/three';
import * as THREE from 'three/webgpu';
import { selectBitmapStrikePpem } from '@pmndrs/text/three/bitmap';
@@ -33,7 +33,7 @@ import type {
ComparisonWorkloadId,
} from '../../../workloads/comparison/contracts';
import {
- committedTextLayout,
+ committedTextMetrics,
exactWidth,
publishWorkloadTexts,
type ComparisonWorkloadEntry,
@@ -67,6 +67,17 @@ import {
type WorkloadEntry = ComparisonWorkloadEntry;
+const textTimingsEnabled =
+ typeof location !== 'undefined' && new URLSearchParams(location.search).get('textTimings') === '1';
+
+function timingBegin(): number {
+ return textTimingsEnabled ? performance.now() : 0;
+}
+
+function timingEnd(name: string, startedMs: number): void {
+ if (textTimingsEnabled) performance.measure(`@pmndrs/benchmark ${name}`, { start: startedMs });
+}
+
export type {
ComparisonWorkloadConfiguration,
ComparisonWorkloadId,
@@ -593,6 +604,12 @@ async function createComparisonWorkloadRuntime(
next.workload === 'icon-grid' && nextIconGridInstance !== undefined
? nextIconGridInstance.activate(next, { height, width })
: undefined;
+ const previous = entries;
+ const previousRoot = batchRoot;
+ const reuseBatchRoot =
+ previousRoot instanceof TextGroup &&
+ comparisonWorkloadDefinition(next.workload).batching !== 'standalone' &&
+ previousRoot.compositing === workloadCompositing(next.workload);
const nextEntries = createEntries(
activeFont().loaded,
technique,
@@ -606,23 +623,28 @@ async function createComparisonWorkloadRuntime(
initialIconWindow?.scrollX ?? (workloadChanged ? 0 : -scene.position.x),
initialIconWindow?.scrollY ?? (workloadChanged ? 0 : scene.position.y),
);
- const nextRoot = createBatchRoot(next.workload, activeFont().loaded);
+ const nextRoot = reuseBatchRoot ? previousRoot : createBatchRoot(next.workload);
const scheduledAt = performance.now();
try {
- // The staged root is published off-scene: a TextGroup shapes, lays out, and packs its whole workload inside
- // one `updateMatrixWorld`, so the committed layouts are readable before anything reaches the live scene.
+ // Compatible grouped workloads replace their children through one retained Rust session. Creating a second
+ // TextGroup would duplicate every reserved engine arena while the outgoing command buffer is still live.
+ if (reuseBatchRoot) for (const { node } of previous) previousRoot.remove(node);
for (const { node } of nextEntries) nextRoot.add(node);
publishWorkloadTexts(nextRoot, nextEntries);
const readyAt = performance.now();
if (disposed || commitRevision !== revision) {
- disposeEntries(nextEntries);
- disposeBatchRoot(nextRoot);
+ entries = nextEntries;
+ batchRoot = nextRoot;
+ disposeEntries(previous);
+ if (!reuseBatchRoot) disposeBatchRoot(previousRoot);
+ if (iconGridInstanceChanged) {
+ iconGridInstance?.dispose();
+ iconGridInstance = next.workload === 'icon-grid' ? nextIconGridInstance : undefined;
+ }
return;
}
const sceneStartedAt = performance.now();
layoutEntries(nextEntries, next, width, height);
- const previous = entries;
- const previousRoot = batchRoot;
entries = nextEntries;
batchRoot = nextRoot;
configuration = next;
@@ -640,7 +662,7 @@ async function createComparisonWorkloadRuntime(
scene.clear();
scene.add(nextRoot);
disposeEntries(previous);
- disposeBatchRoot(previousRoot);
+ if (!reuseBatchRoot) disposeBatchRoot(previousRoot);
if (iconGridInstanceChanged) {
iconGridInstance?.dispose();
iconGridInstance = next.workload === 'icon-grid' ? nextIconGridInstance : undefined;
@@ -657,8 +679,25 @@ async function createComparisonWorkloadRuntime(
iconGridInstance?.settle(next, { height, width }, scene);
}
} catch (error) {
+ if (reuseBatchRoot) {
+ for (const { node } of nextEntries) nextRoot.remove(node);
+ disposeBatchRoot(nextRoot);
+ const restoredRoot = createBatchRoot(configuration.workload);
+ try {
+ for (const { node } of previous) restoredRoot.add(node);
+ publishWorkloadTexts(restoredRoot, previous);
+ batchRoot = restoredRoot;
+ scene.clear();
+ scene.add(restoredRoot);
+ } catch (recoveryError) {
+ disposeBatchRoot(restoredRoot);
+ disposeEntries(nextEntries);
+ if (iconGridInstanceChanged) nextIconGridInstance?.dispose();
+ throw new Error(`comparison workload update also failed: ${String(error)}`, { cause: recoveryError });
+ }
+ }
disposeEntries(nextEntries);
- disposeBatchRoot(nextRoot);
+ if (!reuseBatchRoot) disposeBatchRoot(nextRoot);
if (iconGridInstanceChanged) nextIconGridInstance?.dispose();
throw error;
}
@@ -708,6 +747,7 @@ async function createComparisonWorkloadRuntime(
return;
}
if (contentWidthChanged || fontSizeChanged) {
+ const retainedUpdateStarted = timingBegin();
const readyStarted = performance.now();
const retainedWidths = contentWidthChanged
? next.workload === 'dynamic-layout'
@@ -720,15 +760,23 @@ async function createComparisonWorkloadRuntime(
for (const [index, entry] of entries.entries()) entry.lastWidth = retainedWidths[index]!;
}
const scheduledAt = performance.now();
+ const stagingStarted = timingBegin();
applyRetainedTextLayout(
entries.map(({ text }) => text),
retainedWidths,
fontSizeChanged ? next.fontSize : undefined,
);
- publishWorkloadTexts(batchRoot, entries);
- const readyAt = performance.now();
+ timingEnd('text.stage', stagingStarted);
const sceneStartedAt = performance.now();
+ // Layout metrics are demanded before explicit publication so the query mask rides on the pending semantic
+ // update. The following scene publication sees clean Rust state and only synchronizes renderer transforms.
+ const layoutStarted = timingBegin();
layoutEntries(entries, next, width, height);
+ timingEnd('text.update-and-measure', layoutStarted);
+ const readyAt = performance.now();
+ const publishStarted = timingBegin();
+ publishWorkloadTexts(batchRoot, entries);
+ timingEnd('text.publish-clean', publishStarted);
const finishedAt = performance.now();
textReadyMs = finishedAt - readyStarted;
textUpdateTelemetry.record({
@@ -738,6 +786,7 @@ async function createComparisonWorkloadRuntime(
totalMs: finishedAt - readyStarted,
});
recordReflow(finishedAt - readyStarted);
+ timingEnd('text.retained-update', retainedUpdateStarted);
} else if (viewportChanged) {
layoutEntries(entries, next, width, height);
}
@@ -848,6 +897,7 @@ async function createComparisonWorkloadRuntime(
const started = performance.now();
canvasSurface.render(scene, camera);
const submitMs = performance.now() - started;
+ timingEnd('renderer.submit', started);
if (firstDrawMs === 0) {
firstDrawMs = submitMs;
uploadFrameCompleteMs = submitMs;
@@ -860,7 +910,7 @@ async function createComparisonWorkloadRuntime(
const activeZoomEntry =
configuration.workload === 'zoom-text' ? entries[zoomAnimationState.phraseIndex] : undefined;
const zoomScale = activeZoomEntry?.node.scale.x ?? 1;
- measureVisibleEntries(entries, zoomScale, visibleEntryMetrics, visibleGeometryScratch);
+ measureVisibleEntries(entries, batchRoot, zoomScale, visibleEntryMetrics, visibleGeometryScratch);
const effectiveCssFontSize =
configuration.workload === 'zoom-text' ? ZOOM_TEXT_BASE_CSS_PX * zoomScale : configuration.fontSize;
const framebufferGpuBytes = rendererViewport.drawingBufferWidth * rendererViewport.drawingBufferHeight * 4;
@@ -1085,16 +1135,20 @@ async function createComparisonWorkloadRuntime(
* batch that owns one set of GPU resources. A single-paragraph workload gets a plain Group and keeps its own implicit
* batch of one, which is what it already was.
*/
-function createBatchRoot(workload: ComparisonWorkloadId, font: WorkloadFont): THREE.Object3D {
+function createBatchRoot(workload: ComparisonWorkloadId): THREE.Object3D {
if (comparisonWorkloadDefinition(workload).batching === 'standalone') return new THREE.Group();
// `grow` keeps one buffer per physical resource. A chunked batch would split a paragraph's glyph run at every chunk
// boundary and turn one draw into several, which would make the batched lanes look worse than the standalone one.
- return new TextGroup({
- technique: font.technique,
+ return new TextGroup({
capacity: { size: 4_096, policy: 'grow' },
+ compositing: workloadCompositing(workload),
});
}
+function workloadCompositing(workload: ComparisonWorkloadId): 'ordered' | 'independent' {
+ return workload === 'icon-grid' ? 'independent' : 'ordered';
+}
+
function disposeBatchRoot(root: THREE.Object3D): void {
root.removeFromParent();
root.clear();
@@ -1538,6 +1592,7 @@ function disposeEntries(entries: readonly WorkloadEntry[]): void {
function measureVisibleEntries(
entries: readonly WorkloadEntry[],
+ batchRoot: THREE.Object3D,
zoomScale: number,
metrics: MutableVisibleEntryMetrics,
geometries: Set,
@@ -1549,12 +1604,14 @@ function measureVisibleEntries(
metrics.lineCount = 0;
metrics.missingGlyphCount = 0;
metrics.sourceTextLength = 0;
+ geometries.clear();
+ // Rust-planned TextGroup draws are siblings of the authored entry nodes. Traversing each entry therefore reports
+ // zero even though the shared command buffer submits one draw; traverse the realized batch root exactly once.
+ measureVisibleObject(batchRoot, metrics, geometries);
for (const entry of entries) {
if (!entry.node.visible) continue;
- geometries.clear();
- measureVisibleObject(entry.node, metrics, geometries);
- measureVisibleLayout(committedTextLayout(entry.text), zoomScale, metrics);
- if (entry.labelText !== undefined) measureVisibleLayout(committedTextLayout(entry.labelText), zoomScale, metrics);
+ measureVisibleLayout(committedTextMetrics(entry.text), zoomScale, metrics);
+ if (entry.labelText !== undefined) measureVisibleLayout(committedTextMetrics(entry.labelText), zoomScale, metrics);
metrics.sourceTextLength += entry.sourceText.length;
}
}
@@ -1575,11 +1632,15 @@ function measureVisibleObject(
for (const child of object.children) measureVisibleObject(child, metrics, geometries);
}
-function measureVisibleLayout(layout: ParagraphLayout, scale: number, metrics: MutableVisibleEntryMetrics): void {
+function measureVisibleLayout(
+ layout: ParagraphLayoutSummary,
+ scale: number,
+ metrics: MutableVisibleEntryMetrics,
+): void {
metrics.layoutWidth = Math.max(metrics.layoutWidth, layout.width * scale);
metrics.layoutHeight += layout.height * scale;
- metrics.lineCount += layout.lineGlyphCounts.length;
- for (const glyphId of layout.glyphIds) if (glyphId === 0) metrics.missingGlyphCount += 1;
+ metrics.lineCount += layout.lineCount;
+ metrics.missingGlyphCount += layout.missingGlyphCount;
}
function positive(value: number, label: string): number {
diff --git a/apps/benchmarks/src/techniques/bitmap/conformance-line.ts b/apps/benchmarks/src/techniques/bitmap/conformance-line.ts
index 1c2d4d63..9f6beb64 100644
--- a/apps/benchmarks/src/techniques/bitmap/conformance-line.ts
+++ b/apps/benchmarks/src/techniques/bitmap/conformance-line.ts
@@ -50,7 +50,7 @@ export function createBitmapConformanceLine(
try {
object.updateMatrixWorld(true);
if (object.error !== undefined) throw object.error;
- const layout = object.layout;
+ const layout = object.inspectLayout();
if (layout === undefined) throw new Error('target-v1 Text did not commit a bitmap layout');
const missingGlyphCount = layout.glyphIds.reduce((count, glyphId) => count + (glyphId === 0 ? 1 : 0), 0);
if (missingGlyphCount !== 0) throw new Error(`benchmark specimen contains ${missingGlyphCount} missing glyphs`);
diff --git a/apps/benchmarks/src/techniques/bitmap/persistent-scene.ts b/apps/benchmarks/src/techniques/bitmap/persistent-scene.ts
index 41814350..52662997 100644
--- a/apps/benchmarks/src/techniques/bitmap/persistent-scene.ts
+++ b/apps/benchmarks/src/techniques/bitmap/persistent-scene.ts
@@ -3,7 +3,7 @@ import {
type FontFeature,
type LoadedFont,
type ParagraphContentBox,
- type ParagraphLayout,
+ type ParagraphLayoutSummary,
type ParagraphStyle,
type RegisteredFont,
} from '@pmndrs/text';
@@ -242,8 +242,8 @@ function updateBitmapDrawVisibility(object: THREE.Object3D): void {
object.visible = glyphCount > 0;
}
-function countMissingGlyphs(layout: ParagraphLayout): number {
- return layout.glyphIds.reduce((count, glyphId) => count + (glyphId === 0 ? 1 : 0), 0);
+function countMissingGlyphs(layout: ParagraphLayoutSummary): number {
+ return layout.missingGlyphCount;
}
function bitmapContentBox(width: number, textAlign: 'start' | 'center'): ParagraphContentBox {
@@ -432,8 +432,8 @@ async function activateBitmapTextPersistentScene(
activeText.updateMatrixWorld(true);
if (activeText.error !== undefined) throw activeText.error;
const readyAt = performance.now();
- const committedLayout = (): ParagraphLayout => {
- const layout = activeText.layout;
+ const committedLayout = (): ParagraphLayoutSummary => {
+ const layout = activeText.measureLayout();
if (layout === undefined) throw new Error('live bitmap Text lost its committed layout');
return layout;
};
@@ -523,7 +523,7 @@ async function activateBitmapTextPersistentScene(
revision: presentation.revision,
presentationProgress: presentation.kind === 'settled' ? 1 : presentation.progress,
glyphCount: countRenderedGlyphs(activeText),
- lineCount: layout.lineGlyphCounts.length,
+ lineCount: layout.lineCount,
layoutWidth: layout.width,
layoutHeight: layout.height,
};
@@ -724,7 +724,7 @@ async function activateBitmapTextPersistentScene(
drawCount: countDraws(activeText),
layoutWidth: layout.width,
layoutHeight: layout.height,
- lineCount: layout.lineGlyphCounts.length,
+ lineCount: layout.lineCount,
strikePpem,
cssFontSize: currentFontSize,
renderedPpem: currentFontSize * viewport.dpr,
diff --git a/apps/benchmarks/src/techniques/mtsdf/persistent-scene.ts b/apps/benchmarks/src/techniques/mtsdf/persistent-scene.ts
index 6bdc5958..7df29fc8 100644
--- a/apps/benchmarks/src/techniques/mtsdf/persistent-scene.ts
+++ b/apps/benchmarks/src/techniques/mtsdf/persistent-scene.ts
@@ -3,7 +3,7 @@ import {
type FontFeature,
type LoadedFont,
type ParagraphContentBox,
- type ParagraphLayout,
+ type ParagraphLayoutSummary,
type ParagraphStyle,
type RegisteredFont,
} from '@pmndrs/text';
@@ -462,7 +462,7 @@ export function createMtsdfTextPersistentScene(options: MtsdfTextPersistentScene
drawCount: drawCount(resources.line),
layoutWidth: layout.width,
layoutHeight: layout.height,
- lineCount: layout.lineGlyphCounts.length,
+ lineCount: layout.lineCount,
atlasGpuBytes,
framebufferGpuBytes,
totalGpuBytes: atlasGpuBytes + framebufferGpuBytes,
@@ -675,14 +675,14 @@ function positionLiveLine(
line.position.set(x, y, 0);
}
-function committedLayout(line: Text): ParagraphLayout {
- const layout = line.layout;
+function committedLayout(line: Text): ParagraphLayoutSummary {
+ const layout = line.measureLayout();
if (layout === undefined) throw new Error('live MSDF Text lost its committed layout');
return layout;
}
-function missingGlyphCount(layout: ParagraphLayout): number {
- return layout.glyphIds.reduce((count, glyphId) => count + (glyphId === 0 ? 1 : 0), 0);
+function missingGlyphCount(layout: ParagraphLayoutSummary): number {
+ return layout.missingGlyphCount;
}
function positiveViewportSize(value: number, name: string): number {
diff --git a/apps/benchmarks/src/techniques/shared/glyph-origin-transition.ts b/apps/benchmarks/src/techniques/shared/glyph-origin-transition.ts
index e5223c9a..82d6a1d1 100644
--- a/apps/benchmarks/src/techniques/shared/glyph-origin-transition.ts
+++ b/apps/benchmarks/src/techniques/shared/glyph-origin-transition.ts
@@ -1,4 +1,5 @@
-import type { FontFeature, GlyphOriginUpdate, GlyphSnapshot, ParagraphLayout } from '@pmndrs/text';
+import type { FontFeature, ParagraphLayoutInspection } from '@pmndrs/text';
+import type { TextGlyphOriginSnapshot, TextGlyphOriginUpdate } from '@pmndrs/text/three';
/**
* The part of a committed target-v1 `Text` this helper needs. Core owns glyph snapshots and topology-guarded
@@ -6,9 +7,9 @@ import type { FontFeature, GlyphOriginUpdate, GlyphSnapshot, ParagraphLayout } f
* and interpolation live here — in the application — and every live technique scene shares this one implementation.
*/
export interface TransitionableText {
- readonly layout: ParagraphLayout | undefined;
- snapshotGlyphs(): GlyphSnapshot;
- setGlyphOrigins(update: GlyphOriginUpdate): void;
+ inspectLayout(): ParagraphLayoutInspection | undefined;
+ snapshotGlyphOrigins(): TextGlyphOriginSnapshot | undefined;
+ setGlyphOrigins(update: TextGlyphOriginUpdate): void;
clearGlyphOriginOverrides(): void;
}
@@ -57,7 +58,7 @@ export interface GlyphOriginPresentation {
*/
export function snapGlyphOrigins(text: TransitionableText): GlyphOriginPresentation {
text.clearGlyphOriginOverrides();
- return { transitioned: false, matchedGlyphs: 0, targetGlyphs: text.layout?.glyphIds.length ?? 0 };
+ return { transitioned: false, matchedGlyphs: 0, targetGlyphs: text.inspectLayout()?.glyphIds.length ?? 0 };
}
/** Displayed glyph origins copied out of one committed paragraph. It retains no renderer or core resources. */
@@ -85,10 +86,12 @@ const EMPTY_SNAPSHOT: GlyphOriginSnapshot = { glyphCount: 0, origins: new Map()
* which is a normal first-frame state rather than a failure, so it yields an empty snapshot that matches nothing.
*/
export function captureGlyphOrigins(text: TransitionableText): GlyphOriginSnapshot {
- const layout = text.layout;
+ const layout = text.inspectLayout();
if (layout === undefined) return EMPTY_SNAPSHOT;
- const glyphs = text.snapshotGlyphs();
- const identities = glyphIdentityKeys(layout, glyphs);
+ const glyphs = text.snapshotGlyphOrigins();
+ if (glyphs === undefined || glyphs.layout !== layout)
+ throw new TypeError('glyph origin snapshot lost its inspection');
+ const identities = glyphIdentityKeys(layout);
const origins = new Map();
for (let index = 0; index < identities.length; index += 1) {
origins.set(identities[index]!, [glyphs.displayedX[index]!, glyphs.displayedY[index]!]);
@@ -105,10 +108,12 @@ export function createGlyphOriginTransition(
text: TransitionableText,
from: GlyphOriginSnapshot,
): GlyphOriginTransition {
- const layout = text.layout;
+ const layout = text.inspectLayout();
if (layout === undefined) throw new TypeError('glyph-origin transition requires a committed paragraph');
- const glyphs = text.snapshotGlyphs();
- const identities = glyphIdentityKeys(layout, glyphs);
+ const glyphs = text.snapshotGlyphOrigins();
+ if (glyphs === undefined || glyphs.layout !== layout)
+ throw new TypeError('glyph origin snapshot lost its inspection');
+ const identities = glyphIdentityKeys(layout);
const targetGlyphs = identities.length;
const fromX = glyphs.shapedX.slice();
const fromY = glyphs.shapedY.slice();
@@ -120,7 +125,6 @@ export function createGlyphOriginTransition(
fromY[index] = origin[1];
matchedGlyphs += 1;
}
- const topology = glyphs.topology;
const targetX = glyphs.shapedX;
const targetY = glyphs.shapedY;
const displayedX = new Float32Array(targetGlyphs);
@@ -133,7 +137,7 @@ export function createGlyphOriginTransition(
}
// A reflow publishes a new layout object and a new topology together, which is exactly when interpolating between
// the old and new glyph arrays would be meaningless. Report it as staleness, the way a superseded update reads.
- if (disposed || text.layout !== layout) {
+ if (disposed || text.inspectLayout() !== layout) {
throw new DOMException('The glyph-origin transition is stale', 'AbortError');
}
for (let index = 0; index < targetGlyphs; index += 1) {
@@ -142,7 +146,7 @@ export function createGlyphOriginTransition(
displayedX[index] = startX + (targetX[index]! - startX) * nextProgress;
displayedY[index] = startY + (targetY[index]! - startY) * nextProgress;
}
- text.setGlyphOrigins({ topology, x: displayedX, y: displayedY });
+ text.setGlyphOrigins({ layout, x: displayedX, y: displayedY });
progress = nextProgress;
};
return {
@@ -233,18 +237,18 @@ function sameFontFeatures(previous: readonly FontFeature[], next: readonly FontF
* Reproduces the identity merged-v0 matched on: font handle, glyph id, cluster, exact font size, and the occurrence
* index that separates otherwise identical glyphs within one paragraph.
*/
-function glyphIdentityKeys(layout: ParagraphLayout, glyphs: GlyphSnapshot): readonly string[] {
- assertParallelGlyphIdentity(layout, glyphs);
+function glyphIdentityKeys(layout: ParagraphLayoutInspection): readonly string[] {
+ assertParallelGlyphIdentity(layout);
const counts = new Map();
const keys: string[] = [];
- for (let index = 0; index < glyphs.glyphIds.length; index += 1) {
- const fontHandle = layout.fontHandles[glyphs.fontSlots[index]!];
+ for (let index = 0; index < layout.glyphIds.length; index += 1) {
+ const fontHandle = layout.fontHandles[layout.glyphFontSlots[index]!];
if (fontHandle === undefined) throw new TypeError('paragraph layout references a missing font slot');
// Font size is deliberately absent. The merged renderer keyed on it, which made a glyph fail to match itself across
// the one change the transition exists to animate, so a size change reported every glyph as new and interpolated
// nothing. Font handle, glyph id, cluster, and occurrence still identify a glyph, and a uniform scale preserves
// visual order, so matching across a resize recovers exactly the glyph that moved.
- const baseKey = `${fontHandle}:${glyphs.glyphIds[index]!}:${glyphs.clusters[index]!}`;
+ const baseKey = `${fontHandle}:${layout.glyphIds[index]!}:${layout.clusters[index]!}`;
const occurrence = counts.get(baseKey) ?? 0;
counts.set(baseKey, occurrence + 1);
keys.push(`${baseKey}:${occurrence}`);
@@ -252,15 +256,14 @@ function glyphIdentityKeys(layout: ParagraphLayout, glyphs: GlyphSnapshot): read
return keys;
}
-function assertParallelGlyphIdentity(layout: ParagraphLayout, glyphs: GlyphSnapshot): void {
- const glyphCount = glyphs.glyphIds.length;
+function assertParallelGlyphIdentity(layout: ParagraphLayoutInspection): void {
+ const glyphCount = layout.glyphIds.length;
for (const values of [
- glyphs.clusters,
- glyphs.fontSlots,
- glyphs.shapedX,
- glyphs.shapedY,
- glyphs.displayedX,
- glyphs.displayedY,
+ layout.glyphStableIds,
+ layout.clusters,
+ layout.glyphFontSlots,
+ layout.x,
+ layout.y,
layout.glyphFontSizes,
]) {
if (values.length !== glyphCount) throw new TypeError('paragraph glyph identity arrays are not parallel');
diff --git a/apps/benchmarks/src/techniques/slug/persistent-scene.ts b/apps/benchmarks/src/techniques/slug/persistent-scene.ts
index e6852a22..3c6dc8ec 100644
--- a/apps/benchmarks/src/techniques/slug/persistent-scene.ts
+++ b/apps/benchmarks/src/techniques/slug/persistent-scene.ts
@@ -4,7 +4,7 @@ import {
type FontFeature,
type LoadedFont,
type ParagraphContentBox,
- type ParagraphLayout,
+ type ParagraphLayoutSummary,
type ParagraphStyle,
type RegisteredFont,
} from '@pmndrs/text';
@@ -485,7 +485,7 @@ export function createSlugTextPersistentScene(options: SlugTextPersistentSceneOp
drawCount: drawCount(line),
layoutWidth: layout.width,
layoutHeight: layout.height,
- lineCount: layout.lineGlyphCounts.length,
+ lineCount: layout.lineCount,
slugPageCount: currentFontFixture.rasterConfiguration.pageCount,
slugCurveTexelCount: currentFontFixture.rasterConfiguration.curveTexelCount,
slugCurveGpuBytes: currentFontFixture.rasterConfiguration.curveBytes,
@@ -681,14 +681,14 @@ function positionLiveLine(
line.position.set(x, y, 0);
}
-function committedLayout(line: Text): ParagraphLayout {
- const layout = line.layout;
+function committedLayout(line: Text): ParagraphLayoutSummary {
+ const layout = line.measureLayout();
if (layout === undefined) throw new Error('live Slug Text lost its committed layout');
return layout;
}
-function missingGlyphCount(layout: ParagraphLayout): number {
- return layout.glyphIds.reduce((count, glyphId) => count + (glyphId === 0 ? 1 : 0), 0);
+function missingGlyphCount(layout: ParagraphLayoutSummary): number {
+ return layout.missingGlyphCount;
}
function positiveViewportSize(value: number, name: string): number {
diff --git a/apps/benchmarks/src/v1-async-proof.ts b/apps/benchmarks/src/v1-async-proof.ts
deleted file mode 100644
index 4f37912b..00000000
--- a/apps/benchmarks/src/v1-async-proof.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { createTextPreparationWorker, createTextRuntime } from '@pmndrs/text';
-import { bitmap } from '@pmndrs/text/raster/bitmap';
-
-declare global {
- interface Window {
- targetV1AsyncReady: Promise;
- }
-}
-
-interface TargetV1AsyncResult {
- readonly status: string;
- readonly workerCount: number;
- readonly glyphCount: number;
- readonly progressEvents: number;
- readonly snapshotGlyphCount: number;
- readonly desiredGlyphCount: number;
- readonly superseded: boolean;
- readonly aborted: boolean;
-}
-
-window.targetV1AsyncReady = prepare();
-
-async function prepare(): Promise {
- let workerCount = 0;
- const runtime = await createTextRuntime({
- async: {
- createWorker: () => {
- workerCount += 1;
- return createTextPreparationWorker();
- },
- },
- });
- const font = await runtime.loadFont({
- input: { baked: '/fixtures/rendering/inter-bitmap-16.font.glb' },
- raster: { technique: bitmap, options: { strikes: [16] } },
- });
- const batch = runtime.createParagraphBatch({ technique: bitmap });
- const paragraph = batch.add({ font, text: 'Worker shaping proof' });
- const progress: unknown[] = [];
- try {
- const outcome = await runtime.updateAsync({ onProgress: (value) => progress.push(value) });
-
- paragraph.text = 'Worker A';
- const snapshotA = runtime.updateAsync();
- paragraph.text = 'Worker desired state B';
- await snapshotA;
- const snapshotGlyphCount = paragraph.committed?.layout.glyphIds.length ?? 0;
- runtime.update();
- const desiredGlyphCount = paragraph.committed?.layout.glyphIds.length ?? 0;
-
- paragraph.text = 'Old worker';
- const oldWorker = runtime.updateAsync();
- paragraph.text = 'Newest sync';
- runtime.update();
- const superseded = (await oldWorker).status === 'superseded';
-
- paragraph.text = 'Abort worker';
- const controller = new AbortController();
- const aborting = runtime.updateAsync({ signal: controller.signal });
- controller.abort('proof complete');
- const aborted = (await aborting).status === 'aborted';
- return {
- status: outcome.status,
- workerCount,
- glyphCount: paragraph.committed?.layout.glyphIds.length ?? 0,
- progressEvents: progress.length,
- snapshotGlyphCount,
- desiredGlyphCount,
- superseded,
- aborted,
- };
- } finally {
- batch.dispose();
- font.dispose();
- runtime.dispose();
- }
-}
diff --git a/apps/benchmarks/src/v1-bitmap-proof.ts b/apps/benchmarks/src/v1-bitmap-proof.ts
index 72edafc6..3709a70e 100644
--- a/apps/benchmarks/src/v1-bitmap-proof.ts
+++ b/apps/benchmarks/src/v1-bitmap-proof.ts
@@ -64,7 +64,7 @@ async function render(): Promise {
return {
backend: renderer.backend instanceof THREE.WebGLBackend ? 'webgl2' : 'webgpu',
drawCount: text.children.filter((child) => child instanceof THREE.Mesh).length,
- glyphCount: text.layout?.glyphIds.length ?? 0,
+ glyphCount: text.measureLayout()?.glyphCount ?? 0,
litPixels,
retainedDraw: retainedDraw === firstDraw,
retainedStorage: retainedDraw?.geometry.getAttribute('_pmndrsTextOrigins') === firstStorage,
diff --git a/apps/benchmarks/src/v1-compose-proof.ts b/apps/benchmarks/src/v1-compose-proof.ts
index c618ec4a..c07c24dc 100644
--- a/apps/benchmarks/src/v1-compose-proof.ts
+++ b/apps/benchmarks/src/v1-compose-proof.ts
@@ -1,21 +1,6 @@
-import type {
- GlyphBatchKey,
- LoadedFont,
- ParagraphBatchTarget,
- ParagraphBatchTargetUpdate,
- ParagraphId,
- PreparedGlyphBatch,
- PreparedParagraphBatchRevision,
-} from '@pmndrs/text';
-import { defineRasterTechnique } from '@pmndrs/text';
-import { bitmap, type BitmapPageData } from '@pmndrs/text/three/bitmap';
-import {
- bitmapShader,
- FontLoader,
- registerThreeRasterProgram,
- Text,
- type ThreeRasterTargetOwner,
-} from '@pmndrs/text/three';
+import type { LoadedFont } from '@pmndrs/text';
+import { bitmap } from '@pmndrs/text/three/bitmap';
+import { defineTextMaterial, FontLoader, Text } from '@pmndrs/text/three';
import * as TSL from 'three/tsl';
import * as THREE from 'three/webgpu';
@@ -36,17 +21,16 @@ interface TargetV1ComposeResult {
readonly canonicalGreenPixels: number;
}
-/**
- * A third-party technique is only a distinct program key here: every portable operation stays the first-party Bitmap
- * implementation, so any rendering difference this proof observes comes from the composed shader alone.
- */
-const composedBitmap = defineRasterTechnique({ ...bitmap, id: 'benchmarks.composed-bitmap' });
-
/**
* The composed program keeps the canonical position and coverage and tints only the resolved colour. Preserving the
* canonical glyph footprint while changing the paint is what proves it reuses the exported technique shader.
*/
-registerThreeRasterProgram(composedBitmap, (owner) => new ComposedBitmapTarget(owner));
+const composedMaterial = defineTextMaterial((context) => {
+ if (context.technique !== bitmap.id) throw new TypeError('compose proof requires the Bitmap material context');
+ const material = context.createDefaultMaterial();
+ material.colorNode = context.shader.color.mul(TSL.vec3(1, 0, 0));
+ return material;
+});
window.targetV1ComposeReady = render();
@@ -59,9 +43,8 @@ async function render(): Promise {
const target = new THREE.RenderTarget(256, 128, { format: THREE.RGBAFormat, type: THREE.UnsignedByteType });
target.texture.colorSpace = THREE.NoColorSpace;
let canonicalText: Text | undefined;
- let composedText: Text | undefined;
+ let composedText: Text | undefined;
let canonicalFont: LoadedFont | undefined;
- let composedFont: LoadedFont | undefined;
try {
renderer.setSize(256, 128, false);
renderer.setPixelRatio(1);
@@ -92,15 +75,12 @@ async function render(): Promise {
canonicalText.dispose();
canonicalText = undefined;
- composedFont = await loader.loadAsync({
- input: { baked: '/fixtures/rendering/inter-bitmap-16.font.glb' },
- raster: { technique: composedBitmap, options: { strikes: [16] } },
- });
composedText = new Text({
- font: composedFont,
+ font: canonicalFont,
text: 'Target v1 Bitmap',
style: { fontSize: 28 },
paint: { color: '#ffffff' },
+ material: composedMaterial,
});
composedText.position.set(-112, 24, 0);
scene.add(composedText);
@@ -110,7 +90,7 @@ async function render(): Promise {
return {
backend: renderer.backend instanceof THREE.WebGLBackend ? 'webgl2' : 'webgpu',
drawCount: composedText.children.filter((child) => child instanceof THREE.Mesh).length,
- glyphCount: composedText.layout?.glyphIds.length ?? 0,
+ glyphCount: composedText.measureLayout()?.glyphCount ?? 0,
litPixels: composed.lit,
redPixels: composed.red,
greenPixels: composed.green,
@@ -123,7 +103,6 @@ async function render(): Promise {
composedText?.removeFromParent();
composedText?.dispose();
canonicalFont?.dispose();
- composedFont?.dispose();
loader.dispose();
target.dispose();
renderer.dispose();
@@ -148,157 +127,3 @@ async function countPixels(renderer: THREE.WebGPURenderer, target: THREE.RenderT
}
return { lit, red, green };
}
-
-interface ComposedRevision {
- readonly sourceRevision: number;
- dispose(): void;
-}
-
-interface ComposedResource {
- readonly material: THREE.MeshBasicNodeMaterial;
- geometry(count: number): THREE.InstancedBufferGeometry;
- dispose(): void;
-}
-
-/**
- * A deliberately minimal third-party Three program: it owns its own attributes, geometry, and material, and rebuilds
- * them on every revision. Only the node graph is shared, and it comes from the exported canonical Bitmap shader.
- */
-class ComposedBitmapTarget implements ParagraphBatchTarget {
- readonly technique: typeof composedBitmap = composedBitmap;
- readonly #owner: ThreeRasterTargetOwner;
- readonly #textures = new Map();
-
- constructor(owner: ThreeRasterTargetOwner) {
- this.#owner = owner;
- }
-
- stage(
- _previous: ComposedRevision | undefined,
- next: PreparedParagraphBatchRevision,
- ): ParagraphBatchTargetUpdate {
- const resources = new Map();
- for (const batch of next.glyphBatches) resources.set(batch.key, this.#createResource(batch));
- const draws: THREE.Mesh[] = [];
- const parents: ParagraphId[] = [];
- for (let index = 0; index < next.glyphRuns.length; index += 1) {
- const run = next.glyphRuns[index]!;
- const resource = resources.get(run.batch);
- if (resource === undefined) throw new Error('composed run references an unknown physical batch');
- const mesh = new THREE.Mesh(resource.geometry(run.count), resource.material);
- mesh.userData.pmndrsTextRunStart = run.start;
- mesh.frustumCulled = false;
- mesh.renderOrder = this.#owner.renderOrderBase + index;
- draws.push(mesh);
- parents.push(run.paragraph);
- }
- const dispose = (): void => {
- for (const draw of draws) {
- draw.removeFromParent();
- draw.geometry.dispose();
- }
- for (const resource of resources.values()) resource.dispose();
- };
- let finished = false;
- return {
- status: 'ready',
- stage: {
- sourceRevision: next.revision,
- commit: () => {
- if (finished) throw new Error('composed stage is no longer active');
- finished = true;
- for (let index = 0; index < draws.length; index += 1)
- this.#owner.objectForParagraph(parents[index]!).add(draws[index]!);
- return { sourceRevision: next.revision, dispose };
- },
- abort: () => {
- if (finished) return;
- finished = true;
- dispose();
- },
- },
- };
- }
-
- dispose(): void {
- for (const texture of this.#textures.values()) texture.dispose();
- this.#textures.clear();
- }
-
- #createResource(batch: PreparedGlyphBatch): ComposedResource {
- const page = batch.font.data.strikes[batch.binding.strike]?.pages[batch.binding.page];
- if (page === undefined) throw new TypeError('composed binding references a missing decoded page');
- const storage = batch.storage;
- const origins = storageAttribute(storage.origins, 2);
- const sizes = storageAttribute(storage.sizes, 2);
- const uvOrigins = storageAttribute(storage.uvOrigins, 2);
- const uvSizes = storageAttribute(storage.uvSizes, 2);
- const colors = storageAttribute(storage.colors, 4);
- const runStart = TSL.uniform(0, 'uint').onObjectUpdate(
- ({ object }) => (object?.userData.pmndrsTextRunStart as number | undefined) ?? 0,
- );
- const instance = TSL.instanceIndex.add(runStart);
- const shader = bitmapShader(
- {
- origin: TSL.storage(origins, 'vec2', origins.count).setPBO(true).element(instance),
- size: TSL.storage(sizes, 'vec2', sizes.count).setPBO(true).element(instance),
- uvOrigin: TSL.storage(uvOrigins, 'vec2', uvOrigins.count).setPBO(true).element(instance),
- uvSize: TSL.storage(uvSizes, 'vec2', uvSizes.count).setPBO(true).element(instance),
- color: TSL.storage(colors, 'vec4', colors.count).setPBO(true).element(instance),
- },
- { page: this.#texture(page) },
- );
- const material = new THREE.MeshBasicNodeMaterial({
- depthTest: false,
- depthWrite: false,
- side: THREE.DoubleSide,
- transparent: true,
- });
- material.positionNode = shader.position;
- material.vertexNode = shader.clipPosition;
- material.colorNode = shader.color.mul(TSL.vec3(1, 0, 0));
- material.opacityNode = shader.opacity;
- return {
- material,
- geometry(count) {
- const geometry = new THREE.InstancedBufferGeometry();
- geometry.setAttribute(
- 'position',
- new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 1, 0], 3),
- );
- geometry.setAttribute('uv', new THREE.Float32BufferAttribute([0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1], 2));
- geometry.instanceCount = count;
- geometry.setAttribute('_composedOrigins', origins);
- geometry.setAttribute('_composedSizes', sizes);
- geometry.setAttribute('_composedUvOrigins', uvOrigins);
- geometry.setAttribute('_composedUvSizes', uvSizes);
- geometry.setAttribute('_composedColors', colors);
- return geometry;
- },
- dispose() {
- material.dispose();
- },
- };
- }
-
- #texture(page: BitmapPageData): THREE.DataTexture {
- let texture = this.#textures.get(page.resource);
- if (texture !== undefined) return texture;
- texture = new THREE.DataTexture(page.bytes, page.width, page.height, THREE.RedFormat, THREE.UnsignedByteType);
- texture.colorSpace = THREE.NoColorSpace;
- texture.magFilter = THREE.LinearFilter;
- texture.minFilter = THREE.LinearFilter;
- texture.generateMipmaps = false;
- texture.flipY = false;
- texture.needsUpdate = true;
- this.#textures.set(page.resource, texture);
- return texture;
- }
-}
-
-function storageAttribute(array: Float32Array, itemSize: number): THREE.StorageInstancedBufferAttribute {
- const attribute = new THREE.StorageInstancedBufferAttribute(new Float32Array(array), itemSize);
- attribute.setUsage(THREE.DynamicDrawUsage);
- attribute.needsUpdate = true;
- return attribute;
-}
diff --git a/apps/benchmarks/src/v1-mtsdf-proof.ts b/apps/benchmarks/src/v1-mtsdf-proof.ts
index d8735358..08908ee9 100644
--- a/apps/benchmarks/src/v1-mtsdf-proof.ts
+++ b/apps/benchmarks/src/v1-mtsdf-proof.ts
@@ -71,7 +71,7 @@ async function render(): Promise {
return {
backend: renderer.backend instanceof THREE.WebGLBackend ? 'webgl2' : 'webgpu',
drawCount: text.children.filter((child) => child instanceof THREE.Mesh).length,
- glyphCount: text.layout?.glyphIds.length ?? 0,
+ glyphCount: text.measureLayout()?.glyphCount ?? 0,
litPixels,
retainedDraw: retainedDraw === firstDraw,
retainedStorage: retainedDraw?.geometry.getAttribute('_pmndrsText_geometry') === firstStorage,
diff --git a/apps/benchmarks/src/v1-slug-proof.ts b/apps/benchmarks/src/v1-slug-proof.ts
index a32400f1..f97e2dda 100644
--- a/apps/benchmarks/src/v1-slug-proof.ts
+++ b/apps/benchmarks/src/v1-slug-proof.ts
@@ -71,7 +71,7 @@ async function render(): Promise {
return {
backend: renderer.backend instanceof THREE.WebGLBackend ? 'webgl2' : 'webgpu',
drawCount: text.children.filter((child) => child instanceof THREE.Mesh).length,
- glyphCount: text.layout?.glyphIds.length ?? 0,
+ glyphCount: text.measureLayout()?.glyphCount ?? 0,
litPixels,
retainedDraw: retainedDraw === firstDraw,
retainedStorage: retainedDraw?.geometry.getAttribute('_pmndrsText_geometry') === firstStorage,
diff --git a/apps/benchmarks/src/workloads/dynamic-layout/scene.ts b/apps/benchmarks/src/workloads/dynamic-layout/scene.ts
index 63e139b6..1b33ec0a 100644
--- a/apps/benchmarks/src/workloads/dynamic-layout/scene.ts
+++ b/apps/benchmarks/src/workloads/dynamic-layout/scene.ts
@@ -5,7 +5,7 @@ import type { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition } fr
import { benchmarkContentWidth, LIVE_TEXT_COLOR, LIVE_TEXT_LINE_HEIGHT } from '../shared/text-style';
import {
- committedTextLayout,
+ committedTextMetrics,
exactWidth,
paintColor,
publishWorkloadTexts,
@@ -167,7 +167,7 @@ export function layoutDynamicLayoutEntries(
const inset = 20;
const laneHeight = viewportHeight / Math.max(1, entries.length);
for (const [index, entry] of entries.entries()) {
- const layout = committedTextLayout(entry.text);
+ const layout = committedTextMetrics(entry.text);
const x =
entry.alignment === 'end'
? viewportWidth - inset - layout.width
diff --git a/apps/benchmarks/src/workloads/icon-grid/scene.ts b/apps/benchmarks/src/workloads/icon-grid/scene.ts
index 5e70dbec..f6072693 100644
--- a/apps/benchmarks/src/workloads/icon-grid/scene.ts
+++ b/apps/benchmarks/src/workloads/icon-grid/scene.ts
@@ -5,7 +5,7 @@ import fontAwesomeIcons from '../../../fixtures/fonts/font-awesome-free-6.7.2/ic
import type { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition } from '../comparison/contracts';
import { LIVE_TEXT_COLOR, LIVE_TEXT_LINE_HEIGHT } from '../shared/text-style';
import {
- committedTextLayout,
+ committedTextMetrics,
exactWidth,
paintColor,
publishWorkloadTexts,
@@ -160,7 +160,7 @@ export function positionIconGridEntry(
row: number,
iconSize: number,
): void {
- const iconLayout = committedTextLayout(entry.text);
+ const iconLayout = committedTextMetrics(entry.text);
entry.node.position.set(
layout.inset + column * (layout.cellWidth + layout.gap),
-(layout.inset + row * (layout.cellHeight + layout.gap)),
diff --git a/apps/benchmarks/src/workloads/off-axis-3d/scene.ts b/apps/benchmarks/src/workloads/off-axis-3d/scene.ts
index 12fe8d2d..04e8afd1 100644
--- a/apps/benchmarks/src/workloads/off-axis-3d/scene.ts
+++ b/apps/benchmarks/src/workloads/off-axis-3d/scene.ts
@@ -6,7 +6,7 @@ import { createOklabColorCycle } from '../shared/oklab-color-cycle';
import { benchmarkContentWidth, LIVE_TEXT_COLOR, LIVE_TEXT_LINE_HEIGHT } from '../shared/text-style';
import {
- committedTextLayout,
+ committedTextMetrics,
exactWidth,
paintColor,
type ComparisonWorkloadEntry,
@@ -102,7 +102,7 @@ export function layoutOffAxis3dEntries(
): void {
const entry = entries[0];
if (entry === undefined) return;
- const layout = committedTextLayout(entry.text);
+ const layout = committedTextMetrics(entry.text);
entry.text.position.set(-layout.width / 2, layout.height / 2, 0);
entry.node.position.set(viewportWidth * (0.5 + OFF_AXIS_HORIZONTAL_BIAS_RATIO), -viewportHeight / 2, 0);
}
diff --git a/apps/benchmarks/src/workloads/paint-effects/scene.ts b/apps/benchmarks/src/workloads/paint-effects/scene.ts
index 76a44c31..9f045b40 100644
--- a/apps/benchmarks/src/workloads/paint-effects/scene.ts
+++ b/apps/benchmarks/src/workloads/paint-effects/scene.ts
@@ -4,7 +4,7 @@ import type { RasterTechnique } from '../../benchmark/url-state';
import type { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition } from '../comparison/contracts';
import { benchmarkContentWidth, LIVE_TEXT_LINE_HEIGHT } from '../shared/text-style';
import {
- committedTextLayout,
+ committedTextMetrics,
exactWidth,
paintColor,
type ComparisonWorkloadEntry,
@@ -135,7 +135,7 @@ export function layoutPaintEffectsEntries(
): void {
const entry = entries[0];
if (entry === undefined) return;
- const layout = committedTextLayout(entry.text);
+ const layout = committedTextMetrics(entry.text);
entry.text.position.set(
Math.max(12, (viewportWidth - layout.width) / 2),
-Math.max(18, (viewportHeight - layout.height) / 2),
diff --git a/apps/benchmarks/src/workloads/paragraph-stress/scene.ts b/apps/benchmarks/src/workloads/paragraph-stress/scene.ts
index faec8db7..fe964db2 100644
--- a/apps/benchmarks/src/workloads/paragraph-stress/scene.ts
+++ b/apps/benchmarks/src/workloads/paragraph-stress/scene.ts
@@ -6,7 +6,7 @@ import { paragraphStressScrollProgress } from '../../benchmark/paragraph-stress-
import type { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition } from '../comparison/contracts';
import { benchmarkContentWidth, LIVE_TEXT_COLOR, LIVE_TEXT_LINE_HEIGHT } from '../shared/text-style';
import {
- committedTextLayout,
+ committedTextMetrics,
exactWidth,
paintColor,
type ComparisonWorkloadEntry,
@@ -73,7 +73,7 @@ export function layoutParagraphStressEntries(
): void {
const entry = entries[0];
if (entry === undefined) return;
- const layout = committedTextLayout(entry.text);
+ const layout = committedTextMetrics(entry.text);
entry.text.position.set(
Math.max(12, (viewportWidth - layout.width) / 2),
-Math.max(12, (viewportHeight - layout.height) / 2),
@@ -90,7 +90,7 @@ export function animateParagraphStressScene(
): void {
const entry = entries[0];
if (entry === undefined) return;
- const layout = committedTextLayout(entry.text);
+ const layout = committedTextMetrics(entry.text);
const scrollProgress = paragraphStressScrollProgress(elapsedMs, configuration.animationSpeed);
const maximumScrollY = Math.max(0, layout.height - viewportHeight + 24);
scene.position.y = maximumScrollY * scrollProgress;
diff --git a/apps/benchmarks/src/workloads/rich-text/scene.ts b/apps/benchmarks/src/workloads/rich-text/scene.ts
index 634a2a30..7a7af2f5 100644
--- a/apps/benchmarks/src/workloads/rich-text/scene.ts
+++ b/apps/benchmarks/src/workloads/rich-text/scene.ts
@@ -5,7 +5,7 @@ import type { RasterTechnique } from '../../benchmark/url-state';
import type { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition } from '../comparison/contracts';
import { benchmarkContentWidth, LIVE_TEXT_COLOR_CSS, LIVE_TEXT_LINE_HEIGHT } from '../shared/text-style';
import {
- committedTextLayout,
+ committedTextMetrics,
exactWidth,
type ComparisonWorkloadEntry,
type WorkloadFont,
@@ -311,7 +311,7 @@ export function layoutRichTextEntries(
viewportWidth: number,
viewportHeight: number,
): void {
- const layouts = entries.map(({ text }) => committedTextLayout(text));
+ const layouts = entries.map(({ text }) => committedTextMetrics(text));
if (layouts.length === 0) return;
const stackHeight =
layouts.reduce((total, layout) => total + layout.height, 0) + RICH_TEXT_PARAGRAPH_GAP * (layouts.length - 1);
diff --git a/apps/benchmarks/src/workloads/shared/scene-entry.ts b/apps/benchmarks/src/workloads/shared/scene-entry.ts
index 73dd55ea..3176f70d 100644
--- a/apps/benchmarks/src/workloads/shared/scene-entry.ts
+++ b/apps/benchmarks/src/workloads/shared/scene-entry.ts
@@ -1,4 +1,4 @@
-import type { AnyRasterTechnique, LoadedFont, ParagraphLayout } from '@pmndrs/text';
+import type { AnyRasterTechnique, LoadedFont, ParagraphLayoutSummary } from '@pmndrs/text';
import { TextGroup, type Text } from '@pmndrs/text/three';
import type * as THREE from 'three/webgpu';
@@ -9,7 +9,7 @@ import type * as THREE from 'three/webgpu';
*/
export type WorkloadFont = LoadedFont;
export type WorkloadText = Text;
-export type WorkloadTextGroup = TextGroup;
+export type WorkloadTextGroup = TextGroup;
export interface MutableSpanPaint {
color: string;
@@ -71,8 +71,8 @@ export interface WorkloadTextFactoryContext {
* Commits every pending Text edit beneath `root` and reports the first failure.
*
* Target-v1 has no per-Text readiness promise. A `TextGroup` — and a standalone `Text` that has a parent — reconciles,
- * shapes, lays out, and packs synchronously inside `updateMatrixWorld`, so this call is exactly the point at which
- * `layout` becomes readable and `error` becomes meaningful.
+ * shapes, lays out, and packs synchronously inside `updateMatrixWorld`, so this call is exactly the point at which an
+ * explicit retained-Rust measurement can observe the committed revision and `error` becomes meaningful.
*/
export function publishWorkloadTexts(root: THREE.Object3D, entries: readonly ComparisonWorkloadEntry[]): void {
root.updateMatrixWorld(true);
@@ -83,11 +83,11 @@ export function publishWorkloadTexts(root: THREE.Object3D, entries: readonly Com
}
}
-/** Returns the layout committed by the Text lifecycle before a workload positions its scene. */
-export function committedTextLayout(text: WorkloadText): ParagraphLayout {
- const layout = text.layout;
- if (layout === undefined) throw new Error('workload Text lost its committed layout');
- return layout;
+/** Explicitly queries the aggregate metrics committed by the Rust Text lifecycle before scene positioning. */
+export function committedTextMetrics(text: WorkloadText): ParagraphLayoutSummary {
+ const metrics = text.measureLayout();
+ if (metrics === undefined) throw new Error('workload Text lost its committed layout metrics');
+ return metrics;
}
/** Target-v1 paint takes CSS colors, while the comparison palettes stay authored as 24-bit hex. */
diff --git a/apps/benchmarks/src/workloads/text-ladder/scene.ts b/apps/benchmarks/src/workloads/text-ladder/scene.ts
index f147ee5c..46fb111f 100644
--- a/apps/benchmarks/src/workloads/text-ladder/scene.ts
+++ b/apps/benchmarks/src/workloads/text-ladder/scene.ts
@@ -5,7 +5,7 @@ import type { RasterConformanceSpecimen } from '../../benchmark/font-fixtures';
import type { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition } from '../comparison/contracts';
import { LIVE_TEXT_COLOR, LIVE_TEXT_LINE_HEIGHT } from '../shared/text-style';
import {
- committedTextLayout,
+ committedTextMetrics,
paintColor,
type ComparisonWorkloadEntry,
type WorkloadTextFactoryContext,
@@ -154,7 +154,7 @@ export function setTextLadderScenePosition(
}
export function layoutTextLadderEntries(entries: readonly ComparisonWorkloadEntry[], viewportWidth: number): void {
- const layouts = entries.map(({ text }) => committedTextLayout(text));
+ const layouts = entries.map(({ text }) => committedTextMetrics(text));
const widestLine = layouts.reduce((maximum, layout) => Math.max(maximum, layout.width), 0);
const centeredColumnWidth = Math.min(widestLine, viewportWidth * 0.94);
const x = Math.max(LADDER_INSET_CSS_PX, (viewportWidth - centeredColumnWidth) / 2);
@@ -177,7 +177,7 @@ export function animateTextLadderScene(
): void {
const finalEntry = entries[entries.length - 1];
if (finalEntry === undefined) return;
- const layout = committedTextLayout(finalEntry.text);
+ const layout = committedTextMetrics(finalEntry.text);
setTextLadderScenePosition(positionScratch, {
animationSpeed: configuration.animationSpeed,
elapsedMs,
diff --git a/apps/benchmarks/src/workloads/zoom-text/scene.ts b/apps/benchmarks/src/workloads/zoom-text/scene.ts
index d40ae150..63ee4f20 100644
--- a/apps/benchmarks/src/workloads/zoom-text/scene.ts
+++ b/apps/benchmarks/src/workloads/zoom-text/scene.ts
@@ -4,7 +4,7 @@ import * as THREE from 'three/webgpu';
import type { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition } from '../comparison/contracts';
import { LIVE_TEXT_COLOR, LIVE_TEXT_LINE_HEIGHT } from '../shared/text-style';
import {
- committedTextLayout,
+ committedTextMetrics,
paintColor,
type ComparisonWorkloadEntry,
type WorkloadTextFactoryContext,
@@ -197,7 +197,7 @@ export function animateZoomTextEntries(
}
function layoutZoomTextEntry(entry: ComparisonWorkloadEntry, viewportWidth: number, viewportHeight: number): void {
- const layout = committedTextLayout(entry.text);
+ const layout = committedTextMetrics(entry.text);
entry.text.position.set(-layout.width / 2, layout.height / 2, 0);
entry.node.position.set(viewportWidth / 2, -viewportHeight / 2, 0);
entry.node.scale.setScalar(1);
diff --git a/apps/benchmarks/v1-async.html b/apps/benchmarks/v1-async.html
deleted file mode 100644
index 55b45ccd..00000000
--- a/apps/benchmarks/v1-async.html
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
- target-v1 async preparation proof
-
-
-
-
-
diff --git a/apps/benchmarks/vitexec/paragraph-stress-timing.probe.ts b/apps/benchmarks/vitexec/paragraph-stress-timing.probe.ts
new file mode 100644
index 00000000..d177f23e
--- /dev/null
+++ b/apps/benchmarks/vitexec/paragraph-stress-timing.probe.ts
@@ -0,0 +1,110 @@
+export {};
+
+const viewport = await waitForViewport();
+performance.clearMeasures();
+const initialReflows = integerAttribute(viewport, 'data-reflow-count');
+const targetReflows = initialReflows + 16;
+const frameDeltas: number[] = [];
+let previousFrame = performance.now();
+
+await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => reject(new Error(`timed out at reflow ${viewport.dataset.reflowCount}`)), 30_000);
+ const frame = (timestamp: number): void => {
+ frameDeltas.push(timestamp - previousFrame);
+ previousFrame = timestamp;
+ if (integerAttribute(viewport, 'data-reflow-count') >= targetReflows) {
+ clearTimeout(timeout);
+ resolve();
+ } else {
+ requestAnimationFrame(frame);
+ }
+ };
+ requestAnimationFrame(frame);
+});
+
+const measures = performance.getEntriesByType('measure') as PerformanceMeasure[];
+const summaries = Object.fromEntries(
+ [...new Set(measures.map(({ name }) => name))]
+ .sort()
+ .map((name) => [name, summarize(measures.filter((entry) => entry.name === name).map(({ duration }) => duration))]),
+);
+const elapsed = frameDeltas.reduce((sum, duration) => sum + duration, 0);
+console.log(
+ 'paragraph-stress-timing-ready',
+ JSON.stringify({
+ draws: integerAttribute(viewport, 'data-draw-count'),
+ engineUpdates: measures.filter(({ name }) => name === '@pmndrs/text engine.update').length,
+ finalReflows: integerAttribute(viewport, 'data-reflow-count'),
+ glyphs: integerAttribute(viewport, 'data-glyph-count'),
+ measuredReflows: targetReflows - initialReflows,
+ rafFps: Number(((frameDeltas.length * 1_000) / elapsed).toFixed(1)),
+ rafMaxMs: Number(Math.max(...frameDeltas).toFixed(3)),
+ rafP95Ms: Number(percentile(frameDeltas, 0.95).toFixed(3)),
+ summaries,
+ }),
+);
+
+function waitForViewport(): Promise {
+ const find = (): HTMLElement | undefined => {
+ const candidate = document.querySelector(
+ '[data-testid="comparison-live-viewport"][data-workload="paragraph-stress"]',
+ );
+ return candidate !== null &&
+ Number(candidate.dataset.framesPerSecond) > 0 &&
+ Number(candidate.dataset.glyphCount) > 0
+ ? candidate
+ : undefined;
+ };
+ const current = find();
+ if (current !== undefined) return Promise.resolve(current);
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ observer.disconnect();
+ reject(new Error('timed out waiting for Paragraph Stress'));
+ }, 60_000);
+ const observer = new MutationObserver(() => {
+ const candidate = find();
+ if (candidate === undefined) return;
+ clearTimeout(timeout);
+ observer.disconnect();
+ resolve(candidate);
+ });
+ observer.observe(document.documentElement, { attributes: true, childList: true, subtree: true });
+ });
+}
+
+function integerAttribute(element: HTMLElement, name: string): number {
+ const value = Number(element.getAttribute(name));
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${name} is not a nonnegative integer`);
+ return value;
+}
+
+function summarize(values: readonly number[]): Record {
+ return {
+ count: values.length,
+ maxMs: Number(Math.max(...values).toFixed(3)),
+ medianMs: Number(percentile(values, 0.5).toFixed(3)),
+ p95Ms: Number(percentile(values, 0.95).toFixed(3)),
+ totalMs: Number(values.reduce((sum, value) => sum + value, 0).toFixed(3)),
+ };
+}
+
+function percentile(values: readonly number[], ratio: number): number {
+ if (values.length === 0) return 0;
+ const sorted = [...values].sort((left, right) => left - right);
+ return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)]!;
+}
+
+/* @workflow
+{
+ "name": "benchmark:paragraph-stress-timing",
+ "summary": "Attribute Paragraph Stress reflow and frame time through User Timing spans.",
+ "requirements": "GPU-enabled Chromium and Vitexec.",
+ "writes": "Standard output; optional caller-owned CPU and performance traces.",
+ "args": [
+ "--gpu",
+ "--path",
+ "/presentation?mode=benchmark&technique=mtsdf&backend=webgpu&delivery=baked&dpr=2&font=inter&workload=paragraph-stress&textTimings=1"
+ ]
+}
+*/
diff --git a/apps/benchmarks/vitexec/presentation-framerate-sweep.probe.ts b/apps/benchmarks/vitexec/presentation-framerate-sweep.probe.ts
index 9a151918..4bf2159e 100644
--- a/apps/benchmarks/vitexec/presentation-framerate-sweep.probe.ts
+++ b/apps/benchmarks/vitexec/presentation-framerate-sweep.probe.ts
@@ -147,13 +147,14 @@ async function readyViewport(technique: Technique, workload: string): Promise
+
+
+
+
+
+ @pmndrs/text · R3F hello world
+
+
+
+
+
+
diff --git a/apps/r3f-hello-world/package.json b/apps/r3f-hello-world/package.json
new file mode 100644
index 00000000..89ac0f2f
--- /dev/null
+++ b/apps/r3f-hello-world/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "@pmndrs/text-r3f-hello-world",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "assets:generate": "node ./scripts/generate-fonts.mts",
+ "assets:check": "node ./scripts/generate-fonts.mts --check",
+ "build": "vite build",
+ "check": "pnpm typecheck && pnpm lint && pnpm format:check && pnpm assets:check && pnpm build && pnpm live:check",
+ "dev": "vite",
+ "format:check": "oxfmt --check .",
+ "lint": "oxlint --deny-warnings .",
+ "live:check": "vitexec --gpu ./scripts/live-check.probe.ts",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@pmndrs/text": "workspace:*",
+ "@react-three/fiber": "10.0.0-alpha.2",
+ "react": "19.2.8",
+ "react-dom": "19.2.8",
+ "three": "0.185.1"
+ },
+ "devDependencies": {
+ "@babel/core": "8.0.1",
+ "@rolldown/plugin-babel": "0.2.3",
+ "@types/node": "24.13.3",
+ "@types/react": "19.2.14",
+ "@types/react-dom": "19.2.3",
+ "@types/three": "0.185.1",
+ "@vitejs/plugin-react": "6.0.1",
+ "babel-plugin-react-compiler": "1.0.0",
+ "oxfmt": "0.35.0",
+ "oxlint": "1.75.0",
+ "typescript": "7.0.2",
+ "vite": "8.1.5",
+ "vitexec": "0.1.17"
+ }
+}
diff --git a/apps/r3f-hello-world/scripts/generate-fonts.mts b/apps/r3f-hello-world/scripts/generate-fonts.mts
new file mode 100644
index 00000000..b8e11b1b
--- /dev/null
+++ b/apps/r3f-hello-world/scripts/generate-fonts.mts
@@ -0,0 +1,104 @@
+import { execFile } from 'node:child_process';
+import { createHash } from 'node:crypto';
+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join, resolve } from 'node:path';
+import { promisify } from 'node:util';
+
+import { bitmapBaker } from '@pmndrs/text/bakers/bitmap';
+import { msdfBaker } from '@pmndrs/text/bakers/msdf';
+import { slugBaker } from '@pmndrs/text/bakers/slug';
+import { bakeFont } from '@pmndrs/text/bake';
+
+const run = promisify(execFile);
+const ROOT = resolve(import.meta.dirname, '../../..');
+const ASSETS = resolve(import.meta.dirname, '../assets');
+const HARFBUZZ_VERSION = '14.2.0';
+const BASIC_LATIN = 'U+0020-007E';
+const WORLD_ICONS = ['U+E47B', 'U+F0AC', 'U+F57C', 'U+F57D', 'U+F57E', 'U+F7A2'];
+const check = process.argv.includes('--check');
+
+const sources = [
+ {
+ input: resolve(ROOT, 'apps/benchmarks/fixtures/fonts/inter-v4.1/Inter-Regular.ttf'),
+ name: 'inter-latin',
+ unicodes: BASIC_LATIN,
+ },
+ {
+ input: resolve(ROOT, 'apps/benchmarks/fixtures/fonts/font-awesome-free-6.7.2/fa-solid-900.ttf'),
+ name: 'font-awesome-world',
+ unicodes: WORLD_ICONS.join(','),
+ },
+] as const;
+
+const temporaryDirectory = await mkdtemp(join(tmpdir(), 'pmndrs-text-r3f-example-'));
+try {
+ await assertHarfBuzzVersion();
+ const generatedAssets = check ? join(temporaryDirectory, 'assets') : ASSETS;
+ await mkdir(generatedAssets, { recursive: true });
+ const manifest = [];
+ for (const source of sources) {
+ const subset = join(temporaryDirectory, `${source.name}.ttf`);
+ await run('hb-subset', [source.input, `--unicodes=${source.unicodes}`, `--output-file=${subset}`]);
+ const asset = `${source.name}.font.glb`;
+ const output = resolve(generatedAssets, asset);
+ const report = await bakeFont({
+ input: subset,
+ output,
+ font: { fontFaceIndex: 0 },
+ rasters: [
+ {
+ baker: bitmapBaker,
+ packaging: { artifact: 'embedded', pages: 'embedded' },
+ options: { strikes: [32] },
+ },
+ {
+ baker: msdfBaker,
+ packaging: { artifact: 'embedded', pages: 'embedded' },
+ },
+ {
+ baker: slugBaker,
+ packaging: { artifact: 'embedded', pages: 'embedded' },
+ },
+ ],
+ });
+ const bytes = await readFile(output);
+ manifest.push({
+ asset,
+ bytes: bytes.byteLength,
+ sha256: createHash('sha256').update(bytes).digest('hex'),
+ source: source.input.slice(ROOT.length + 1),
+ unicodes: source.unicodes,
+ outputs: report.execution.outputs.map(({ role, bytes: outputBytes, sha256 }) => ({
+ role,
+ bytes: outputBytes,
+ sha256,
+ })),
+ });
+ if (check && !(await readFile(resolve(ASSETS, asset))).equals(bytes)) {
+ throw new Error(`${asset} is not byte-identical to a fresh authenticated subset bake`);
+ }
+ }
+ const manifestText = `${JSON.stringify(
+ { schemaVersion: 0, harfBuzzVersion: HARFBUZZ_VERSION, assets: manifest },
+ undefined,
+ 2,
+ )}\n`;
+ if (check) {
+ if ((await readFile(resolve(ASSETS, 'manifest.json'), 'utf8')) !== manifestText) {
+ throw new Error('R3F example font manifest is stale');
+ }
+ } else {
+ await writeFile(resolve(ASSETS, 'manifest.json'), manifestText);
+ }
+} finally {
+ await rm(temporaryDirectory, { force: true, recursive: true });
+}
+
+async function assertHarfBuzzVersion(): Promise {
+ const { stdout } = await run('hb-subset', ['--version']);
+ const version = stdout.trim().match(/\d+\.\d+\.\d+$/u)?.[0];
+ if (version !== HARFBUZZ_VERSION) {
+ throw new Error(`R3F example assets require hb-subset ${HARFBUZZ_VERSION}; received ${String(version)}`);
+ }
+}
diff --git a/apps/r3f-hello-world/scripts/live-check.probe.ts b/apps/r3f-hello-world/scripts/live-check.probe.ts
new file mode 100644
index 00000000..9dfd82a2
--- /dev/null
+++ b/apps/r3f-hello-world/scripts/live-check.probe.ts
@@ -0,0 +1,66 @@
+export {};
+
+const canvas = await waitForCanvas();
+for (const [technique, clientX] of [
+ ['msdf', 224],
+ ['bitmap', 96],
+ ['slug', 352],
+] as const) {
+ if (canvas.dataset.exampleTechnique !== technique) clickCanvas(canvas, clientX, 200);
+ await waitForTechnique(canvas, technique);
+ if (canvas.dataset.exampleDraws !== '2' || canvas.dataset.exampleRecords !== '11') {
+ throw new Error(
+ `${technique} rendered ${String(canvas.dataset.exampleRecords)} records in ` +
+ `${String(canvas.dataset.exampleDraws)} draws; expected 11 records in two Rust-planned draws`,
+ );
+ }
+}
+
+console.log('r3f-hello-world-live-ok');
+
+async function waitForCanvas(): Promise {
+ for (let frame = 0; frame < 600; frame += 1) {
+ const value = document.querySelector('canvas');
+ if (value instanceof HTMLCanvasElement) return value;
+ await nextFrame();
+ }
+ throw new Error('R3F hello-world did not create a canvas');
+}
+
+async function waitForTechnique(targetCanvas: HTMLCanvasElement, technique: string): Promise {
+ for (let frame = 0; frame < 600; frame += 1) {
+ if (targetCanvas.dataset.exampleTechnique === technique && targetCanvas.dataset.exampleReady === 'true') return;
+ await nextFrame();
+ }
+ throw new Error(`R3F hello-world did not settle the ${technique} technique`);
+}
+
+function clickCanvas(targetCanvas: HTMLCanvasElement, clientX: number, clientY: number): void {
+ for (const event of [
+ new PointerEvent('pointerdown', {
+ bubbles: true,
+ button: 0,
+ buttons: 1,
+ clientX,
+ clientY,
+ pointerId: 1,
+ pointerType: 'mouse',
+ }),
+ new PointerEvent('pointerup', {
+ bubbles: true,
+ button: 0,
+ buttons: 0,
+ clientX,
+ clientY,
+ pointerId: 1,
+ pointerType: 'mouse',
+ }),
+ new MouseEvent('click', { bubbles: true, button: 0, clientX, clientY }),
+ ]) {
+ targetCanvas.dispatchEvent(event);
+ }
+}
+
+function nextFrame(): Promise {
+ return new Promise((resolve) => requestAnimationFrame(() => resolve()));
+}
diff --git a/apps/r3f-hello-world/src/app.tsx b/apps/r3f-hello-world/src/app.tsx
new file mode 100644
index 00000000..027a0ddd
--- /dev/null
+++ b/apps/r3f-hello-world/src/app.tsx
@@ -0,0 +1,22 @@
+import { Canvas } from '@react-three/fiber/webgpu';
+import { Suspense, useState } from 'react';
+
+import { TechniqueScene, type Technique } from './technique-scene';
+
+export function App() {
+ const [technique, setTechnique] = useState('msdf');
+
+ return (
+
+ );
+}
diff --git a/apps/r3f-hello-world/src/main.tsx b/apps/r3f-hello-world/src/main.tsx
new file mode 100644
index 00000000..bc5968d1
--- /dev/null
+++ b/apps/r3f-hello-world/src/main.tsx
@@ -0,0 +1,23 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+
+import shaperWasmUrl from '@pmndrs/text/text-shaper.wasm?url';
+
+import { App } from './app';
+import './styles.css';
+
+const root = document.querySelector('#root');
+if (root === null) throw new Error('R3F hello-world root is missing');
+
+const shaperPreload = document.createElement('link');
+shaperPreload.rel = 'preload';
+shaperPreload.as = 'fetch';
+shaperPreload.crossOrigin = 'anonymous';
+shaperPreload.href = shaperWasmUrl;
+document.head.append(shaperPreload);
+
+createRoot(root).render(
+
+
+ ,
+);
diff --git a/apps/r3f-hello-world/src/styles.css b/apps/r3f-hello-world/src/styles.css
new file mode 100644
index 00000000..704f5c62
--- /dev/null
+++ b/apps/r3f-hello-world/src/styles.css
@@ -0,0 +1,29 @@
+:root {
+ color-scheme: dark;
+ font-family: Inter, ui-sans-serif, system-ui, sans-serif;
+ background: #07090f;
+}
+
+html,
+body,
+#root {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ overflow: hidden;
+}
+
+canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+ touch-action: none;
+}
+
+.fallback {
+ display: grid;
+ width: 100%;
+ height: 100%;
+ place-items: center;
+ color: #d7deef;
+}
diff --git a/apps/r3f-hello-world/src/technique-scene.tsx b/apps/r3f-hello-world/src/technique-scene.tsx
new file mode 100644
index 00000000..f9192ed4
--- /dev/null
+++ b/apps/r3f-hello-world/src/technique-scene.tsx
@@ -0,0 +1,177 @@
+import { createFontStack, type FontSelection, type LoadedFont } from '@pmndrs/text';
+import { Text, useFont } from '@pmndrs/text/r3f';
+import { bitmap } from '@pmndrs/text/three/bitmap';
+import { msdf } from '@pmndrs/text/three/msdf';
+import { slug } from '@pmndrs/text/three/slug';
+import { useThree, type ThreeEvent } from '@react-three/fiber/webgpu';
+import { useEffect, useMemo, useRef } from 'react';
+import type { Group, InstancedBufferGeometry, Mesh } from 'three/webgpu';
+
+import iconFontUrl from '../assets/font-awesome-world.font.glb?url';
+import latinFontUrl from '../assets/inter-latin.font.glb?url';
+
+export type Technique = 'bitmap' | 'msdf' | 'slug';
+
+interface TechniqueSceneProps {
+ readonly onTechniqueChange: (technique: Technique) => void;
+ readonly technique: Technique;
+}
+
+const WORLD_ICON = '\uf0ac';
+const BitmapText = Text;
+const MsdfText = Text;
+const SlugText = Text;
+
+const bitmapLatinRequest = {
+ input: { baked: latinFontUrl },
+ raster: { technique: bitmap, options: { strikes: [32] } },
+} as const;
+const bitmapIconRequest = {
+ input: { baked: iconFontUrl },
+ raster: { technique: bitmap, options: { strikes: [32] } },
+} as const;
+const msdfLatinRequest = {
+ input: { baked: latinFontUrl },
+ raster: { technique: msdf },
+} as const;
+const msdfIconRequest = {
+ input: { baked: iconFontUrl },
+ raster: { technique: msdf },
+} as const;
+const slugLatinRequest = {
+ input: { baked: latinFontUrl },
+ raster: { technique: slug },
+} as const;
+const slugIconRequest = {
+ input: { baked: iconFontUrl },
+ raster: { technique: slug },
+} as const;
+
+export function TechniqueScene({ onTechniqueChange, technique }: TechniqueSceneProps) {
+ const viewport = useThree((state) => state.viewport);
+ const buttonFont = useFont(msdfLatinRequest);
+ const root = useRef(null);
+
+ useEffect(() => {
+ const canvas = document.querySelector('canvas');
+ if (!(canvas instanceof HTMLCanvasElement)) throw new Error('R3F hello-world canvas is missing');
+ canvas.dataset.exampleReady = 'false';
+ const frame = requestAnimationFrame(() => {
+ const copy = root.current?.getObjectByName('r3f-example-copy');
+ let draws = 0;
+ let records = 0;
+ copy?.traverse((object) => {
+ const mesh = object as Mesh;
+ if (mesh.isMesh !== true || mesh.userData.pmndrsTextRunStart === undefined) return;
+ draws += 1;
+ records += mesh.geometry.instanceCount;
+ });
+ canvas.dataset.exampleTechnique = technique;
+ canvas.dataset.exampleDraws = String(draws);
+ canvas.dataset.exampleRecords = String(records);
+ canvas.dataset.exampleReady = draws === 2 && records === 11 ? 'true' : 'false';
+ });
+ return () => cancelAnimationFrame(frame);
+ }, [technique]);
+
+ return (
+
+
+
+
+ );
+}
+
+function TechniqueCopy({ technique }: { readonly technique: Technique }) {
+ switch (technique) {
+ case 'bitmap':
+ return ;
+ case 'msdf':
+ return ;
+ case 'slug':
+ return ;
+ }
+}
+
+function BitmapCopy() {
+ const latin = useFont(bitmapLatinRequest);
+ const icons = useFont(bitmapIconRequest);
+ const font = useMemo(() => createFontStack(latin, icons), [icons, latin]);
+ return ;
+}
+
+function MsdfCopy() {
+ const latin = useFont(msdfLatinRequest);
+ const icons = useFont(msdfIconRequest);
+ const font = useMemo(() => createFontStack(latin, icons), [icons, latin]);
+ return ;
+}
+
+function SlugCopy() {
+ const latin = useFont(slugLatinRequest);
+ const icons = useFont(slugIconRequest);
+ const font = useMemo(() => createFontStack(latin, icons), [icons, latin]);
+ return ;
+}
+
+function Copy({
+ TextComponent,
+ font,
+}: {
+ readonly TextComponent: typeof Text;
+ readonly font: FontSelection;
+}) {
+ return (
+
+ Hello world {WORLD_ICON}
+
+ );
+}
+
+function TechniqueButtons({
+ font,
+ onTechniqueChange,
+ selected,
+}: {
+ readonly font: LoadedFont;
+ readonly onTechniqueChange: (technique: Technique) => void;
+ readonly selected: Technique;
+}) {
+ return (
+
+ {(['bitmap', 'msdf', 'slug'] as const).map((technique, index) => (
+
+ ) => {
+ event.stopPropagation();
+ onTechniqueChange(technique);
+ }}
+ onPointerEnter={() => {
+ document.body.style.cursor = 'pointer';
+ }}
+ onPointerLeave={() => {
+ document.body.style.cursor = 'default';
+ }}
+ position={[48, 0, -1]}
+ >
+
+
+
+
+ {technique.toUpperCase()}
+
+
+ ))}
+
+ );
+}
diff --git a/apps/r3f-hello-world/tsconfig.json b/apps/r3f-hello-world/tsconfig.json
new file mode 100644
index 00000000..486ab780
--- /dev/null
+++ b/apps/r3f-hello-world/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "composite": false,
+ "declaration": false,
+ "declarationMap": false,
+ "isolatedDeclarations": false,
+ "jsx": "react-jsx",
+ "lib": ["DOM", "DOM.Iterable", "ES2025"],
+ "types": ["vite/client"]
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/apps/r3f-hello-world/vite.config.ts b/apps/r3f-hello-world/vite.config.ts
new file mode 100644
index 00000000..bf1b0298
--- /dev/null
+++ b/apps/r3f-hello-world/vite.config.ts
@@ -0,0 +1,40 @@
+import babel from '@rolldown/plugin-babel';
+import react, { reactCompilerPreset } from '@vitejs/plugin-react';
+import { readFile } from 'node:fs/promises';
+import { defineConfig } from 'vite';
+
+const CROSS_ORIGIN_ISOLATION_HEADERS = {
+ 'Cross-Origin-Embedder-Policy': 'require-corp',
+ 'Cross-Origin-Opener-Policy': 'same-origin',
+} as const;
+const FONT_LICENSES = [
+ {
+ name: 'Inter 4.1',
+ url: new URL('../benchmarks/fixtures/fonts/inter-v4.1/LICENSE.txt', import.meta.url),
+ },
+ {
+ name: 'Font Awesome Free 6.7.2',
+ url: new URL('../benchmarks/fixtures/fonts/font-awesome-free-6.7.2/LICENSE.txt', import.meta.url),
+ },
+] as const;
+
+export default defineConfig({
+ plugins: [
+ react(),
+ babel({ presets: [reactCompilerPreset()] }),
+ {
+ name: 'font-notices',
+ async generateBundle() {
+ const notices = await Promise.all(
+ FONT_LICENSES.map(
+ async ({ name, url }) => `${name}\n${'='.repeat(name.length)}\n\n${await readFile(url, 'utf8')}`,
+ ),
+ );
+ this.emitFile({ type: 'asset', fileName: 'font-notices.txt', source: notices.join('\n\n') });
+ },
+ },
+ ],
+ build: { target: 'es2022' },
+ preview: { headers: CROSS_ORIGIN_ISOLATION_HEADERS },
+ server: { headers: CROSS_ORIGIN_ISOLATION_HEADERS },
+});
diff --git a/docs/engineering/code-style.md b/docs/engineering/code-style.md
index 0ca3b683..bea05ca5 100644
--- a/docs/engineering/code-style.md
+++ b/docs/engineering/code-style.md
@@ -18,8 +18,8 @@ sources:
resource: ../../packages/text/src/internal/runtime-bake-protocol.ts
title: Runtime bake protocol
- id: paragraph
- resource: ../../packages/text/src/paragraph.ts
- title: Paragraph engine
+ resource: ../../packages/text/src/three/text.ts
+ title: Retained paragraph and Three.js synchronization boundary
- id: benchmark-runner
resource: ../../apps/benchmarks/src/benchmark/runner.ts
title: Shared benchmark lifecycle
diff --git a/docs/log.md b/docs/log.md
index 9c0398e3..3c2e7716 100644
--- a/docs/log.md
+++ b/docs/log.md
@@ -1,7 +1,871 @@
# pmndrs/text documentation update log
+## 2026-08-10
+
+- **Provisioned the R3F asset subsetter on clean CI hosts** — The example's byte-exact asset check requires HarfBuzz
+ 14.2.0, but CI had provisioned only the separate 13.0.0 shaping oracle and CJK fixture tool. The authenticated utility
+ provisioner now accepts either recorded release, verifies the 14.2.0 source archive as
+ `94017020…eaff`, and keeps each build in its versioned ignored cache. CI publishes only the 14.2.0 utility directory to
+ later steps. A fresh source build self-identifies as 14.2.0, and the complete R3F type/lint/format, byte-exact asset,
+ production build, and live GPU interaction gate passes without changing either checked font artifact.
+
+- **Fixed stacked-PR size reporting at the action boundary** — The pinned Size Limit action executes its configured
+ command directly rather than through a shell, so the compatibility pipe had been passed to the measurement script as
+ inert arguments and the action received the full report object. The workflow now supplies the same base-compatible
+ adapter through an explicit `sh -c` boundary. Executing the exact parsed workflow command locally emits 39 validated
+ `{name, size}` rows.
+
+- **Closed detached WebGL2 PBO updates** — The complete Presentation matrix exposed a deterministic transparent Zoom Text
+ frame on forced WebGL2 Bitmap. Three's PBO setup had replaced each storage attribute array with a padded retained copy,
+ while later Rust command-buffer patches still changed only canonical storage. Dirty patches now copy their exact byte
+ ranges into the detached upload view before texture invalidation; WebGPU retains direct aliasing. A focused integration
+ fixture proves canonical/upload equality and untouched padding. All 48 Bitmap/MTSDF/Slug × WebGPU/WebGL2 workload
+ cells remain visible with one renderer. The matrix also closed a benchmark-only transition seam where Off-axis's 120%
+ default could be observed for one render under the preceding workload's 100% contract. The PBO fix adds 587 raw / 112
+ gzip / 64 Brotli bytes to Three; core JavaScript and Wasm remain byte-identical.
+
+- **Final renderer lifecycle size evidence** — Regenerated the canonical package-size record after the final Three retry,
+ dirty-range, disposal, and transform-identity fixes. Renderer-neutral JavaScript and the optimized shaper Wasm remain
+ byte-identical. The complete Three adapter adds 764 raw / 355 minified / 118 gzip / 82 Brotli bytes, putting the
+ Three-plus-core total at 1,488,082 raw / 498,494 gzip / 395,212 Brotli bytes with Three, React, and R3F external.
+ Every reviewed absolute and cumulative size ceiling still passes.
+
+- **Reproducible TypeScript-to-Rust migration evidence** — Rebuilt exact base commit `90964be0` in an isolated worktree
+ with its own lockfile and original public layout benchmark, then ran the unchanged 22,000-glyph target at eight warmups
+ and 31 measured repetitions on the same Darwin arm64 host as the current optimized artifact. The base
+ cold/font-size/width/suffix-edit medians are 58.32/12.09/9.15/39.61 ms. Current Bitmap, MTSDF, and Slug complete
+ `text_update` plus render-plan medians are respectively 15.90/6.04/2.78/13.48, 16.50/6.41/2.73/13.52, and
+ 16.73/6.64/2.96/14.37 ms. Checked JSON records retain the exact summaries, one shared Wasm identity, technique,
+ allocation strategy, cadence, and glyph target; a fixture contract requires every comparable Rust median to remain
+ below the recorded TypeScript median. This establishes the migration direction on this machine without turning timing
+ observations into cross-host CI thresholds or declaring the p95-under-4-ms target complete.
+
+- **Restored executable contract generation and compiled-ABI fuzzing** — Bidi/policy/UIKit and full CJK paragraph
+ contracts now regenerate through the public Rust-plan `Text` query path and run in `--check` mode from ordinary
+ benchmark gates. The checked fixtures stay byte-identical: a pre-f32-ABI numeric literal survives only when the current
+ value is exactly f32-equivalent, and the known UIKit rounding seam is recomputed independently. A new fixed-seed Wasm
+ smoke corpus mutates 64 policy and frame requests twice, requires identical bounded statuses with both valid and invalid
+ paths, and proves a fresh valid transaction succeeds after every mutation. This replaces the deleted legacy-export
+ fuzzing at the actual `text_update` and policy-registration boundaries; the package now passes 165 integration and
+ three fuzz-smoke tests in addition to 158 Rust tests.
+
+- **Closed the final adversarial Three lifecycle findings** — Consecutive render plans now accumulate and coalesce
+ attribute upload ranges until Three consumes them, preserving presentation restoration and retry writes across
+ multiple updates before one render. Disposed descendants leave the active batch without requiring synchronous host
+ detachment; complete batch validation stays inside the group error boundary; committed paragraph removals recycle
+ transform identities instead of growing the indexed table forever; and an unexpected semantic-query plan remains
+ recoverable through the owned-publication retry path. Focused regressions cover pending-range unions, attached disposal,
+ survivor rendering, and twelve create/remove cycles at constant transform capacity. All 158 Rust and 165 Node
+ integration tests pass. The direct benchmark's default changes from 5/11 to 8/31 warmup/measured samples so p95 is no
+ longer the maximum observation by construction.
+
+- **Passed the complete foundation gate and refreshed release evidence** — The exact pushed foundation commit passes
+ 158 Rust library tests, 165 Node integration tests, unchanged Unicode 17 vectors, 112 benchmark-app tests, all 16
+ isolated Chromium targets,
+ production builds, the R3F live GPU interaction, formatting, lint, types, packaging, and OKF validation. Sequential
+ eight-warmup/31-sample 25,515-positioned-glyph runs put Bitmap/MTSDF/Slug cold medians at 16.02/16.60/16.83 ms,
+ font-size at 6.01/6.42/6.67 ms, column width at 2.77/2.77/2.87 ms, and suffix edits at 13.73/13.75/13.59 ms. Every
+ comparable median beats the retained TypeScript checkpoint; the p95-under-4-ms optimization target remains open.
+ The live 11,510-glyph Paragraph Stress probe holds one draw and 121 RAF FPS while attributing 5.725/7.405 ms
+ median/p95 to public text update-and-measure versus 0.405/0.905 ms for renderer submission.
+
+## 2026-08-09
+
+- **Deduplicated exact ordered/stable planner machinery with measured delivery savings** — A focused Mori 0.19.1 audit
+ identified shared identity membership, error conversion and capacity classification, cold buffer allocation, and
+ draw-span invariants. Rust now owns each once while keeping the distinct ordered-direct and stable-indirect address
+ loops local and allocation-free. The optimized shaper moves from 1,160,505 / 442,612 / 348,594 raw/gzip/Brotli bytes
+ to 1,159,317 / 442,284 / 347,850, saving 1,188 / 328 / 744 bytes. All 158 Rust tests pass; the 22k complete benchmark
+ shows no material warm-path change, and a 51-sample cold check measures 15.452 ms median / 15.670 ms p95 at 1.0% RSD.
+
+- **Proved mixed fallback techniques and the R3F example in a live browser** — A public compiled-Wasm integration loads
+ Bitmap Inter with Slug Font Awesome fallback and proves Rust partitions one paragraph into exact Bitmap `vec2` and
+ Slug `vec4` program draws without a user-facing technique selector. The bounded R3F Vite example now has a durable GPU
+ Chromium probe in its ordinary package check: it waits for authenticated fonts, verifies 13 laid-out glyphs as 11
+ visible records across two Rust-planned resource meshes, then clicks the real in-canvas Bitmap, MSDF, and Slug controls
+ through pointer events. TypeScript, React Compiler-aware lint, and the live interaction pass.
+
+- **Closed the Three command-buffer retry and ownership gaps** — Three now advances `consumedPlanRevision` only after
+ successful plan application and automatically retries retained owned bytes before another engine update. Upload ranges
+ clear once per plan then accumulate across origin restoration, presentation edits, and Rust patches. Exact retired
+ buffer generations dispose dependent materials even after a replacement occupies the ID, indexed table growth retains
+ direct materials, and loaded-font disposal removes owner-scoped decoded resources. Material realization rejects
+ synchronous text reentrancy before another Wasm call can invalidate borrowed views; semantic-only queries assert that
+ Rust emitted no render work. Public compiled-Wasm regressions cover every failure. The 25,515-glyph public Three lane
+ measures 17.84/6.32/3.04/13.84 ms median for cold/font-size/width/text versus the adjacent recorded
+ 19.42/6.59/3.10/14.24 ms, establishing no regression without assigning a cross-process speedup.
+
+- **Removed the redundant homogeneous-policy glyph scan and preserved promoted-range alignment** — The first-party
+ renderer policy uses one allocation strategy across Bitmap, MTSDF, Slug, and external programs, so Rust now selects
+ that strategy once before delegating to the planner; mixed policies retain exact per-glyph discovery. Planner
+ compilation remains the authority that validates program existence and input shape. Dirty-range whole-buffer
+ promotion now rounds its record end so `end * stride` still satisfies the renderer's byte alignment. All 157 Rust
+ library tests pass. A short 22k-target run shows no material regression and the optimized shaper is 1,160,505 raw /
+ 442,612 gzip / 348,594 Brotli bytes, +182 / +42 / +233 bytes from the preceding artifact.
+
+- **Made application gates respect their shared build artifact dependency** — Root package checks already complete before
+ application checks, but the benchmark app rebuilds those runtime packages as part of its standalone contract. Running
+ application checks concurrently let that rebuild remove `packages/text/dist` while the R3F example authenticated its
+ freshly baked assets, intermittently hiding `bitmap_baker.wasm`. Root application checks now run serially; each app's
+ standalone check remains unchanged, and the ordering removes the filesystem race rather than adding a retry.
+
+- **Ported the external raster proof off the deleted host packer** — The private glyph-example consumer now uses the
+ same public boundary required of third parties: its portable technique owns only identity, decode, retained resource,
+ and disposal, while its Three registration supplies the declarative policy program and material realization. Removed
+ its stale selector, binding object, canonical storage allocator, TypeScript glyph writer, paint hook, and exported
+ legacy types. The focused compiled-Wasm lifecycle verifies Rust-produced sizes and colors before checking retained
+ draw and geometry identity; no test-only core API or compatibility contract was added. All six package tests and its
+ TypeScript, lint, and formatting gates pass.
+
+- **Shared the compiled draw emitter and removed a quadratic stable-plan scan** — A symbol-bearing `-Oz` build attributes
+ 33.3 KiB of optimized function bodies to ordered planning and 50.1 KiB to stable planning, while confirming that the
+ planners retain different storage, order-buffer, and retirement work. Their identical final primitive/draw record
+ construction now calls one non-generic out-of-line kernel once per draw span. The strict stable benchmark then exposed
+ that every changed range rescanned every sorted slot write; exact range partitioning reduces 22k-target font-size from
+ 350.136 to 7.982 ms median and column resize from 49.636 to 3.767 ms. Stable splice is 9.372/9.583 ms median/p95 with
+ 452 B written. The combined final artifact is 1,160,323 raw / 442,570 gzip / 348,361 Brotli bytes, 220 / 485 / 423 bytes
+ smaller than the pre-extraction Wasm. Compile-time `lite`, `cjk`, and `full` runtime profiles remain a later measured
+ delivery experiment with one ABI; separate Wasm assets, not one bundle containing every variant, provide transfer wins.
+
+- **Deleted the duplicate TypeScript raster packing and lifecycle path** — Raster techniques now stop at identity,
+ artifact decoding, retained CPU resource ownership, and disposal; Rust policy programs remain the only production
+ instance packers and dirty-range publishers. Removed `RasterRuntime`, candidate/commit staging, glyph selection,
+ storage allocation, record writers, their obsolete public types, and tests that reconstructed the deleted packers.
+ A Mori 0.19.1 production scan corroborated the parallel path and separated it from the live ordered-direct and
+ stable-indirect planners, whose shared draw-emission shape has distinct allocation and retirement semantics. All 154
+ Rust engine tests, all 161 package integration tests, Unicode 17 conformance, TypeScript, lint, formatting, and OKF
+ validation pass. The cleanup leaves Wasm unchanged and reduces core JS + Wasm from 461,917 to 460,901 gzip bytes and
+ complete Three + Wasm from 501,815 to 498,922 gzip bytes, with renderer peers external.
+
+- **Made raster policy origins exact without widening retained glyph storage** — The first-party policy had treated
+ positioned ink-box starts as baseline origins, then subtracted the baked raster plane a second time. The mapping was
+ dormant while the legacy TypeScript renderer remained authoritative and became visible only after the single-path
+ Rust cutover. The independent Bitmap CPU oracle exposed a 12 px vertical displacement and 33,492 differing channel
+ bytes; no tolerance or fixture changed. Rust now exposes explicit origin policy fields and maps each renderable glyph
+ to its existing semantic-glyph record with one `u32` index. The already-retained cluster-ID lane supplies plan semantic
+ identity, so the hot render glyph record does not grow. The public WebGL2 Bitmap target passes 32/32 exact frames with
+ zero differing bytes and pinned SHA-256 `a47930d3…15e893`; the complete paragraph matrix passes 32/32. The 22k direct
+ benchmark returns to the pre-fix 107.56 MiB retained high-water mark. Optimized Wasm is 1,159,121 raw / 441,811 gzip /
+ 347,554 Brotli bytes, 818 / 451 / 415 bytes above the prior checkpoint.
+
+- **Regenerated package-size and edit-latency truth from the final stable-addressing artifact** — The renderer-neutral
+ core is 1,257,322 raw / 460,673 gzip / 364,097 Brotli bytes, including the 1,159,121 raw / 441,811 gzip /
+ 347,554 Brotli shaper Wasm. The complete Three adapter plus engine is 1,505,897 / 500,509 / 396,903 bytes; Three,
+ React, and React Three Fiber remain external peers. A sequential eight-warmup/31-sample 22k Bitmap run measures the
+ ordered-direct equal-length edit at 1.330/6.328 ms and middle splice at 8.369/8.473 ms median/p95. Stable-indirect
+ middle splice measures 10.683/11.149 ms and writes only 452 B. The earlier 51.067 ms stable figure was the maximum of
+ an 11-sample run (the benchmark's percentile index selects the maximum at that sample count), did not reproduce, and
+ is not retained as ordinary latency evidence. A stricter stable equal-length run detected late Wasm growth before it
+ could publish a report, so stable-indirect remains a correctness capability rather than the first-party default.
+
+- **Completed stable-indirect Three record addressing without changing the default** — The Three executor now resolves
+ one validated logical-to-physical record address for Bitmap, MSDF, Slug, custom programs, indexed transforms, and
+ origin augmentation. A product integration regression proves a paragraph reorder patches only the Rust order buffer,
+ retains physical glyph storage, and reuses the existing draw objects. The shared nested-storage oracle renders the
+ red record behind a green decoy exactly on forced WebGL2 and hardware WebGPU (16/16 pixels, identical SHA-256), while
+ the complete ordered Bitmap/MSDF/Slug/custom-material matrix remains green on both backends. Stable slot lookup reuses
+ the committed identity index for revision-only/reorder updates, improving two short 22k localized-edit medians from
+ 2.903 to 2.446 and 2.376 ms. Those runs are implementation checkpoints, not final tail evidence; the stricter current
+ measurement and growth result are recorded above. Optimized Wasm grows 992 bytes from 1,157,311 to 1,158,303 raw bytes.
+
+- **Added the missing middle-splice workload before choosing edit storage** — The unchanged replacement case remains the
+ canonical comparison, while a new `localized-splice` case alternates one UTF-16 insertion and deletion in the middle
+ of the same 22,000-glyph fixture. Ordered-direct measures 9.119 ms median / 10.016 ms p95 and writes 511.3 KiB because
+ physical records after the insertion move. Stable-indirect proves the intended bandwidth result at 452 B but currently
+ regresses to 10.776/51.067 ms; equal-length stable replacement is also 2.903/19.312 ms versus ordered-direct's roughly
+ 1.15/5.74 ms. No default changes: stable planning and Three indirection must become correct and fast before chunk-local
+ UTF-16 storage can be credited with the smaller remaining edit cost.
+
+- **Retained ordered-plan topology when physical storage membership stayed invariant** — The ordered-direct compiler now
+ reuses committed glyph-to-batch and glyph-to-slot mappings under the exact policy fingerprint and capability set. It
+ still validates every glyph and stable identity, and any physical storage-key mismatch returns to complete batch
+ discovery; a material-partition regression proves both paths. Three consecutive optimized 101-update runs measure
+ 1.164/5.761, 1.153/5.740, and 1.155/5.738 ms median/p95 with five roughly 1.2 KiB patches. The preceding checkpoint
+ measured 1.314/5.863 ms. Optimized Wasm grows 4,189 bytes to 1,157,311, and retained high-water memory falls from
+ 80.19 to 79.81 MiB. The fast class is now near 1 ms, while 81.4–81.6% RSD and the roughly 5.74 ms p95 keep the
+ break-sensitive tail open.
+
+- **Retained policy inputs and rebuilt only from the first storage mismatch** — Gathered field-major policy inputs now
+ commit under the exact session revision, policy fingerprint, and capability set. A one-byte selection lane skips
+ binding/resource/policy work for zero-change glyphs; changed records update only reachable fields. Identity replacement
+ stays in the same physical topology, while technique, program, resource, transform, material, clip, or depth changes
+ retain the verified prefix and fully gather the suffix. Commit/abort and disposal tests cover cache lifecycle, and an
+ oracle proves identity/field updates plus a material-triggered suffix rebuild. The unchanged optimized 101-update lane
+ improves from 2.607/6.184 to 1.314/5.863 ms median/p95 with five roughly 1.2 KiB patches. RSD remains 76.2%, so the
+ break-sensitive tail is open. Optimized Wasm grows 5,856 bytes to 1,153,122; retained high-water memory is 80.19 MiB.
+
+- **Continued recomposition until the edited line cursor actually converges** — The first retained-line proof required
+ a whole recomposed line to equal its predecessor, which made convergence after a shifted line boundary impossible.
+ Rust now recomposes consecutive old bands until cursor, height, and baseline return to the retained ending state, then
+ reuses the suffix. A three-line regression transfers advance across one boundary and converges at the next. Exact
+ equal-length ASCII-letter edits also retain structurally invariant Unicode and bidi results; punctuation, spacing,
+ non-ASCII, and structural edits remain on the complete analysis path. On the unchanged 101-update optimized workload,
+ median/p95 improve from 5.881/8.406 to 2.607/6.184 ms and RSD falls to 42.4%, with five roughly 1.2 KiB patches.
+ Optimized Wasm grows 66 raw bytes to 1,147,266. The break-sensitive p95 remains above the 4 ms contract.
+
+- **Rebuilt clusters only for the incrementally shaped source run** — Exact grapheme/glyph topology now permits the
+ cluster builder to retain all other SoA lanes and rebuild the changed run's advances, bindings, glyph adjacency,
+ safe/break flags, and identities. The affected window includes its predecessor break because that decision depends on
+ the changed run's first safe-concatenation flag; any mismatch falls back cold. A field-for-field cold oracle covers
+ every retained lane. On 101 optimized updates, median/p95 improve from 6.894/9.314 to 5.881/8.406 ms with the same
+ five roughly 1.2 KiB patches. Optimized Wasm grows 7,633 raw bytes to 1,147,200; the p95 contract remains unmet.
+
+- **Narrowed content-revision work to the recomposed line** — The line-convergence proof now carries exact old/new
+ glyph spans into positioning. Retained prefix and suffix records preserve their revisions and publish zero semantic
+ change masks; only the changed span compares fields or looks up stable identities. A focused test proves revisions
+ `[10,20,30]` become `[10,40,30]` for a middle-span change. The 101-update optimized benchmark improves from 7.633
+ to 6.894 ms median (9.7%); p95 is effectively flat at 9.358→9.314 ms, so no tail improvement is claimed. Optimized
+ Wasm grows 1,693 raw bytes to 1,139,567.
+
+- **Kept transactional text buffers synchronized across equal-length edits** — The retired UTF-16 and stable-identity
+ buffers now copy only the proven changed range after commit or restore on abort, so the next replacement does not
+ begin by cloning the paragraph. A 101-update rerun measured 7.633 ms median / 9.358 ms p95 against the preceding
+ 7.668 / 9.620 ms, while optimized Wasm shrank 520 raw bytes to 1,137,874. This removes redundant work but is not a
+ latency claim; inserts and deletes still await the bounded semantic chunk gaps.
+
+- **Stopped layout and positioning after a proven line-state convergence** — A same-length localized edit now
+ recomposes only its affected line when geometry, metrics, safety limits, and overflow behavior are compatible, then
+ retains the exact prefix/suffix lines and positioned glyphs only after cluster cursor, metrics, fragment slots, text
+ boundaries, stable identities, and hard-break state match. Nonconvergence discards the partial result and exercises
+ the full path. A 101-update production optimized SIMD Wasm run on the unchanged 22,000-glyph Bitmap case improved
+ from the preceding 9.372 ms checkpoint to 7.668 ms median (18.2%), with 9.620 ms p95 and five roughly 1.2 KiB
+ patches. Optimized Wasm grows 6,937 raw bytes to 1,138,394. The 80.38 MiB high-water mark and remaining broad
+ cluster/revision/plan scans keep this outside the target; the planned semantic 64-cluster edit slack is still not
+ implemented by the current flat A/B arenas.
+
+- **Made warm HarfRust plan lookup allocation-free without claiming a latency win** — Cached shaping plans now compare
+ borrowed language and feature fields; owned cache keys are created only on a genuine miss. The optimized SIMD Wasm
+ shrank from 1,131,513 to 1,131,457 raw bytes. The 22,000-glyph localized-edit median remained effectively unchanged
+ at 9.375 ms versus 9.372 ms, and the strict lane still observed the same later 1,114,112-byte memory claim, so neither
+ issue is attributed to this lookup.
+
+- **Bounded localized reshaping to one stable shaping run** — A retained UTF-16 edit whose style, script, bidi level,
+ direction, and fallback topology remain stable now copies unchanged shaped runs and reshapes only the affected run;
+ hard breaks, style boundaries, script changes, and bidi changes are therefore explicit correctness boundaries rather
+ than heuristic byte windows. On the production optimized SIMD Wasm and the unchanged 22,000-glyph Bitmap fixture,
+ complete Rust `text_update` plus render-plan publication fell from 16.223 ms to 9.372 ms median over 31 measured edits.
+ This checkpoint is not a budget claim: cluster construction, composition, positioning, and plan gathering still scan
+ globally, p95 remains 9.723 ms, and the strict eight-warmup lane still detects later Wasm memory growth.
+
+- **Added narrow paragraph editing without exposing engine complexity** — Three `Text` now provides `insertText`,
+ `deleteText`, and `replaceText` over DOM-compatible UTF-16 offsets; direct `text` assignment derives the smallest
+ scalar-aligned replacement. Multiple edits queue into the same next-frame Rust transaction, surrogate-pair splits fail
+ synchronously, and rich-text spans shift with explicit boundary semantics. A wire-level integration regression inspects
+ the serialized request rather than inferring narrowness from final pixels.
+
+- **Regenerated package-size truth after isolating baker build variants** — The renderer-neutral browser core plus the
+ sole published SIMD shaper measures 1,224,539 raw / 447,121 gzip / 353,986 Brotli bytes; the Three adapter plus that
+ core measures 1,466,450 / 485,864 / 385,930 bytes. Optional Three, React, and React Three Fiber peers remain excluded.
+ Browser-core JavaScript stays effectively flat against the preceding record and the Three adapter shrinks, while the
+ shaper accounts for the net compressed growth. The complete MTSDF baker is again 552,025 raw / 215,030 gzip / 168,758
+ Brotli bytes after separating its 60,993-byte kernel-only Cargo target, and build-time ABI guards now prevent a partial
+ test module from being published as a baker.
+
+- **Prevented test-only Wasm variants from entering published baker artifacts** — Distributable MTSDF and Slug
+ artifact-baker builds and the optional SIMD compatibility switch now use feature-specific Cargo target directories;
+ the MTSDF kernel test uses a separate target. The package build rejects any optimized baker missing an export declared
+ by its Rust-generated TypeScript ABI. The full MTSDF artifact remains 552,025 bytes with SHA-256 `ec6eb164…7de8` before
+ and after the 60,993-byte kernel-only test. Generated ABI constants replace instance-ignoring or duplicate reader
+ functions across the font, Bitmap, MTSDF, and Slug baker hosts.
+
+- **Removed timing instrumentation and stale-output risk from the published Three graph** — The package no longer exports
+ or calls its temporary phase profiler. One-crossing integration evidence now wraps the Wasm export solely in the test
+ harness, while benchmark workload markers and outside frame timing remain application-owned. Package builds recreate
+ `dist` before TypeScript emission so deleted profiler and legacy modules cannot survive in a published tarball.
+
+- **Closed the retained paragraph browser matrix under the f32 frame contract** — The sole UIKit mismatch was a stale
+ JavaScript-double style-input expectation, not a Rust or Yoga precision defect. Independent f32 line-box arithmetic
+ reproduces the retained engine's final baseline, content height, centered final row, and exact layout hash. The public
+ browser target now passes two bidi, nine policy, twelve CJK, and one UIKit-shaped contract without runtime widening or
+ comparison tolerances.
+
+- **Separated final customer timing from temporary phase instrumentation** — The public Three workload can now retain a
+ single outside timer while disabling its internal phase collector. A 25,515-glyph, 31-sample release-artifact run
+ measures complete frame preparation, Rust update/render-plan publication, and Three application without internal clock
+ calls. The packaged shaper is Cargo release + LTO + SIMD followed by Binaryen `-Oz`; adjacent `-O3`/`-O4` artifacts cost
+ more bytes without a demonstrated speed gain. Production profiling hooks remain an explicit removal gate.
+
+- **Specified paragraph-scoped synchronous preparation without triple buffering** — Current `measureLayout()` either
+ returns committed cache or drives a complete session update and plan. The reviewed follow-up design retains one
+ speculative session transaction with paragraph-keyed pending states, linear identity reservation, explicit
+ prepare/adopt/leave-committed modes, inactive-slot copied query results, host lease retention, and new-paragraph
+ candidate ownership. Sequential paragraph queries extend the same transaction and the next frame adopts that exact
+ work before global plan compilation. Roadmap items 11.17 and 11.18 queue the query layer and promised realtime
+ publishing set as independent `feat/*` follow-up stacks after the Rust/Three cutover merges; neither is a hidden
+ prerequisite for consuming the cutover. Factoring preparation from plan commit is cohesive but not a safe flag-only
+ change.
+
+- **Completed adaptive Rust planning for physical and stable-order buffers without accepting repeated packing** — The
+ first per-buffer execution prototype regressed cold Bitmap/MTSDF/Slug by roughly 1.2/2.2/2.4 ms. Grouping identical
+ selected ranges back into one active-buffer job closes that regression while preserving independent costing and
+ committed gap bytes. Canonical before/after results are mixed and standard resize remains one unchanged-size patch, so
+ sparse browser upload evidence is still required before claiming a win.
+
+- **Started per-physical-buffer dirty-range costing without changing publication** — Added a stride-specific Rust
+ coalescer with exact tests for divergent narrow/wide gap decisions, fragmentation and 75% full-live promotion, zero
+ stride, and overflow. Existing ordered/stable callers remain on the compatibility wrapper until the next atomic
+ checkpoint, so no upload or frame-time gain is claimed.
+
+- **Moved ordered and stable physical writes onto per-buffer range plans** — Each compiler retains fixed reusable scratch
+ for the policy buffer ceiling, applies semantic dependency liveness before range selection, aligns by the concrete
+ stream stride, and packs the independently chosen spans. The order buffer and end-to-end timing remain open, so this
+ checkpoint claims correct ownership and bounded allocation rather than a speedup.
+
+- **Moved ellipsis and its real boundary reshape into the Rust frame transaction** — Only truncated flow threads build a
+ retained boundary arena; ordinary reflow retains zero boundary reshapes. Font-stack ellipsis selection, complete
+ no-wrap overflow, narrowed final-tail context, spacing, stable glyph identity, positioning, semantic inspection, and
+ render-plan publication now share the one Rust update. A public Amiri/Three regression proves the result differs from
+ incorrect whole-run reuse and matches the narrowed shaping oracle. All 136 Rust library tests and 204 package tests
+ pass. Same-machine detached-baseline comparison finds column-resize medians within 0.15 ms and mixed cold results, so
+ the checkpoint is recorded as performance-adjacent rather than assigned a speedup. Its aggregate optimized Wasm delta,
+ including adjacent renderer-integration fixes, is +13,639 raw / +5,797 gzip / +5,579 Brotli bytes.
+
+- **Recorded the Paragraph Stress integration defects without changing shaping invalidation** — Origin lookup indexing is
+ now lazy and Bitmap strike replacement initializes every required input stream. The observed 11,510-glyph MTSDF probe
+ moved `plan.apply` from about 1.02 ms to 0.14 ms and total retained update from about 6.89 ms to 4.63 ms, with differing
+ sample histories explicitly preventing a universal speedup claim. Focused public Three fixtures cover both defects.
+
+- **Queued adaptive dirty-range upload refinement from three-flatland evidence** — The research finds that Rust already
+ generalizes Flatland's dirty buckets through exact spans, gap costs, fragmentation limits, and a full-live cutover.
+ Follow-on work will calibrate per-physical-buffer costing and stable-order coalescing; TypeScript will not duplicate the
+ planner. Renderer-local transform/origin edits remain the only candidate for a Flatland-style retained tracker.
+
+- **Made semantic queries share the retained update that invalidated them** — Three now sends only changed text, style,
+ or geometry sections; an empty update and cached query make no Rust call, while pending measurement or inspection rides
+ on the same `text_update`. A two-paragraph compiled-Wasm regression proves all-paragraph semantic retention and exact
+ command-buffer output. Controlled old-Rust Paragraph Stress runs isolate 14.295 ms baseline, 13.615 ms
+ measurement-only, and 7.450 ms semantic-tier medians; the complete candidate measures 6.885 ms at 11,510 glyphs and
+ one draw. Optional User Timing markers preserve phase evidence without claiming finer inlined Rust attribution.
+
+- **Made compositing freedom an explicit Rust planning input** — `TextGroup` and R3F now expose the same `ordered` or
+ `independent` construction policy. Ordered remains the prose-safe default; independent permits the Rust ordered-direct
+ and stable-indirect planners to coalesce compatible interleaved resources. Icon Grid selects independent mode. The
+ optimized shaper is 1,101,079 raw / 417,984 gzip / 328,164 Brotli bytes.
+
+- **Made Presentation workflow failures and command-buffer work observable** — The workflow runner now rejects Vitexec
+ browser/page errors even when its process exits zero. Stale stats cannot erase a retained-scene update failure, grouped
+ draw/glyph telemetry reads the realized batch root, and the 27-cell sweep requires positive counts. Every
+ Bitmap/MTSDF/Slug transition passes. The truthful result identifies Icon Grid's remaining policy gap: 2,926–3,021
+ glyphs are split into 476 draws and run at 30.8–47.0 FPS despite only 0.27–0.76 ms median submit and 0.57–2.02 ms GPU
+ time; this is open batching work, not an accepted performance result.
+
+- **Made multi-workload Rust sessions recycle storage without recycling semantics** — Frame admission now covers removal
+ plus insertion records rather than only final paragraph count. A recycled paragraph clears every semantic arena and
+ identity marker while retaining its allocations, and one session prewarms one reusable paragraph instead of applying a
+ 4,096-glyph batch capacity to every child. This removes the invalid-request and allocation failures across the
+ text-ladder, zoom, and 476-paragraph icon-grid transitions. Rust capacity identity, public Three double replacement,
+ all 201 package tests, all 131 Rust tests, and a complete 27-cell Bitmap/MTSDF/Slug WebGPU transition sweep pass. The
+ optimized shaper is 1,090,859 raw / 411,106 gzip / 325,149 Brotli bytes.
+
+- **Made cold command-buffer growth recover instead of failing the benchmark scene** — Decoupled the 64 MiB output
+ safety limit from the smaller retained A/B arenas. Rust's exact required-result watermark now drives one bounded cold
+ reserve/retry; the host re-resolves the request pointer and recopies after possible Wasm-memory detachment. A
+ compiled-Wasm test forces growth from a header-sized arena. The live MTSDF paragraph-stress scene consequently publishes
+ 11,510 glyphs in one draw instead of status 7 at 1,382,592 bytes. Three settled WebGPU A/B runs rejected an eighth,
+ split origin/size storage binding: CPU submit was unchanged and median GPU time trended worse, so MTSDF stays packed.
+
+- **Made the 25,515-glyph Rust benchmark self-validating** — The result header's `primitiveCount` counts primitive-table
+ rows, so the benchmark's former “1 renderable instance” label did not prove the workload even though its 1–2.4 MiB
+ writes showed full packing. It now sums glyph primitive `recordCount` values and rejects an undersized plan. On the
+ unchanged `--glyphs 22000` fixture, Rust publishes 21,805 renderable records from 25,515 positioned TypeScript glyphs.
+ Five-warmup/11-sample Bitmap/MTSDF/Slug resize medians are 3.779/4.345/5.082 ms with p95
+ 4.156/4.951/5.679 ms, versus TypeScript's 8.33 ms median. Rust wins, but all policies still fail the sub-4 ms p95 gate;
+ isolated warm-session Wasm high-water marks of 64.75/77.75/78.56 MiB also remain open rather than accepted costs.
+
+- **Deleted the redundant Three paragraph-target transaction** — The Rust command buffer is now the sole render-state
+ transition authority. Removed the candidate/current `ThreeBitmapTarget`, `ThreeMtsdfTarget`, `ThreeSlugTarget`, retained
+ revision, and old renderer-program registry; the executor keeps only GPU resource/draw/material tables, synchronization,
+ and reversible presentation overrides. First-party technique imports no longer register targets as side effects. Migrated
+ the composition proof to ordinary Bitmap policy packing plus `defineTextMaterial`, so customization changes canonical
+ shader output without owning layout, attributes, geometry, or another transaction. This deletes 1,496 source lines. The
+ measured technique runtime graphs shrink only 45 raw bytes each (19–20 gzip bytes for Bitmap/MTSDF and 20 for Slug),
+ proving the deleted targets were already outside those consumer graphs rather than attributing an invented payload win.
+
+- **Kept layout inspection and presentation outside rendering authority** — Added an explicit Rust semantic-glyph
+ inspection mask alongside measurement; ordinary rendering still publishes no layout arrays. First-party policy
+ programs now carry one stable glyph ID per renderable instance so Three can direct optional Bitmap/MTSDF/Slug origin
+ presentation without reconstructing glyph topology. The executor restores authoritative origins before every later
+ command-buffer update and retains only resource tables plus reversible overrides, eliminating any need to revive the
+ candidate/current target state machine. Compiled-Wasm fixtures cover semantic spaces, shared two-paragraph batching,
+ isolated overrides, transform-only retention, and semantic-update retirement. The refreshed canonical checkpoint is
+ 1,089,889 raw / 414,204 gzip / 325,805 Brotli shaper bytes; legacy-path deletion and a Rust size pass remain open.
+
+- **Separated semantic measurement from the render plan** — Activated the existing `semanticViewMask` for an explicit
+ retained-Rust measurement query while ordinary rendering continues to request zero semantic records. The first view
+ publishes one paragraph summary plus its line records in the immutable A/B sidecar; Three's command-buffer executor
+ ignores it. Public `Text.measureLayout()` caches the frozen result until a committed semantic update. Rust exact and
+ at-most/overflow tests plus a compiled-Wasm Three lifecycle prove the query retains the existing mesh and does not
+ restore the removed `Text.layout` arrays.
+
+- **Proved external Rust plan programs on both Three backends** — Replaced the glyph-example package's renderer-side
+ `ParagraphBatchTarget`, revision transfer, packing, dirty upload, and mesh transaction with a static policy program,
+ cold font-binding compiler, and plan-buffer material factory. A compiled-Wasm lifecycle proves Rust-packed buffers
+ and retained draw/geometry identity. The live product proof now passes twice on hardware WebGPU and forced WebGL2
+ with the same `817495c4…ba9d46` frame. Its cover baseline exposed individual visibility as a batching concern; indexed
+ draws now suppress only the hidden text's matrix slot without a Wasm call or draw split, while direct draws mirror
+ object visibility.
+
+- **Completed material naming through Three and R3F** — Removed the obsolete `ThreeRenderVariant` generic and every
+ `renderVariant` property, setter, span field, comparison, and no-op binding hook from the command-buffer-backed public
+ adapters. `material` is now the sole authored name through numeric Rust `materialId` and renderer factory realization;
+ legacy core/TypeGPU variants remain scoped to the path awaiting deletion.
+
+- **Removed authored technique from Three grouping and font stacks** — `createFontStack` now accepts heterogeneous
+ Bitmap/MSDF/Slug fonts from one runtime and preserves their technique union, while the legacy single-technique
+ `ParagraphBatch` rejects that union at its own boundary. `TextGroup` and its R3F wrapper no longer accept or expose a
+ technique. A compiled-Wasm public lifecycle fixture proves one Bitmap-root paragraph with an MSDF span is partitioned
+ by the Rust policy/plan into two draws and resolves one custom material factory under both technique contexts.
+
+- **Cut imperative Three rendering over to the Rust command buffer** — Replaced the public binding's private
+ `ParagraphBatch` plus attachment `prepare`/`commit` state machine with one retained Rust session and renderer
+ executor. A `TextGroup` now submits every descendant paragraph in one update and owns shared draws; standalone text
+ uses the same path with its own root. Public `material` definitions resolve through Rust `materialId`, while scene
+ transforms and render-order bases remain renderer-local. Focused compiled-Wasm tests prove mixed-font spans, one
+ indexed draw across two public text transforms, retained custom material realization, reparenting, and disposal.
+ Rendering deliberately does not publish layout arrays, and the old Three layout/snapshot/origin surface is removed;
+ a future interaction or measurement query remains separate.
+
+- **Executed both policy-selected transform modes in Three** — Generalized the command-buffer target across indexed
+ and direct transform realizations for Bitmap, MSDF, and Slug. The same compiled-Wasm fixture now registers a direct
+ first-party policy: Rust emits draw transforms `[1,2]`, omits transform buffers, and Three updates retained draw
+ matrices from their scene objects. The existing indexed policy still emits draw transforms `[0,0]`, buffer 15, and
+ the shared matrix sidecar. A hybrid policy additionally publishes indexed Bitmap and direct MSDF draws together;
+ scene-only synchronization updates both realizations without Wasm. The engine policy chooses each program contract;
+ Three does not rebatch the plan.
+
+- **Bounded Three residency and retained draw identity** — Applied exact Rust buffer/resource retirements to dependent
+ material and texture realizations, retaining shared renderer resources until their final plan reference leaves. The
+ compiled-Wasm fixture now checks exact live storage-plus-resource bytes after Bitmap → MSDF → Slug transitions.
+ Lifecycle-only reorder retains the same meshes/geometries/materials and changes range/order metadata; coalescing
+ retains one compatible draw and retires only the other. Live backend submission still owns native-fence proof.
+
+- **Carried Three material factories through Rust material IDs** — Added the public factory definition and a runtime-
+ scoped identity registry while keeping Rust callback-free. The executor resolves each nonzero `materialId` only when
+ a compatible realization is absent and supplies the canonical technique shader, final indexed-transform position,
+ and a DRY default-material constructor. A compiled-Wasm fixture proves two material draws over shared storage, zero
+ new factory calls on reorder/coalescing, and one new selected-factory call when the retained glyphs switch to MSDF and
+ Slug. The public `Text` property route and fence-bounded retirement remain cutover work.
+
## 2026-08-08
+- **Executed Slug from the Rust command buffer** — Bound Rust's five float `vec4`, two integer `uvec4`, and indexed-
+ transform streams directly to the canonical Slug graph. The renderer retains validated curve/header/reference
+ textures and keeps the WebGL-compatible packed-reference representation. A new full-MVP dilation input applies the
+ exact per-instance transform to both placement and analytic half-pixel expansion while retaining the legacy row
+ interface. The compiled-Wasm fixture republishes the same six retained glyphs as one program-3 draw after Bitmap and
+ MSDF, without resending text or geometry. Wasm bytes are unchanged. Live shader compilation/pixels, bounded
+ retirement, material factories, and public cutover remain open.
+
+- **Executed MSDF from the Rust command buffer** — Extended the shared Three plan executor rather than adding another
+ target path. Rust policy buffers 1–7 bind directly as MSDF `vec4` storage, buffer 15 indexes the shared transform
+ sidecar, the renderer resolves and builds the validated layered atlas once, and the canonical `msdfShader` remains the
+ coverage authority. A compiled-Wasm fixture changes two retained paragraphs from Bitmap-first to MSDF-first fallback
+ without text or geometry resend and receives one six-instance program-2 draw. Wasm bytes are unchanged. Live pixels,
+ Slug, bounded retirement, material factories, and public cutover remain open.
+
+- **Made transform batching policy-selectable and executed indexed Bitmap draws** — Corrected the temporary global
+ transform draw boundary. Programs may now split on transform and consume nonzero draw-level IDs, or omit that key and
+ pack stable region transform slots into first-party policy buffer 15. The Bitmap Three executor applies Rust buffer
+ patches, binds direct resources, keeps matrices in a renderer-owned sidecar, and updates scene transforms without a
+ Wasm call. Visible overflow no longer invents a clip boundary. Rust tests prove split and indexed modes; compiled Wasm
+ retains two material draws and collapses the same two paragraphs to one six-instance draw after their material IDs
+ converge, with exact slots `[2,2,2,1,1,1]`. All 129 Rust tests and the focused Three integration pass. Optimized Wasm
+ is 1,083,255 raw / 411,409 gzip / 324,539 Brotli bytes. Browser pixels, MSDF/Slug, and public Three cutover remain open.
+
+- **Resolved Rust resource references directly in Three** — The Three coordinator now registers each validated Bitmap
+ page, MTSDF atlas, and Slug analytic page under the same collision-checked numeric identity compiled into the Rust
+ font binding. A command-buffer `referenceId` resolves in one map lookup; Three does not scan fonts or repeat resource
+ partitioning. Incompatible technique reuse is rejected. Focused type-check, build, and compiled-Wasm coordinator tests
+ pass; physical buffer, patch, and draw realization remain open.
+
+- **Made transform ownership explicit and removed per-cluster draws** — The first real multi-paragraph publication
+ exposed that paragraph-local positions had no renderer transform owner and cluster `semantic_id` prevented primitive
+ coalescing. The policy now requires a paragraph-derived transform draw key, forbids transform from physical storage
+ identity, and publishes `transformId` in the expanded 64-byte draw record. Compatible clusters coalesce; a span that
+ crosses semantic IDs publishes zero rather than lying about one cluster. The compiled-Wasm fixture falls from six
+ draws to exactly two and reverses `(materialId, transformId)` from `[(7,1),(8,2)]` to `[(8,2),(7,1)]` without semantic
+ resend. Optimized Wasm changes by only +222/+83/+52 raw/gzip/Brotli bytes to
+ 1,082,773 / 407,870 / 324,551.
+
+- **Published multiple retained paragraphs as one Rust command buffer** — Engine sessions now own an ordered stable-ID
+ paragraph set rather than one paragraph. Lifecycle upsert/reorder/remove, every child semantic transaction, shared
+ policy gather, plan serialization, and commit/abort form one atomic publication. Missing semantic spans retain a
+ child, and per-child geometry retains only referenced regions/exclusions instead of inheriting invalidation from a
+ sibling's global table prefix. A compiled-Wasm Three-coordinator fixture publishes material groups `[7, 8]`, then
+ sends only reorder records and publishes `[8, 7]`; all 128 Rust unit tests pass. Optimized Wasm changes from
+ 1,073,248 / 404,463 / 321,189 to 1,082,551 / 407,787 / 324,499 raw/gzip/Brotli bytes. This proves the retained,
+ renderer-neutral command-buffer delta; public Three GPU realization and end-to-end latency remain open.
+
+- **Preserved multi-paragraph Three batching in the Rust session design** — Existing `TextGroup` batches independent
+ paragraphs, while the current Rust session's multiple constraints all flow the same prose. The cutover therefore uses
+ one group/session containing stable-ID paragraph states and one shared planner/publication, rather than one Wasm call
+ and buffer set per `Text`. The policy gather workspace now appends independent positioned SoA inputs after one total
+ reservation, with an exact two-layout proof and no allocation inside append. Paragraph-keyed frame mutation and
+ transactional session state remain the next Rust slice. Adjacent rebuilt-Wasm Bitmap runs measured 4.083 ms before
+ and 4.078 ms after for full-column resize; that does not establish a speed change and does rule out a visible
+ regression in this run. Wasm changes by +105/+40/+252 raw/gzip/Brotli bytes.
+
+- **Bound Three plan consumption directly to Wasm publication memory** — A reusable package-internal reader validates
+ every Rust-emitted render-plan table and reads its fixed records in place. It retains one `DataView` across ordinary
+ A/B publications and replaces it only after `memory.grow()`, so it does not materialize per-glyph JavaScript objects.
+ A real compiled-Wasm Three fixture now shapes and lays out Inter in one update and observes nonempty resource, buffer,
+ patch, primitive, and draw tables through that reader. GPU resource realization remains the next cutover slice.
+
+- **Added a lazy Three-owned engine coordinator** — The renderer-neutral runtime does not statically import first-party
+ raster programs. On first Three use, a runtime-scoped coordinator registers the all-technique policy, compiles loaded
+ font bindings, allocates session handles, and reference-counts exact ordered font-stack handles. A real Inter fixture
+ binds Bitmap and MTSDF to the same retained shaping font, proves identical stack acquisition shares one handle,
+ reversed fallback order does not, last release retires the stack, and retired handles are not immediately reused. The
+ coordinator remains outside the public Three graph until batch/session render-plan consumption lands.
+
+- **Separated shaping-font identity from render-binding identity** — Rust font stacks now contain loaded-font binding
+ handles, and each binding names its shared shaping-font handle. Shaping, metrics, and extents continue through the
+ retained font once; policy gather follows the selected binding, so the same face may carry multiple raster techniques
+ and fallback preserves its technique into the emitted plan. A compiled-Wasm Inter → Devanagari fallback emits the
+ second technique, and another fixture registers two techniques against one shaping font. The unchanged
+ 25,515-positioned/21,805-renderable resize medians are 4.217/4.791/5.633 ms for Bitmap/MTSDF/Slug; 6–7% run RSD does
+ not establish a regression from 4.120/4.646/5.622 ms. Wasm is 1,070,580 / 402,114 / 319,662 raw/gzip/Brotli bytes.
+
+- **Promoted complete frame-request serialization into production** — A package-internal compiler now lowers text
+ mutations; full style records; constraints; sequential rectangle or polygon regions; polygon exclusions; inline
+ objects; policy parameters; and revision/fence state into one compiler-mapped allocation. It performs no shaping,
+ layout, batching, or packing. The current benchmark request is byte-identical to its established helper, including a
+ surrogate pair, while a broad structural fixture covers language, OpenType features, material, word/letter spacing,
+ baseline shift, decoration, vertical mode, holes, and inline objects. Public-state normalization and a real rich-frame
+ Rust acceptance test remain part of the Three cutover.
+
+- **Compiled the first production Three policy and raster bindings** — One deterministic policy now registers Bitmap,
+ MTSDF, and Slug together against the retained Rust planner. Draw identity includes numeric `material_id`; storage
+ identity excludes it, so a renderer may share physical buffers while splitting draws by material. Production binding
+ compilers lower every validated first-party raster record directly into one field-major request allocation, including
+ every bitmap strike, and exact tests compare every emitted lane with the established renderer-parity fixtures. Public
+ string technique and resource identities use deterministic UTF-8 FNV-1a `u32` wire IDs with one runtime-scoped
+ collision registry; collisions fail registration instead of silently aliasing. This checkpoint does not yet switch
+ the public Three adapter from its legacy paragraph batches.
+
+- **Promoted the retained frame ABI into a production host** — `RuntimeShaper` now exposes one package-internal,
+ ownership-checked view of its existing Wasm instance to a typed text-engine host. The host owns cold policy,
+ font-binding, font-stack, and session registration; reserves before pinning; writes requests into the retained arena;
+ and returns the published A/B slot as borrowed bytes without copying. An integration test publishes slots A and B
+ through the compiled module and proves B does not mutate A. Three still consumes the legacy paragraph batch in this
+ checkpoint; request/policy compilation and render-plan lowering are the next cutover slices.
+
+- **Made policy work dependency-directed from gather through execution** — Policy registration now compiles both
+ input-to-buffer and operation-to-buffer reachability. A positioned update gathers only source lanes reaching a
+ semantically changed output and the scalar/SIMD executors skip operations reaching no active output; checkpoints,
+ new glyphs, and non-positioning changes remain conservative full evaluations. Consecutive glyphs reuse their resolved
+ font binding and policy program. The mechanisms compound: selective gather alone regressed and operation liveness
+ alone was neutral, while together moved canonical Bitmap/MTSDF/Slug resize medians from 4.878/5.355/6.001 ms to
+ 4.207/4.833/5.615 ms. Adding the lookup cache measured 3.981 then 4.120 ms for Bitmap, 4.646 ms for MTSDF, and
+ 5.622 ms for Slug. An isolated Bitmap resize measured 4.799 ms, so JIT-sensitive evidence does not yet close the
+ sub-4 ms gate. Optimized Wasm is 1,069,973 / 405,888 / 319,558 raw/gzip/Brotli bytes.
+
+- **Removed visual-order scratch from proven LTR positioning** — A positioning pass now checks once that all retained
+ bidi levels are even and no run is direction-overridden, then walks logical clusters directly. Odd levels and
+ overrides retain the complete UAX #9 L1/L2 path. On the unchanged 25,515-positioned/21,805-renderable stress case,
+ two adjacent eight-warmup/31-sample Bitmap baselines measured 5.197/5.162 ms and two optimized runs measured
+ 4.849/4.878 ms with the same single 170.4 KiB patch. Post-change MTSDF/Slug medians are 5.355/6.001 ms; the sub-4 ms
+ gate remains open. Optimized Wasm is 1,065,857 / 403,525 / 318,137 raw/gzip/Brotli bytes.
+
+- **Avoided identity hashing for order-preserving reflow** — Positioned glyph reconciliation now checks the common
+ equal-length/equal-stable-ID order first and compares exact content by slot; reordered output retains the exact
+ identity-index fallback. A symbolized, non-shipping Wasm CPU profile identifies positioning as the largest sampled
+ column-resize function and policy gather as the next largest. The benchmark can now isolate one case and accept an
+ explicit profiling Wasm without changing the canonical default sequence. On the unchanged five-warmup/11-sample
+ workload, Bitmap/MTSDF/Slug resize medians are 4.414/4.984/5.196 ms; variance prevents attributing a precise speedup,
+ and the sub-4 ms gate remains open. The optimized module is 1,065,543 / 399,248 / 318,131 raw/gzip/Brotli bytes.
+
+- **Made physical render-plan patches dependency-directed** — Positioning now records exact six-F32/four-U32 change
+ bits in a compact side lane while preserving the 60-byte `PlanGlyph`. Policy validation propagates those bits through
+ the straight-line program once and records each output buffer's semantic dependencies. Ordered-direct and
+ stable-indirect compilers execute and publish only intersecting buffers; new, rebound, and conservatively described
+ records still rewrite every output. On 21,805 real raster instances, a full-column resize now writes 170.4 KiB for
+ Bitmap and 340.7 KiB for MTSDF or Slug instead of 1,022.1/2,384.9/2,384.9 KiB cold-plan payloads. Font-size writes
+ 340.7/340.7/681.4 KiB respectively. Five-warmup/11-sample latency remains above the gate at
+ 4.599/4.916/6.057 ms for Bitmap/MTSDF/Slug resize; the reduction does not support a packing-dominance claim and makes
+ layout the next measured optimization target. The optimized module is 1,065,394 / 399,111 / 317,830 raw/gzip/Brotli
+ bytes.
+
+- **Proved all three canonical render-policy shapes** — The retained Rust frame now consumes validated real Inter
+ Bitmap, MTSDF, and Slug records, derives linear color channels and inverse font size during policy gather without
+ retained per-glyph arrays, omits the same absent raster records as the portable techniques, and emits the exact
+ first-party buffer schemas: 48 bytes per Bitmap instance and 112 bytes per MTSDF or Slug instance. SIMD output now
+ transposes SoA arithmetic lanes into tightly packed vec2/vec4 records before contiguous 128-bit stores. On the
+ unchanged 25,515-positioned-glyph stress text (21,805 renderable instances), five warmups and 11 samples measure
+ Bitmap/MTSDF/Slug font-size medians of 5.506/6.396/7.237 ms and full-column-resize medians of
+ 4.477/5.276/6.259 ms. These exceed the sub-4 ms gate and identify the next required invariant: policy validation must
+ derive physical-buffer dependencies so resize and font-size updates do not execute or publish static UV, color,
+ band, address, and count buffers. The optimized module is 1,060,971 / 400,835 / 317,139 raw/gzip/Brotli bytes.
+
+- **Measured exact retained Rust frame invalidation** — Style changes now invalidate bidi, shaping, metrics, and
+ positioning independently; exact rectangle geometry skips flow when safe; unchanged ordered-direct frames publish an
+ empty reuse transaction without scanning glyphs. A hard-break regression now skips the deliberately unshaped cluster
+ before visual-run lookup. Over 25,515 glyphs, eight warmups, and 31 samples, Rust cold/no-op/font-size/full-column
+ resize/suffix edit/localized edit measure 13.693/0.001/4.090/3.374/13.927/13.986 ms median and
+ 14.111/0.001/4.236/3.706/14.511/14.381 ms p95. The unchanged TypeScript cold/font-size/width/suffix-edit medians are
+ 55.25/11.90/8.36/38.55 ms. The comparison remains provisional because the Rust lane writes one F32 policy lane rather
+ than Bitmap's complete five-buffer record. The optimized module is 1,060,175 / 400,500 / 316,984
+ raw/gzip/Brotli bytes; the sequential 76.25 MiB process high-water mark is unresolved memory evidence, not a
+ per-session budget. All 192 package tests, six fuzz tests, 115 Rust unit tests, and Unicode 17 conformance pass.
+
+- **Primary HarfRust shaping now runs inside `text_update`** — A borrowed run view lets legacy batching and the retained
+ engine share the prewarmed UnicodeBuffer, UTF-16 context, and reusable feature scratch. Retained style payloads feed
+ HarfRust without an owned request, and glyph SoA appends directly into a pre-reserved A/B session arena. A real-Inter
+ compiled-Wasm proof observes shape-plan count 0→1 after the frame and no increase after abort. Rust tests and
+ host/SIMD Clippy pass. Optimized Wasm is 973,367 / 364,517 / 287,942 raw/gzip/Brotli bytes (+5,281 / +1,853 /
+ +1,504). Ordered fallback, layout, nonempty plan output, and complete timing remain open.
+
+- **Retained bidi and shaping-run itemization moved inside `text_update`** — UAX #9 output now fills reusable
+ active/pending level, class, paragraph, and equal-level-run arrays. Root direction changes paragraph base level;
+ nested direction carries a distinct override bit and forces parity during one style×script×level interval sweep.
+ The sweep skips mandatory hard-break controls and commits/aborts with the session. Rust tests and host/SIMD Clippy
+ pass. Optimized Wasm is 968,086 / 362,664 / 286,438 raw/gzip/Brotli bytes (+4,067 / +1,899 / -2,304). Fallback
+ shaping, layout, nonempty plan output, and complete-path timing remain open.
+
+- **Retained Unicode 17 analysis moved inside `text_update`** — The shared Unicode generator now emits compact Rust
+ Script/Script_Extensions partitions beside the TypeScript tables. A no-std Unicode 17 grapheme iterator validates
+ UTF-16, preserves UTF-16 boundaries, resolves contextual scripts, and reuses pre-reserved active/pending session
+ arrays. Analysis commits and aborts with text/styles and is skipped for unchanged text. Rust tests, host/SIMD Clippy,
+ and focused compiled-Wasm tests pass. Optimized Wasm is 964,019 / 360,765 / 288,742 raw/gzip/Brotli bytes. Bidi/run
+ intersection, fallback shaping, layout, nonempty plan output, and complete-path timing remain open.
+
+- **Resolved the retained style cascade in Rust** — A derived A/B segment arena now sweeps validated containment order
+ once, carries resolved parents in pre-reserved scope scratch, applies stated fields at scope entry, restores parents at
+ exit, and coalesces equal neighbors without copying retained language/features. A nested/equal-range proof emits five
+ exact segments and covers shaping, spacing, paint, material, and authored tie precedence. Absent root line height
+ remains natural-metrics state. Host/SIMD Clippy and real compiled-Wasm lifecycle tests pass. The module is 895,593 /
+ 335,396 / 264,355 raw/gzip/Brotli bytes (+7,170 / +2,656 / +1,607). Unicode/run intersection and shaping remain open.
+
+- **Admitted transactional retained styles in Rust** — `text_update` now decodes canonical style snapshots and
+ removals without allocation, merge-compacts them by stable ID into pre-reserved flat A/B session arenas, and validates
+ authored cascade order, nesting, UTF-16 ranges, language/features, registered stacks, root completeness, numeric
+ domains, and request aliasing before commit. A real-font compiled-Wasm transaction commits text plus its root style,
+ rejects root removal without revision advance, and preserves `memory.buffer` after session creation. The module is
+ 888,423 / 332,740 / 262,748 raw/gzip/Brotli bytes (+31,592 / +13,737 / +10,512). Payload admission is linear and
+ retained validation uses one reusable-scratch O(n log n) sort. Layout does not consume styles yet,
+ so no frame-latency claim is attached.
+
+- **Fixed the retained style wire semantics before admission** — The compiler-mapped style record is now 88 bytes and
+ separates stable `styleId` from authored `cascadeOrder`. A stated-property field mask preserves inheritance and
+ explicit zero values, target raster density is available for Rust-owned bitmap strike selection, and generated
+ vocabularies pin style, decoration-style, and decoration-line flags. Rust unit tests and the compiled-Wasm frame ABI
+ test pass. Nonempty styles remain rejected until the transactional retained arena consumes this contract, so no
+ shaping/layout or frame-latency claim is attached.
+
+- **Admitted one-call editorial geometry into the Rust frame transaction** — Constraints, regions, exclusions, bounded
+ rectangle/polygon vertices, and inline objects now decode as borrowed records from one pinned request. Validation
+ covers limits, finite ordered bounds, enum/reserved data, identities, region ownership/ranges, pending-text anchors,
+ and cross-section payload aliasing before mutation. Sessions stage a semantic fingerprint that excludes pointer-only
+ vertex offsets. Compiled Wasm commits a complete rectangle/exclusion/object update and rejects a forged region link
+ without advancing A/B publication. Optimized Wasm measures 856,832 / 318,999 / 252,620 raw/gzip/Brotli bytes. The
+ still-TypeScript 25,515-glyph baseline records 54.02/12.29/8.50/39.12 ms cold/font-size/width/text medians; geometry
+ does not run there yet. Styles and actual layout consumption remain open, so plans are still empty.
+
+- **Reusable HarfRust initialization workspace** — Module initialization now reserves HarfRust's real 32,768-codepoint
+ info/position allocation and a reusable UTF-16 context array beside the existing plan/gather arena. Segment shaping
+ returns that allocation through `GlyphBuffer::clear` on success and restores it on fallible setup without boxing.
+ Optimized Wasm initialization grows 57 pages in total (25 new pages for shaping/context), repeated initialization
+ preserves `memory.buffer`, focused compiled-Wasm shaping/frame tests pass 11/11, and the module measures 847,814 raw /
+ 315,809 gzip / 249,629 Brotli bytes. Legacy batch-result vectors and the not-yet-landed bidi/layout arrays remain
+ explicit allocation gaps rather than being included in the claim.
+
+- **Connected policy-directed gather to the Rust plan pipeline** — One reusable workspace now resolves every program's
+ semantic/glyph/strike/resource recipe into 16-byte-aligned four-record F32/U32 lanes and feeds the plan compiler. A
+ Rust proof emits a nonempty ordered plan with exact packed bytes across all source scopes and unchanged warm capacity.
+ Compiled Wasm reserves its policy-independent 32,768-entry plan-glyph arena at initialization (1,245,184→3,342,336
+ bytes) and one declared F32 lane at policy registration (→3,538,944); both repeated operations are growth-free. The
+ production frame reaches the gather with empty layout input, so nonempty timing remains open. Optimized size changes
+ 838,060 / 312,606 / 246,732→845,580 / 315,285 / 249,221 raw/gzip/Brotli bytes.
+
+- **Registered normalized per-font render bindings in Rust** — Added a cold compiler-mapped ABI for one font-owned
+ technique/program variant, field-major glyph/strike/resource lanes, scalable or ordered physical strikes, dense
+ strike×glyph resource selection, and exact shaping-coverage validation. Rust hostile-wire and strike-selection tests
+ pass; compiled Wasm registers a binding against real baked Inter, proves owned/idempotent state and conflict, retains
+ it through the stack lifecycle, and removes it with final font disposal. The optimized module changes from 829,906 /
+ 309,646 / 244,790 to 838,060 / 312,606 / 246,732 raw/gzip/Brotli bytes. Policy gather and frame timing remain open.
+
+- **Made render-policy input shaping explicit data** — Policy programs now retain compiler-mapped source records for
+ every typed input lane, selecting numeric semantic, glyph, resource, or strike data without callbacks. Source order
+ is validated and fingerprinted; Rust and compiled-Wasm tests cover exact decoding, conflict, unknown/reserved data,
+ count mismatch, and overlap. The optimized ABI grows from 828,401 / 309,252 / 244,402 to 829,906 / 309,646 / 244,790
+ raw/gzip/Brotli bytes. Per-font binding tables and gather execution remain open, so there is no frame timing claim.
+
+- **Registered ordered font-stack ownership in Rust** — Added cold, direct-memory font-stack lifecycle operations with
+ nonempty/unique member validation, exact-order idempotence, conflict detection, and member-font retention. A
+ compiled-Wasm test registers a real baked Inter font, proves disposal fails while its stack is live, releases the
+ stack, and then disposes the font. Size measurement rejected a generic tree map at 837,865 raw / 312,057 gzip /
+ 246,478 Brotli bytes in favor of a compact cold vector at 828,401 / 309,252 / 244,402. Technique/resource binding and
+ fallback shaping remain open, so this adds no frame latency claim.
+
+- **Made Wasm engine initialization explicit and eager** — The compiler-derived ABI now publishes `initialize()`, and
+ the standard host invokes it immediately after instantiation so module state is not lazily allocated by the first
+ font, session, or update operation. The focused compiled-Wasm frame test exercises the export. Concrete 32,768-record
+ shaping/layout lanes have not landed, so this checkpoint does not claim first-shape allocation or latency evidence.
+
+- **Retained ordered UTF-16 edits transactionally inside Rust sessions** — The frame decoder now borrows and validates
+ replacement records/payloads without allocating mutation objects. Sessions apply sequential edits to retained scratch
+ and swap only on commit; abort or an invalid later replacement preserves committed text. Compiled Wasm proves cold
+ reserve/re-pin, retained follow-up edit, invalid rollback, A/B preservation, and no same-capacity memory growth. Styles,
+ shaping, layout, and nonempty plans remain open, so this adds no end-to-end timing claim. The reachable slice adds
+ 2,829 / 1,528 / 616 raw/gzip/Brotli bytes.
+
+- **Prewarmed retained text capacity without multiplying shaping scratch per session** — Session creation now reserves
+ both UTF-16 transaction buffers to 1,024 units by default, while cold create/reserve accepts an explicit text capacity.
+ The production 32,768-record analysis/shaping/layout workspace is fixed as one engine-global synchronous allocation
+ when those arrays land, covering the 25,515-glyph target without assigning that footprint to every paragraph.
+
+- **Fixed the semantic update record grammar in the compiler-derived ABI** — Added exact UTF-16 text replacement,
+ stable style, constraint, flow-vertex, region, exclusion, and inline-object layouts. Rectangle and bounded-polygon
+ geometry resolve inside the same request; style records carry shaping, spacing, material/color, and decoration data.
+ Generated ABI tests pin the record sizes and tags. Nonempty sections remain rejected until the Rust decoder lands, so
+ this checkpoint makes no layout or performance claim.
+
+- **Made Rust render-plan state session-owned and fence-safe** — The 124-byte compiler-derived update request now carries
+ a monotonic renderer-fence acknowledgment distinct from consumed plan revision. Each session owns the Rust mixed-plan
+ dispatcher and pins its committed policy identity. Wasm prepares, validates, stages, and only then commits planner and
+ revision state; failure aborts the planner while preserving a valid already-completed fence acknowledgment. Host and
+ compiled-Wasm tests cover accepted/future fences and A/B preservation; host tests cover stale fences, abort/retry,
+ capability changes, and policy replacement. Post-prepare Wasm abort coverage waits on nonempty semantic input. Reachability
+ raises optimized Wasm from 739,909 / 272,624 / 214,395 to 822,443 / 308,033 / 242,447 raw/gzip/Brotli bytes. Mutation
+ sections still reject nonempty semantic input, so this publishes an empty Rust plan and makes no shaping/layout
+ latency claim; the shared-runtime size increase is now a measured optimization target.
+
+- **Compiled mixed allocation strategies without semantic partitions** — Added a retained dispatcher that keeps the
+ homogeneous ordered-direct or stable-indirect path as one compiler and one direct plan view. A heterogeneous frame
+ lets both compilers filter the same borrowed glyph/field slices, then merges only resource, buffer, patch, primitive,
+ draw, retirement, and payload records. Disjoint low/high buffer-ID namespaces make the merged bindings unambiguous;
+ shared resources are validated and retained across an allocation-strategy transition; draws recover original global
+ order. Alternating-strategy, transition, mixed no-op, and settled-capacity tests pass. The dispatcher remains
+ unreachable from `text_update`; optimized Wasm stays 739,909 raw / 214,395 Brotli bytes. The unchanged shipping path's
+ 25,515-glyph cold/font-size/width/text medians are 57.18/12.44/8.58/39.28 ms, so session integration and target timing
+ remain open rather than inferred.
+
+- **Completed ordered-direct display-list compilation** — Dirty retained updates now publish complete compact binding
+ and command tables while keeping physical payloads revision-directed. Consecutive compatible glyphs compile into one
+ primitive span and draw packet; interleaved `A, A, B, A` resources preserve three ordered spans over two deduplicated
+ resources and buffers. Material IDs split ordered draws without splitting shared physical glyph storage. Draw records
+ also carry numeric clip and depth identities, and the generated TypeScript ABI derives their 60-byte compiler layout
+ from Rust. No-op output remains empty and one changed glyph remains one four-byte payload in the focused policy fixture.
+ Policies independently select storage and draw keys, proving material-split draws both over shared storage and over
+ material-partitioned buffers. The planner remains LTO-stripped until session wiring; reachable ABI/policy growth from
+ the preceding checkpoint measures 266 raw / 70 gzip / 139 Brotli bytes. Stable-indirect compilation and end-to-end
+ timing remain open.
+
+- **Replaced the unimplemented effects vocabulary with material routing** — Superseded `renderVariant` and the declared-
+ only `TextEffect` proposal with one batch → text → span `material` property and numeric Rust/Wire `material_id` identity.
+ The Rust contract is fixed: policies control material draw compatibility, material changes never reshape or relayout,
+ and different materials may share canonical glyph buffers. The exact Three material-factory API remains deliberately
+ provisional for a later design pass; current first-party targets do not yet implement it.
+
+- **Implemented retained ordered-direct physical patches** — Added an abortable native planner that groups glyphs by
+ policy program/resource and uses stable instance IDs plus semantic revisions, never full-buffer byte comparison, to
+ select writes. Capability alignment and upload costs coalesce ranges; consecutive changed records retain SIMD policy
+ execution. Tests prove zero-output no-ops, one-record writes, ordered suffix movement, metadata-only tail deletion,
+ checkpoint/growth, retirement generations, abort preservation, wire validity, and stable warm scratch capacities.
+ Primitive/draw compilation, stable-indirect storage, Wasm session wiring, and latency evidence remain open. The
+ unreachable native slice is LTO-stripped; optimized Wasm is 739,643 raw / 272,537 gzip / 214,149 Brotli bytes.
+
+- **Completed the capability-shaped policy ABI** — Extended the compiler-mapped registration transaction with exact
+ capability-set, program-planning, and physical-buffer metadata: backend limits and upload costs, capability-specific
+ program selection, technique/resource and batch-key masks, ordered-direct versus stable-indirect allocation, and
+ aligned padded strides. Unknown capabilities and unsupported combinations fail before revision change; the executor
+ proves padding-safe writes. V0 keeps independently bindable vector streams and uses policy bytecode to pack `vec2`/
+ `vec4` records instead of adding aliased mutable interleaving. The focused Rust and Node gates pass; the optimized SIMD
+ artifact measures 739,647 raw / 272,532 gzip / 214,186 Brotli bytes. Retained diff compilation remains the next proof.
+
+- **Fixed the compiler-mapped render-plan wire grammar** — Extended the aligned result header from 128 to 144 bytes to
+ carry policy handle, capability set, and a deterministic validated-policy fingerprint. Added exact semantic, resource,
+ buffer, patch, primitive, draw, retirement, and diagnostic records plus tagged actions and allocation strategies.
+ Field-wise little-endian serialization rebases write-patch payloads inside the same immutable A/B publication and
+ rejects malformed spans before touching its inactive arena. Rust unit tests cover every table and the header-to-table
+ linkage; real Wasm integration reproduces policy identity, checkpoint/delta revisions, failure isolation, and A/B
+ immutability. Retained semantic compilation is not yet claimed.
+
+- **Admitted explicit-SIMD render-policy execution** — Added a production scalar interpreter for validated straight-line
+ render policies and a four-record `simd128` executor with scalar tails. Registration resolves policy buffer IDs once;
+ warm execution consumes borrowed semantic SoA fields, preflights every output, allocates nothing, and requires every
+ direct-memory region to remain inside a live host allocation before borrowing retained engine state. Scalar,
+ auto-vectorized, and explicit-SIMD artifacts produce identical horizontal, vertical, partial-tail, and four-byte-
+ aligned outputs. At 25,515 glyphs, the representative 17-operation policy improves p95 from 1.174 to 0.428 milliseconds
+ in Node and 1.113 to 0.438 milliseconds in Chromium. The production SIMD artifact is 530 raw bytes smaller and 62
+ Brotli bytes larger than scalar. Boundary search, native SIMD, and whole-update contribution remain unmeasured.
+
+- **Selected the first SIMD-shaped retained storage** — Added a test-only direct-pointer kernel lab over real 25,515-
+ and 100,602-glyph paragraph arrays and three isolated Wasm builds. Node 24 and Chromium 149 reproduce exact scalar
+ hashes for horizontal, vertical, partial-tail, and four-byte-aligned inputs with no warm allocation path or memory
+ growth. Compiler-vectorized source beats hand-written record packing; explicit 16-lane break/bidi masks and
+ integer-exact summaries pass the 20% admission threshold; large-workload evidence selects ABI-private 64-cluster,
+ 16-byte-aligned SoA chunks. The selected lab delta is 1,652 raw / 1,158 Brotli bytes, while the standard production
+ SIMD artifact is 289 raw / 26 Brotli bytes smaller than scalar. SIMD now builds by default without runtime dispatch;
+ `PMNDRS_TEXT_SHAPER_SIMD=0` produces the same-ABI scalar artifact, whose disassembly contains no vector instructions
+ and whose 34 focused semantic tests pass. Policy execution, boundary search, native SIMD, and end-to-end contribution
+ stay measured follow-ups rather than inferred wins.
+
+- **Proved worker-owned frame transfer and return** — Added a test-only, byte-opaque transfer state machine around raw
+ Wasm publication bytes. One copy enters a bounded capacity-classed worker buffer; transfer to root detaches it and
+ charges its actual capacity to explicit count/byte backpressure limits. Retirement transfers the same storage back,
+ where a valid token/capacity pair either re-enters the bounded best-fit pool or becomes unreachable for worker-side
+ collection. Four focused tests prove exact bytes, two-way detachment, reuse, missing-return backpressure, forged and
+ duplicate return rejection, failed-send recovery, oversize rejection, and over-limit worker-side discard. The module
+ does not decode the compiler-defined frame ABI and remains unwired from the shipping TypeScript layout path.
+
+- **Proved the retained A/B frame transaction in the optimized Wasm** — Added session lifecycle, cold reservation, one
+ retained 16-byte-aligned request arena, two retained 16-byte-aligned result arenas, explicit engine/plan/publication
+ revisions, base-revision checkpoints, and a compiler-derived 120-byte request plus 128-byte result header. The result
+ already reserves fixed semantic, resource, buffer, patch, primitive, draw, retirement, and diagnostic table fields;
+ unimplemented nonempty sections fail at the Rust boundary instead of falling back to host typography. Updates return
+ the selected result pointer in their single call. Success alternates slots, while malformed or stale-revision requests
+ write only the inactive slot and leave the active publication byte-identical. A real Node/Wasm proof observes zero
+ warm memory growth, then forces an 8 MiB cold reserve, observes the old fixed buffer detach, re-reads the aligned request
+ pointer, and disposes the session exactly. Twenty-five Rust unit tests, both complete Unicode 17 bidi suites, and the
+ optimized-Wasm policy/frame proofs pass. The frame shell grows the optimized shaper from 698,238 to 725,302 raw bytes,
+ from 260,228 to 269,438 gzip bytes, and from 203,760 to 210,867 Brotli bytes. The tables are still empty, so this proves
+ ownership and transaction semantics rather than shaping/layout performance. The still-unwired 25,515-glyph host path
+ remains within run variance at 54.42/12.15/8.31/38.98 millisecond cold/font-size/layout-width/text medians and
+ 70.38/14.48/11.31/40.89 millisecond p95 values. The package's 186 integration tests, six fuzz targets, 117 benchmark
+ application tests, 20/20 warmed headless conformance scenarios, and the 172,156-byte packed-consumer proof
+ (`af7bfb85f04a6a63c6462735a6e8ec6d739576adb354c07ca51e744814db2f7b`) also pass. The aggregate benchmark script
+ still stops at its deliberately stale checked package-size snapshot; this stage records the new measured size instead
+ of rewriting unrelated historical evidence.
+
+- **Registered fixed-layout render policies at the Rust/Wasm boundary** — Added compiler-derived `#[repr(C)]` policy
+ headers and fixed-width program, buffer, and operation records to the existing generated shaper ABI. Registration
+ performs one bounded direct-memory decode, rejects overlapping tables, forged lengths, nonzero reserved fields,
+ noncanonical op encodings, and semantically incomplete programs, then retains typed Rust policy state independent of
+ the caller's allocation. Identical handle registration is idempotent, conflicting registration is observable, and
+ disposal is exact. No JSON, string dispatch, runtime reflection, or frame-path schema decode entered the Wasm module.
+ Twenty-one Rust unit tests, both complete Unicode 17 bidi conformance suites, and a real optimized-Wasm lifecycle test
+ pass. Making validation and lifecycle reachable grows the optimized shaper from 680,312 to 698,238 raw bytes, from
+ 253,568 to 260,228 gzip bytes, and from 199,365 to 203,760 Brotli bytes; this is registration-time infrastructure, not
+ evidence of frame-path performance. As expected for unreachable hot-path code, the 25,515-glyph comparison remains
+ within run variance: cold/font-size/layout-width/text medians move from 55.28/12.02/8.42/38.66 milliseconds to
+ 52.48/11.92/8.23/38.73 milliseconds, with corresponding p95 values of 69.57/14.87/11.07/41.06 milliseconds.
+
+- **Began the Rust render-policy foundation without growing shipping Wasm** — Added the first renderer-neutral engine
+ module to the existing shaper `rlib`, preserving the single `no_std + alloc` Rust/Wasm codebase rather than creating a
+ second module. A bounded straight-line policy representation and total verifier now reject invalid identities,
+ duplicate technique variants/programs/buffers/stores, invalid vector lanes, unknown buffers, out-of-range semantic
+ fields, uninitialized or mistyped registers, and incomplete physical records before execution. Technique variants use
+ the same data and functions; no technique-specific class or packer entered the core. Sixteen focused Rust tests and the
+ two complete Unicode 17 bidi conformance suites pass. Because no Wasm export reaches the new module yet, the optimized
+ shaper remains exactly 680,312 raw bytes; the compiler-derived direct-memory ABI remains unchanged until the policy
+ records join it in the next commit.
+
+- **Locked the Rust text-engine and retained render-plan architecture** — Expanded the narrower layout-boundary proposal
+ into one `no_std + alloc` Rust semantic pipeline for Unicode analysis, bidi, fallback, shaping, per-line editorial
+ composition, typography geometry, and policy-directed incremental render-plan compilation. The steady-state host
+ transaction is one revisioned Wasm update with A/B synchronous publication, worker-owned transferable buffers for
+ retained/asynchronous consumers, explicit return-to-worker retirement, invalidation-directed patches, and
+ scalar-versus-SIMD admission on the target 25,515-glyph workload. The plan makes sequential regions and declarative
+ exclusions one-call inputs, cuts unbounded publishing solvers and second authored text channels, and puts the
+ Wasm/policy/display-list proof before added typography. Review of the base font-fallback implementation also exposed
+ that its same-technique restriction came from the old one-program/one-schema API rather than shaping or measured
+ performance. D-161 now makes technique and resource binding properties of each loaded font, permits heterogeneous
+ same-runtime stacks, removes technique from user-facing `Text` and `TextGroup`, requires every first-party engine policy
+ to support Bitmap, MSDF, and Slug, and lets third-party policies declare a runtime-validated subset. The render plan
+ partitions resolved glyphs by technique/resource/program and publishes all participating resources atomically instead
+ of requiring synthetic composite techniques.
+
- **Boundary reshaping was redundant by construction** — The largest single cost left in a warm update turned out to be work that could not change its own output. Each reshape range supplied `contextStart: run.start, contextEnd: run.end`, which is exactly the context the retained paragraph shape was produced with, so the shaper returned the glyphs it had already returned — on roughly every line, on every layout. The buffer's beginning- and end-of-text flags did not rescue it either: they describe the buffer edge, and the surrounding text shipped as context overrides them. Three independent lines of evidence agree. The mechanism above; a measurement over 640 ranges and 20,280 glyphs across Latin word wrap, Arabic word wrap, and Arabic character wrap narrow enough to force breaks inside joined words, where every reshaped glyph matched the retained shape; and the pinned natural, wide, and narrow layout hashes plus the entire alignment, clipping, max-lines, ellipsis, and justification contract, all unchanged with it removed. An early attempt to measure this by disabling the range emission alone was wrong and briefly looked like proof that reshaping mattered: clearing the ranges while leaving the fragments flagged made positioning look for a result that no longer existed. `ReshapeRange` stays, because a narrowed context is a real future need — a truncated line whose last letter should take its final form, or a line composed as an isolated unit for per-line widths — and the contract tests now assert zero crossings so reintroducing one is deliberate rather than silent. Against the pre-optimization commit on an identical workload at 25,515 glyphs, a reflow now lays out in 8.09ms against 110.40ms, inside the 8.33ms budget at 120Hz; a resize in 10.59ms against 103.54ms; a text edit in 33.62ms against 109.66ms.
- **Distance-field subpaths renamed, and the line that made it safe** — `./raster/mtsdf` and `./three/mtsdf` sat beside `./bakers/msdf`, so a consumer wrote one spelling to bake and another to render. The export paths and the symbols reachable through them now read msdf. The rule that made the change tractable is that identifiers move and string literals do not: a first attempt swept 370 occurrences across the monorepo and broke four separate things, every one of them a literal. The worst was msdfgen's own `mtsdf` CLI mode, which is a different algorithm from its `msdf` mode and would have silently changed what the native quality oracle generates without failing loudly. The others were the baked artifact kind, the packaged schema enum, and fixture filenames. Nothing persisted moved in the landed change: the glTF extension encoding value, the schema enum, the validator's diagnostic codes, the Rust crate and bin target names, the generated ABI module, the baker Wasm filenames, and every fixture filename keep their spelling. The benchmark application keeps `mtsdf` throughout, because its conformance scenario identifiers and `?technique=mtsdf` URL vocabulary appear in checked-in GPU performance evidence and moving them would mean regenerating hardware results for a spelling change; it consumes the renamed package symbols by aliasing them at its ten import sites instead.
diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md
index 711d8893..1603874e 100644
--- a/docs/packages/benchmarks.md
+++ b/docs/packages/benchmarks.md
@@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur
resource: ../../apps/benchmarks
workspace_package: '@pmndrs/text-benchmarks'
documentation_type: reference
-source_digest: 'sha256:79e8650499e9764b5e7449d16ca6446f60caaef77c8b53ac08fc2901cfd10985'
+source_digest: 'sha256:4e29ddcc1d3eab0c460a7243984bffe40418ce6255d30ea3bc139161e660d50d'
tags: [package, benchmarks, react, vite, product-e2e]
sources:
- id: manifest
@@ -83,9 +83,9 @@ sources:
- id: v1-compose-proof
resource: ../../apps/benchmarks/src/v1-compose-proof.ts
title: Target-v1 composed canonical-shader browser proof
- - id: v1-async-proof
- resource: ../../apps/benchmarks/src/v1-async-proof.ts
- title: Target-v1 Worker synchronization browser proof
+ - id: paragraph-contracts
+ resource: ../../apps/benchmarks/src/benchmark/targets/conformance/paragraph-contracts.ts
+ title: Public Rust paragraph conformance target
- id: bitmap-text-product-target
resource: ../../apps/benchmarks/src/benchmark/targets/product/bitmap-text.ts
title: Finite Bitmap public Text product target
@@ -194,12 +194,18 @@ sources:
- id: presentation-framerate-sweep
resource: ../../apps/benchmarks/vitexec/presentation-framerate-sweep.probe.ts
title: Complete Presentation workload performance sweep
+ - id: workflow-runner
+ resource: ../../apps/benchmarks/scripts/workflows.mts
+ title: Specialized workflow runner
+ - id: workflow-output
+ resource: ../../apps/benchmarks/scripts/workflow-output.mts
+ title: Vitexec failure-output classifier
- id: raster-technique-compare-probe
resource: ../../apps/benchmarks/vitexec/raster-technique-compare.probe.ts
title: Realtime comparison product probe
generated:
- by: anthropic-claude/opus-5
- at: '2026-08-08T08:15:00Z'
+ by: openai-codex/gpt-5.6
+ at: '2026-08-10T02:40:43Z'
---
# Package reference: `@pmndrs/text-benchmarks`
@@ -214,10 +220,16 @@ when a visibly populated draw claims no GPU residency, so the accessor is proven
a unit fixture. The Worker proof distinguishes call-time snapshots, later desired state, supersession, abort, progress,
and one reusable module Worker.
-A fifth proof covers composition over the exported canonical technique shaders. It renders one paragraph through the
-pre-registered Bitmap program, then through a third-party program that owns its own attributes, geometry, and material and
-composes only its final colour over `bitmapShader`. The verification compares the two passes on the same page rather than
-against a stored golden: an identical lit-pixel set proves the composed program inherited the canonical placement,
+The public paragraph-contract target now passes its complete exact matrix through the retained Rust path: two bidi
+layouts, nine line-policy layouts, twelve CJK layouts, and one UIKit-shaped measurement/layout seam. The UIKit fixture
+models the frame ABI's f32 style input rather than the deleted TypeScript engine's JavaScript-double input. Its final-line
+baseline and centered glyph row are derived independently from the f32 line box followed by f64 accumulation and one final
+f32 publication; the exact full-layout hash remains a deterministic byte contract rather than a tolerance assertion.
+
+A fifth proof covers material customization over the canonical Bitmap command-buffer path. It renders one paragraph with
+the default Bitmap material, then renders the same Rust-produced draw with a `defineTextMaterial` factory that starts from
+`createDefaultMaterial()` and changes only its final colour. The verification compares the two passes on the same page
+rather than against a stored golden: an identical lit-pixel set proves the custom material inherited canonical placement,
snapping, and coverage, and an empty green channel proves it still emitted its own output.
The finite Bitmap conformance lane now drives that adapter directly. `bitmap-finite-scene` builds its paragraph with the
@@ -232,8 +244,8 @@ bounds, so the oracle changed renderer without changing what counts as correct.
The three live technique scenes moved to target-v1 next. `techniques/{bitmap,mtsdf,slug}/persistent-scene.ts` now build a
standalone `Text` — an implicit batch of one, deliberately left off `TextGroup` so the single-paragraph adapter path stays
exercised and their `drawCount` stays directly comparable with merged v0 — from the `LoadedFont` that
-`workloads/font-assets` already produced, commit it by parenting and forcing `updateMatrixWorld`, and read `error` and
-`layout` instead of awaiting readiness. Flat merged-v0 properties become nested `contentBox`, `style`, and `paint`, with
+`workloads/font-assets` already produced, commit it by parenting and forcing `updateMatrixWorld`, and read `error` plus
+explicit `measureLayout()` or `inspectLayout()` results instead of awaiting readiness. Flat merged-v0 properties become nested `contentBox`, `style`, and `paint`, with
the paragraph measure expressed as an exact width constraint and the live colour as `#ffffff`, which resolves through the
same transfer function as the numeric constant it replaces. Because a rejected generation would otherwise leave the failed
candidate font leased and undisposable, each scene commits through one apply-or-roll-back step that restores the previously
@@ -241,10 +253,10 @@ committed inputs before rethrowing.
Their presentation transitions are now owned by the application. Merged v0 exported `captureBitmapGlyphPositions` and
`createBitmapGlyphPositionTransition`, which packaged glyph identity matching and interpolation together for Bitmap only.
-Target-v1 core deliberately stops at owned glyph snapshots and topology-guarded displayed-origin writes, so
-`techniques/shared/glyph-origin-transition.ts` reimplements the policy once for all three techniques: it matches glyphs on
-the identity merged v0 used — font handle, glyph id, cluster, exact font size, and occurrence index — interpolates toward
-the shaped origins rather than the current displayed ones, writes through `setGlyphOrigins`, clears the overrides when
+Target-v1 exposes explicit Rust inspection and topology-guarded renderer presentation writes, so
+`techniques/shared/glyph-origin-transition.ts` owns the application policy once for all three techniques: it matches glyphs on
+font handle, glyph id, cluster, and occurrence index, interpolates toward the shaped origins rather than the current
+displayed ones, writes through `setGlyphOrigins`, clears the overrides when
settled, and reports `matchedGlyphs` so the existing viewport telemetry keeps its meaning. Bitmap keeps its host-driven
progress because its React viewport already animates the timeline; MTSDF and Slug, whose surfaces do not drive progress,
advance the same smoothstep from their own frame clock and gain the transition they previously lacked.
@@ -283,15 +295,16 @@ already produced, so no comparison scene names or loads a raster module. Type er
`LoadedFont` is covariant in its technique, so a concrete `LoadedFont` widens to
`LoadedFont` and every `Text`, `TextGroup`, and `TextUpdate` downstream is uniformly erased without a
cast. Erasing at the `Text` instead does not compile: the `set` method and the `font` accessor make `Text` invariant in
-its technique.
+its technique. `TextGroup` no longer receives the selected technique at construction; its shared Rust session and
+renderer policy derive each draw's technique from the loaded font binding.
Batching is a per-workload policy on the definition rather than a host-wide rule. Text ladder, Zoom text, Icon grid,
Off-axis / 3D, Dynamic layout, and Paint & effects mount under one shared `TextGroup`, so every paragraph in the workload
-prepares and packs into a single batch owning one set of GPU resources; Icon grid's recycled icon and label Texts share
-that batch across two font fixtures because both load through the one registry-scoped runtime. Paragraph stress stays
-standalone: it is a single `Text` holding a large repeated-ipsum body, already a batch of one, and keeping it standalone
-holds both adapter paths under test. The group takes a `grow` capacity because a chunked batch would split a paragraph's
-glyph run at each chunk boundary and turn one draw into several.
+enters one Rust frame transaction and shared render plan; compatible policy packets may share physical storage and one
+draw. Icon grid's recycled icon and label Texts share that session across two font fixtures because both load through
+the one registry-scoped runtime. Paragraph stress stays standalone: it is a single `Text` holding a large repeated-ipsum
+body, already a session of one, and keeping it standalone holds both adapter paths under test. The group takes a `grow`
+capacity so its retained staging and output regions settle for the workload rather than repeatedly growing.
Batching shares preparation and GPU resources, not draws. Target-v1 emits one mesh per packed glyph run and a run never
spans paragraphs, so the shared group leaves the draw topology of each paragraph exactly as it was. Measured at the
@@ -330,7 +343,7 @@ glyphs or paints.
Font delivery is an explicit benchmark axis. **Baked asset** exercises the normal sibling asset, while **Runtime bake** passes `{ source, runtimeBake }`, downloads the source font, builds the core font in the serial core-baker Worker, then builds the selected Bitmap or MSDF raster in its serial lazy Worker. The inspector distinguishes the always-loaded runtime/shaper graph from the conditional core and raster baker host, Worker, and Wasm graphs; it reports source download bytes, generated core/raster CPU bytes, bake durations, and atlas GPU memory. The runtime-fallback conformance workload renders both delivery paths through the same public pipeline and requires an exact RGBA frame match. The headless conformance suite always runs baked delivery, so `benchmark:runtime-fallback` is the lane that exercises runtime delivery: canonical Inter matched exactly for Bitmap, MTSDF, and Slug on hardware WebGPU, each reporting `1/1 exact` with zero mismatched bytes, zero changed pixels, and zero maximum error. The observed cold MSDF raster bake was roughly 114 seconds on this host and remains an observation, not a portability threshold.
-The benchmark manifest exposes only `build`, `dev`, `test`, and `check`. Specialized maintenance files declare their own names, requirements, write behavior, arguments, and runner; the root `pnpm scripts` command validates and indexes that metadata. An ordinary build consumes the checked-in canonical package-size record without rewriting it for the current host. `release:size:generate` is the sole writer, while the test gate measures the current host read-only and enforces the reviewed absolute and cumulative ceilings. `benchmark:presentation` runs every sequential workload through Bitmap, MTSDF, and Slug on WebGPU and forced WebGL2; `benchmark:demo` runs the timed sequence; `benchmark:raster-comparison` owns finite-job recovery; and `benchmark:presentation-performance` records the current complete cadence sweep. Closed milestone experiments and technique-specific performance matrices are retained as results, not executable product gates. The authenticated HarfBuzz freshness gate remains separate from ordinary repository checks because Meson, Ninja, and GLib belong only to that workload. Install the scoped `apps/benchmarks/mise.toml` pins when needed, then run `pnpm scripts run fixture:harfbuzz:provision` and `pnpm scripts run fixture:japanese-showcase:check`. React Doctor remains a manual review tool rather than a package or CI script; when requested, run `mise exec -- pnpm --dir apps/benchmarks dlx react-doctor@0.7.2 . --scope full --blocking warning --verbose --no-supply-chain --no-color`.[^presentation-framerate-sweep]
+The benchmark manifest exposes only `build`, `dev`, `test`, and `check`. Specialized maintenance files declare their own names, requirements, write behavior, arguments, and runner; the root `pnpm scripts` command validates and indexes that metadata. Vitexec can exit zero after an injected module or page failure, so the runner forwards and inspects its captured output and rejects `[error]` or `[page error]` records; focused negative controls prove both markers while ordinary logs remain accepted.[^workflow-runner][^workflow-output] An ordinary build consumes the checked-in canonical package-size record without rewriting it for the current host. `release:size:generate` is the sole writer, while the test gate measures the current host read-only and enforces the reviewed absolute and cumulative ceilings. `benchmark:presentation` runs every sequential workload through Bitmap, MTSDF, and Slug on WebGPU and forced WebGL2; `benchmark:demo` runs the timed sequence; `benchmark:raster-comparison` owns finite-job recovery; and `benchmark:presentation-performance` records the current complete cadence sweep. Closed milestone experiments and technique-specific performance matrices are retained as results, not executable product gates. The authenticated HarfBuzz freshness gate remains separate from ordinary repository checks because Meson, Ninja, and GLib belong only to that workload. Install the scoped `apps/benchmarks/mise.toml` pins when needed, then run `pnpm scripts run fixture:harfbuzz:provision` and `pnpm scripts run fixture:japanese-showcase:check`. React Doctor remains a manual review tool rather than a package or CI script; when requested, run `mise exec -- pnpm --dir apps/benchmarks dlx react-doctor@0.7.2 . --scope full --blocking warning --verbose --no-supply-chain --no-color`.[^presentation-framerate-sweep]
`pnpm scripts run benchmark:demo` exercises the complete 60-second timed sequence through a focused control on WebGPU and forced WebGL. Off-axis / 3D and Icon Grid each receive two seconds before Paint & Effects begins at second four; the more visual Zoom Text and returning Icon Grid scenes receive longer holds than Dynamic Layout. Advanced Shaping resets to CJK and reveals one complete five-case cycle at 180 grapheme units per second. Playing case transitions begin the next script at its first grapheme; a font-changing handoff deliberately blanks the live line until that generation commits instead of showing mismatched old-script state. Zoom Text pre-shapes all 16 fixed-Inter, language-tagged words during cold scene preparation and retains one node per word; animation performs only scale, opacity, and visibility changes, so it continues its normal word cycle without an animation-time readiness boundary and cuts after three complete default-speed drops. Text Ladder receives the derived 7.2 seconds required for its default-speed vertical travel and 1024 px marquee to pass completely through the left edge before the nine-second Icon Grid return. A final 8.016-second Off-axis / 3D scene supplies the closing frame. The probe requires window-capture Space handling, exact workload defaults after preload, advancing telemetry, a retained canvas, exactly one renderer, both Icon Grid entries, the configured backend throughout, and the final Off-axis / 3D scene.
@@ -349,8 +362,11 @@ Every live benchmark identity resolves through one typed catalog under `apps/ben
The root `app.tsx` owns only runner detection, shell Suspense, and URL route selection. Both route branches render the same `routes/harness-route.tsx` component type, preserving one runtime-world identity while `controllers/harness-controller.tsx` owns URL revisions, post-preload transitions, presentation playback, shortcuts, and execution state. Persistent renderer provisioning and exclusive conformance-action adaptation live in `surfaces/harness/persistent-layout.tsx`; Benchmark/Conformance scene composition lives in `surfaces/harness/scene.tsx`; Main and Presentation chrome remains in `components/harness-layout.tsx`; and runtime control binding remains in `components/runtime-controls.tsx`. The three persistent Bitmap, MTSDF, and Slug live-text viewport controllers live under `surfaces/benchmark`, keeping their host lease, synchronous update path, staged font-fixture loading, loading state, telemetry, and probe contract beside the rendered surface. Their renderer imports remain literal dynamic boundaries: type-only references use `import type`, so the production build retains separate technique chunks rather than pulling renderer implementations into the route entry. Authored scenes load fixtures through `workloads/font-assets`: one discriminated adapter selects only the requested Bitmap, MTSDF, or Slug lane through literal dynamic imports, while each lane loads its fixture exactly once through the target-v1 `FontLoader` from `@pmndrs/text/three`, using the public raster technique and `@pmndrs/text/runtime-bake` entrypoint. Baked delivery authenticates the artifact first and then publishes those bytes as a blob URL, because `LoadedFontInput` names URLs rather than bytes; runtime delivery passes the measured core baker as the request's `runtimeBake`. Because the loader registers into the registry the caller supplies, `BenchmarkFontAsset.font` is a projection of `loaded.font` rather than a second registration, and the retained merged-v0 `raster` module resolves the raster key the load already attached instead of baking again. Loads that name no registry share one `THREE.LoadingManager`, so their fonts share one text runtime as a paragraph batch requires; each caller-supplied registry keeps its own manager, runtime, and loader, preserving the ownership isolation those surfaces already had. The adapter owns source-font URLs, baked transport URLs, gzip and SHA-256 authentication, and runtime progress and delivery metrics; renderer modules retain only live GPU lifecycle, configuration, statistics, and compatibility delegates. Delivery metrics instrument the technique's runtime baker through a clone, which still renders because the Three program registry resolves programs by stable technique ID rather than object identity. Direct font-baker imports and Wasm URLs remain prohibited from this workload-facing path. Conformance React composition lives under `surfaces/conformance`: both the retained comparison and finite captures can receive only the host-owned renderer, while executable low-level work lives below `benchmark/targets/conformance`, `benchmark/targets/product`, and `benchmark/targets/measurement`. The realtime MTSDF/Slug comparison, runtime-fallback capture, external raster proof, React reconciliation target, and finite Bitmap/MTSDF/Slug product lifecycles are owned by those explicit target trees rather than `renderer`. The finite product targets accept the runner's renderer and abort signal, lazily load their public `Text` scenes, render deterministic frames, and dispose only resources they own. Shared Bitmap line construction, exact CPU-reference composition, renderer-state restoration, and RGBA8 readback normalization live below `benchmark/low-level/raster`; MTSDF and Slug product scenes remain target-owned because they are executable benchmark examples. Pure CPU raster and source-outline oracles live in the same low-level tree, so renderer-adjacent finite capture code can share primitives without importing executable targets; a source-boundary regression prohibits renderer-to-target dependencies. Advanced Shaping lives in the conformance target hierarchy behind the registry's literal selected-target dynamic import. Bitmap, MTSDF, and Slug conformance dispatch now enters technique-owned target modules rather than live renderer files. MTSDF and Slug sampling plus source-outline targets implement the same warm session contract, preserving `load → capture → dispose` reuse and forwarding the borrowed renderer and abort signal unchanged; Bitmap's thin target wrapper reuses its neutral low-level finite scene. The target modules own CPU comparison, renderer-state restoration, standard visual captures, and Slug role/external-resource proofs; renderer modules retain only live persistent-scene and font/configuration infrastructure. The targets share the explicitly named `targets/shared/direct-wasm.ts` dependency adapter only after target selection. The public missing-sibling loader Worker is conformance because it proves authenticated Worker bytes and loader fallback behavior; it is not a rendering product target. Boundary tests reject workload imports back into renderer implementation, reject renderer imports of executable targets, authenticate literal selected-target imports, preserve selected-technique asset chunks, and reject direct font-baker or Wasm URL imports outside the shared adapter, preventing raw tooling from leaking into the normal Presentation module graph.
The external raster product proof renders a competing transparent cover and public `Text` under different parent Groups on
-WebGPU and WebGL2. Framebuffer differences prove that the composite Text and neutral plugin batch preserve the caller-owned
-primary group order through actual Three.js sorting.
+WebGPU and WebGL2. The external package's static policy and font binding run through the Rust command-buffer path; its
+renderer factory receives only plan-selected buffers. Framebuffer differences prove caller-owned Group order through
+actual Three.js sorting, while the baseline toggles public `Text.visible` to prove one indexed instance can disappear
+without splitting the shared draw or rerunning layout. Two samples per backend reproduce the same RGBA SHA-256
+`817495c4afe3a8f88d2af85d972f43be88b9f834ed0268d0d0b2e3de86ba9d46`.
Timed playback compares each frame with the latest requested location rather than the last committed scene, so an in-flight preload receives exactly one request and cannot be superseded by a duplicate transition that skips workload-default initialization. Presentation captures Space at the window capture boundary to start or stop timed playback even while a button, switch, slider, select, or combobox owns focus; matching key-up activation is suppressed, while inputs, textareas, and editable text retain ordinary space entry. Arrow navigation remains disabled on interactive controls.
@@ -366,7 +382,7 @@ Live CPU, FPS, and GPU telemetry share frame timestamps, one RAF-driven presenta
The responsive shell reserves the three-column rail/main/control layout for viewports at least 1,200 CSS pixels wide. The wordmark toggles the workload rail at desktop size and opens the workload drawer below desktop. Main mode keeps technique selection above independently scrolling workloads and presents font fixtures as buttons below them. The fixture panel remains content-height when its buttons fit, grows only as they require, and caps at half of the post-Technique rail region; only then does its button list scroll with the same contained overscroll and directional edge fades as the workload list. Compact Main controls use the same fixture buttons, while Presentation keeps its intentionally compact custom dropdown. Tablet and phone keep the live scene mounted, carry the shared Bitmap/MSDF/Slug switcher in a compact header with explicit separation between techniques, and present controls as a scrollable floating panel capped at 60% of viewport height; they do not replace the scene with a bottom-tab flow. Form controls inherit only the design-system font family, leaving their explicit Tailwind size and line-height utilities authoritative. The scene header stacks before its chips on narrow widths, and control labels stay at or below 13 CSS pixels without clipping while interactive controls retain at least 28 CSS pixels of height. Shared range controls remove text-field padding and draw their visible rail exactly one half-thumb radius inside each edge, so the minimum and maximum rail endpoints equal the native thumb-center travel. An accent segment fills from the minimum to the controlled value over the remaining neutral rail. Component tests distinguish range and text geometry, normalize and clamp fill progress, and the live product probe verifies zero horizontal padding, the explicit inset, visible fill/rail layers, and both numeric endpoints. The responsive product probe owns technique switching, drawer and panel interaction, retained-scene visibility, the 60%-height panel cap, and horizontal-overflow gates at 390, 1,024, and 1,280 CSS pixels; its in-process Vite lifecycle resolves the compiler from the application workspace, selects an available local port, and closes both browser and server after success or failure.
-This application owns the shared target/scenario runner, responsive Figma-backed interface, URL state, validation/report/export views, deterministic synthetic target, real portable-baker target, real public loader/Worker-fallback target, real HarfRust shaping-conformance target, real paragraph measurement/positioned-layout/policy/CJK targets, the dual-backend TSL shader baseline, the public `Text` bitmap target, and the public React `Text` reconciler target. The runner disposes partial target state when loading fails, and the UI retains typed WebGPU availability through label and tone rendering. The interactive UI, browser headless CLI, Vitest, Vitexec, and Playwright all call the same strict registry execution module. Inter 4.1 remains the default fixture; Amiri 1.002 owns complex-script evidence; Noto Sans CJK JP 2.004 owns the maximum-cardinality universality lane. Each is immutable, licensed, hash-authenticated, and paired with checked HarfRust/HarfBuzz evidence. Chromium 149 runs the bounded conformance suite through forced WebGL2, including exact bitmap readback and deterministic React reconciliation; the maintainer-local lane repeats the bitmap frame on hardware WebGPU. The CJK result fixes thirteen corpus cases, four paragraphs, twelve layouts, eight plans, one direct shape call, four paragraph shape calls, zero reshapes, 10,622 output bytes, and the exact composite hash `a1a833f2:fbe2aa07:922f9a2e:8c977f4d:85a2f640:fd42b9f7:53d8ec89:8cb3050c:bbfd039d:837a2b43:2f450f5e:9900b4af:c49f3e68`; Vitexec repeats it with WebGPU active.
+This application owns the shared target/scenario runner, responsive Figma-backed interface, URL state, validation/report/export views, deterministic synthetic target, real portable-baker target, real public loader/Worker-fallback target, real HarfRust shaping-conformance target, real paragraph measurement/positioned-layout/policy/CJK targets, the dual-backend TSL shader baseline, the public `Text` bitmap target, and the public React `Text` reconciler target. The TSL baseline now uses a green decoy record plus a u32 logical-order table selecting a red physical record, so its exact readback proves the nested storage lookup used by stable-indirect plans rather than only proving a constant node graph. Forced WebGL2 and hardware WebGPU both return 16/16 red pixels with hash `fec0f57d…c77`. The runner disposes partial target state when loading fails, and the UI retains typed WebGPU availability through label and tone rendering. The interactive UI, browser headless CLI, Vitest, Vitexec, and Playwright all call the same strict registry execution module. Inter 4.1 remains the default fixture; Amiri 1.002 owns complex-script evidence; Noto Sans CJK JP 2.004 owns the maximum-cardinality universality lane. Each is immutable, licensed, hash-authenticated, and paired with checked HarfRust/HarfBuzz evidence. Chromium 149 runs the bounded conformance suite through forced WebGL2, including exact bitmap readback and deterministic React reconciliation; the maintainer-local lane repeats the bitmap frame on hardware WebGPU. The CJK result fixes thirteen corpus cases, four paragraphs, twelve layouts, eight plans, one direct shape call, four paragraph shape calls, zero reshapes, 10,622 output bytes, and the exact composite hash `a1a833f2:fbe2aa07:922f9a2e:8c977f4d:85a2f640:fd42b9f7:53d8ec89:8cb3050c:bbfd039d:837a2b43:2f450f5e:9900b4af:c49f3e68`; Vitexec repeats it with WebGPU active.
Milestone 9 adds deterministic package-owned Slug GLB fixtures for all seven visual families and one copied-and-adapted Three.js/TSL benchmark adapter. The fixtures compose the shared core artifact with embedded analytic curve, exact header, and exact reference resources, authenticate raw and gzip identities, and publish bake plus decoded-GPU totals in one checked manifest. The adapter supports baked and serial runtime delivery, reports curve/header/reference allocations separately from the framebuffer, and exercises the public Slug raster through the same retained `Text`, dual-backend renderer, frame readback, and live telemetry boundaries as the established raster targets. Slug is a genuine third technique across all seven live comparison workloads, the independent CPU sampling comparison, source-outline fidelity, and baked/runtime parity surfaces. Its imports remain dynamic so selecting Bitmap or MTSDF does not load Slug. Deterministic WebGPU and forced-WebGL2 product, sampling, and source-outline probes pass. The shared hardware-WebGPU product probe covers Bitmap, MTSDF, and Slug over Text Ladder, Zoom Text, Icon Grid, Off-axis / 3D, Dynamic Layout, Paragraph Stress, and Paint & Effects. Paint & Effects limits Slug to animated fill and opacity and disables both outline and shadow controls; MTSDF retains both effects. The retained quality and performance matrices use authored Latin, Arabic, Devanagari, and Japanese specimens instead of sending every font through Latin fallback. The packed-hull experiment retains exact dual-backend quality, timing, payload, and residency evidence but is rejected and removed from the shipping tree because no source clears the performance gate on both backends. The root-contribution experiment likewise retains its precommitted manifest, complete 28-cell quality result, final-program identities, and 140 paired sessions while removing its temporary graph selector and probes. All pixels and resources remain exact. Final Three programs prove that the baseline already lowers `select` to eight root-condition branches across both axes, while the candidate coalesces the same work into four but adds 304 generated bytes on each backend. No source clears 5% on both backends: median paired deltas across the seven sources are +0.84% on WebGPU and -1.56% on WebGL2, with an 8.43% CJK WebGPU regression and 11.22% Inter WebGL2 regression. The applicable older-fork baseline and retained challenger queue are complete for Milestone 9; new hypotheses remain future measured research.
@@ -388,7 +404,7 @@ GitHub CI uses the Ubuntu runner's rolling system Chromium as a deliberate compa
The independent package-size lane measures the initial public browser graph, lazy font validator, runtime Worker boundary, baker and shaper JavaScript/Wasm, and Unicode 17 analysis without zero-byte placeholders. Static entry closures and dynamic chunks are separated from Rollup metadata rather than conflated; the browser-core lane externalizes the package's declared `three`, React, and R3F peers, and package-owned Wasm URLs are externalized from JavaScript measurements regardless of their owning package. The report records its measurement platform and architecture plus the SHA-256 identity of each measured payload: minified bundle bytes for JavaScript and emitted module bytes for Wasm. Same-host regeneration is exact; every foreign-host raw/minified/gzip/Brotli result must satisfy the shared reviewed budget table because native Rust/Binaryen and Rolldown output has small cross-architecture byte variance. Coverage-capability growth is independently bounded against its pre-coverage baseline, and foreign-host failures report the measured payload, reviewed ceiling, and exceeded dimensions. The product inspector's selected-runtime total is the gzip transfer sum of the selected raster runtime graph and separately emitted shaper Wasm. The raster graph already contains the shared core and shaper JavaScript host, so adding the independent browser-core or text-shaper-JavaScript measurements would double-count code. Selected runtime and conditional runtime-bake totals are default-collapsed disclosures; their component rows remain available on demand without displacing the separate font-asset total. The font-asset card reports only one transport quantity: gzip bytes for compressed MTSDF artifacts and exact transferred bytes for uncompressed Bitmap/runtime-source assets. Decoded container, raster, and GPU allocation sizes never appear as children of that transfer total; GPU texture allocation remains isolated in the resource card. Each full row is the interaction target, while fixed label, status, and byte columns use a neutral centered chevron, a green check for loaded code, and a gray X for unloaded code. The total intentionally excludes external Three.js, React, and R3F peers plus font assets; those assets remain separate rows rather than being mislabeled as a complete application bundle.
-The current Darwin arm64 record reports a 258,370 minified / 74,531 gzip / 57,310 Brotli peer-externalized browser graph and an independently measured 139,936 / 42,047 / 30,989 Unicode analysis graph. The first target-v1 technique contract adds 613 raw / 333 minified / 74 gzip / 20 Brotli bytes to browser core. Splitting renderer-neutral atlas decoding from Three realization changes the Bitmap closure by +491 / +242 / +47 / +16 and the MTSDF closure by −678 / −550 / −168 / −233 raw/minified/gzip/Brotli bytes; Slug is byte-identical. The validator, runtime host, runtime Worker JavaScript, portable baker JavaScript, baker Wasm, shaper JavaScript, and shaper Wasm report 584,479, 9,524, 8,936, 6,077, 422,538, 36,966, and 680,312 minified/raw bytes respectively. The configurable MTSDF baker host measures 19,117 minified / 5,530 gzip / 4,908 Brotli bytes and its coverage-capable full Wasm measures 552,025 raw bytes; the reviewed host ceiling includes authenticated quality and coverage policy. The exact-distance-outline removal experiment measured Slug's isolated runtime graph falling from 288,338 to 278,977 minified bytes, from 83,445 to 81,151 gzip bytes, and from 64,856 to 63,013 Brotli bytes on its original same-host comparison. The current Bitmap, MTSDF, and Slug runtime closures measure 287,587 / 82,310 / 63,980, 290,959 / 83,549 / 65,085, and 292,301 / 84,209 / 65,667 minified/gzip/Brotli bytes. Slug's baker host measures 12,913 / 4,129 / 3,680 and its Wasm measures 465,046 raw / 186,683 gzip / 146,720 Brotli bytes. Bitmap-only and MTSDF-only size entries inspect their initial module closures and fail if Slug runtime, shader, baker, or runtime-baker modules enter either graph. Paragraph layout hashes and the policy composite hash share one implementation over the actual normalized layouts; the generator, benchmark target, unit tests, and Vitexec probes no longer maintain parallel digest logic.
+The current Darwin arm64 record reports a 64,157 minified / 17,846 gzip / 15,469 Brotli peer-externalized browser graph and an independently measured 141,127 / 42,406 / 31,287 Unicode analysis graph. The validator, runtime host, runtime Worker JavaScript, portable baker JavaScript, portable baker Wasm, and shaper Wasm report 584,479, 9,524, 8,880, 6,017, 422,538, and 1,159,317 minified/raw bytes respectively. The configurable MTSDF baker host measures 19,076 minified / 5,522 gzip / 4,901 Brotli bytes and its coverage-capable full Wasm measures 552,025 raw bytes; the reviewed host ceiling includes authenticated quality and coverage policy. The current Bitmap, MTSDF, and Slug runtime closures measure 210,635 / 54,492 / 45,949, 210,639 / 54,494 / 45,983, and 210,634 / 54,431 / 45,956 minified/gzip/Brotli bytes. Slug's baker host measures 12,877 / 4,116 / 3,667 and its Wasm measures 465,031 raw / 186,665 gzip / 146,606 Brotli bytes. Bitmap-only and MTSDF-only size entries inspect their initial module closures and fail if Slug runtime, shader, baker, or runtime-baker modules enter either graph. Paragraph layout hashes and the policy composite hash share one implementation over the actual normalized layouts; the generator, benchmark target, unit tests, and Vitexec probes no longer maintain parallel digest logic.
The local Worker-queue Vitexec probe authenticates every output and reports observations rather than asserting machine-sensitive timing. Two Chromium runs measured a three-font queued burst at 30.8–32.0 ms and three separately initialized sequential Workers at 68.3–88.6 ms. The correctness suite separately proves one active post, FIFO completion, queued cancellation, and active-cancellation recovery without timers. The combined live lane runs its performance observation before interaction and conformance probes so accumulated renderer work cannot contaminate cold/steady telemetry.
@@ -406,6 +422,31 @@ The bitmap baker now retains Zeno's actual integer mask placement with `planeUni
Benchmark mode exposes the same seven specialized workloads for Bitmap, MSDF, and Slug: Text Ladder, Zoom Text, Icon Grid, Off-axis / 3D, Dynamic Layout, Paragraph Stress, and Paint & Effects. Benchmark Ipsum and Advanced Shaping use the same bounded-content policy through their technique-specific renderers. Screen-bounded paragraphs preserve an explicit authored minimum width on narrow canvases, where the existing pan interaction reveals overflow, then expand inside a shared 24 CSS-pixel viewport inset. A resize updates the surface and camera immediately but reshapes only when the resolved content width changes. Off-axis / 3D authors one continuous paragraph, starts at the viewport-neutral 100% content width, and exposes a distinct 40–200% control in both Main and Presentation. The control therefore owns wrapping and can extend the line beyond the viewport before perspective projection instead of preserving forced line breaks. Text Ladder and Icon Grid retain intrinsic world geometry and pan rather than fitting; centered Zoom Text deliberately retains its specialized no-pan viewport-fit behavior. Icon size remains a logarithmic zoom control over the grid while labels stay at 11 CSS pixels. Scaling remaps the fractional grid row and column under the viewport center into the new non-uniform tile pitch, so the icon being inspected remains centered unless an outer grid boundary requires clamping.
+Grouped workload changes reuse one `TextGroup` and replace its children in one retained Rust publication, avoiding a
+second set of session arenas while the outgoing command buffer is live. Stale stats no longer erase an update failure,
+and non-abort failures reach the browser console. Draw/glyph telemetry traverses the realized batch root once because
+Rust-planned meshes are siblings of authored entry nodes; the complete performance sweep rejects every zero-glyph or
+zero-draw cell. The current 27-cell WebGPU sweep completes every Bitmap/MTSDF/Slug transition. It also makes the remaining
+Icon Grid gap explicit: 2,926–3,021 glyphs currently produce 476 draws and 30.8–47.0 RAF FPS even though median submit is
+0.27–0.76 ms and median GPU work is 0.57–2.02 ms. That is batching-policy evidence, not a shaping-performance result or
+an accepted release cost.[^presentation-framerate-sweep]
+
+Paragraph Stress can opt into Chrome User Timing with `?textTimings=1`. Its retained update is split into authored
+property staging, Rust update plus demanded measurement, clean publication, renderer submission, and the package's
+internal Three phases. The production path performs no timing calls while the option is absent. The workload now asks
+for layout metrics before explicit scene publication, so the semantic mask shares the pending mutation and the later
+matrix traversal sees clean Rust state. Icon Grid constructs its batch with independent compositing; other workloads
+retain ordered semantics.
+
+An identical direct Chromium 149/WebGPU/DPR-2 Paragraph Stress comparison retained 11,510 glyphs, one draw, and 64
+authored reflows in every case. The committed baseline measured 14.295 ms median reflow; measurement piggyback alone on
+that baseline measured 13.615 ms; adding semantic dirty tiers while retaining the same old Rust measured 7.450 ms; the
+complete candidate measured 6.885 ms. These telemetry histories contain 13–16 settled samples and establish direction
+and isolation, not a portable frame-time gate. A normal phase capture attributes most changed-frame CPU time to the
+single Rust `text_update`; TypeScript preparation, semantic readback, plan application, and renderer submit are smaller.
+A symbol-preserving diagnostic did not provide honest finer Rust attribution because LTO inlines most warm work into the
+export, so internal phase timers are required before claiming a particular Rust loop is dominant.
+
Paint & Effects is one live paragraph whose per-word hue advances continuously; opacity is shared, bounded white outline and hard shadow are MTSDF-only, and Bitmap plus Slug disable both controls. Paragraph Stress treats text volume as a topology change: moving its volume control immediately rebuilds the repeated corpus, while controls that only alter retained animation or paint state avoid replacement layouts. Dynamic Layout derives its initial three phase-offset widths from the same elapsed animation clock as subsequent frames, awaits every paragraph layout, and publishes the trio atomically; the first visible frame therefore continues directly into animation instead of flashing a uniform-width staging layout. One benchmark-owned interaction component gives navigable live canvases mouse drag and two-finger touch pan; Off-axis additionally enables pinch and wheel zoom. It translates gestures into renderer-neutral view commands and does not put DOM listeners in `@pmndrs/text`. Workloads are deliberately not React Activities. They are framework-neutral retained scenes behind the route-owned render host, so a swap releases the old scene's text and font/raster residency without replacing the host canvas, renderer, timestamp timer, or telemetry history. Only the Benchmark and Conformance modes retain React state as Activities. The shared multi-technique workload implementation remains a dynamic chunk; Benchmark schedules a cancellable no-timeout idle import, while pointer hover or keyboard focus warms it immediately. Unsupported idle-callback hosts simply retain interaction warming, so the chunk never enters the initial graph and ordinary Benchmark startup never waits for it. The host serializes scene activation and retains the current scene until its replacement is ready, preventing an asynchronously initialized workload from publishing partial text or inheriting the prior workload's configuration. Renderer-published configuration revisions make product probes causal rather than reflections of React props. Dynamic layout separately reports one completed three-paragraph reflow cost and count instead of hiding reshape work inside the CPU-submit graph. The MTSDF base-level scene and sampling paths require deterministic pixels within each renderer invocation, authenticated artifacts and resource counts, and bounded error against the independent scalar reconstruction. Hardware Apple Metal and headless SwiftShader framebuffer hashes remain labeled observations because filtered analytic coverage is not byte-portable across drivers. The current SwiftShader comparison reports `0.0957/255` mean absolute error, maximum error `10`, and 3,233 pixels above its threshold, all inside the reviewed `0.25/255`, `48`, and 2% envelopes. The current direct WebGPU observation reports framebuffer hash `4da56d…`, 14,400 changed pixels, 2,420 colors, and a 6,798,412-byte compressed artifact; its scalar comparison reports mean absolute error `0.0184937`, maximum error `1`, and zero threshold error pixels. The gate names base-level behavior rather than the removed generated-mip path.
Cross-technique fidelity is the final conformance workload. It renders the selected Bitmap, MSDF, or Slug candidate through the real public `Text` pipeline, then independently rasterizes the same pinned source TTF/OTF, authored lines, physical size, direction, and paragraph baselines through browser Canvas2D. The synchronized candidate/reference/difference panels share pan and zoom. Chromium 149 WebGPU records canonical Inter at `7.393` mean error and `5,852` pixels over `2/255` for the native 16-device-pixel Bitmap strike, and `6.884` / `19,326` at the MTSDF 64-device-pixel base level. The retained Slug matrix covers all seven font sources at DPR 1 and 2 through both renderer backends: its independent analytic CPU comparison records mean error from `0.0032` to `0.0838/255`, zero severe pixels for every ordinary outline, and 27–54 severe pixels for DotGothic16's grid-aligned pixel outlines. Ordinary Canvas comparisons retain the `12/255` mean gate; DotGothic16 has a separate `24/255` pinned-browser envelope because Canvas applies a materially different hinted pixel-style raster, while its analytic comparison remains bounded to at most 64 severe pixels. Every source retains the common 20% over-tolerance-pixel gate, and unit negative controls prove the ordinary and pixel-style envelopes fail independently. Canvas2D is a pinned-browser source-outline reference, not a claim of cross-browser bit identity, while the separate pipeline-accuracy cases continue to own exact Bitmap reconstruction and scalar MTSDF/Slug sampling.
@@ -452,13 +493,27 @@ The showcase corpus is an immutable TypeScript discriminated union with exact in
The CI-safe advanced-shaping target derives all 68 finite frames from that same corpus and sends each through the public `Text` object and bitmap batch construction at an explicit 800 CSS-pixel viewport and 16 px font size. Its exact Chromium 149 record covers five cases, 709 laid-out glyphs, 625 rendered instances, 72 draws, zero missing glyphs, 17,362 normalized layout bytes, and composite hash `51ba1d14`; a wrong hash and a missing-glyph mutation are negative controls. The three recorded 8.5–11.3 ms durations describe this machine's end-to-end conformance execution only. They are neither live renderer costs nor portability thresholds. Hardware GPU pixels remain owned by the exact bitmap readback lane, while the admitted Vitexec product probe proves that each authenticated showcase fixture reaches the live WebGPU canvas.
+The foundation closure gate executes 111 Vitest cases and 16 isolated headless Chromium targets from the current
+manifest, including forced-WebGL2 Bitmap/MTSDF/Slug, conformance, source-outline, React reconciliation, Worker fallback,
+paragraph contracts, advanced shaping, and rich spans. A package-owned live Paragraph Stress timing run retains 11,510
+glyphs in one draw at 121 RAF FPS: frame p95 is 8.66 ms, renderer submission median/p95 is 0.405/0.905 ms, and the public
+text update-and-measure median/p95 is 5.725/7.405 ms. This attributes the remaining live CPU cost to text preparation
+rather than GPU submission; it is one local observation, not a portable budget guarantee.
+
+The retained bidi/policy/UIKit and full CJK paragraph contracts again have executable generators. Both use the public
+Rust-plan `Text` API, keep each multi-width paragraph resident, and run in deterministic `--check` mode from the ordinary
+benchmark test and bake-fixture gate. The committed contracts remain byte-identical. Historical measurements produced
+before the f32 ABI retain their original numeric literal only when the regenerated public value is exactly f32-equivalent;
+the existing UIKit exact-width rounding seam is independently recomputed before its legacy literal is retained. Any
+material number, layout array, hash, identity, call contract, or document-shape change still fails generation.
+
The separate live performance observation runs the human WebGPU surface at explicit 1× DPR on Chromium 149 and an Apple `metal-3` adapter. Each paragraph-scale script lane must settle its exact authored state with zero missing glyphs and then publish twelve causal FPS and GPU-report intervals; there are no sleeps or timing thresholds. The refreshed run observed 119.46–120.16 FPS, 0.2–0.3 ms median CPU submission, 0.3–0.5 ms CPU P95, 0.679–3.457 ms median GPU time, and 3.261–5.033 ms GPU P95 across 112–278 glyphs and one to fifteen draws. Initial public `Text` readiness was 7.2–22.0 ms and total startup 17.0–122.9 ms; the first cold Inter fetch dominates the high end. `Text.ready` includes shaping, paragraph layout, and bitmap-batch publication, so it is not mislabeled as a pure shape call; the dedicated HarfRust target owns that narrower metric. These machine observations are authenticated evidence, not cross-device budgets.
The package-size lane measures the item 8.1 MTSDF kernel separately from the coverage-capable item 8.6 baker and every initial browser or unrelated raster graph. The validated generator host is 11,543 raw, 8,466 minified, 2,658 gzip, and 2,364 Brotli bytes; the corrected optimized scalar kernel is 52,633 raw, 23,115 gzip, and 19,660 Brotli bytes. The complete MTSDF baker adds Fontations, bounded face-resolved coverage, and artifact packaging behind the optional subpath and measures 552,025 raw, 215,030 gzip, and 168,758 Brotli Wasm bytes plus a 26,940 raw / 19,117 minified / 5,530 gzip / 4,908 Brotli host. The Bitmap baker with the same coverage contract measures 626,940 raw, 234,735 gzip, and 180,503 Brotli Wasm bytes. The private TypeScript diagnostic entry is neither packed nor reachable from production graphs, and Rust profiling remains a non-default feature; the size lane rejects diagnostic code in shipped baker graphs and profiling/timing Wasm boundaries. The final `Text` lifecycle remediations move the browser-core graph from 329,665 / 251,133 / 73,068 / 56,025 to 330,343 raw / 251,524 minified / 73,143 gzip / 56,131 Brotli bytes without changing any baker host or Wasm artifact. A separate pre-coverage regression table bounds the accepted growth of browser core, both optional hosts and runtimes, and both baker Wasm modules in every measured representation. Complete reviewed ceilings apply on foreign hosts, while same-host regeneration must remain byte-exact. `pnpm scripts run text:mtsdf-generator-profile` additionally reports compile, initialization, cold-corpus, and warm-corpus observations only after all seven independent oracle hashes pass; it is generator evidence, not frame-rendering performance. Rejected SIMD variant reports remain historical decision evidence rather than maintained browser capture workflows.
Inter and Amiri retain their established roles. A pinned static Noto Sans Devanagari face adds the Indic lane without weakening the baker's explicit variable-font rejection. Advanced Shaping recommends a script-appropriate font for each case but exposes every baked fixture so a human can inspect coverage failures instead of having the selection silently locked. The CJK default is a reproducible HarfBuzz 13 subset of the authored Noto Sans CJK JP case; DotGothic16 remains available and explicitly labeled as pixel style. The subset is showcase evidence, not an answer to complete CJK distribution: the full 65,535-glyph Noto face remains the authoritative shaping/paragraph oracle and Milestone 13 owns chunked raster paging.
-The Japanese showcase freshness check is reproducible on a declared build host but intentionally does not provision tools or download source. Its explicit prerequisite step authenticates and builds both pinned HarfBuzz 13.0.0 utilities through the source-archive hash and executable-version gate; the check then requires that ignored cache, rebuilds the subset in temporary storage, and compares the font, license, and manifest exactly. Upstream creates `hb-shape` and `hb-subset` only when GLib development metadata is available, so provisioning requires `-Dglib=enabled` and fails during Meson configuration when that prerequisite is absent. CI installs `libglib2.0-dev` explicitly, reports the resolved `glib-2.0` version, provisions the utilities, and then runs the freshness check. Local macOS hosts that choose to provision must install both Homebrew `glib` and `pkgconf` so the equivalent metadata is discoverable through `pkg-config`. The provisioner disables unrelated optional HarfBuzz backends explicitly, keeping the source-build graph independent of other libraries installed on the host. GLib supplies the command-line frontend rather than the shaping or subset implementation, and the exact HarfBuzz version plus generated bytes remain the fixture authorities.
+The Japanese showcase freshness check is reproducible on a declared build host but intentionally does not provision tools or download source. Its explicit prerequisite step authenticates and builds both pinned HarfBuzz 13.0.0 utilities through the source-archive hash and executable-version gate; the R3F example uses the same provisioner with its separately recorded 14.2.0 asset-subsetter identity. The checks then require those ignored caches, rebuild their subsets in temporary storage, and compare the font, license, and manifest exactly. Upstream creates `hb-shape` and `hb-subset` only when GLib development metadata is available, so provisioning requires `-Dglib=enabled` and fails during Meson configuration when that prerequisite is absent. CI installs `libglib2.0-dev` explicitly, reports the resolved `glib-2.0` version, provisions both authenticated versions, and adds only the R3F asset version to later steps' `PATH`. Local macOS hosts that choose to provision must install both Homebrew `glib` and `pkgconf` so the equivalent metadata is discoverable through `pkg-config`. The provisioner disables unrelated optional HarfBuzz backends explicitly, keeping the source-build graph independent of other libraries installed on the host. GLib supplies the command-line frontend rather than the shaping or subset implementation, and the exact HarfBuzz version plus generated bytes remain the fixture authorities.
The browser product also carries the React 19 subpath proofs. A shared registry target mounts public nested `` through a real React Three Fiber root backed by `WebGPURenderer`, retains one forwarded core object through width reflow and canonical restoration, matches pinned natural/narrow paragraph oracles, verifies two span paints in one draw, and submits a real renderer frame over three deterministic samples. The live pending-resource probe intercepts the exact composed Inter request behind a manually released promise, observes the Suspense fallback before publication, releases the request without a timer, then proves the registered font key and all 2,937 glyphs before deterministic cleanup. The test renderer remains confined to package integration evidence and does not enter the product registry or application dependencies.
@@ -485,6 +540,18 @@ The V0 autoresearch baseline is a fail-closed control artifact, not an active op
The packed-consumer lane builds and packs both workspace packages, extracts only their published tarballs into an isolated Vite application, and executes `@pmndrs/text/runtime-bake` through the installed module Worker in Chromium. Canonical Inter returns the exact 172,156-byte artifact and SHA-256 `af7bfb85f04a6a63c6462735a6e8ec6d739576adb354c07ca51e744814db2f7b`. This closes the gap between source-workspace Worker evidence and what an installed consumer actually resolves.
+The `text:kernel-lab-browser` workflow runs the package-owned scalar, compiler-vectorized, and selected hybrid shaper
+artifacts in the project-pinned Chromium from a trustworthy loopback origin. It consumes the same captured 25,515- and
+100,602-glyph typed arrays as the Node workflow and fails before timing unless every artifact reproduces the scalar
+horizontal, vertical, partial-tail, and unaligned hashes. The current Chromium 149 run executed the SIMD artifact,
+observed no warm memory growth, and supports the 64-cluster choice: at 100,602 glyphs its selected-hybrid p95 was 0.01875
+ms versus 0.0625 ms scalar for chunk summaries, 0.009375 versus 0.053125 ms for break masks, and 0.0125 versus 0.04375
+ms for bidi masks. The same run executes the production validated-policy interpreter over a representative 17-operation
+program and includes its F32×4, U32, and U16 buffers in the scalar/auto/SIMD byte-identity gate. At 25,515 glyphs,
+explicit SIMD measures 0.438 ms p95 versus 1.113 ms scalar; at 100,602 it measures 1.750 versus 4.350 ms. Browser timer
+quantization is visible in those figures, so Node retains the finer candidate ranking while Chromium supplies the
+independent engine-admission check.
+
The bake-host report separates the consumer phases without timing conformance work. Each offline sample creates a fresh Wasm baker and records initialization plus first bake as cold, then records a second bake on that instance as warm. Each isolated Chromium context queues two requests onto one Worker: first completion contains Worker/Wasm startup plus its bake, while the interval to second completion is the warm reused-instance bake. Three captured arm64/Chromium 149 samples preserve complete artifact parity; medians were 4.16 ms cold / 2.94 ms warm offline and 21.70 ms cold / 3.50 ms warm in the Worker. These are observations, not cross-host thresholds.
A released scene leaves the provider-owned canvas attached with its last complete frame while its replacement effect activates, avoiding a detach/reparent flash; provider teardown or removal of the owning anchor remains the detach boundary. The continuity probe requires the same connected canvas and exactly one renderer through explicit DPR 2→1→2 and Bitmap→MTSDF→Bitmap handoffs.
diff --git a/docs/packages/font-baker.md b/docs/packages/font-baker.md
index d8f6a14c..727b65a6 100644
--- a/docs/packages/font-baker.md
+++ b/docs/packages/font-baker.md
@@ -5,7 +5,7 @@ description: Implements the internal portable Rust/Wasm shaping-resource bake co
resource: ../../packages/font-baker
workspace_package: '@pmndrs/text-font-baker'
documentation_type: reference
-source_digest: 'sha256:80fb2bd14e5b3ad0f8f9e87bd455057998c0a1c7f9e5acda51b45815857d480c'
+source_digest: 'sha256:d85b56b06a26945e220248f487e87465027376929238e1a8206a78c5880d1700'
tags: [package, rust, wasm, baking, internal]
sources:
- id: manifest
@@ -42,7 +42,7 @@ The separate `@pmndrs/text-font-baker/validate` ESM entry treats every baked ass
The integration suite also compiles the canonical MTSDF and Slug Draft-04 schemas directly from the knowledge bundle with their shared resource references. Positive V0 specimens and one-field mutations keep required members, 20/40-byte record strides, MTSDF encoding, linear color space, lossless RGBA8 MTSDF pages, and lossless RGBA16F Slug curve pages executable before either generator lands. These schema tests do not claim an implemented raster; they prevent Milestone 8 and 9 code from beginning against an internally inconsistent draft.
-The build applies pinned Binaryen 129.0.0 `-Oz` after Rust release linking. Canonical path remapping removes host workspace and Cargo registry prefixes before compilation. The current hardened zero-import module is 422,538 raw bytes while preserving the canonical font artifact hash. Pinned dynamic Talc 5.0.4 owns the ABI-private Wasm heap; it saves 9,801 raw, 3,352 gzip, and 2,342 Brotli bytes relative to the measured `dlmalloc` build without imposing a fixed arena reservation. Its ABI JSON remains a published tool artifact, but production Wasm embeds neither that JSON nor ABI pointer/length exports; production TypeScript imports the generated constant directly. Native Rust/Binaryen hosts may permute equivalent internal Wasm function indices across CPU architectures, so source/product goldens and the optimized length are portable checks while the exact module hash is canonical release-builder provenance. This package is the sole owner of those optimized bytes and exposes one browser-safe canonical URL; the offline Node host reads that URL and the runtime Worker fetches it instead of `@pmndrs/text` shipping a second copy. Reports keep raw and transport costs distinct.
+The build applies pinned Binaryen 129.0.0 `-Oz` after Rust release linking. Canonical path remapping removes host workspace and Cargo registry prefixes before compilation. The current hardened zero-import module is 422,538 raw bytes while preserving the canonical font artifact hash. Pinned dynamic Talc 5.0.4 owns the ABI-private Wasm heap; it saves 9,801 raw, 3,352 gzip, and 2,342 Brotli bytes relative to the measured `dlmalloc` build without imposing a fixed arena reservation. Its ABI JSON remains a published tool artifact, but production Wasm embeds neither that JSON nor ABI pointer/length exports; production TypeScript imports and publicly re-exports the generated constant directly, while construction validates the contract-declared Wasm exports once. Native Rust/Binaryen hosts may permute equivalent internal Wasm function indices across CPU architectures, so source/product goldens and the optimized length are portable checks while the exact module hash is canonical release-builder provenance. This package is the sole owner of those optimized bytes and exposes one browser-safe canonical URL; the offline Node host reads that URL and the runtime Worker fetches it instead of `@pmndrs/text` shipping a second copy. Reports keep raw and transport costs distinct.
The direct-memory boundary owns every request and response allocation in a module registry. Its fixed-width `#[repr(C)]` response header publishes compiler-derived size, alignment, and offsets from `size_of`, `align_of`, and `offset_of!`. Rust serialization consumes those same facts; build-only generation makes them an exact TypeScript type and value, and CI fails when checked-in output is stale. There is no numeric layout mirror to maintain and no runtime JSON parse, QuickType, JSON Schema, or binding-generator dependency in the baker. Caller-controlled requests are capped at 64 MiB and use fallible reservation; use and release require the exact active pointer/length pair, forged or repeated releases are harmless, checked response arithmetic prevents truncation, and response metadata cannot outlive its owned bytes. The TypeScript wrapper enters cleanup before its first copy, releases each successful allocation after any later failure, and validates every promised Wasm export and response/error field before constructing a public result. It decodes the response while the Wasm allocation is live and copies only the artifact ranges that must survive release, avoiding a redundant full-response copy. The fixed, tiny `WasmState` allocation still uses stable Rust's infallible `Box::new` once per Wasm instance; replacing that theoretical OOM trap would require unstable allocator APIs or a disproportionate static-state design.
diff --git a/docs/packages/glyph-example-raster.md b/docs/packages/glyph-example-raster.md
index 8475c8e7..42e7086e 100644
--- a/docs/packages/glyph-example-raster.md
+++ b/docs/packages/glyph-example-raster.md
@@ -5,7 +5,7 @@ description: Proves the published raster and baker extension boundary with a pri
resource: ../../packages/glyph-example-raster
workspace_package: '@pmndrs/text-glyph-example-raster'
documentation_type: reference
-source_digest: 'sha256:f668c47d4500e4fe1d98d84b73dbf95f8d842d04c1fed4b144ad293b2c3c5610'
+source_digest: 'sha256:d3fc6c1fc5a3ff728ebf9ac8bb9112c4b0e1e9fc4f9304376ded7ecef41a1764'
tags: [package, raster, extension-proof, threejs, tsl]
sources:
- id: manifest
@@ -28,7 +28,7 @@ sources:
title: Dual-backend product rendering probe
generated:
by: openai-codex/gpt-5.6
- at: '2026-08-04T18:59:39Z'
+ at: '2026-08-10T02:32:26Z'
---
# Package reference: `@pmndrs/text-glyph-example-raster`
@@ -38,9 +38,9 @@ Status: ✅ Milestone 10.4 external extension proof
This private workspace package is a consumer proof, not a fourth recommended production raster. It imports only published
`@pmndrs/text` entry points and its own pinned Three.js dependency. It owns the literal `glyphExample` kind, companion
extension and descriptor, deterministic baker, standalone-valid GLB framing, embedded or authenticated external RGBA glyph
-records, decoder validation, runtime baker, TSL material, retained instance storage, dirty upload policy, overflow replacement,
-paragraph/local-run render-order inheritance, abort behavior, and disposal. A source boundary test rejects imports from
-core internals or the three first-party raster and baker subpaths.
+records, decoder validation, runtime baker, declarative Rust packing policy, TSL material, paragraph/local-run render-order
+inheritance, abort behavior, and disposal. Rust owns retained instance storage, dirty-range publication, and overflow handling.
+A source boundary test rejects imports from core internals or the Three first-party raster and baker subpaths.
The technique makes the proof observable by assigning each source-local glyph ID a deterministic color and drawing a framed
em-relative diagnostic cell at the position produced by core shaping and paragraph layout. Its visual output is deliberately
@@ -48,20 +48,24 @@ diagnostic rather than a text-quality recommendation. The baker accepts both emb
The external lane authenticates the companion GLB and its separate record payload through the public raster and resource
resolvers; the embedded lane proves recursive `BufferView` rebasing through the public Node composition host.
-The package now supplies both halves of the target-v1 boundary separately. `glyphExample` is a portable
-`defineRasterTechnique` that decodes, selects one shared resource, and packs canonical positive-down instance storage while
-importing no renderer; `@pmndrs/text-glyph-example-raster/three` registers the Three program for it through the public
-`registerThreeRasterProgram` registry, so nothing in `@pmndrs/text` names this package. Instance capacity and dirty ranges
-are now core's, not the plugin's: the program reads `PreparedGlyphBatch.capacity` and `.dirtyRanges` and retains its meshes,
-geometry, and buffers while both hold, which deleted this package's own slack planner and bucket coalescer. Focused tests
-cover deterministic bytes, public Node bake, standalone companion validation, external resource resolution,
-abort-before-decode, selection, range writes, binding identity, and paint admission.
+The package now supplies both halves of the Rust render-plan boundary separately. `glyphExample` is a portable
+`defineRasterTechnique` that owns identity, decoding, one shared resource, and disposal while importing no renderer or
+instance-packing contract.
+`@pmndrs/text-glyph-example-raster/three` registers a static policy program through public
+`registerThreeRasterPlanProgram`, so nothing in `@pmndrs/text` names this package. The policy describes the exact Rust
+inputs, buffers, scalar operations, and storage/draw keys. A cold compiler lowers validated glyph colors and inset data
+into one font binding; a renderer factory consumes the resulting buffers to construct the TSL material. The package no
+longer owns a `ParagraphBatchTarget`, target revision, slack planner, dirty-range upload loop, or mesh transaction.
+Focused tests cover deterministic bytes, public Node bake, standalone companion validation, external resource
+resolution, abort-before-decode, plus a compiled-Wasm public `Text` lifecycle that verifies Rust-packed sizes and colors
+and observes retained draw/geometry identity. No test reconstructs the removed TypeScript selector, storage, or writer.
The hardware-browser target uses the public source-font fallback, package runtime baker, the target-v1 `FontLoader`, public
`Text` and `TextGroup`, warm matrix-lifecycle publication, TSL compilation, draw, asynchronous render-target readback, and
complete disposal. WebGPU and forced WebGL2 each produced two deterministic samples with visible glyph frames, one draw,
-retained mesh and geometry identity, and the same RGBA SHA-256
-`0e0ec025a2121ec3b29317276c12978e7a7a062197b0a9ad448a6b37c270b368`.
+retained mesh and geometry identity, individual `Text.visible` behavior inside an indexed shared draw, caller-owned
+Group ordering, and the same RGBA SHA-256
+`817495c4afe3a8f88d2af85d972f43be88b9f834ed0268d0d0b2e3de86ba9d46`.
When the benchmark route supplies an exclusive execution context, the target borrows that renderer, restores render target,
clear, viewport, scissor, and scissor-test state, and never creates or disposes a parallel renderer. Run the focused lane with
`pnpm scripts run benchmark:external-raster`.
diff --git a/docs/packages/index.md b/docs/packages/index.md
index afe49be6..fa62704a 100644
--- a/docs/packages/index.md
+++ b/docs/packages/index.md
@@ -3,5 +3,6 @@
- [`@pmndrs/text`](text.md) — public loading, baking, HarfRust shaping, paragraph layout, static discovery, and portable bitmap artifact core.
- [`@pmndrs/text-font-baker`](font-baker.md) — internal portable Rust/Wasm bake core.
- [`@pmndrs/text-benchmarks`](benchmarks.md) — Figma-backed benchmark and product-verification application.
+- [`@pmndrs/text-r3f-hello-world`](r3f-hello-world.md) — minimal public R3F Bitmap, MSDF, Slug, and fallback example.
Each package concept carries a deterministic `source_digest`. Repository validation fails when package source changes without a corresponding concept review and digest refresh.
diff --git a/docs/packages/r3f-hello-world.md b/docs/packages/r3f-hello-world.md
new file mode 100644
index 00000000..02e3e21d
--- /dev/null
+++ b/docs/packages/r3f-hello-world.md
@@ -0,0 +1,56 @@
+---
+type: Workspace Package
+title: '@pmndrs/text-r3f-hello-world'
+description: Demonstrates the public React Three Fiber API with Bitmap, MSDF, Slug, and font-stack fallback.
+resource: ../../apps/r3f-hello-world
+workspace_package: '@pmndrs/text-r3f-hello-world'
+documentation_type: reference
+source_digest: 'sha256:e13744eb1f7b218f5bf3bbc2efc3dd0bdb87eea3f3cc859a01bd97523234f897'
+tags: [package, example, react, react-three-fiber, vite]
+sources:
+ - id: manifest
+ resource: ../../apps/r3f-hello-world/package.json
+ title: Example application manifest
+ - id: scene
+ resource: ../../apps/r3f-hello-world/src/technique-scene.tsx
+ title: Public R3F technique and fallback example
+ - id: asset-generator
+ resource: ../../apps/r3f-hello-world/scripts/generate-fonts.mts
+ title: Reproducible subset and multi-technique bake
+ - id: asset-manifest
+ resource: ../../apps/r3f-hello-world/assets/manifest.json
+ title: Authenticated checked-in example assets
+generated:
+ by: openai-codex/gpt-5.6
+ at: '2026-08-10T03:47:15Z'
+---
+
+# Package reference: `@pmndrs/text-r3f-hello-world`
+
+This private Vite application is the minimal product-shaped React Three Fiber example. One full-page canvas renders
+`Hello world` through the public `@pmndrs/text/r3f` `Text` component and resolves a Font Awesome globe through an ordered
+font stack. In-canvas MSDF controls replace the rendered text component between Bitmap, MSDF, and Slug; the example does
+not retain a second renderer path or manually pack glyph data.
+
+The checked-in assets are deliberately bounded at source before baking:
+
+- Inter contains Basic Latin `U+0020–U+007E`.
+- Font Awesome contains six globe and earth PUA scalars, including the displayed `U+F0AC` glyph.
+
+Each GLB embeds Bitmap, MSDF, and Slug raster resources for its subset. The manifest authenticates the exact artifacts,
+and `assets:check` uses pinned HarfBuzz 14.2.0 to subset and rebake both fonts in temporary storage before requiring
+byte-identical output. Vite emits the public shaper Wasm URL and a combined Inter/Font Awesome notice file. Three, React,
+and React Three Fiber remain ordinary workspace peers rather than part of the core package-size graph.
+
+## Commands
+
+```sh
+mise -C apps/benchmarks exec -- node ./scripts/provision-harfbuzz.mts --version=14.2.0
+mise exec -- pnpm --filter @pmndrs/text-r3f-hello-world dev
+mise exec -- pnpm --filter @pmndrs/text-r3f-hello-world check
+```
+
+The check runs TypeScript 7 isolated typechecking, React Compiler-aware Oxlint with warnings denied, Oxfmt, deterministic
+asset rebaking, a production Vite build, and a GPU Chromium acceptance probe. The probe clicks all three in-canvas
+controls through pointer events and requires 13 laid-out glyphs—11 visible records plus two spaces—in two Rust-planned
+meshes: one for Latin and one for the icon fallback resource.
diff --git a/docs/packages/text.md b/docs/packages/text.md
index e740a3ea..9136d78e 100644
--- a/docs/packages/text.md
+++ b/docs/packages/text.md
@@ -1,481 +1,393 @@
---
type: Workspace Package
title: '@pmndrs/text'
-description: Implements public font loading, shaping, paragraph measurement, static discovery, and portable raster artifact contracts.
+description: Implements portable font loading, retained Rust shaping and layout, renderer-directed command planning, and maintained Three.js and React Three Fiber adapters.
resource: ../../packages/text
workspace_package: '@pmndrs/text'
documentation_type: reference
-source_digest: 'sha256:6ae55db41be216c0ccf2a79b55167c964770e5fbc6c490940d6b68b4b3bca512'
-tags: [package, public-api, typescript, contracts]
+source_digest: 'sha256:61242870023c632bd2ce25324a2d961b249c6de523013d9935cc1ec1f543319c'
+tags: [package, public-api, rust, wasm, threejs, typography]
sources:
- id: manifest
resource: ../../packages/text/package.json
title: Package manifest
- - id: api-contract
- resource: ../planning/api-shapes.md
- title: Runtime and bake API V0
- - id: discovery
- resource: ../../packages/text/src/discovery.ts
- title: Static project discovery implementation
- - id: compiler-adapter
- resource: ../../packages/text/src/compiler-adapter.ts
- title: Pinned TypeScript compiler adapter
- - id: typescript-go-node-variance
- resource: https://github.com/microsoft/typescript-go/issues/4528
- title: TypeScript Go Three.js Node variance expansion
- - id: definitelytyped-node-extras
- resource: https://github.com/DefinitelyTyped/DefinitelyTyped/pull/75246
- title: Upstream NodeExtras lookup-map fix
- - id: bitmap-identity
- resource: ../../packages/text/src/raster/bitmap-technique.ts
- title: Bitmap descriptor and raster identity implementation
- - id: bitmap-baker
- resource: ../../packages/text/rust/bitmap-baker
- title: Portable bitmap generator implementation
- - id: raster-coverage
- resource: ../../packages/text/src/raster-coverage.ts
- title: Bounded runtime raster coverage contract
- - id: bitmap-validator
- resource: ../../packages/text/src/bakers/bitmap-validator.ts
- title: Layered bitmap artifact validator
- - id: mtsdf-admission
- resource: ../../packages/text/rust/mtsdf-admission
- title: Non-shipping MTSDF generator admission harness
- - id: mtsdf-baker-profile
- resource: ../../packages/text/scripts/profile-mtsdf-baker.mjs
- title: MTSDF artifact baker phase profiler
- - id: mtsdf-baker-profile-evidence
- resource: ../../packages/text/rust/mtsdf-admission/evidence/baker-phases-v0.json
- title: MTSDF artifact baker phase evidence
- - id: mtsdf-host
- resource: ../../packages/text/src/internal/mtsdf-generator.ts
- title: MTSDF direct-memory TypeScript host
- - id: mtsdf-contract
- resource: ../../packages/text/src/raster/msdf.ts
- title: Portable MTSDF runtime technique
- - id: mtsdf-baker
- resource: ../../packages/text/src/bakers/msdf.ts
- title: Fixed MTSDF baker host
- - id: mtsdf-validator
- resource: ../../packages/text/src/bakers/msdf-validator.ts
- title: Layered MTSDF artifact validator
- - id: mtsdf-fontations
- resource: ../../packages/text/rust/mtsdf-fontations
- title: Shared Fontations MTSDF provider
- - id: slug-contract
- resource: ../../packages/text/src/internal/slug-contract.ts
- title: Fixed Slug V0 identity contract
- - id: slug-validator
- resource: ../../packages/text/src/bakers/slug-validator.ts
- title: Layered Slug artifact validator
- - id: slug-baker
- resource: ../../packages/text/rust/slug-baker
- title: Portable Slug artifact baker
- - id: slug-baker-host
- resource: ../../packages/text/src/bakers/slug.ts
- title: Direct-memory Slug baker host
- - id: slug-runtime
- resource: ../../packages/text/src/raster/slug-technique.ts
- title: Portable analytic Slug runtime technique
- - id: slug-shaders
- resource: ../../packages/text/src/internal/slug-shaders
- title: Three.js TSL Slug shader implementation
- - id: slug-outline-research
- resource: ../planning/slug-outline-research.md
- title: Slug outline architecture
- - id: raster-wasm-host
- resource: ../../packages/text/src/internal/raster-baker-wasm.ts
- title: Shared direct-memory raster baker host
- - id: raster-atlas-runtime
- resource: ../../packages/text/src/internal/raster-atlas.ts
- title: Renderer-neutral lossless-atlas decoder
- - id: raster-technique-api
- resource: ../../packages/text/src/raster-technique.ts
- title: Portable raster technique contract
- - id: text-runtime-v1
+ - id: public-api
+ resource: ../../packages/text/src/index.ts
+ title: Renderer-neutral public exports
+ - id: runtime
resource: ../../packages/text/src/text-runtime.ts
- title: Target-v1 renderer-neutral text runtime
- - id: paragraph-batch-v1
- resource: ../../packages/text/src/paragraph-batch.ts
- title: Target-v1 paragraph batching and canonical storage
- - id: formatted-text-v1
- resource: ../../packages/text/src/formatted-text.ts
- title: Target-v1 formatted text and span composer
- - id: paragraph-attachment-v1
- resource: ../../packages/text/src/paragraph-batch-attachment.ts
- title: Target-v1 renderer attachment coordinator
- - id: three-v1
+ title: Font and Rust-runtime ownership
+ - id: text-properties
+ resource: ../../packages/text/src/text-properties.ts
+ title: Paragraph input contract
+ - id: layout-query
+ resource: ../../packages/text/src/layout.ts
+ title: Explicit layout-query values
+ - id: rust-engine
+ resource: ../../packages/text/rust/shaper/src/engine/state.rs
+ title: Retained Rust text engine
+ - id: frame-host
+ resource: ../../packages/text/src/internal/text-engine-host.ts
+ title: Single-export Wasm host
+ - id: three-api
resource: ../../packages/text/src/three.ts
- title: Maintained target-v1 Three.js integration
- - id: r3f-v1
- resource: ../../packages/text/src/r3f.ts
- title: Maintained target-v1 React Three Fiber integration
- - id: typegpu-v1
- resource: ../../packages/text/src/typegpu.ts
- title: Maintained target-v1 TypeGPU integration
- - id: raster-ktx
- resource: ../../packages/text/src/internal/raster-ktx.ts
- title: Shared dependency-light KTX2 validation
- - id: raster-records
- resource: ../../packages/text/src/internal/raster-records.ts
- title: Shared dependency-light dense-record validation
- - id: raster-validation
- resource: ../../packages/text/src/internal/raster-artifact-validation.ts
- title: Shared standalone raster artifact validation
- - id: composition
- resource: ../../packages/text/src/internal/compose-bake.ts
- title: Generic core/raster artifact composer
- - id: node-host
- resource: ../../packages/text/src/node/bake.ts
- title: Node bake API and filesystem host
- - id: loader
- resource: ../../packages/text/src/loader.ts
- title: Baked-first loader and registry
- - id: runtime-bake
- resource: ../../packages/text/src/runtime-bake.ts
- title: Lazy module-Worker bake host
- - id: core-bake-policy
- resource: ../../packages/text/src/internal/core-bake-policy.ts
- title: Shared offline/runtime core bake policy
- - id: raster-bake-plan
- resource: ../../packages/text/src/internal/raster-bake-plan.ts
- title: Single-evaluation raster plan resolution
- - id: shaper-bridge
- resource: ../../packages/text/src/shaper.ts
- title: Direct-memory runtime shaper bridge
- - id: shaper-core
- resource: ../../packages/text/rust/shaper
- title: HarfRust Wasm shaper implementation
- - id: paragraph
- resource: ../../packages/text/src/paragraph.ts
- title: Paragraph engine implementation
- - id: text-object
+ title: Three.js public exports
+ - id: three-text
resource: ../../packages/text/src/three/text.ts
- title: Framework-neutral Three.js Text object
- - id: raster-runtime
- resource: ../../packages/text/src/raster-runtime.ts
- title: Shared decoded-raster runtime
- - id: mtsdf-technique
- resource: ../../packages/text/src/raster/msdf.ts
- title: Renderer-neutral MTSDF technique
- - id: bitmap-technique
- resource: ../../packages/text/src/raster/bitmap-technique.ts
- title: Renderer-neutral Bitmap technique
- - id: slug-technique
- resource: ../../packages/text/src/raster/slug-technique.ts
- title: Renderer-neutral Slug technique
- - id: react-runtime
+ title: Three.js retained text lifecycle
+ - id: three-plan
+ resource: ../../packages/text/src/three/engine-plan-target.ts
+ title: Three.js render-plan executor
+ - id: three-policy
+ resource: ../../packages/text/src/three/plan-program-registry.ts
+ title: Three.js policy-program registry
+ - id: r3f
resource: ../../packages/text/src/r3f.ts
- title: React Three Fiber reconciliation layer
- - id: unicode-analysis
- resource: ../../packages/text/src/internal/unicode.ts
- title: Unicode analysis implementation
+ title: React Three Fiber adapter
+ - id: engine-design
+ resource: ../planning/rust-layout-engine.md
+ title: Rust text engine and render-plan design
+ - id: core-api-reference
+ resource: ../planning/core-api.md
+ title: Core text API reference
+ - id: three-api-reference
+ resource: ../planning/three-api.md
+ title: Three.js text API reference
generated:
- by: anthropic-claude/opus-5
- at: '2026-08-08T08:15:00Z'
+ by: openai-codex/gpt-5.6
+ at: '2026-08-10T03:47:15Z'
---
# Package reference: `@pmndrs/text`
-Status: 🚧 Target-v1 core and maintained integrations are in progress
-
-Target-v1 now has an executable renderer-neutral `TextRuntime`, `ParagraphBatch`, attachment state machine, and Bitmap,
-MTSDF, and Slug techniques. The maintained `/three` adapter renders all three techniques through `WebGPURenderer` on native
-WebGPU and forced WebGL2, `/r3f` retains those Three objects through React 19 Strict Mode without leaking font leases, and
-the first `/typegpu` slice provides the caller-owned-root engine plus exact program/target boundary. Built-in TypeGPU raster
-programs and their live-pixel proof remain open. `RasterTechnique` preserves
-exact options, descriptor, decoded data, binding, and canonical storage types without `any`; its public helpers validate
-and brand technique and resource identities without requiring casts. A `RasterGlyphInput` is valid only for the `select`
-or `writeStorage` call that receives it, because packing pools one input per glyph and rewrites it on every update rather
-than allocating a glyph-sized set each time; a technique that needs a field beyond the call copies the value.
-`/raster/bitmap`, `/raster/mtsdf`, and `/raster/slug`
-decode and authenticate CPU resources without importing Three, explicitly omit absent records, select stable physical
-bindings, and pack positive-down paragraph origins plus technique fields into typed canonical arrays. Bitmap selects a
-strike/page per glyph and retains R8 pages; MTSDF retains one RGBA8 atlas-array binding per font; Slug retains its original
-RGBA16F curve, R32 header, and R16 reference bytes so Three's R16-to-R32 workaround remains target-owned. Focused package
-tests prove selection, range writes, binding identity, coordinates, paint, and analytic addresses. The merged-v0 Bitmap and
-Slug renderer modules, the `/raster/msdf` spelling, the merged-v0 `Text`, and the `/react` binding are deleted; `/raster/bitmap`, `/raster/mtsdf`, `/raster/slug`, `/three`, `/r3f`, and `/typegpu` are the whole renderer surface. The
-Bitmap conformance lane no longer needs a fallback: driven by the target-v1 `Text`, `ThreeBitmapTarget`, and
-`LoadedFont` raster data, it reproduces the benchmark's independent CPU atlas compositor in zero mismatched bytes and
-returns the same pinned full-frame hash `a47930d3…e893`, the same 5,930 lit and 3,473 half-coverage pixels, and the same
-`[68, 18, 313, 112]` ink bounds the merged-v0 renderer produced. Reaching that required two corrections to the exported
-Bitmap graph, both invisible to a coverage-threshold smoke check and both caught only by the exact oracle: the graph had
-inherited merged-v0's vertical atlas flip, which belongs to that renderer's `flipY`-enabled upload rather than to the
-target-v1 pages, and it had dropped the physical-pixel snap the strike's integer placement depends on. Every Presentation
-surface now renders through the target-v1 techniques and the `/three` adapter.
-
-The `/three` adapter resolves each technique's target through a program registry keyed by the technique's stable
-identifier rather than its object identity, and pre-registers the three first-party programs. Identifier keying preserves
-the public raster extension boundary proven in milestone 10: a third party registers a Three program for its own technique
-through `registerThreeRasterProgram`, and an application may wrap a first-party technique to instrument its runtime baker
-without the wrapper losing its program. An unregistered technique fails at batch construction with a typed error naming
-the identifier instead of rendering nothing. `registerThreeRasterProgram` infers that technique, so a program may type its
-prepared batches, storage, and binding concretely; the registry itself stays heterogeneous and holds the erased form after
-the pairing is proven at the registration call.
-
-`/three` also exports each canonical technique shader as `bitmapShader`, `mtsdfShader`, and `slugShader`.[^three-v1] Each
-takes one glyph instance's resolved nodes plus that batch's bound GPU resources and returns a named readonly output:
-position, coverage, resolved colour, opacity, and the intermediate stages the technique produces, such as MTSDF's separate
-fill, outline-ring, and shadow coverage or Slug's dilated render coordinate. These are not a parallel copy maintained for
-external use. `ThreeBitmapTarget`, `ThreeMtsdfTarget`, and `ThreeSlugTarget` build their materials from exactly these
-functions, so a composed program cannot drift from what the first-party path renders and deleting an export breaks the
-built-in target rather than an unused mirror. Each function reads `positionLocal` and `uv()` from the technique's unit
-quad, so a program supplying its own geometry owns that correspondence.
-
-`bitmapShader` additionally publishes `clipPosition`, the projected quad rounded to whole physical pixels, which a program
-assigns to `material.vertexNode`. Bitmap coverage is authored at one atlas texel per device pixel, so an unsnapped quad
-resamples the strike rather than reproducing it, and placing that snap in the exported shader rather than in the built-in
-target is what makes a composed program inherit it by construction instead of by convention. The output carries no other
-route to a vertex stage, so the seam cannot be silently skipped. MTSDF and Slug deliberately publish no such member: a
-distance field reconstructs its edge from the screen-space gradient and Slug integrates coverage analytically from
-outlines, so both are correct at any subpixel placement and must keep the default projection. Bitmap pages upload in the
-atlas's own top-down row order with `flipY` disabled, and `atlasUv` addresses that same space directly, so the sampled row
-is the baked row on both backends.
-
-The composed-program proof renders one paragraph twice on native WebGPU and forced WebGL2: once through the pre-registered
-Bitmap program, then through a third-party program that owns its own attributes, geometry, and material and composes only
-its final colour over `bitmapShader`. Both passes light an identical 2,616-pixel set while the composed pass emits no green
-channel, so the custom program inherited the canonical placement, snapping, and coverage instead of reimplementing them.
-The retained proof pages light 2,606 pixels for Bitmap, 1,935 for MTSDF, and 1,510 for Slug on both backends.
-
-Two target-v1 raster defects surfaced when the benchmark began driving these programs against the exact conformance
-oracles rather than against themselves. Slug published each quad's lower-left em corner while its shader documented and
-consumed the upper-left, so every glyph integrated its coverage vertically mirrored inside a correctly placed quad;
-publishing the top and walking em space downward moved the CPU band-walk reference from 22.94 mean absolute error with
-22,911 severe error pixels to 0.223 with none, and restored the independent browser-rasterized source-outline envelope.
-Bitmap carried two defects at once. It kept the merged renderer's vertical atlas flip, which belonged to a `flipY`
-upload the target-v1 program no longer performs, so every fragment sampled the mirrored row of its page; and it dropped
-the device-pixel snapping milestone 1 records as a hard density contract. Correcting both restores the pinned merged-v0
-frame exactly: hash `a47930d3…e893`, 3,473 half-coverage pixels, and ink bounds `[68, 18, 313, 112]`.
-
-Both defects were masked by self-comparison. A rendered-pixel count taken from the program under test only proves the
-program is stable, not correct, so each technique is held against a reference computed independently of it.
-
-The Three `FontLoader` forwards the two per-load capabilities the core runtime already accepted but the adapter withheld.
-A request may carry an `AbortSignal`, so a cancelled load stops instead of running to completion; the merged-v0 registry
-and loader both accepted one, and several consumers abort mid-load. Loader options may name a `FontRegistry`, so an
-application holding registry-scoped state reaches the fonts this loader produces rather than receiving fonts owned by a
-registry it cannot address.
-
-Readonly `Text.gpuBytes` and `TextGroup.gpuBytes` report the bytes of the GPU resources their attached target currently
-retains: the textures it shares across batches plus the instance buffers its committed revision owns. Reporting belongs to
-the target because only the target knows the realized allocation — Bitmap's R8 pages, MTSDF's layer-padded RGBA8 atlas
-array, and Slug's RGBA16F curves, R32UI headers, and pair-packed R32UI references — while the portable techniques end at
-CPU data and never describe engine residency. A revision that transferred its resources to a successor reports nothing, so
-a warm commit cannot count the same buffers twice; an unbound `Text` and a third-party target that omits the optional
-`ThreeRasterTargetAccounting` accessor both report zero. A `Text` inside a `TextGroup` shares that group's target, so both
-objects report the same batch-wide total rather than a per-paragraph share. On the retained proof pages at the default 256-glyph capacity,
-16-pixel Inter Bitmap measures 707,584 bytes as one 1024×679 R8 page plus 12,288 attribute bytes, MTSDF measures
-41,971,712 bytes as its 41,943,040-byte padded atlas array plus 28,672 attribute bytes, and Slug measures 3,190,784 bytes;
-the same totals are reported on WebGPU and forced WebGL2.
-
-The `txt` and `span` composer emits UTF-16 ranges over the composed string.[^formatted-text-v1] One span carries two kinds
-of data with different consumption points: shaping data (`font`, `fontSize`, `lineHeight`, `letterSpacing`, `language`,
-`direction`, `features`) must resolve before shaping because it segments runs and changes advances, while paint data
-(`color`, `opacity`, `outline`, `shadow`) and the render variant resolve at glyph-instance packing. Both kinds resolve
-through one cascade with one set of semantics, and part company only where each is consumed: the resolved shaping style
-becomes disjoint segments that intersect with UAX #24 script and UAX #9 bidi runs before shaping, and the resolved paint
-becomes per-glyph values indexed during packing. Resolution therefore cannot give one answer for `fontSize` and a
-different one for `color`.
-
-The cascade folds every span covering a cluster from the outermost inward and merges **per property**, so a span states
-only what it changes and inherits the rest from the scope enclosing it. A style-only span shapes from the surrounding
-font; a span stating only `color` keeps the surrounding opacity, outline, and shadow; a span stating only `opacity`
-re-applies that opacity to the inherited fill, outline, and shadow colours. An absent property group stays absent rather
-than arriving as an empty object, so a span cannot silently reset a range to a default glyph colour or shaping style.
-
-Precedence follows containment rather than array order: the innermost covering span wins each property, and spans over
-exactly the same range fall back to array order. Producer emission order is therefore not load-bearing, and a hand-built
-span array that lists a contained span before the span enclosing it still resolves innermost-first. Partial overlap has no
-innermost span at all, so it is rejected with a typed `SpanNestingError` naming both offending spans and their ranges
-instead of resolving to whichever span a consumer happened to visit last. The font-fallback overlay the layout path
-generates is machine-produced rather than authored, so it is split at the authored boundaries it crosses and stays inside
-the same invariant. Resolution runs once per paragraph revision, keyed on the property snapshot, so packing indexes a
-precomputed per-glyph result instead of rescanning the span array for every glyph.
-
-Ranges count UTF-16 code units, so an astral character before a span shifts that
-span by two. Replacement content owns its own formatting on both the core `Paragraph` and the Three `Text`: assigning a
-literal installs that literal's spans, and assigning a plain string clears the spans it replaced rather than reinterpreting
-stale ranges against unrelated text. Runtime integration covers each of these against real shaped output — inherited font
-handles and glyph IDs, a nested style-only span shaping from the font its enclosing span selected, each paint property
-inherited independently through MTSDF fill, outline, and shadow storage, a span font size moving both shaped advances and
-the line break, the typed nesting error and order-independent precedence, per-glyph font sizes and canonical linear
-colours, cluster indices across a surrogate pair, tuple-spread and direct `span` calls producing identical layout, and a
-formatted literal driven through `TextGroup` binding, `updateMatrixWorld`, and the drawn per-run instance counts.
-
-`Text` is a composite `Object3D`, not a `Group`, so it honors the primary `groupOrder` of any caller-owned parent Group.
-Generated raster batches also use neutral `Object3D` roots rather than nested Groups. `Text.renderOrder` becomes the secondary
-base applied to drawable meshes, which preserve their first-glyph/page-run-local offsets. Cold publication, warm retained
-commits, base changes, and multi-font spans need no reshaping or per-frame descendant walk. Bitmap, MTSDF, Slug, and the
-external proof package implement the required batch method, and the Three adapter rejects a plugin batch that would reset
-inheritance with a nested Group.
-
-Milestone 10.4 proves that the open contract is implementable outside this package. The private
-`@pmndrs/text-glyph-example-raster` consumer owns a new literal kind, companion GLB, embedded/external records, static and runtime
-bakers, decoder, retained Three.js/TSL adapter, dirty uploads, overflow, abort, and disposal without importing this package's
-internals or first-party raster modules. The proof made the Three adapter requirement explicit through public
-`RasterObjectDrawBatch