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 ( + WebGPU or WebGL2 is required.} + flat + orthographic + > + + + + + + ); +} 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` and `ThreeRasterDrawBatch` types while keeping portable `RasterDrawBatch` renderer-neutral. Its -neutral Three.js root preserves renderer-local transparent-run order beneath the caller-owned parent Group and `Text` base. It -also corrected `RasterRuntime.load` to retain the caller's `resolveResource` callback in cache-owned options; authenticated -external records now traverse the same deduplicated load as their companion artifact. The public type additions erase at -runtime. Compact forwarding of the complete public option bag makes browser core and every first-party runtime closure 55 raw -/ 49 minified bytes smaller than the parent. Browser core changes by −11 gzip / +76 Brotli bytes, Bitmap by −15 / −74, MTSDF -by −14 / −41, and Slug by −13 / +6. The lazy validator, every baker host, and every Wasm artifact remain byte-identical. - -Slug V0 renders fill and opacity and rejects outline or shadow paint before batch allocation or paint mutation. The removed exact-distance outline and the bounded replacement gate are retained in the [outline research record](../planning/slug-outline-research.md). - -This package owns the accepted public core and React contract types. Its fixtures prove literal font and raster inference, capability composition, source/baked input rules, paragraph constraints, React prop derivation, lazy raster and `useFont` inference, and invalid combinations at compile time. React and React Three Fiber remain optional peer capabilities and are not reachable from the core entry point. Three.js-facing runtime values and types resolve through the public `three/webgpu` and `three/tsl` subpaths rather than the legacy root or internal source exports, matching the renderer boundary used by first-party raster work; package lint rejects those forbidden imports. Public raster-baker descriptors are constrained to `JsonValue` while preserving their exact inferred shape. Plugin-produced values are still revalidated during their unavoidable RFC 8785 canonicalization pass: exotic prototypes, cycles, excessive nesting, non-finite numbers, invalid Unicode, and non-JSON values cannot collide with a valid raster identity, while repeated non-cyclic references remain legal. Project plans resolve each descriptor and `rasterKey` once, then carry that same pair through ordering, packaging, and baking so a stateful plugin cannot make identity drift within one bake. - -Every `Text` generation now uses one required renderer-neutral raster transaction. `stageBatch` receives the prior compatible batch when one exists and returns an unpublished target plus synchronous `commit` and idempotent `abort`; there is no separate build, optional retained-update, or repaint-mutation contract. A candidate publishes only after every participating raster stages successfully. Failure, cancellation, or stale completion aborts staging without touching the live generation, while commit transfers exact batch ownership atomically. The portable batch surface owns only idempotent disposal and imports no Three.js type; the Three-backed `Text` adapter separately validates and attaches each target object. Bitmap, MTSDF, and Slug allocate deterministic 25% instance slack capped at 256 glyphs, track logical draw count independently, and retain geometry, material, texture, attribute, and backing-array identity while arbitrary replacement glyphs fit. Bitmap updates origin, size, UV, and color fields; MTSDF updates its complete 28-float instance record; Slug updates both its 17-float and six-integer records. Shrinks and exact-capacity growth update authoritative `instanceCount`, while overflow, strike changes, or incompatible ordered Bitmap/Slug page-run topology stage a correctness-preserving replacement. Dirty instances coalesce through 32-instance buckets and fall back to one logical full-range upload above eight disjoint ranges; unconsumed Three.js ranges carry into the next stage so a later commit cannot lose an earlier GPU upload.[^bitmap-identity][^mtsdf-contract][^slug-runtime] - -Milestone 9 introduces the fixed Slug V0 identity and standalone artifact-validation boundary.[^slug-contract][^slug-validator] The validator layers the pinned Khronos and byte-identical extension schemas over exact 40-byte dense records, exclusive buffer-view ownership, lossless native RGBA16F KTX2 curve pages, R32UI header grids, R16UI reference grids, checked page-relative addressing, authenticated external resources, and bounded GPU residency. Malformed identity, record, address, padding, KTX2 descriptor, integer-grid tail, external hash, and residency cases are named negative controls. That boundary feeds the package-owned Slug baker, registered-raster loader, analytic runtime, and framework-neutral public `Text` path described below. The retained all-external public-loader framebuffer gate and performance-review packets close Milestone 9; additional Slug tuning remains future measured research rather than unfinished renderer integration. - -The package-owned Slug baker now composes the ported outline conversion and exact packing crates into deterministic embedded or independently authenticated external-page artifacts.[^slug-baker] Its Rust-generated V0 ABI drives the same shared direct/segmented host mechanics used by the existing raster bakers, including bounded artifact windows, synchronous progress forwarding, owned-copy-before-release behavior, structured errors, and transactional allocation cleanup.[^slug-baker-host] The serial module-Worker entry remains lazy; neither the baker host nor Wasm enters the initial renderer graph. - -The fixed Slug renderer copies and adapts the reviewed Three Flatland TSL implementation to the package's exact V0 resources rather than recreating the analytic coverage graph.[^slug-runtime][^slug-shaders] It uploads lossless RGBA16F curves and R32UI headers directly; the Three.js adapter pair-packs exact R16UI glyph-local references into R32UI texels because Three 0.185.1's WebGL TSL backend otherwise declares a float sampler for `UnsignedShortType`. The adapter retains two bytes per reference plus at most one terminal padding value, while the authenticated artifact remains unchanged. Integer instanced addresses, consecutive page runs, hostile per-band work caps, the stable q-form solver, and loop-invariant derivative and reciprocal hoists remain intact. The patched `NodeExtras` declaration makes ordinary public TSL operator calls tractable; the private compatibility boundary now erases only the two runtime-supported operations Three 0.185.1 does not type precisely: boolean-form `Loop` and unsigned `textureLoad`. Shader construction stays inside `Fn` boundaries; assignment nodes append through the active TSL stack, axis-specific declaration names produce clean diagnostics, and package tests prove that ordinary core, Bitmap, and MTSDF entry graphs do not eagerly include Slug. The shared registered-raster resource seam resolves embedded views or independently packaged resources through a caller resolver or the companion artifact's retained URL/fetch context, then enforces declared length, SHA-256 identity, cancellation, and the registry resource ceiling before Slug creates any GPU object. Embedded and external baker outputs prove byte-identical records and curve/header/reference resources; retained WebGPU and forced-WebGL2 captures now cover exact pixels plus the seven-source visual and performance corpus. - -The shipped Slug runtime retains only the original 17-float fill instance layout and one fill material. The removed one-draw outline was visually correct but added a separate closest-distance curve solver after ordinary coverage. Retained 268-glyph measurements placed it at `2.44×–4.33×` fill-only GPU time across WebGPU/WebGL2 and DPR 1/2, so the implementation, TSL distance graph, outline attributes, CPU stroke authority, and benchmark controls were deleted rather than preserved as a fallback. Any future outline must reuse ordinary fill traversal, stay within the documented near-fill performance gate, and match or beat MTSDF outline quality.[^slug-outline-research] - -The non-shipping `autoresearch-fixed32-bands` baker feature proves that V0's per-glyph band counts are an executable optimization seam without becoming a JavaScript option or alternate distribution. It produces separately retained candidate artifacts; the ordinary Wasm build remains fixed 16. Exact dual-backend quality and interleaved product evidence reject universal fixed 32 as the production policy because its curve-work reduction comes with double-digit payload and residency growth. The separately gated `autoresearch-adaptive-bands` feature selects only among 16, 32, and 64 at bake time and never enters the production Wasm build. Its first six-reference target is rejected at the artifact gate because 64-band escalation grows every source. The distinct `autoresearch-adaptive32-bands` feature caps the same trigger at 32 under a separately precommitted manifest; its exact quality and complete dual-backend timing evidence also reject the universal policy because the added resources do not yield a reliable guard-wide product win. The copied packed-hull format likewise preserves exact pixels but is rejected after complete dual-backend product measurement: no source clears the 5% gate on both backends, while the incompatible reference layout adds 29.0–44.2% gzip bytes and 17.4–25.2% GPU residency. Its implementation remains at the retained candidate commit rather than adding a runtime format branch. The root-contribution challenger is also rejected and removed from the shipping tree after exact same-build measurement. Inspection of final Three 0.185.1 programs corrected the older mechanism: boolean `select` already emits control flow, so the candidate coalesces eight root-condition branches into four rather than replacing eager arithmetic. All 28 quality cells remain byte-identical and resources remain exact, but no source clears the 5% gate on both backends; the seven-source median paired deltas are a 0.84% WebGPU regression and a 1.56% WebGL2 improvement. Its implementation remains available at the retained candidate commit without adding an experiment API to the public raster. None of these experiments changes production output or rewrites the retained rejections. - -The workspace pnpm patch carries DefinitelyTyped's `NodeExtras` lookup-map rewrite for `@types/three` 0.185.1.[^definitelytyped-node-extras] It replaces the deeply nested `Node` conditional/intersection chain identified by TypeScript Go issue 4528 without changing the public node extensions.[^typescript-go-node-variance] The compile-only TSL regression covers the previously explosive method chain, uint shift/bitwise operations, integer division/modulo, vector derivatives, and object-form loop; it completes in 215 milliseconds at 4 MiB peak RSS. The complete text project completes in 346 milliseconds at 252 MiB, so package and build scripts invoke the pinned compiler directly and the former native-process memory guard is removed. The private unsigned-texture adapter remains separately justified because Three 0.185.1 types `textureLoad` as a generic float `TextureNode` even for an unsigned data texture; it is not compiler containment. - -Milestone 8.1 adds a repository-owned `no_std + alloc` Rust MTSDF core and a non-shipping admission harness beside the package's existing bakers. Typed AoS outline construction lowers once into kind-segregated SoA spans with contour identity; reusable scratch keeps per-pixel traversal allocation-free and now retains corner-coloring storage between glyphs. True signed curve distances, contour-aware overlap combination, nonzero-fill sign correction, and deterministic edge coloring produce zero coverage mismatches against pinned native `msdfgen` 1.13.0 across ordinary, acute, overlapping, self-intersecting, quadratic, cubic, and counter fixtures. Quadratic segments use the exact stationary-point polynomial solve from the pinned reference rather than nine seeded Newton searches; a regression fixture proves the former approximation can choose a materially wrong distance. Post-quantization correction retains the reference-compatible edge-fast pass for ordinary channel collisions, then applies at most four MTSDF-specific passes only to cells where bilinear RGB coverage disagrees with true-distance alpha near the coverage boundary. Both correction stages are glyph-local. Mean alpha error stays between 0.470 and 0.549 bytes with zero oracle coverage mismatches. The optimized `wasm32-unknown-unknown` admission module imports nothing and its compiled graph contains no font parser, WGPU, native binding, or WASI dependency. Its recorded binary hash and compressed sizes are explicitly host-labeled: the recorded host requires exact freshness, while every foreign host must rebuild the module, reproduce the portable admission contract and synthetic output, retain zero imports, and remain under the same reviewed raw/optimized/gzip/Brotli ceilings. A bounded cargo-fuzz lane covers malformed outline streams. The oracle, channel-SIMD, scalar-tile, and adjacent-texel SIMD implementations remain test-only; scalar is the single production kernel. - -The geometry core is independent of its host boundary. A sibling `mtsdf-baker` crate owns the package allocator and seven-function generator C ABI for allocation, release, generation, and borrowed-result access. Build-only Rust generation derives the portable JSON and exact typed TypeScript contract; production Wasm does not embed or export that contract. Callers write one checked header plus fixed command records directly into Wasm memory; the module accepts only exact active pointer/length pairs and rejects a released allocation. The generator exposes a checked sampling transform for production baking: every glyph may be placed on one global plane grid with one authoritative distance range, rather than stretching each glyph independently to its rounded texture dimensions. The legacy one-em oracle path uses the same implementation and remains byte-identical. A feature-minimal admission build preserves the independently measured generator boundary, while the package publishes one full baker module containing that kernel and the artifact pipeline rather than duplicating it as a second Wasm resource. Its internal TypeScript host writes discriminated move/line/quadratic/cubic/close commands directly into linear memory, maps statuses to typed errors, verifies the exact RGBA8 length, copies borrowed output before release, and releases requests after every later failure. All seven native-oracle cases retain their independent SHA-256 identities through the host; malformed numeric/outline input, forged release ranges, stale allocations, ABI drift, and cleanup after invalid output are named regressions. The feature-minimal scalar boundary remains separately measured from its host. - -Milestone 8.2 composed that kernel into the original fixed `@pmndrs/text/bakers/msdf` artifact path. One shared Fontations adapter supplies maintained unscaled line, quadratic, and cubic outlines to both admission evidence and the baker; no second parser or outline bridge exists. Its 64 px/em, full-eight-pixel-range descriptor hashes to `e944ba8d…fe93`. Item 8.6 now exposes `emSize` and full `pixelRange` as authenticated integer bake options in `1..=1022` and `1..=1020`. Omitted or partial options resolve against 64/8; explicit effective 64/8 canonicalizes to the legacy fieldless descriptor and raster key, while every non-default descriptor carries both effective values. `planeUnitsPerEm` equals `emSize`, and each glyph is evaluated only over its tight source-outline rectangle plus `ceil(pixelRange / 2)` field-padding texels on that global plane grid. Correction operates over the same glyph-local rectangle before copying into a 1024-pixel atlas page. Real 155-glyph subset bakes at 32/4 and 32/6 pass artifact validation, establishing the control path without changing the recommended default before quality and payload benchmarking. Bitmap and MTSDF descriptors may additionally authenticate bounded raster coverage while retaining the full source-local glyph namespace and dense record table. Standalone validation derives the expected coverage only from that authenticated descriptor; its public context has no second coverage field that could silently disagree. Degenerate non-rendering selected glyphs become exact absent records, while malformed command streams remain typed failures. The shared TypeScript direct-memory host owns allocation, response framing, nested metadata validation, copying, and transactional cleanup for both bitmap and MTSDF bakers. - -Direct raster-baker ABI revision 1 keeps ordinary responses contiguous and moves oversized results through bounded borrowed windows: the host reads metadata once, copies each window while Wasm owns it, and explicitly releases that ownership before the Worker transfers exact result buffers. Every Wasm pointer, status, length, and count is normalized as unsigned at the JavaScript boundary. The generator-only no-default-feature MTSDF module remains valid because artifact-baker fields are optional to the generator host, while the packaged baker requires and validates them. MTSDF quality options travel in the authenticated descriptor and do not change the low-level Wasm ABI. Build output removes obsolete ABI revision 0 files before packing. - -Bitmap and MTSDF runtime fallback share one serial ESM module-Worker host. The same normalized descriptor options drive deliberate Node baking and missing-artifact fallback; Bitmap's Worker normalizer retains both strikes and coverage instead of projecting coverage away. Each dynamically imported baker receives an owned source copy, one active job uses the reusable Worker, queued jobs remain FIFO, cancellation replaces active ownership safely, and an idle Worker terminates. Preparation failures reject through the promised asynchronous API rather than escaping synchronously. Core provenance now retains the authenticated collection-face index; legacy artifacts may default only when their descriptor hash proves face zero, and runtime raster baking always reuses that selected face. Registry subscriptions are released when their final tracked font is disposed. - -Runtime font and raster bake requests accept one optional `onProgress` listener. Its closed contract reports `font` or `raster` stage, a versioned phase, and bounded `completed`/`total` units. The host reports `queued` immediately; Workers report loading, packaging, transfer, and completion; Rust reports raster glyph progress through one generated synchronous Wasm import at roughly one hundred updates per complete pass. Progress messages never resolve a request, mutate render state, or enter the shaping/rendering graph. Consumers may therefore present deterministic progress without polling, timers, or main-thread baking, while omitting the listener pays only the fixed callback branch in optional baker code. - -Item 8.6 makes runtime raster coverage an explicit bounded capability rather than forcing every request through a complete-face atlas. One frozen typed contract accepts sorted, non-overlapping inclusive Unicode scalar ranges, authored scalar text, and sorted exact font-local `u16` glyph IDs under fixed count limits. Unicode ranges skip code points absent from the selected face; authored text fails if any scalar is unmapped; exact glyph IDs fail outside that face. The union selects raster work only: it does not subset the shaping font, remap IDs, or claim GSUB/GPOS closure. Sparse artifacts retain dense records and add one exact `ceil(glyphCount / 8)` little-endian selection bitset, where bit `n % 8` of byte `n / 8` names glyph `n`; unused terminal bits are zero. Selected spaces may remain absent records, while every unselected record must be absent. The canonical seed descriptor and bitset must appear together and are strictly validated. - -Node composition, direct Wasm calls, and the serial module-Worker use the same canonical descriptor and produce byte-identical bounded Bitmap/MTSDF artifacts. Complete descriptors retain their legacy raster keys and complete-face artifact/page hashes. Both runtime decoders rederive the canonical raster key from the authenticated artifact policy before creating GPU resources, so mutated strikes, quality settings, or coverage cannot retain a stale declared identity. Progress totals count selected glyphs rather than the source face, active cancellation replaces the Worker before queued recovery, and warm runtime preparation throws public `RasterCoverageError` with sorted missing font-local IDs before publishing a partial batch. This boundary intentionally leaves transitive shaping closure and compiler source subsetting to Milestone 17. - -The Darwin arm64 coverage-capable Binaryen-optimized MTSDF baker measurement is 552,025 raw, 215,030 gzip, and 168,758 Brotli bytes with one generated synchronous progress import; its independent host is 26,940 raw, 19,117 minified, 5,530 gzip, and 4,908 Brotli bytes and remains lazy outside the shaper and renderer graphs. The corresponding Bitmap baker is 626,940 raw, 234,735 gzip, and 180,503 Brotli bytes. The shared boundary materializes one validated JSON value for artifact packaging and canonical policy hashing instead of instantiating a second derived serializer graph. Relative to the pre-coverage modules, the remaining Brotli growth is 5,188 bytes for MTSDF and 6,951 bytes for Bitmap; dedicated raw, minified, gzip, and Brotli regression gates keep that strict decoding, cmap resolution, and policy-validation cost explicit. Compiled Wasm size and hash remain host-labeled package-size evidence with reviewed foreign-host ceilings; raster fixtures authenticate the generator version and exact baked outputs rather than treating a host compiler binary length as portable artifact identity. Profiling uses a private diagnostic entry that is neither packed nor present in the package export map and is unreachable from every production raster-baker graph, so ordinary bakes perform no clock, memory, or output-size observation. The package-size build fails if a shipped JavaScript graph reaches that module or retains its profiling symbols, if a thin baker host retains `performance.now`, or if a production Wasm import/export exposes profiling or timing. Canonical 64/8 Inter still produces 58,740 record bytes and ten independently addressable single-level lossless RGBA8 KTX2 pages. Their authored levels contain 39,111,736 unpadded texel bytes and occupy 41,943,040 exact bytes after texture-array layer padding; the corrected embedded full-font artifact remains 39,347,712 raw and 6,798,412 gzip bytes. The exact legacy descriptor, artifact, and page hashes remain unchanged. On the 1,402-icon Font Awesome fixture, the exact quadratic solve reduced a two-icon targeted raster pass from about 392 to 170 milliseconds and the refreshed complete-face bake measured 113.5 seconds; these are same-host observations, not portable performance gates. The complete artifact contains nine pages, 32,511,100 bytes of authored texture payload, 36,347,904 bytes of padded GPU allocation, 32,580,900 raw transport bytes, and 7,227,824 gzip transport bytes. The Worker forwards bounded progress while the synchronous Wasm call is active. - -That complete-face duration is a measured kernel cost, not an unexplained Worker stall. The discoverable `text:mtsdf-baker-profile` workflow accepts `--case=small|medium|complete|all` and compiles its native phase observer once. It measures the shared optimized pipeline by selection, outline extraction, texel generation, packing, KTX2 encoding, and GLB serialization, then runs the real optimized Wasm directly and through the serial module Worker. On the recorded Apple arm64 host, texel generation consumes 245.40 of 248.32 native milliseconds for the 39-selected-glyph case, 4,429.17 of 4,445.46 milliseconds for the 524-selected-glyph case, and 25,871.57 of 25,957.06 milliseconds for complete Inter. Those cases generate 71,341, 1,289,496, and 7,233,197 texels while visiting 1,694,576, 36,939,819, and 227,327,416 colored edges. Direct Wasm-to-host copies cost 0.69, 0.74, and 4.51 milliseconds; final Worker delivery costs 0.46, 0.61, and 0.47 milliseconds. Wasm linear-memory high-water marks are 6,488,064, 36,634,624, and 227,737,600 bytes; isolated Worker-process peaks are 109,002,752, 147,177,472, and 343,343,104 bytes. The corresponding artifacts are 595,752, 7,074,796, and 39,175,608 bytes. Direct Wasm and Worker identities are exact. The complete native-arm artifact is recorded separately because native and Wasm floating-point targets diverge there; it is not substituted for the shipped Wasm identity. Texel traversal is therefore the measured dominant phase and triggers the adjacent-texel scalar/SIMD comparison, while bounded runtime atlases remain the interactive path and complete faces remain explicit offline/stress work. - -Thin production artifacts use `pnpm build`; its MTSDF Wasm command is an optimized `--no-default-features --features artifact-baker` build, so the Rust `profiling` feature and phase observer do not exist in the module. `pnpm scripts run release:size:check` rebuilds those artifacts and enforces the JavaScript/Wasm diagnostic-exclusion boundary alongside exact size identity. An optimized diagnostic run is deliberate: `pnpm scripts run text:mtsdf-baker-profile -- --case=small` compiles the native binary with `--features=profiling` and imports the private TypeScript diagnostic entry; `--case=all --write` is reserved for refreshing retained phase evidence. The profiling entry is neither a public package export nor transitively imported by a production entry. The only remaining `performance.now` calls in package production source belong to the Node bake API's documented `timingsMs` result; that observable output is part of the maintained host contract rather than a hidden debug path. - -The retained SIMD decision evidence compares an equivalent four-texel scalar tile with a feature-gated adjacent-texel SIMD candidate. Both preserved every seven-case native-oracle hash, the complete 2,937-slot Inter checksum, rejection set, composite identity, warm allocation count, and steady-state Wasm memory. The candidate vectorized line-distance work across four neighboring texels while retaining the exact scalar quadratic and cubic solvers lane by lane. Adjacent SIMD improved the bounded Node corpus from 45.712 to 44.608 milliseconds (2.4%) and Chromium from 28.965 to 28.700 milliseconds (0.9%), but complete Inter was indistinguishable warm at 45.068 versus 45.066 seconds and its 50.110-second cold pass was slower than scalar's 44.089 seconds. Those bounded improvements did not justify a second target-specific artifact: optimized size grew from 52,633 to 63,549 bytes (20.7%) and Brotli size from 19,660 to 21,904 bytes (11.4%). The closed experiment runners are no longer maintained as product workflows; the accepted scalar kernel, independent native oracle, public baker tests, and package-size gate remain active. - -The shaper, Bitmap baker, and MTSDF generator/artifact boundary define every direct-memory header and table as a fixed-width `#[repr(C)]` Rust type. Build-only Rust generators derive each JSON size, alignment, and field offset from `size_of`, `align_of`, and `offset_of!`, then emit exact typed `as const` TypeScript modules from that JSON. Rust readers/writers and production TypeScript therefore consume one compiler-led truth; CI compares regenerated output byte-for-byte and fails on stale checked-in modules. Production Wasm embeds no duplicate JSON and exports no ABI pointer/length bootstrap. This keeps QuickType, JSON Schema, runtime JSON parsing, and binding-generator code out of the shipping graph. Wasm linear memory is guaranteed little-endian; serialized GLB, KTX2, SFNT, and extension records continue to follow their own portable format contracts. - -Item 8.3 promotes `@pmndrs/text/raster/msdf` from an identity-only contract to the browser module and adds the isolated `@pmndrs/text/bakers/msdf/validate` entry. The standalone path layers the pinned Khronos validator, byte-identical Draft-04 schema, and semantic checks for reciprocal identity, descriptor-authenticated generation values, `planeUnitsPerEm = emSize`, view ownership, exact dense records, page bounds, embedded/external length and SHA-256 authentication, single-level linear RGBA8 KTX2 structure and data-format metadata, arithmetic limits, and a 256 MiB padded-base-array residency ceiling. Canonical Inter's ten legacy-default pages round-trip through both packaging forms; field deletion, record/page mutations, KTX2 and DFD corruption, missing/tampered external pages, and budget failures are named negative controls. - -The runtime repeats no parallel wire-format implementation. Bitmap and MTSDF renderers plus both standalone validators consume the same dependency-light KTX2 and dense-record rules; only the standalone layer imports Khronos/Ajv. The renderers also share the lossless-atlas adapter, unit quad, parallel-array checks, and resolved-paint lookup. The MTSDF resource uploads only its authenticated base levels into one padded texture array, samples them bilinearly, sizes reconstruction with screen derivatives, and owns one material per logical array; disposal releases materials and textures transactionally. - -One instanced batch family handles fill, outline, opacity, and translated hard shadow. The version-matched TSL graph reconstructs the fill edge from the RGB median and consumes alpha's true signed distance for effects. Shadow offsets expand each instance's geometric bounds and shift the same authenticated atlas sample, while clamped sampling and an explicit in-glyph mask prevent neighboring atlas cells from bleeding into the result. V0 outlines are bounded to half of the resource's authenticated full `pixelRange`; the exported `MTSDF_MAX_OUTLINE_ATLAS_PIXELS` is specifically the four-pixel limit for the default 64/8 configuration, while non-default resources derive their limit from their own authenticated range. A larger request fails instead of silently clipping. Paint updates reuse geometry and rewrite only owned instance attributes. The canonical Inter integration test decodes all ten real legacy-default pages, creates and repaints a live batch, verifies normalized effect attributes, and proves idempotent batch/resource cleanup without loading baker Wasm into the runtime graph. A real 32/4 artifact accepts its exact two-atlas-pixel boundary, normalizes it to half of that resource's field range, and rejects `2.0001`, distinguishing the configured limit from the exported default. - -The checked SIMD comparison builds scalar, compiler-auto-vectorized, and explicit-four-lane kernels from isolated target directories. Every variant preserves all seven corrected native-oracle hashes and the complete Inter result of 2,915 generated glyphs, 22 non-rendering rejected slots, checksum `a5a6aa6e`, and composite SHA-256 `f6381c2f…eef6`; an instrumented warm seven-call corpus records seven request allocations, zero reallocations, and seven deallocations, one owned output copy occurs per call, and Wasm memory does not grow after the cold corpus. On Node 24, scalar measured 46.462 milliseconds for seven warm calls versus 47.079 milliseconds for explicit SIMD. Chromium 149 measured 47.6 versus 48.1 milliseconds. Explicit SIMD improves the complete Inter warm pass from 48.13 to 45.38 seconds, a 5.7% stress/offline win, and saves 297 Brotli bytes. Because the supported runtime default is bounded interactive baking and the target feature would require an alternate artifact, scalar remains the only shipped baker kernel; item 8.6 retains the explicit variant as evidence for phase-led optimization rather than exposing a toggle now. The repository-local Vitexec capture and full-font request emitter preserve the experiment as repeatable evidence rather than product complexity. - -The Three-backed `Text` object is a real Three.js `Group` over the portable transaction contract rather than making Three part of that contract. It validates one complete candidate state before committing a patch, resolves every distinct root/span font through registry-scoped loader and HarfRust caches, shares decoded raster resources through `RasterRuntime`, and owns the resulting paragraph and raster batches as one generation. The first incomplete generation stays hidden; a later load keeps the prior complete generation visible until every raster target can swap atomically. React Suspense owns cold font, shaper, raster decode, and page preparation. The loader, shaper, and raster runtime additionally expose package-internal synchronous cache peeks: once those dependencies and layout-required pages are resident, `setProperties` shapes, lays out, plans paint, and stages synchronously without a consumer readiness wait. The committed generation remains live until `updateMatrixWorld` or `updateWorldMatrix` publishes the candidate before traversing raster children, and the React adapter invalidates its R3F root after changing core properties. Revision-scoped cancellation prevents stale work from publishing. Semantic no-op updates preserve an in-flight cold generation, abort signal, queued warm publication, and their original readiness observations instead of restarting work. Callback-only updates also retain that work and the latest `onLayout` observes the committed layout; after a genuine generation failure, the same semantic input explicitly retries rather than becoming permanently inert. Terminal font-disposal invalidation is distinct: a semantic no-op matching the invalidated generation's input preserves its rejected readiness state, while replacing that input may schedule recovery. The terminal state is scoped to the invalidated generation's own input, so an already-staged replacement remains recoverable if the superseded font is disposed; disposing that old font preserves the valid candidate and its readiness observation. Explicit disposal clears that saved terminal state along with pending and committed ownership. Every committed replacement releases its superseded font-disposal subscription while preserving a shared paragraph when only constraints changed. Paint-only updates stage through the same lifecycle while reusing the positioned layout, resident pages, and one glyph-to-span paint-index plan while text and normalized shaping ranges are unchanged. Validation and staging receive the same resolved `GlyphPaint` value, and repeated same-range updates reuse its `Uint16Array` index storage instead of rebuilding a code-unit map, palette-key map, and glyph-index array. One reusable Three color converter removes transient `Color` objects without weakening public color validation. Semantic no-op paint updates skip staging, width updates reuse paragraph shaping, shaping changes replace the paragraph, and disposal releases every owned batch and paragraph. MTSDF batches retain outline-width and shadow-offset structure per instance: color-only updates write only paint attributes, while structural paint changes take the full geometry/UV path. Direct scalar attribute writes avoid short temporary arrays on both paths. Runtime performance instrumentation does not ship in this package; the benchmark measures public scheduling externally. Integration evidence proves the previous layout remains live until object traversal, publication precedes retained-child traversal, React performs no consumer `ready` wait, same-range paint storage retains identity, asynchronous multi-font preparation aborts after a sibling failure, superseded-font disposal preserves a resident replacement, commit-contract faults cannot abort sibling traversal, and MTSDF color-only updates preserve origin/size/UV structure. A raw span font inherits the root raster definition but resolves its own font-local resource, preventing cross-font atlas reuse. - -The `@pmndrs/text/react` export now provides the thin runtime described by the accepted API. It flattens nested text nodes into one UTF-16 string plus ordered inherited spans, rejects nested object/layout props and non-text children, creates one core object only after React 19 dependencies resolve, forwards that object through its ref, and reconciles ordinary R3F transforms separately from core text properties. The forbidden source `text` and `spans` props remain explicit `never` fields because R3F v10's wider intrinsic-element types would otherwise weaken that public boundary. Semantic feature and inline-paint comparison prevents fresh-but-equal React values from scheduling layout or glyph-buffer work; a fresh `onLayout` callback updates ownership without repainting. `useFont`, `.preload`, `.clear`, and `lazyRaster` reuse the same loader, shaper, and raster dependencies as the core. A deterministic microtask-delayed disposal distinguishes React Strict Mode's setup/cleanup/setup cycle without sleeps or timer cushions. - -React Three Fiber 10.0.0-alpha.2 declares compatibility with the repository's React 19.2 and Three.js 0.185.1 pins, and repository code imports only `@react-three/fiber/webgpu`. Two narrow package patches own upstream prerelease gaps: the WebGPU entry no longer eagerly imports and auto-extends Three's browser-only Inspector during module evaluation, and test renderer 9.1.0 resolves both Three and R3F through their WebGPU entries. Upstream should make Inspector registration lazy and publish a v10-aware WebGPU test-renderer entry; no renderer implementation is forked. The Node reconciler harness installs a no-op animation request surface required by `frameloop: 'never'` and restores every global after the file, without timers or readiness polling. It proves resolved reconciliation, span flattening, identity retention, ref forwarding, update classes, invalid nesting, and disposal. The shared benchmark-registry target mounts public nested `` through a real R3F root backed by `WebGPURenderer`, matches pinned paragraph oracles across reflow, retains one core object, and submits a renderer frame. A live-browser Vitexec probe separately owns pending Suspense evidence; no application workaround is carried for the package test renderer's uncached-suspension behavior. - -The Node host rejects distinct source files that collapse onto one output path before any bake begins, reports mutually exclusive phase timings, and retries the lazily loaded default bitmap baker after a failed initialization instead of pinning a rejected promise. These rules keep batch publication deterministic and make measured phase totals honest. The loader also refuses URI-addressed external raster entries without SHA-256 authentication; resolver-only delivery remains explicit and hash-optional. - -The browser-safe `@pmndrs/text/raster/bitmap` subpath now owns bitmap generator/format constants, the exact `1..=1022` ppem V0 range, runtime validation of the non-empty static strike tuple, ascending canonical strike order, the complete generator-versioned descriptor, RFC 8785 serialization, and SHA-256 raster-key derivation. Equivalent strike sets therefore produce one identity regardless of caller order, while duplicate, non-integral, non-finite, non-positive, or out-of-range values fail before baking. The implementation uses Web Crypto and imports no Node built-ins.[^bitmap-identity] - -The optional `@pmndrs/text/bakers/bitmap` subpath wraps a `no_std + alloc` Wasm generator through its Rust-generated JSON ABI and direct linear-memory shim. The contract declares its sole `env.pmndrs_text_bake_progress(completed, total)` import so a Worker can report a long synchronous bake without polling, timers, or main-thread work. Fontations/Skrifa owns font and outline interpretation; a small pen bridge feeds Zeno's maintained antialiased rasterizer. The dependency-light `raster-artifact` crate owns the dense 20-byte record writer, channel-agnostic shelf atlas, lossless R8/RGBA8 KTX2 encoding, GLB framing, content hashing, and packaging enums shared by bitmap and MTSDF bakers. Bitmap output remains byte-for-byte identical after the extraction. Artifact and page filenames bind both `shapingHash` and `rasterKey`, preventing two fonts with the same raster configuration from overwriting one another. Glyph masks are placed as they are rasterized instead of retaining a second full-face bitmap set, fixed buffers reserve fallibly, and the atlas-compatible ppem bound rejects structurally impossible requests before font work. The bridge decodes borrowed response metadata while the allocation is live and copies only returned artifact ranges. Canonical path remapping plus Binaryen 129.0.0 `-Oz` produces a hardened distributed module of 621,645 raw bytes. The shared artifact boundary does not enter rendering or shaping bundles. - -The isolated `@pmndrs/text/bakers/bitmap/validate` entry reuses the core package's strict GLB framing and pinned Khronos validator, evaluates byte-identical Draft-04 bitmap/resource schemas, parses every declared page variant with `ktx-parse` 1.1.0, and enforces reciprocal identity, exact strikes, dense records, page bounds, KTX2 dimensions/format/levels, GPU-format/feature/quality mapping, external length/hash, arithmetic limits, and GPU budgets. Rust independently parses every native-test KTX2 through `ktx2` 0.5.0. Canonical Inter source/artifact/report/record/page identities, the fixed optimized Wasm size, embedded/external parity, 65,535-glyph boundaries, generated/published ABI identity, deterministic arbitrary-font Rust fuzz smoke, and fixed-seed artifact mutation fuzz smoke are executable fixtures. The source-remapped macOS arm64 and Ubuntu x64 modules have identical lengths and exact product output but different internal function-index order, so a release hash identifies the canonical builder output rather than pretending native code generators are cross-architecture byte canonicalizers.[^bitmap-baker] - -The internal generic composer authenticates every returned artifact, checks reciprocal shaping/glyph/raster identity, retains external companions and pages, and embeds package-owned companion data without interpreting its semantics. Integer glTF buffer-view references are rebased through the shared naming convention, so multiple distinct extension types compose without a closed registry. Exact Inter goldens cover combined embedded, combined external, and the identity-neutral empty raster set; both the core and bitmap validators round-trip the combined bytes. - -The Node-only `@pmndrs/text/bake` subpath closes roadmap item 2.4 around the item-2.1 TypeScript 7 AST/symbol discovery engine. `bakeFont` handles an explicit filesystem input/output pair and retains each selected raster package's exact option type. `bakeProject` finds composed tokens and statically visible core/React raw forms across TypeScript, TSX, JavaScript, and JSX; reduces immutable font/raster expressions; maps URL pathnames into canonical asset roots; groups identical sources; and dynamically imports only the exact verified raster-package ESM entry. It never executes application modules. One internal compiler adapter owns every unstable TypeScript import, project snapshot, symbol handle, alias, and declaration-resolution operation; an exact-version assertion and source-boundary test make compiler upgrades explicit. - -The native-ESM `pmndrs-text-bake` command is a thin `bakeProject` adapter. The host writes exclusive same-directory temporary files, backs up existing regular-file targets, publishes only after every artifact is staged, and restores all earlier targets if a later rename fails; process termination during the multi-file swap is not claimed as a filesystem transaction. It rejects lexical and filesystem-identity input/output overlap, non-regular existing targets, and unsafe package-owned filenames, then cleans temporary or backup files after success, cancellation, and ordinary failure. Its plugin type guard proves each required property with `in` checks before reading it rather than asserting a partial module shape. Discovery reports are sorted by source file and lexical AST offset after concurrent analysis. Its report adds phase/total timing, before/after RSS, explicitly process-lifetime peak RSS, output paths and hashes, and raw/gzip/Brotli transport sizes to the authoritative core/raster/container byte report.[^node-host] - -The public `FontLoader` and `FontRegistry` close item 3.1. They normalize every accepted input form into deterministic source/baked URLs, deduplicate request promises and validated shaping identities, and run the same hostile-input validator before registration. The large pinned Khronos/Ajv validation graph is cached behind a separate dynamic import: package import stays small, while the first actual registration still validates before publishing anything. Registration owns the bytes and retains the extracted reduced SFNT, glyph extents/availability, metrics, Unicode/source provenance, source candidates, and opaque raster directory required by later stages. Exact Inter fixtures compare those retained shaping views byte-for-byte with independent GLB validation. Embedded and external raster delivery variants merge by raster identity; companion attachment authenticates generic framing, ranges, reciprocal identity, and hashes before package-owned decoding. Streaming limits precede allocation, lifecycle handles are registry-scoped and invalidated on disposal, and a deterministic loader mutation corpus is part of the ordinary fuzz smoke.[^loader] - -Source inputs remain baked-first by default: a string, URL, or `{ source }` request derives and probes the canonical sibling asset. `{ source, baked: null }` is the explicit source/runtime form and performs no sibling request; omission and `null` have distinct React preload identities. Runtime raster generation authenticates the returned package against the caller-derived raster key, shaping hash, glyph count, glyph-ID width, extension, and version before attaching it to a source-only registered font. Public arbitrary companion attachment still requires a directory reference, so the runtime seam does not weaken the hostile-asset boundary. Integration tests cover both the fetch decision and the complete core-only font → generated bitmap → decoded raster path. - -Milestone 7.1 strengthens that lifecycle at the asynchronous raster publication boundary. If a decode completes after its runtime or owning font generation has been disposed, the decoded resource is released exactly once and the awaiting caller receives `AbortError`; it can never observe a resource that was already torn down. Named tests also prove stale raster handles, same-artifact re-registration with a fresh font handle, stale-handle shaping rejection, and independent shape-plan ownership. The packed tarball is exercised as the consumer artifact: every declared JavaScript and Wasm/JSON subpath resolves as ESM, build-only `.tsbuildinfo` state is excluded, the executable CLI answers through its installed path, the fallback runs as a real module Worker from an isolated packed Vite consumer, and CommonJS loading fails rather than discovering a hidden compatibility build. - -Paragraph instances retain only the 32 most recently used entries in each measurement, line-plan, positioning, geometry, and final-layout cache. Each registered font likewise retains at most 64 least-recently-used HarfRust shape plans. Equivalent hot calls still reuse the same results, while adversarial constraint or language/feature variation has a fixed retention ceiling; updating or disposing a paragraph and disposing a font release the respective caches immediately. Paragraph preparation indexes cluster starts plus spacing/space prefix sums once, and positioned fragments use binary bounds over monotone HarfRust clusters instead of rescanning the paragraph or complete shaped run at every glyph boundary. - -The `@pmndrs/text/runtime-bake` boundary closes item 3.2. It is dynamically imported only after a missing, invalid, or incompatible baked probe; creates one named module Worker; transfers provenance-preserving owned byte ranges; and runs the exact portable `@pmndrs/text-font-baker` wrapper plus its package-owned optimized Wasm. Offline and Worker hosts share dependency-light V0 descriptor, sole-artifact, successful-promise-cache, and owned-transfer rules while keeping filesystem and fetch behavior separate. The host owns a strict FIFO with one active bake: queued cancellation removes only that job, active cancellation replaces the Worker before resuming queued work, and the Worker entry independently serializes accepted messages. This bounds active CPU/Wasm memory without relying on async message ordering. The host predicate promises only the message fields it proves and consumes instead of overclaiming the complete baker report. A failed core initialization is retryable in both hosts. Canonical Inter fixtures execute the offline host and Worker entry, compare their complete artifacts byte-for-byte with the direct portable core, and then send the Worker result through loader provenance and hostile-input validation. The current independent size lanes report a 9,524-byte minified runtime host, 8,936-byte Worker JavaScript, and one 422,538-byte Wasm artifact; reviewed ceilings prevent heavy validation, Node, discovery, composition, or raster dependencies from entering those runtime graphs. - -Milestone 3 closes with browser-executed parity and cancellation. The benchmark product's public loader target first hashes the real module-Worker artifact against the canonical Node artifact, validates and registers it, then runs the complete missing-sibling fallback in Chromium. Shared loads now reference-count consumers: one abort detaches safely, the final abort reaches fetch/stream/Worker work, and an otherwise-idle Worker terminates immediately after the final success, failure, or cancellation and recreates on demand without timers. Stale events from a terminated Worker cannot settle requests owned by its replacement. The explicit queue keeps one active post under concurrent integration tests; two live Chromium evidence runs preserved the canonical hash while a three-font burst completed in 30.8–32.0 ms versus 68.3–88.6 ms for three separately initialized sequential Workers. These observations are recorded without a timing threshold. Shaping-identity deduplication retains source bytes only when their source hash matches the registered primary provenance; alternate URLs remain hash-qualified candidates. - -Milestone 4 closes the package-owned HarfRust runtime. The Rust 1.97.1 module uses HarfRust 0.12.0 and matching `read-fonts` 0.41.0 under `no_std + alloc`, exposes a compiler-described direct-memory C ABI, and keeps its allocator private. Build-only output publishes JSON for tools and typed TypeScript for the host without embedding either representation in Wasm. Its request registry owns zero-initialized, caller-sized buffers capped at 64 MiB and accepts only exact live pointer/length pairs, eliminating reconstructed raw ownership. The TypeScript bridge releases earlier allocations if a later registration copy fails. Canonical Inter contributes 147,192 SFNT bytes, 23,496 dense-extents bytes, and 368 availability bytes, or exactly 171,056 retained bytes. Registration is registry-scoped and idempotent; font/shaper disposal releases owned data and plans. - -The four parallel Bitmap, shaper, MTSDF, and Slug ABI producers share the font-baker package's causal command-capture boundary. Their successful process exit is not enough to publish a contract; the build waits for stdout EOF before validating and freshness-checking the complete compiler-generated JSON. - -One `shapeBatch` or `reshapeRanges` call packs validated UTF-16, run, feature, language, and range records through offsets from the generated ABI. It returns aligned borrowed SoA views with absolute UTF-16 clusters, glyph IDs, four positions, and mapped flags. Result layout and arena publication reserve fallibly before writing, so allocation exhaustion returns `RESULT_TOO_LARGE` rather than trapping after shaping. Every pinned Inter case passes bit-for-bit through the complete source → baker GLB → validator → registry extraction → Wasm chain for both calls; multi-run batching, plan reuse/disposal, surrogate boundaries, extents conversion, malformed records, and forged release metadata are executable. The fixed-seed raw-ABI mutation lane registers real validated Inter views first, so seed and surviving mutated requests reach HarfRust while malformed variants remain deterministic. The browser product batches all eight cases into one 97-glyph call with exact output hash `dc30c21c`. The hardened dynamic-Talc optimized module is 680,312 bytes raw, 253,568 bytes gzip, and 199,365 bytes Brotli; its JavaScript bridge is 32,778 bytes minified, 9,288 bytes gzip, and 8,257 bytes Brotli. - -Roadmap item 5.1 adds synchronous paragraph preparation and measurement. Unicode 17 Script/Script_Extensions tables are generated deterministically from the pinned UCD package; `unicode-segmenter` supplies extended grapheme boundaries and `@cto.af/linebreak` supplies line-break opportunities. The ordinary suite executes all 766 official grapheme vectors and all 19,338 official line-break vectors from hash-pinned gzip fixtures. Prepared text is split only at grapheme-safe style/script boundaries, shaped once through the existing GLB-retained HarfRust path, copied immediately out of its borrowed result arena, and measured into legal break clusters with explicit baselines. Equivalent width constraints reuse frozen measurement objects and width-only reflow performs zero Wasm calls. - -The canonical integration lane derives its natural width directly from the checked-in HarfRust glyph advances, then compares exact natural, 720 px, and 360 px measurements after source TTF → baker GLB → validator → registry → Wasm shaping. A second paragraph invalidates the shaper's borrowed arena before the first is measured, proving paragraph ownership rather than accidental view lifetime. Chromium repeats the same three measurements with deterministic hash `79874b9d`, one preparation shape, zero reflow calls, and no positioned glyph arrays. - -Item 5.2 implements final positioned `ParagraphLayout`. It caches line plans independently from full constraint results, materializes paragraph-owned typed arrays only when requested, scales the exact HarfRust advances/offsets through retained GLB metrics, and emits parallel glyph and line SoA arrays in top-left/positive-down coordinates. Boundary-sensitive line fragments are gathered into one `reshapeRanges` call per changed width with full shaping context and line BOT/EOT flags. The canonical fixture fixes every glyph ID, UTF-16 cluster, flag, line range, baseline, advance, x/y placement, and normalized byte hash for natural, wide, and narrow layouts. The live Chromium aggregate is 3,786 bytes with hashes `bb15bbcc:4f111a3f:e8c0e9d5`, one broad shape, and two reshape calls total. Registry-scoped handles are validated separately and deliberately excluded from the portable hash. - -Item 5.3 now has a conformant Unicode 17 bidi foundation. The package-owned shaper reuses `unicode-bidi` 0.3.18's maintained post–Unicode-15 UAX #9 algorithm under `no_std + alloc`, disables its Unicode 16 tables, and supplies generated Unicode 17 `Bidi_Class` and normalized paired-bracket data through the crate's custom data-source seam. The Rust-generated JSON ABI describes one direct-memory UTF-16 analysis call and borrowed SoA levels/classes/paragraph arrays; no browser ICU, WASI, binding generator, or ambient Unicode version participates. Hash-pinned official inputs cover `DerivedBidiClass.txt`, `BidiTest.txt`, and `BidiCharacterTest.txt`. Ordinary integration tests expand the generic corpus to all 770,241 requested paragraph-direction cases and execute all 91,707 character-specific cases, comparing paragraph level, every specified resolved level, and complete visual order. Wasm integration separately proves supplementary-plane code units and explicit/automatic paragraph directions. - -Item 5.3 completes paragraph-level bidi and line policy. Preparation resolves nested span properties through the shared per-property cascade, which folds covering spans from the outermost inward in one boundary sweep, then intersects the resulting style segments with UAX #24 script and precomputed UAX #9 runs in one interval pass rather than rescanning every cross-product. It shapes each run in its resolved direction, copies borrowed analysis/shaping data, applies line-specific L1 reset and L2 visual ordering, and batches only unsafe changed boundaries. Boundary validation occurs once while copying/normalizing public input; normalized shaping and layout loops do not repeat generic object checks. A pinned Amiri 1.002 fixture covers joining, combining marks, lam-alef forms, Arabic numbers, and Latin: HarfRust over the source font equals HarfRust over the reduced SFNT extracted from the validated GLB exactly, and pinned HarfBuzz 13 independently agrees on every glyph field. - -The generated `paragraph-bidi-layout-v0.json` contract owns complete SoA values for two mixed-direction Amiri layouts plus exact start/center/end/justify, clip, max-lines, and width/height ellipsis policies over Inter. Alignment-only and height-only compatible layouts share cached boundary shaping; every changed boundary is reported as one batched reshape. Ellipsizing a line ending in a mandatory break removes that control cluster before inserting the ellipsis, so the visible range never crosses into the hidden line. Fixed-seed fuzzing mutates Unicode text—including expected malformed UTF-16 rejection—axis modes, widths/heights, wrapping, alignment, truncation, letter spacing, line height, and direction twice, requiring finite, internally consistent, deterministic output. - -The current-uikit-shaped fixture lives in the benchmark application rather than core. It derives `CustomLayouting` intrinsics, maps Yoga Undefined/AtMost/Exactly modes, ignores the numeric `NaN` payload of undefined axes, preserves uikit's 1/100-point upward rounding, skips measurement for two definite axes, subtracts padding/border from the authoritative resolved box, and translates content-local positions into centered host coordinates. Twenty repeated measurements materialize no glyph arrays. Text and shaping-policy updates dirty layout; paint and raster updates do not. Chromium 149 fixes the twelve-layout aggregate hash at `8859ef19:8d5b98a3:e492fa7d:19a5a03e:32f8722c:0691e0de:e492fa7d:0132eed7:0ddc10b5:0ddc10b5:00f73fd9:c1a7730c`, with 8,098 output bytes, four broad shapes, and five reshape crossings; the GPU Vitexec lane repeats it with WebGPU active. - -Roadmap item 5.4 completes this same bake → retained-SFNT → HarfRust → paragraph path with Noto Sans CJK JP Regular 2.004 at the 65,535-glyph V0 limit. Thirteen Simplified/Traditional Chinese, Japanese, Korean, supplementary-Han, SVS/IVS, punctuation, ideographic-space, and mixed-script cases match source/reduced HarfRust and authenticated HarfBuzz 13 field-for-field. Contextual Script_Extensions avoid assigning shared punctuation arbitrarily; valid language tags survive the Wasm boundary, malformed tags fail explicitly, and natural-width overflow uses the same Float32 geometry as final layout. - -Four public-pipeline paragraphs produce twelve exact natural/wide/narrow contracts with grapheme- and UTF-16-safe runs, clusters, and lines, one broad shape per paragraph, and zero reshapes for the fixed corpus. Fixed-seed CJK mutations cover malformed surrogates, variation selectors, language tags, and constraints twice. Node, Chromium 149, and GPU-enabled Vitexec report one composite hash, 10,622 output bytes, 1,539,372 retained bytes, and 4,587,520 Wasm-memory bytes. The item adds no raster paging, rendering, fallback, or vertical layout. - -Roadmap item 6.1 completes the optional bitmap runtime module. It validates reciprocal font/raster identity, exact dense 20-byte records, absent-glyph sentinels, square strikes, embedded lossless linear R8 KTX2 dimensions and format, and page references before publishing a resource. Decode is transactional across pages and strikes, so any later failure disposes every `DataTexture` already created. Public `Text.rasterPixelRatio` defaults to one and is supplied explicitly by a rendering integration; it is raster-only state, so changing it rebuilds draw batches while reusing the paragraph and preserving logical CSS geometry. Bitmap targets the maximum run CSS size multiplied by that ratio, chooses the nearest strike with deterministic lower-strike ties, exposes the selected `strikePpem` on each zero-copy draw batch, uploads each page once, preserves glyph order through contiguous page runs, and emits instanced position, size, UV, and linear-color attributes. The core never reads browser DPR or installs input listeners. The baker records Zeno's integer mask placement in strike-pixel units; at native density every quad dimension therefore equals its atlas rectangle dimension. The shared TSL vertex graph snaps each projected edge to a physical framebuffer pixel before the same graph emits WGSL or fallback GLSL. The package-owned `@types/three` patch corrects `modelViewProjection` to its runtime `Node<'vec4'>` type and replaces the pathological node conditional tree; the compile fixture guards both upstream gaps. Independently fetched density strikes and external page residency remain explicitly deferred to Milestone 13. - -Roadmap item 7.2 adds an optional presentation seam to this bitmap subpath without changing core `Text`, React, paragraph layout, or the generic raster contract. A snapshot copies rendered glyph identities and their currently displayed origins but retains no renderer resources. A transition matches font handle, glyph ID, UTF-16 cluster, exact font-size bits, and occurrence ordinal, then updates only the target batch's existing origin attributes. Unmatched shaping changes stay at their newly committed positions; sizes, UVs, paint, shaping, line breaking, and the authoritative `ParagraphLayout` remain discrete. Target-origin storage is lazy, per-frame progress updates are allocation-free, stale batches reject mutation, and the unchanged TSL vertex graph applies the final physical-pixel snap. Integration tests cover exact midpoint interpolation, mid-transition continuation, topology changes, invalid progress, idempotent finish, and disposal. - -The canonical composed Inter fixture proves GLB → registry → public `Text` → HarfRust → paragraph layout → bitmap decode → GPU upload → instanced draw in the benchmark product. The five-lane benchmark ipsum produces 120 visible glyphs, zero missing glyphs, and one draw on both backends. Density fixtures carry 16 and 32 ppem strikes; exact-strike rendering keeps public geometry at 16 CSS px while selecting 16 device pixels at 1× and 32 device pixels at 2×. A record-level Rust invariant proves atlas and native plane dimensions are identical. The benchmark independently CPU-composes decoded atlas texels at snapped placements and requires every normalized GPU byte to match for both the full frame and a resized, intentionally clipped frame; WebGPU and WebGL2 produce the same full-frame hash at each DPR. Bitmap accepts fill and opacity but rejects outline and shadow through the optional raster paint-validation seam instead of silently discarding them. Hinted grayscale and four-phase coverage packing remain measured research, while LCD/ClearType rendering is an explicit non-goal. The [roadmap](../roadmap/roadmap.md) remains the only completion ledger. - -The five-line, 120-glyph text above is the bounded conformance specimen. The separate live benchmark ipsum exceeds 1,000 characters and renders 1,150 glyphs through the same one-draw public `Text` path. - -Paragraph layout is tiered by what each product depends on, so a change enters at its own tier instead of rebuilding the paragraph. Text analysis follows the text and its base direction, shaping adds the fonts, spans, and style topology, metrics add font size and spacing, the line plan adds the content box, and geometry adds alignment. A retained layout session holds the prepared paragraph across updates, so a content-box change never enters preparation and reuses the caches the paragraph already keeps, while font fallback reads shaped glyph identity rather than laying the paragraph out to locate `.notdef`. Positioning writes its output into typed arrays sized from the shaped runs and resolves a text offset to a cluster through a table built once per preparation, replacing a lower-bound search that ran twice at every cluster boundary of every glyph. Both position axes accumulate in double precision and narrow once, because alignment and justification read an axis back after storing it. - -`pnpm scripts run text:layout-benchmark` measures that path. It reports a median of warmed repetitions for each invalidation class separately, with the relative standard deviation beside it, because the classes invalidate different tiers and an average across them hides whichever one is slow. Boundary reshaping is gone: it requested the whole run as shaping context, which is the context the retained shape was produced with, so it returned the glyphs it already held on roughly every line of every layout. Measured on an identical workload against the pre-tiering commit at 25,515 glyphs, a reflow lays out in 8.12 ms against 110.40 ms, a resize in 11.98 ms against 103.54 ms, and a text edit in 31.85 ms against 109.66 ms, with the pinned layout hashes unchanged. Phase attribution came from opt-in spans that have since been removed once their evidence was recorded; reinstating them is a diagnostic change, not a shipped feature. - -## Package scripts - -| Script | Purpose | -| ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `build` | Check generated Unicode data, derive portable ABI contracts, emit ESM/declarations, compile the no-WASI Bitmap, MTSDF, Slug, and shaper Wasm modules, and optimize them with pinned Binaryen. | -| `check` | Run the complete test, main and Slug/TSL type-check, lint, and format gates. | -| `test` | Build once, then run compile-only public API fixtures, Rust/Wasm unit and integration gates, MTSDF evidence, Unicode 17 conformance, package/registration/golden tests, malformed artifacts, and deterministic fuzz smoke. | - -Run `pnpm scripts list text` from the workspace root to discover Unicode, fixture, bake-evidence, and explicit MTSDF diagnostic workflows. - -The [API contract](../planning/api-shapes.md) remains authoritative for public behavior; this concept explains the package that implements its current loading, baking, shaping, and paragraph surfaces. The [canonical roadmap](../roadmap/roadmap.md) alone owns program-wide completion status. - -[^bitmap-identity]: Raster-specific descriptor fields remain owned by this subpath and never enter a closed core union. - -[^bitmap-baker]: Artifact generation, validation, and generic composition are complete; GPU resource creation is deliberately deferred to the renderer milestone. - -[^node-host]: The Node host trusts selected installed baker code but authenticates every returned artifact; hostile baked assets are independently revalidated at the loader boundary. - -[^loader]: Raster-package schema and payload semantics remain in each module's `decode`; the generic registry validates only package-neutral container and reciprocal identity invariants. - -[^slug-contract]: Slug V0 identity is fixed independently from the optional baker and renderer modules. - -[^slug-validator]: Standalone validation authenticates embedded and external resource forms before runtime ownership begins. +Status: foundation cutover in progress; publishing-feature stacks follow after merge -[^slug-baker]: The Rust baker owns outline conversion, exact curve/band packing, and deterministic package construction. +## Ownership -[^slug-baker-host]: The TypeScript host owns direct-memory transfer, progress, errors, and cleanup without entering the renderer graph. +The package owns five runtime layers: -[^slug-runtime]: The public runtime subpath owns resource upload, batching, paint admission, and lifetime. +| Layer | Owner | Responsibility | +| ----------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| Font and raster loading | TypeScript core | Validate portable GLB assets, register shaping payloads, decode selected raster resources, and retain font identity. | +| Shaping and layout | Rust/Wasm | Unicode analysis, bidi, font fallback, shaping, line composition, positioning, ellipsis, and semantic query state. | +| Policy and render plan | Rust/Wasm | Interpret a validated renderer policy, pack canonical technique records, coalesce dirty ranges, and emit a compact command buffer. | +| Three.js integration | `@pmndrs/text/three` | Compile policy programs, resolve font/material resources, apply command-buffer deltas, upload dirty ranges, and maintain draw proxies. | +| React integration | `@pmndrs/text/r3f` | Reconcile React values into the same imperative `Text` and `TextGroup` objects. | -[^slug-shaders]: The internal shader directory preserves the copied analytic algorithm while adapting its storage and node graph to the package contract and installed Three.js release. +Rust remains `no_std + alloc` with the package allocator contract. It uses the existing compile-time direct-memory mapping +for font registrations and the single `text_update(requestOffset, requestLength)` export for retained engine sessions. +TypeScript does not independently shape, lay out, or pack paragraphs. -[^slug-outline-research]: The planning concept preserves the rejected mechanism, measurements, external implementation survey, uncertainty, and go/no-go criteria. +## Public package surfaces -[^typescript-go-node-variance]: The upstream issue identifies stable type ordering and variance computation over Three's augmented `Node` as the runaway checker path. +| Subpath | Purpose | +| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `@pmndrs/text` | Font/raster contracts, loading, fallback stacks, formatting helpers, paragraph inputs, layout-query values, and portable bakers. | +| `@pmndrs/text/three` | Three `FontLoader`, `Text`, `TextGroup`, material factories, and policy registration. | +| `@pmndrs/text/three/bitmap` | Bitmap technique, policy program, and canonical TSL shader. | +| `@pmndrs/text/three/msdf` | MSDF technique, policy program, and canonical TSL shader. | +| `@pmndrs/text/three/slug` | Slug technique, policy program, and canonical TSL shader. | +| `@pmndrs/text/r3f` | React Three Fiber ``, ``, and `useFont`. | +| `@pmndrs/text/raster/*` | Renderer-neutral Bitmap, MSDF, and Slug decoding and raster-technique contracts. | +| `@pmndrs/text/bakers/*` | Optional portable raster bakers and validators. | -[^definitelytyped-node-extras]: The upstream patch replaces the nested conditional with a keyed lookup map while retaining the same extension intersections. +`@pmndrs/text/typegpu`, the TypeScript paragraph engine, paragraph batches/attachments, direct shaping exports, and the +text-preparation Worker are removed. TypeGPU is a later adapter stack built against the Rust render plan; it is not a +compatibility wrapper over the removed batch model. + +## Retained frame transaction + +One `TextGroup` owns one Rust engine session. A traversal sends only changed paragraph sections: + +- text replacement sends text plus any dependent style/geometry state; +- font, spans, shaping style, paint, raster ratio, or material send style state; +- content-box changes send geometry; +- transform and visibility changes update Three's renderer-local sidecar without calling Wasm; +- an empty or normalized-equal update sends nothing. + +Rust publishes one revision containing: + +- engine and plan revision headers; +- physical-buffer allocation and retirement commands; +- coalesced per-buffer dirty byte ranges; +- resource bindings; +- ordered draw commands with technique/program, resource, material, transform, and clip identity; +- optional semantic measurement or inspection sections only when explicitly demanded. + +The Three executor does not infer paragraph layout from GPU records and does not maintain a parallel candidate/current +target state machine. It applies the Rust command buffer transactionally and retains only renderer resources required by +future deltas. + +## Renderer policy + +Each Three technique registers a static policy descriptor and a cold font compiler. Rust validates and interprets the +compiled policy; it never invokes a JavaScript callback in shaping, layout, or packing. + +The first-party policy can select indexed transform batching, direct per-draw transforms, or a hybrid. Indexed mode adds a +stable transform-table ID to each rendered glyph so compatible paragraphs may collapse into one draw. Direct mode splits +draws by transform for integrations that prefer ordinary object matrices. Policy programs may use ordered-direct or +stable-indirect physical storage. Stable draws carry one reserved u32 order buffer; Three validates its draw/primitive +addressing once, then uses the same logical-to-physical mapping for technique records, transform indices, explicit origin +queries, and third-party program material contexts. `TextGroup.compositing` determines whether Rust must preserve authored +ordering or may reorder independent work. Ordered-direct remains the first-party default until stable planning meets the +same tail-latency target. + +`materialId` is explicit through the frame ABI and render plan. Three maps it to a `defineTextMaterial()` factory. Material +identity may split draws without forcing a second copy of the canonical glyph buffers. + +## Font fallback and techniques + +`createFontStack()` accepts fonts from one runtime in explicit fallback order. Members may use different techniques. The +font carries both shaping identity and raster binding, so `Text` has no redundant technique property. Rust resolves the +font for each cluster and partitions the render plan according to the active renderer's supported technique programs. + +This permits an MSDF or Bitmap prose font to fall back to a Slug emoji font while keeping third-party renderers safe: an +unregistered technique fails at the policy boundary instead of producing an unsupported draw. +A public compiled-Wasm integration loads Bitmap Inter plus Slug Font Awesome, shapes one paragraph through that ordered +fallback stack, and observes two Rust-planned draws with exact Bitmap `vec2` and Slug `vec4` physical records. The +selected font binding—not a `Text` technique selector—carries the renderer program and resource. + +## Semantic queries + +Ordinary rendering requests no layout readback. `Text.measureLayout()` explicitly requests aggregate measurements and +counts; `Text.inspectLayout()` additionally copies line and glyph arrays. Query results are cached by committed revision. +If a query observes pending changes, it synchronizes the containing Rust session once and the following render traversal +reuses that publication. + +The semantic values preserve information useful to callers: + +- resolved box dimensions remain distinct from intrinsic content extents; +- clipping does not discard off-viewport semantic layout; +- semantic truncation retains visible positioned lines while reporting intrinsic overflow; +- glyph/font identity, UTF-16 clusters, stable IDs, flags, line membership, and positioned origins remain available on + explicit inspection; +- presentation origin overrides never mutate authoritative Rust layout. + +## Wasm memory and copying + +The host pins request/result staging views and re-pins after any `memory.grow()`, because growth detaches existing views. +Growth is permitted only at the `text_update` boundary. Result capacity is negotiated and retried without publishing a +partial revision. + +WebGPU may alias compatible Wasm-backed typed arrays. Three's WebGL2 PBO path owns a padded array and therefore requires +one retained copy. The architecture does not add complexity to pretend WebGL2 can preserve a Wasm alias it replaces. + +Each raster baker's Rust contract generator emits both published JSON and an exact typed TypeScript constant. Bitmap, +MTSDF, and Slug may own different internal ABI shapes—MTSDF exposes both its glyph generator and artifact baker—but their +TypeScript hosts consume those generated constants directly and validate the declared exports once during construction. +There are no instance-ignoring runtime ABI readers. Package builds isolate the distributable MTSDF and Slug +`artifact-baker` feature sets from kernel-only test targets and reject an optimized module missing any contract-declared +artifact export, preventing Cargo's shared top-level artifact path from silently publishing a smaller test variant. + +The renderer-neutral core owns the completed asynchronous Worker transfer contract: it copies opaque frame bytes once +into a bounded worker-owned transferable pool, applies explicit backpressure, and requires root to transfer each retired +buffer back so reuse or final collection occurs in the owning realm. Adopting that mode in the Three host is deferred; +the synchronous Three path does not restore the deleted TypeScript shaping Worker. TypeGPU is likewise a later adapter +slice built directly against the Rust render plan. + +## Current correctness evidence + +The foundation currently has: + +- 154 passing Rust engine tests, including exact retained-cluster, revision-range, immediate line-convergence, and + later cursor-convergence regressions; +- the package JavaScript/integration gate passing through the single-path public exports; +- exact retained Amiri bidi, policy, ellipsis, clipping, UIKit-layout, and CJK contracts exercised by the browser + `paragraph-contracts` target through public `FontLoader`, `Text`, `TextGroup`, `measureLayout()`, and `inspectLayout()`; +- 32/32 pixel-exact public Bitmap WebGL2 frames against the independent CPU oracle, including resize and clipping, with + zero differing channel bytes and pinned SHA-256 `a47930d3…15e893`; +- source-font SHA-256, registered shaping hashes, and HarfRust/HarfBuzz oracle identities authenticated independently of + the browser behavior check; +- byte-identical Bitmap, MSDF, and Slug packing/consumer gates retained elsewhere in the benchmark suite. + +The browser paragraph target is fully green under the explicit f32 frame contract. The former UIKit mismatch came from a +fixture generated by the deleted TypeScript path, where authored JavaScript-double line height survived until final array +publication. The retained engine deliberately receives that style scalar as f32, accumulates line positions in f64, and +narrows published values once. An independent calculation from the f32 line box reproduces the corrected final baseline, +centered glyph row, content height, and complete layout hash exactly; no runtime precision or tolerance changed. + +## Legacy-path and duplication audit + +The Rust command buffer is the only glyph-packing implementation. The former TypeScript `RasterRuntime`, raster +candidate/commit transaction, `select`, `createStorage`, and `writeStorage` surfaces are deleted from production source +and public exports. Current raster techniques own identity, artifact decoding, retained CPU resource data, and disposal; +Rust policy programs own instance packing and dirty-range publication. The package gate retains production render-plan, +font-binding, Three execution, artifact-validation, and Unicode conformance coverage instead of test-only TypeScript +packers. + +A Mori 0.19.1 production-source scan (review profile, same-language threshold 0.85, minimum 40 tokens) corroborated the +deleted parallel path and identified exact shared planner machinery. Ordered and stable planning now use one retained +epoch-cleared identity set, one plan-error and result-capacity classifier, one cold physical-buffer allocator, one inline +draw-span predicate, and one deliberately out-of-line final primitive/draw emitter. The optimized Wasm moved from +1,160,505 raw / 442,612 gzip / 348,594 Brotli bytes to 1,159,317 / 442,284 / 347,850, saving 1,188 / 328 / 744 bytes. + +The remaining similar bodies are not two implementations of one behavior. Ordered-direct compacts physical records in +draw order; stable-indirect preserves slots, publishes a separate order buffer, and quarantines retirements until renderer +acknowledgement. A symbol-bearing optimized build attributes 33.3 KiB of function bodies to ordered planning and 50.1 KiB +to stable planning; those complete strategy totals are upper bounds, not deduplicable byte estimates. Their draw compilers +resolve different physical address spaces. Normalizing those addresses into another staging array or dispatching through a +dynamic strategy interface would add hot-path memory traffic or indirect calls, so the audit retains the strategy-local +loops and shares their exact invariants instead. The 22k-glyph complete Rust benchmark remains within adjacent-run noise; +a 20-warmup/51-sample cold check measured 15.452 ms median / 15.670 ms p95 at 1.0% RSD. + +## Current size and performance evidence + +The latest checked package-size record after the baker ABI cleanup reports: + +| Graph | Raw | gzip | Brotli | +| --------------------------------------- | ----------: | --------: | --------: | +| Core JavaScript plus shaper Wasm | 1,247,715 B | 460,130 B | 363,319 B | +| Three adapter plus core and shaper Wasm | 1,488,669 B | 498,606 B | 395,276 B | + +Three, React, and React Three Fiber are optional peers and excluded from these bundle totals. JavaScript and Wasm are +measured independently and then summed because browsers transfer them as separate assets. + +The optimized shaper is 1,159,317 raw / 442,284 gzip / 347,850 Brotli bytes. The renderer-neutral JavaScript graph is +88,398 raw / 17,846 gzip / 15,469 Brotli, and the complete Three JavaScript graph is 329,352 raw / 56,322 gzip / +47,426 Brotli. Deleting the legacy TypeScript raster packing/lifecycle path reduced the measured core total from 461,917 +to 460,901 gzip bytes and the complete Three total from 501,815 to 498,922 gzip bytes; the later shared-emitter and stable +range-scan work reduces those totals to 460,416 and 498,437 gzip bytes. The homogeneous-policy dispatch and dirty-range +alignment correction moved those totals to 460,458 and 498,479 gzip bytes; the focused planner deduplication and current +Three graph now measure 460,130 and 498,606 gzip bytes. The final renderer-lifecycle fixes and exact WebGL2 PBO range +copy leave core and Wasm byte-identical and add 1,351 raw / 230 gzip / 146 Brotli bytes to the complete Three graph. + +WebGPU continues to alias canonical plan arrays directly. Three's WebGL2 PBO builder replaces a storage attribute's +array with power-of-two-padded retained texture storage, so later Rust patches copy only their dirty byte ranges into +that detached upload view before invalidating its texture. A focused integration fixture simulates the replacement and +proves exact canonical/upload equality with untouched padding. The complete 48-cell presentation matrix keeps every +Bitmap, MTSDF, and Slug workload visible on WebGPU and forced WebGL2; this is the deliberate one-copy WebGL2 fallback, +not another renderer-side layout or packing path. +The corrected complete MTSDF baker remains 552,025 raw / 215,030 gzip / 168,758 Brotli bytes; the earlier 52 KiB +observation was a kernel-only test artifact that reused the distributable Cargo target directory. + +The public Three benchmark now supports an outside-only mode that leaves the internal phase collector disabled and wraps +one `updateMatrixWorld()` call with a host timer. An eight-warmup/31-sample run over 25,515 positioned glyphs measured +19.42/6.59/3.10/14.24 ms median and 21.00/6.86/4.75/15.26 ms p95 for cold/font-size/width/text updates. Those values cover +frame preparation, the complete Rust transaction and render-plan publication, and Three plan application; they exclude +GPU submission. An adjacent phase-instrumented run was indistinguishable within process noise. Those temporary profiler +exports, calls, branches, and clock reads are now absent from the package source and clean publishing output; benchmark +workload markers and the direct Wasm timer remain outside the shipped library. + +After the final plan-application lifecycle audit, Three sizes indexed transforms from live paragraph IDs instead of +scanning every glyph record in JavaScript. A renderer failure retains an unconsumed owned plan for zero-crossing retry; +dirty upload ranges accumulate across presentation restoration and Rust patches; buffer/resource generations dispose +only their exact dependent materials; direct materials survive indexed transform-table growth; and loaded-font disposal +removes its decoded renderer resources. The unchanged eight-warmup/31-sample public 25,515-glyph lane measures +17.84/6.32/3.04/13.84 ms medians and 18.99/6.64/4.60/14.01 ms p95 for cold/font-size/width/text. The adjacent recorded +run was 19.42/6.59/3.10/14.24 ms median; process-separated samples support no regression and a plausible cold-path +reduction, not causal attribution. + +The canonical direct benchmark loads the packaged `dist/text_shaper.wasm`: Cargo release optimization, LTO, one codegen +unit, default-on `simd128`, stripping, and `wasm-opt -Oz --enable-simd` have already run. On the identical Rust artifact, +Binaryen `-O3` and `-O4` added 11,976 and 13,661 raw bytes without a demonstrated latency improvement, so `-Oz` remains +the evidence-backed setting. The `<4 ms` warm-path target and stable p95 closure remain open. + +The final sequential eight-warmup/31-sample checkpoint uses the unchanged 22,000-target corpus, which resolves to 25,515 +positioned and 21,805 renderable glyphs. Values below are medians in milliseconds for the complete packaged Rust +transaction and technique-specific render plan; GPU submission is outside this direct benchmark. + +| Technique | Cold | Font size | Column width | Suffix edit | Local edit | Middle splice | +| --------- | ----: | --------: | -----------: | ----------: | ---------: | ------------: | +| Bitmap | 15.90 | 6.04 | 2.78 | 13.48 | 1.18 | 8.52 | +| MTSDF | 16.50 | 6.41 | 2.73 | 13.52 | 1.18 | 8.73 | +| Slug | 16.73 | 6.64 | 2.96 | 14.37 | 1.31 | 8.94 | + +The migration comparison is checked evidence rather than a reconstructed recollection. Commit `90964be0`, the exact +`feat/three-api` base, was rebuilt in an isolated worktree using its own lockfile and original +`text:layout-benchmark` workflow on this Darwin arm64 host. At the same eight-warmup/31-sample cadence its retained +TypeScript path measured 58.32/12.09/9.15/39.61 ms for cold/font-size/width/suffix-edit medians. The current Bitmap, +MTSDF, and Slug records all use one byte-identical optimized shaper Wasm and the complete `text_update` plus +technique-specific Rust render plan. The base reports 25,515 positioned glyphs; the current plan reports 21,805 +renderable instances from the unchanged 22,000-glyph target because it omits non-rendering glyphs from GPU records. + +The exact [TypeScript baseline](../../apps/benchmarks/fixtures/results/typescript-layout-baseline-90964be0-darwin-arm64.json) +and current [Bitmap](../../apps/benchmarks/fixtures/results/rust-layout-bitmap-0bdb9e93-darwin-arm64.json), +[MTSDF](../../apps/benchmarks/fixtures/results/rust-layout-mtsdf-0bdb9e93-darwin-arm64.json), and +[Slug](../../apps/benchmarks/fixtures/results/rust-layout-slug-0bdb9e93-darwin-arm64.json) records are authenticated by +the benchmark fixture gate. Every comparable median is faster through Rust: Bitmap is 3.67× faster cold, 2.00× on font +size, 3.29× on width, and 2.94× on suffix edit; even the slowest technique for each case remains 3.49×, 1.82×, 3.09×, +and 2.76× faster. This proves the migration comparison on this machine; it does not close the stricter p95-under-4-ms +objective. Local-edit p95 remains about 6 ms and high-variance, while width p95 ranges from 4.29 to 4.75 ms across +techniques. + +The preceding unchanged 22,000-glyph localized-edit lane measured the complete production `text_update` plus Bitmap render +plan at 2.607 ms median / 6.184 ms p95 after 40 warmups over 101 updates. The fast ASCII-letter path reuses Unicode and +bidi state and recomposes until the line cursor converges; punctuation and spacing edits deliberately retain the full +break-sensitive path, so the 42.4% RSD describes remaining workload classes rather than a completed latency result. The +optimized SIMD shaper is 1,147,266 raw bytes. Five patches write roughly 1.2 KiB per update, and the retained high-water +mark remains 80.38 MiB. Median is now below 4 ms, but p95 and memory-growth gates remain open. + +Policy gather now retains complete prior input lanes by committed session/policy/capability revision. Zero-change glyphs +reuse them without binding or policy work; changed glyphs update only reachable lanes. A resource or draw-storage key +change retains the verified prefix and fully rebuilds the suffix, preserving correct replacement-buffer inputs without +double-scanning the prefix. The same production lane now measures 1.314 ms median / 5.863 ms p95 with 76.2% RSD, five +patches, and roughly 1.2 KiB written. The 1,153,122-byte optimized shaper is 5,856 bytes larger than the prior checkpoint, +and retained high-water memory is 80.19 MiB. The fast class approaches 1 ms; the break-sensitive p95 remains open. + +The ordered-direct compiler additionally retains committed glyph-to-batch and glyph-to-slot topology while policy, +capability, glyph count, and every physical storage key remain compatible. It still validates every glyph and stable +identity; the first storage mismatch falls back to complete batch discovery. Three consecutive optimized runs measured +1.164/5.761, 1.153/5.740, and 1.155/5.738 ms median/p95, versus the preceding 1.314/5.863 ms checkpoint. The optimized +shaper is 1,157,311 raw bytes, a 4,189-byte increase, and retained high-water memory is 79.81 MiB. The repeated median gain +is established; the roughly 5.74 ms p95 and 81.4–81.6% RSD still fail the tail-latency gate. + +The direct benchmark also keeps an independent middle-splice lane. On the current optimized artifact, a sequential +eight-warmup/31-sample run measures ordered-direct insertion/deletion at 8.452/9.033 ms median/p95 and 511.3 KiB written +because following physical records move. Stable-indirect reduces that publication to 452 B and measures 9.372/9.583 ms. +The earlier 51.067 ms figure was the maximum selected as p95 from only 11 samples and did not reproduce. This establishes +the storage-policy tradeoff without changing the default: stable planning remains optimization/correctness work, and +chunk-local text storage cannot be claimed as the dominant splice fix while the physical plan has this cost. + +Three now consumes stable-indirect plans through one shared record-addressing abstraction rather than technique-specific +branches. A Rust/Three integration regression proves lifecycle reorder mutates only the order table and preserves physical +glyph bytes and draw objects. A two-record GPU oracle makes slot zero green and slot one red, then renders logical slot zero +through `order[0] = 1`: forced WebGL2 and hardware WebGPU both return 16/16 exact red pixels and the same readback hash. +The complete ordered Bitmap/MSDF/Slug/custom-material matrix remains green on both backends. A strict 31-sample stable +run exposed a quadratic dependency scan: each changed physical range rescanned every slot write. Binary-partitioning the +sorted writes to the requested range reduces stable font-size from 350.136 to 7.982 ms median and column resize from +49.636 to 3.767 ms; localized edit is 2.172/6.628 ms and splice is 9.372/9.583 ms median/p95. Stable no-op remains +1.083 ms versus ordered-direct's 0.001 ms, so stable remains an explicit policy rather than the first-party default. The +sequential benchmark high-water marks are 107.56 MiB ordered and 114.25 MiB stable; retained-memory right-sizing remains +open and neither figure is presented as ordinary application demand. + +The first-party policy declares one allocation strategy for every registered technique. Rust now resolves that uniform +strategy once per update instead of looking up a program for every glyph before the selected planner performs its own +validated compilation. Mixed-strategy policies retain the per-glyph discovery path and stop once both strategies are +observed. A five-warmup/11-sample ordered run measures 6.005 ms font-size, 2.813 ms column-resize, 1.212 ms localized-edit, +and 8.281 ms middle-splice medians. The adjacent prior medians were 6.178, 2.817, 1.353, and 8.452 ms; these short runs +show no regression and suggest a small scan reduction, but do not establish a latency win. The same change preserves +whole-buffer update alignment after dirty-range promotion and costs 182 raw / 42 gzip / 233 Brotli bytes. + +Three retains pending attribute upload ranges until its renderer consumes them. Consecutive Rust publications, +presentation-origin restoration, and a retry before rendering coalesce overlapping or adjacent ranges instead of +clearing earlier writes. Paragraph transform identities return to a binding-local free list only after the Rust removal +transaction commits, bounding the indexed transform table under create/dispose churn. A disposed `Text` may remain in +the Three scene graph until its host detaches it without poisoning the surviving batch, and batch-wide runtime validation +runs inside the group error boundary before reconciliation mutates ownership. An internal semantic-query contract failure +advances the observed engine revision and retains unexpected render work for the ordinary zero-crossing retry path rather +than leaving the Wasm session permanently revision-conflicted. The focused public integration exercises all four +lifecycles, and the complete package gate passes 158 Rust and 165 Node tests. The canonical direct benchmark now defaults +to eight warmups and 31 measured samples so its reported p95 is not the maximum of an 11-sample run. + +The Wasm boundary also retains fixed-seed mutation coverage for the two replacement parsers. Sixty-four policy and frame +mutations run twice with identical status sequences, include accepted and rejected paths, and prove that every malformed +input leaves a fresh valid transaction usable. This supplements the Rust parser unit cases at the compiled ABI rather +than restoring any deleted `shapeBatch`, `reshapeRanges`, or TypeScript paragraph state machine. The package gate now +contains 165 Node integration tests plus three deterministic fuzz-smoke tests. + +## Merge gates still open + +Before the foundation stack is publishable: + +- finish the stale-code and stale-documentation audit; +- regenerate affected ABI, optimized Wasm, package-size records, and package digests from source; +- run package checks, strict Rust checks, benchmark conformance, packed consumers, WebGPU and forced-WebGL2 live rendering, + and the full repository gate; +- run the unchanged 25k-glyph comparison with enough samples and report cold, font-size, width, and text-update tables; +- profile and reduce any path that misses the target without weakening correctness; +- run a read-only Claude adversarial review if the CLI is available, then address supported findings; +- commit and push the coherent stack with a clean worktree. + +The query/candidate-adoption API and the two publishing-feature stacks remain follow-on work after this foundation merge. +They must reuse retained Rust paragraph state and the same render-plan architecture rather than reintroducing a second +layout path. diff --git a/docs/planning/api-shapes.md b/docs/planning/api-shapes.md index a6ca6974..1dbf531c 100644 --- a/docs/planning/api-shapes.md +++ b/docs/planning/api-shapes.md @@ -34,6 +34,10 @@ generated: at: '2026-08-07T05:01:15Z' --- +> Historical merged-v0 fixture. Names such as `RasterRuntime`, `RasterModule`, and the TypeScript paragraph target below +> are retained only for migration archaeology; they are not current exports. Use +> [the current package reference](../packages/text.md) for the single Rust render-plan path. + # Merged v0 runtime and bake API fixture Status: merged v0 surfaces are implemented but unreleased; sections labeled deferred remain proposals diff --git a/docs/planning/core-api.md b/docs/planning/core-api.md index 69ce3994..f316567e 100644 --- a/docs/planning/core-api.md +++ b/docs/planning/core-api.md @@ -1,74 +1,56 @@ --- type: API Specification title: Core text API -description: Canonical API and rationale for loading fonts, composing ordered same-technique font stacks, editing paragraphs, synchronizing shaping, and producing renderer-ready glyph batches. +description: Reference for renderer-neutral font loading, raster selection, mixed-technique fallback, paragraph inputs, and explicit layout-query values. documentation_type: reference -tags: [api, fonts, shaping, paragraphs, batching, rendering, async] +tags: [api, fonts, shaping, paragraphs, layout, rendering] status: stable sources: - id: decision-register resource: decision-register.md title: Accepted architectural decisions - - id: engine-contract - resource: engine-integration-contract.md - title: Engine integration contract - - id: raster-technique - resource: raster-technique-api.md - title: Raster technique and engine resource API - - id: extraction-plan - resource: engine-integration-boundary.md - title: Renderer-neutral extraction plan - - id: three-api - resource: three-api.md - title: Three.js text API - - id: typegpu-api - resource: typegpu-api.md - title: TypeGPU raster programs and text engine - - id: gpucat-integration - resource: gpucat-integration.md - title: External gpucat integration fitness plan - - id: current-api - resource: api-shapes.md - title: Existing API migration fixture - - id: current-shaper - resource: ../../packages/text/src/shaper.ts - title: Current synchronous shaper - - id: current-paragraph - resource: ../../packages/text/src/paragraph.ts - title: Current paragraph implementation - - id: current-raster - resource: ../../packages/text/src/raster.ts - title: Current raster transaction contract + - id: rust-engine + resource: rust-layout-engine.md + title: Rust text engine and render-plan ABI + - id: current-runtime + resource: ../../packages/text/src/text-runtime.ts + title: Current text runtime + - id: current-font-selection + resource: ../../packages/text/src/loaded-font.ts + title: Loaded-font ownership and fallback + - id: current-properties + resource: ../../packages/text/src/text-properties.ts + title: Current paragraph properties + - id: current-layout-query + resource: ../../packages/text/src/layout.ts + title: Current layout-query values + - id: current-three-api + resource: ../../packages/text/src/three.ts + title: Current Three.js exports generated: by: openai-codex/gpt-5.6 - at: '2026-08-07T04:31:24Z' + at: '2026-08-09T18:30:00Z' --- # Core text API -This is the canonical public API and the authority for implementation. +`@pmndrs/text` owns portable font loading, raster-technique selection, font fallback, paragraph input types, and layout +query result types. Rust owns shaping, bidi, line composition, positioning, instance packing, and the renderer-directed +command buffer. A renderer integration owns synchronization and GPU realization. -```ts -fontFile - -> bakeFont() // optional build-time work - -> runtime.loadFont() // explicit asynchronous loading - -> createFontStack() // optional ordered missing-glyph resolution - -> runtime.createParagraphBatch()// one intentional render phase - -> paragraph.text = next // cheap desired-state mutation - -> runtime.update() // synchronous synchronization point - // or runtime.updateAsync() // asynchronous synchronization point - -> PreparedGlyphBatch[] // core-partitioned GPU instance data - -> PreparedGlyphRun[] // ordered text runs with resolved render intent - -> engine draw compiler // compatible pipelines, effects, and final draws -``` +Applications using Three.js normally import scene objects from `@pmndrs/text/three` or React components from +`@pmndrs/text/r3f`; they do not drive the Rust engine directly. -## The complete API +## Runtime and font loading ```ts +interface TextRuntimeOptions { + readonly registry?: FontRegistry; + readonly wasm?: BufferSource | WebAssembly.Module; +} + interface TextRuntime { - readonly current: TextRuntimeRevision; - readonly hasPendingChanges: boolean; - readonly isPreparing: boolean; + readonly registry: FontRegistry; readonly disposed: boolean; loadFont( @@ -76,111 +58,34 @@ interface TextRuntime { options?: { readonly signal?: AbortSignal }, ): Promise>; - createParagraphBatch( - options: ParagraphBatchOptions, - ): ParagraphBatch; - - update(): TextRuntimeRevision; - - updateAsync(options?: AsyncTextUpdateOptions): Promise; - updateAsync(callback: TextUpdateCallback): void; - updateAsync(options: AsyncTextUpdateOptions, callback: TextUpdateCallback): void; - - subscribe(listener: (revision: TextRuntimeRevision) => void): () => void; dispose(): void; } -interface TextRuntimeOptions { - readonly registry?: FontRegistry; - readonly shaper?: RuntimeShaper; - readonly async?: Readonly<{ - readonly worker?: TextPreparationWorker; - readonly createWorker?: () => TextPreparationWorker; - }>; -} - -interface LoadedFontRequest { - readonly input: - | { readonly baked: string | URL } - | { readonly source: string | URL; readonly runtimeBake: RuntimeFontBake }; - readonly raster: { - readonly technique: Technique; - readonly options?: RasterOptionsOf; - }; -} - -interface RuntimeFontBakeRequest { - readonly source: Uint8Array; - readonly sourceUrl: string; - readonly bakedUrl?: string; - readonly signal?: AbortSignal; -} - -type RuntimeFontBake = (request: RuntimeFontBakeRequest) => Promise; - -interface TextPreparationWorker { - postMessage(message: unknown, transfer?: readonly Transferable[]): void; - addEventListener(type: 'message', listener: (event: MessageEvent) => void): void; - removeEventListener(type: 'message', listener: (event: MessageEvent) => void): void; - addEventListener(type: 'error', listener: (event: ErrorEvent) => void): void; - removeEventListener(type: 'error', listener: (event: ErrorEvent) => void): void; - terminate(): void; -} - declare function createTextRuntime(options?: TextRuntimeOptions): Promise; ``` -Runtime options provision capabilities. They do not choose whether every update is synchronous or asynchronous. That -choice belongs to each `update()` or `updateAsync()` call. `createTextRuntime()` takes exclusive lifecycle ownership of an -injected registry, shaper, worker, or worker produced by `createWorker`; callers must not share those objects with another -runtime or dispose them independently. - -`AnyRasterTechnique`, `RasterDataOf`, `RasterBindingOf`, and `GlyphBatchStorageOf` come from the portable -[raster technique API](raster-technique-api.md). A technique owns artifact decoding, physical glyph-resource selection, and -canonical CPU instance packing without importing a rendering engine. - -## Bake and load explicitly +The default runtime instantiates the packaged `text_shaper.wasm`. Supplying `wasm` is intended for controlled builds, +tests, and compile-time SIMD variants. A runtime owns its Rust registration domain and all fonts loaded through it. ```ts -import { rasterBake } from '@pmndrs/text'; -import { bakeFont } from '@pmndrs/text/bake'; -import mtsdfBaker from '@pmndrs/text/raster/mtsdf/baker'; +type LoadedFontInput = + | { readonly baked: string | URL } + | { readonly source: string | URL; readonly runtimeBake: RuntimeFontBake }; -await bakeFont({ - input: new URL('./Inter-Regular.ttf', import.meta.url), - output: new URL('./Inter.font.glb', import.meta.url), - font: { fontFaceIndex: 0 }, - rasters: [ - rasterBake(mtsdfBaker, { - packaging: { artifact: 'embedded', pages: 'embedded' }, - options: undefined, - }), - ], -}); +interface LoadedFontRequest { + readonly input: LoadedFontInput; + readonly raster: { + readonly technique: Technique; + readonly options: RasterOptionsOf; + }; +} ``` -`@pmndrs/text/raster/mtsdf` is the intentional target-v1 name. The merged v0 package still exports the historical -`@pmndrs/text/raster/msdf` spelling even though its artifact is MTSDF; migration removes that alias when the v1 surface -lands. +`baked` loads a portable GLB artifact. `source` requires an explicit runtime baker and never silently adds a baker to the +consumer graph. The request selects Bitmap, MSDF, Slug, or a third-party raster technique independently for each loaded +font. -Baking produces font metrics, glyph records, and technique resources before the application runs. Runtime fallback may -perform the same bake in a Worker, but loading remains explicit in either case. - -```ts -import { createFontStack, createTextRuntime, span, txt } from '@pmndrs/text'; -import { mtsdf } from '@pmndrs/text/raster/mtsdf'; - -const runtime = await createTextRuntime({ - async: { - createWorker: () => new Worker(new URL('./text-worker.js', import.meta.url)), - }, -}); - -const inter = await runtime.loadFont({ - input: { baked: '/fonts/Inter.font.glb' }, - raster: { technique: mtsdf }, -}); -``` +## Loaded fonts and fallback ```ts interface LoadedFont { @@ -192,242 +97,28 @@ interface LoadedFont { readonly disposed: boolean; dispose(): void; } -``` - -`loadFont()` completes after shaping data and the selected technique data are decoded into renderer-neutral CPU state. It -does not create textures, buffers, pipelines, materials, meshes, entities, or scene objects. - -## Compose one logical font with fallback - -A `FontStack` is one immutable logical font choice. Its first concrete font is primary; later fonts resolve missing glyphs -in order. A single loaded font already satisfies the same text-facing contract and needs no wrapper. - -```ts -const noto = await runtime.loadFont(notoMtsdfRequest); -const amiri = await runtime.loadFont(amiriMtsdfRequest); -const iconMtsdf = await runtime.loadFont(iconMtsdfRequest); - -const uiFont = createFontStack(inter, noto, amiri); -const iconFont = iconMtsdf; -``` - -```ts -type FontSelection = LoadedFont | FontStack; interface FontStack { - readonly technique: Technique; readonly fonts: readonly [LoadedFont, ...LoadedFont[]]; } -declare function createFontStack( - primary: LoadedFont, - ...fallback: readonly LoadedFont>[] -): FontStack; -``` - -Every concrete font must use the same technique. TypeScript rejects a mixed stack through `NoInfer`; runtime validation -provides the same guarantee to JavaScript and untrusted boundaries. The immutable stack owns no font lifecycle. Adding a -paragraph acquires a lease on every concrete font in its selection until that paragraph or its owning batch is disposed. -`LoadedFont.dispose()` fails while any live paragraph lease remains, so disposal can never silently turn fallback into a -missing glyph. A stack containing a successfully disposed member is rejected when used to create or update a paragraph. - -```ts -createFontStack(interMtsdf, iconBitmap); // compile-time error and runtime rejection -``` - -A renderer that combines Bitmap and Slug data is a new technique with its own artifacts, instance schema, resource -bindings, and shader. It is not a font stack that mixes the existing Bitmap and Slug techniques. - -## Create an intentional paragraph batch - -A paragraph batch contains paragraphs that the application permits core to order and submit as one render phase. - -```ts -const worldText = runtime.createParagraphBatch({ - technique: mtsdf, -}); -``` - -```ts -interface ParagraphBatchOptions { - readonly technique: Technique; - readonly capacity?: GlyphBufferCapacity; - readonly rasterPixelRatio?: number; - readonly renderVariant?: Variant; -} - -interface GlyphBufferCapacity { - readonly size: number; - readonly policy: 'grow' | 'chunk' | 'fixed'; -} - -interface ParagraphBatch { - readonly runtime: TextRuntime; - readonly technique: Technique; - readonly capacity: GlyphBufferCapacity; - readonly current: PreparedParagraphBatchRevision; - readonly paragraphCount: number; - readonly hasPendingChanges: boolean; - readonly preparationError: TextPreparationError | undefined; - readonly disposed: boolean; - - rasterPixelRatio: number; - renderVariant: Variant | undefined; - - add(properties: ParagraphProperties): Paragraph; - setCapacity(capacity: GlyphBufferCapacity): void; - has(paragraph: Paragraph): boolean; - subscribe(observer: ParagraphBatchObserver): () => void; - attach( - target: ParagraphBatchTarget, - ): ParagraphBatchAttachment; - dispose(): void; -} - -interface ParagraphBatchObserver { - next(revision: PreparedParagraphBatchRevision): void; - complete(): void; -} +declare function createFontStack[]>( + primary: LoadedFont, + ...fallback: Fallback +): FontStack>; ``` -`subscribe()` synchronously replays `current`, then reports each later published batch revision exactly once. Disposing the -batch calls `complete()` exactly once; unsubscribing is idempotent and prevents later `next()` or `complete()` calls. This -public observation contract is sufficient to build renderer coordination without access to shaping, allocation, or other -batch internals. - -`attach()` is the retained convenience for that coordination. It validates technique compatibility, records published -source revisions, exposes explicit renderer-owned `prepare()` and `commit()` boundaries, and couples attachment disposal -to batch disposal. It is policy built on the -same public revisions and lifecycle events, not a second shaping or batching API. The exact target contract is specified in -the [engine integration contract](engine-integration-contract.md). +Fallback order is explicit. Every member must belong to the same runtime, but members may use different raster +techniques. This permits, for example, an MSDF prose font followed by a Slug color-emoji font. The active renderer must +have a policy program and material implementation for every selected technique; the maintained Three integration ships +Bitmap, MSDF, and Slug support. -### Use the default or preallocate explicitly +A loaded font retains its registered font, raster resource, and runtime until disposed. Live `Text` objects lease their +fonts; disposing a leased font throws `FontLeaseError` instead of invalidating retained Rust state. -Omitting `capacity` uses `{ size: 4_096, policy: 'chunk' }`. Core allocates storage lazily when the first glyph resolves to -a physical font resource. Paragraph handles and paragraph metadata grow normally; only glyph-instance storage has a -capacity policy. +## Paragraph input ```ts -const denseText = runtime.createParagraphBatch({ - technique: mtsdf, - capacity: { size: 20_000, policy: 'chunk' }, -}); -``` - -`size` applies independently to every physical technique/resource buffer produced beneath the logical paragraph batch. It -is not a total glyph limit for the paragraph batch. Under `chunk`, core preserves existing storage and allocates another -`size`-slot buffer when one fills. Under `grow`, core transactionally replaces a full buffer and doubles its capacity until -the pending glyphs fit. Under `fixed`, exceeding `size` fails preparation and preserves the last published revision. -Ordered glyph runs make cross-buffer paragraph and fallback-font order explicit. - -`ParagraphBatch.add()` cannot reject a capacity overflow because fallback, shaping, wrapping, and later mutations determine -the physical per-resource glyph demand. `update()` or `updateAsync()` discovers overflow after shaping but before -publication. Fixed overflow returns a typed `capacity-exceeded` preparation failure with the batch, configured limit, the -maximum per-resource requirement, and every overflowing physical resource. One resize to `error.required` -therefore satisfies the complete shaped generation rather than revealing overflows one at a time. The complete prior -runtime revision remains current; on a first update no partial revision becomes visible. Desired state remains available -for correction or an explicit capacity change. - -The first failing synchronization throws or rejects and records the error on `batch.preparationError`. That exact failed -desired generation is then latched rather than remaining eligible work: `batch.hasPendingChanges` is false when its only -unpublished state is the unchanged failure, and later runtime updates may publish other dirty batches. Any relevant -paragraph or membership mutation clears the latch and schedules a new attempt. Successful publication clears -`preparationError`. Calling `setCapacity()` with a different normalized capacity also clears the latch and schedules one -new attempt while the last committed revision remains live. - -`runtime.hasPendingChanges` and `batch.hasPendingChanges` report unpublished desired work that the next synchronization may -attempt. A latched unchanged failure reports false; its retained `preparationError` is the observable state. `isPreparing` -is true only while an asynchronous candidate is actively shaping or awaiting its Worker result. It becomes false on -publication, failure, abort, or supersession and is independent of a latched error. - -Resize a batch when an application wants to replace a fixed allocation explicitly: - -```ts -worldText.setCapacity({ size: 40_000, policy: 'fixed' }); -runtime.update(); - -worldText.has(label); // true: batch and paragraph identity did not change -``` - -`setCapacity()` validates and records the normalized requested capacity synchronously but does not mutate published -canonical storage. The next `update()` or `updateAsync()` reuses compatible shaping and layout results, stages replacement -storage, and atomically publishes it only when complete. Failure preserves the previous revision and every handle. -Existing attachments record the new source revision. Each target stages replacement engine buffers when its owner next -calls `prepare()`, commits at its safe frame boundary, and retires old buffers after its fences. The `ParagraphBatch`, its `Paragraph` handles, -subscriptions, attachments, desired state, order, glyph overrides, and font leases never change identity. - -Changing from `fixed` to `grow` or `chunk`, growing a fixed size, and deliberately shrinking are all explicit capacity -changes. A shrink that cannot hold the desired generation reports `capacity-exceeded` at synchronization and retains the -previous complete revision. Passing the current normalized capacity is a no-op and does not retry a latched failure. - -Every non-no-op capacity change creates a new physical-allocation generation at synchronization. Core repacks all live -slots, retires every old `GlyphBatchKey`, increments `GlyphBatchKey.generation`, and interns fresh keys. This -gives targets one unambiguous replacement signal. `chunk` numbers are reassigned densely from zero per resource in the new -generation; semantic paragraph, batch, subscription, and attachment identities remain unchanged. - -Create another paragraph batch when text must be rendered in another phase, even if it uses the same technique. - -```ts -const overlayText = runtime.createParagraphBatch({ - technique: mtsdf, -}); -``` - -Core never merges `worldText` and `overlayText`. The application may place non-text draws between them or give them -different depth, stencil, clipping, compositing, lifetime, or render-pass policies. - -`renderVariant` is the batch's optional inherited render intent. Core treats it as an opaque, exactly typed value: it does -not know whether the value represents an effect graph, material binding, palette entry, clipping mode, or application -state. Paragraph and span values may override it. `undefined` means inherit; an integration that needs an explicit “no -effect” choice defines that as an ordinary member of its own variant type. A variant never changes the declared raster -technique and does not by itself require another physical glyph buffer, pipeline, or draw. - -Core retains an opaque variant value and compares replacements with `Object.is`; it cannot clone or inspect integration -objects. Treat ordinary variant records as immutable snapshots and assign a replacement to change run identity. A stable -binding object may expose integration-owned mutable parameters, but changing those parameters does not mark core dirty; -the owning program must update its sidecar/uniform storage directly. Disposing a binding still referenced by a live batch, -paragraph, or span is an integration lifecycle error. - -## Everything added is a paragraph - -A paragraph is one independently shaped and laid-out sequence. A multiline block, a one-line label, and a font-backed -icon use the same API. - -```ts -const body = worldText.add({ - font: uiFont, - text: 'A paragraph resolves missing glyphs through its FontStack.', - contentBox: { - width: { mode: 'at-most', size: 480 }, - wrap: 'word', - }, -}); - -const label = worldText.add({ - font: inter, - text: 'Player 1', -}); - -const icon = worldText.add({ - font: iconFont, - text: '\uf013', -}); -``` - -Every paragraph owns a concrete `Font` or `FontStack`. A Bitmap font cannot appear in an MTSDF paragraph batch. Supporting -both resource types in one paragraph requires a technique expressly designed to render both. - -```ts -interface ParagraphBaseProperties { - readonly font: FontSelection; - readonly contentBox?: ParagraphContentBox; - readonly style?: ParagraphStyle; - readonly paint?: GlyphPaintInput; - readonly rasterPixelRatio?: number; - readonly order?: number; - readonly renderVariant?: Variant; -} - type ParagraphAxisConstraint = | { readonly mode: 'unconstrained' } | { readonly mode: 'at-most'; readonly size: number } @@ -442,711 +133,103 @@ interface ParagraphContentBox { readonly overflow?: 'visible' | 'clip' | 'ellipsis'; } -type LinearRgba = readonly [number, number, number, number]; -type ColorInput = string | LinearRgba; - -interface GlyphPaintInput { - readonly color?: ColorInput; - readonly opacity?: number; - readonly outline?: { readonly color: ColorInput; readonly width: number }; - readonly shadow?: { readonly color: ColorInput; readonly offset: readonly [number, number] }; -} - -type ParagraphContentProperties = - | Readonly<{ - text: string; - spans?: readonly ParagraphSpan[]; - }> - | Readonly<{ - text: FormattedText; - spans?: never; - }>; - -type ParagraphProperties = ParagraphBaseProperties< - Technique, - Variant -> & - ParagraphContentProperties; - -interface ParagraphSpan { - readonly start: number; - readonly end: number; - readonly font?: FontSelection; - readonly style?: ParagraphStyle; - readonly paint?: GlyphPaintInput; - readonly renderVariant?: Variant; -} - -type FormattedText = TextLiteral | TextLiteral; - -type TextInput = string | FormattedText; - -declare const textLiteralTechnique: unique symbol; - -interface TextLiteral { - readonly [textLiteralTechnique]: (technique: Technique) => Technique; - readonly text: string; - readonly spans: readonly ParagraphSpan[]; -} - -declare const textSpanFragmentTechnique: unique symbol; - -interface TextSpanFragment { - readonly [textSpanFragmentTechnique]: (technique: Technique) => Technique; - readonly text: string; - readonly spans: readonly ParagraphSpan[]; - readonly properties: Omit, 'start' | 'end'>; -} - -type TextTemplateValue = - | string - | number - | TextLiteral - | TextLiteral - | TextSpanFragment - | TextSpanFragment; - -type SpanStyle = Readonly; - -type SpanFormat = FontSelection | SpanStyle; - -interface SpanTag { - (strings: TemplateStringsArray, ...values: readonly TextTemplateValue[]): TextSpanFragment; +interface ParagraphStyle { + readonly fontSize?: number; + readonly lineHeight?: number; + readonly letterSpacing?: number; + readonly language?: string; + readonly direction?: 'auto' | 'ltr' | 'rtl'; + readonly features?: readonly FontFeature[]; } - -interface UnboundSpanTag { - ( - strings: TemplateStringsArray, - ...values: readonly TextTemplateValue[] - ): TextSpanFragment; -} - -declare function txt( - strings: TemplateStringsArray, - ...values: readonly TextTemplateValue[] -): TextLiteral; - -declare function span(...styles: readonly [SpanStyle, ...SpanStyle[]]): UnboundSpanTag; - -declare function span( - font: FontSelection, - ...formats: readonly SpanFormat>[] -): SpanTag; -``` - -The paragraph font and every explicit span font must match the paragraph batch technique. A span without `font` inherits -the paragraph selection. A `FontStack` resolves missing glyphs in its own stored order; batch membership never changes a -paragraph's shaping semantics. - -The renderer-neutral `txt` and `span` tags compose the same string-plus-range representation without parsing an embedded -markup language. `span()` accepts a `SpanStyle` by itself, or a concrete `Font` / `FontStack` followed by any number of -same-technique font selections and styles. A style-only tag inherits the surrounding paragraph or span font. A `SpanStyle` -flattens paragraph style and glyph paint for concise authoring; the helper normalizes it back into the canonical nested -`ParagraphSpan.style` and `ParagraphSpan.paint` snapshot. - -A fragment or literal containing no font-bearing value carries `never` as its technique marker and is explicitly accepted -by `TextTemplateValue` and `FormattedText`. The first font-bearing fragment fixes the literal -technique; until then the composition remains neutral and inherits its eventual paragraph font. - -Formats merge from left to right. When a font is supplied it is the first argument, allowing that selection to fix the -technique; a later same-technique font replaces it. Later style fields replace earlier fields. Nested values such as -`features`, `outline`, and `shadow` replace as complete values; the helper does not deep-merge them. `NoInfer` makes -TypeScript reject mixed-technique later fonts in addition to unknown properties and invalid value types. Core snapshots -the formats when `span()` is called, then computes UTF-16 ranges and offsets for nested fragments. - -```ts -const importantStyle = { - color: '#ffddff', - fontSize: 18, -} satisfies SpanStyle; - -const important = span(amiri, importantStyle); - -const title = txt`Fast ${important`accurate`} text`; - -label.text = title; -label.text = 'Plain text'; // replaces the source and clears spans ``` -The returned `SpanTag` is reusable. When format inputs must remain independently composable, keep them in a readonly tuple -and bind them later: +`ParagraphContentBox` is layout-system-neutral. Omitted axes are unconstrained. `exact` fixes the resolved box dimension; +`at-most` clamps it. `contentWidth` and `contentHeight` in query results still report the intrinsic laid-out requirement. -```ts -const importantFormat = [amiri, importantStyle] as const; -const importantAmiri = span(...importantFormat); -``` +Text may be a string plus explicit spans, or a `FormattedText` value created with `txt` and `span`. Styles cascade at +extended-grapheme boundaries. Paint and `material` are rendering values; they do not alter shaping or line composition. -Assigning a `TextLiteral` replaces text and spans atomically. Passing a formatted literal together with separate `spans` -is a type error. Manual `{ text: string, spans }`, `setSpan()`, and `removeSpan()` remain available when an integration -already owns explicit UTF-16 ranges. +The foundation stack currently implements horizontal text, font size, line height, letter spacing, language, direction, +OpenType features, wrapping, alignment, clipping policy, line limits, and ellipsis. The publishing-feature stages in the +[Rust engine plan](rust-layout-engine.md) own vertical writing, decorations, editorial regions/exclusions, and the +remaining admitted typography features; this reference does not claim those future inputs as shipped. -`SpanStyle` deliberately contains portable layout and paint only. Set an opaque `renderVariant` through explicit -`ParagraphSpan` values or `setSpan()`; an integration such as React Three Fiber may normalize its own nested variant props -into those spans. The renderer-neutral `txt` tag never captures an engine object accidentally. +## Layout query values -## Mutate handles; synchronize later - -`add()` returns the retained interface for that paragraph. Setters change desired state and mark the paragraph dirty; they -do not shape immediately. +Rendering does not carry layout arrays in every command buffer. An integration may expose explicit, demand-driven Rust +queries using these public result types. ```ts -interface Paragraph { - readonly id: ParagraphId; - readonly batch: ParagraphBatch; - readonly disposed: boolean; - readonly committed: PreparedParagraph | undefined; - - font: FontSelection; - get text(): string; - set text(value: TextInput); - spans: readonly ParagraphSpan[]; - contentBox: ParagraphContentBox; - style: ParagraphStyle; - paint: GlyphPaintInput; - rasterPixelRatio: number; - order: number; - renderVariant: Variant | undefined; - - set(properties: ParagraphUpdate): void; - setSpan(index: number, span: ParagraphSpan): void; - removeSpan(index: number): void; - - snapshotGlyphs(): GlyphSnapshot; - setGlyphOrigins(update: GlyphOriginUpdate): void; - clearGlyphOriginOverrides(): void; - snapshotProperties(): ParagraphSnapshot; - - dispose(): void; +interface ParagraphMeasurement { + readonly width: number; + readonly height: number; + readonly contentWidth: number; + readonly contentHeight: number; + readonly firstBaseline: number; + readonly lastBaseline: number; + readonly overflowed: boolean; } -declare const paragraphIdBrand: unique symbol; -type ParagraphId = number & { readonly [paragraphIdBrand]: true }; - -interface ParagraphSnapshot { - readonly font: FontSelection; - readonly text: string; - readonly spans: readonly ParagraphSpan[]; - readonly contentBox: ParagraphContentBox; - readonly style: ParagraphStyle; - readonly paint: GlyphPaintInput; - readonly rasterPixelRatio: number; - readonly order: number; - readonly renderVariant: Variant | undefined; +interface ParagraphLayoutSummary extends ParagraphMeasurement { + readonly glyphCount: number; + readonly lineCount: number; + readonly missingGlyphCount: number; } - -type ParagraphUpdate = - | (Partial> & - Readonly<{ - text?: string; - spans?: readonly ParagraphSpan[]; - }>) - | (Partial> & - Readonly<{ - text: FormattedText; - spans?: never; - }>); ``` -```ts -label.text = 'First value'; -label.text = 'Second value'; -label.text = 'Player 2'; -label.contentBox = { width: { mode: 'exact', size: 320 } }; -``` +`width` and `height` are the resolved paragraph box. `contentWidth` and `contentHeight` are the intrinsic extents required +by the complete paragraph before box clamping. Viewport clipping does not destroy layout outside the viewport. `maxLines` +and ellipsis are semantic truncation: positioned output contains the retained visible result, while intrinsic extents and +`overflowed` continue to report that additional content existed. -Those mutations create one dirty paragraph. The next synchronization shapes only `Player 2` with the final content box. - -Nested configuration values are immutable snapshots. Replace `paragraph.contentBox` or call `paragraph.set()`; mutating -`paragraph.contentBox.width.size` is not observable and is unsupported. - -Creation and disposal are staged in the same way: - -```ts -const pending = worldText.add({ font: inter, text: 'Not shaped yet' }); -pending.dispose(); - -runtime.update(); // coalesces the add and removal to no work -``` - -## Paragraph handles never move between batches - -A `Paragraph` belongs permanently to the `ParagraphBatch` that created it. Core has no detach, reparent, or handle-transfer -operation. Snapshot desired properties, create a destination handle, then dispose the source handle: - -```ts -const desired = label.snapshotProperties(); -const movedLabel = overlayText.add(desired); -label.dispose(); - -runtime.update(); // destination addition and source removal publish atomically -``` - -`snapshotProperties()` returns immutable normalized desired state, not membership, prepared glyph storage, target -attachments, or ownership. The snapshot itself acquires no font lease. The destination must belong to the same runtime, -must use the same technique, and must receive still-live fonts. Glyph-origin snapshots remain topology-bound and are -reapplied separately after the destination has a compatible shaped topology. - -Disposing a paragraph releases only that handle, its dirty work, cached paragraph state, and font leases. It marks -`paragraph.disposed`, removes it from `batch.has()`, and makes every method except idempotent `dispose()` fail. - -Disposing a paragraph batch is terminal and cascades only through objects it owns: - -```ts -const desired = label.snapshotProperties(); - -worldText.dispose(); - -worldText.disposed; // true -label.disposed; // true: the batch owned this core handle - -const replacement = overlayText.add(desired); // a new handle; never the old label -``` - -`ParagraphBatch.dispose()` cancels its pending work, disposes every owned paragraph handle, releases their font leases, -removes the batch from future runtime revisions, and retires its canonical storage and target attachments. It does not -dispose the runtime or loaded fonts. `add()` and subscriptions on a disposed batch fail; `dispose()` remains idempotent. -Any snapshots required for recreation must be taken before disposal. - -## Synchronize now - -```ts -label.text = 'Ready for this frame'; -body.contentBox = { width: { mode: 'at-most', size: 360 }, wrap: 'word' }; - -const revision = runtime.update(); -``` - -```ts -interface TextRuntimeRevision { - readonly revision: number; - readonly paragraphBatches: readonly PreparedParagraphBatchRevision[]; -} -``` - -`update()` snapshots every currently dirty paragraph across every paragraph batch in the runtime, performs the required -shaping and layout synchronously, updates prepared glyph batches, publishes one atomic runtime revision, and returns it. -When nothing is dirty it returns `runtime.current` without allocating or notifying subscribers. - -Runtime and paragraph-batch revision numbers advance only when a new complete revision publishes. A clean call, a failed -preparation, an aborted request, and a superseded asynchronous candidate do not consume a published revision number. - -Public `add()` and mutation methods reject invalid values, disposed handles, and technique incompatibility immediately. -All data required by synchronous shaping must also have been loaded already. Missing preparation data, fixed-capacity -overflow, or another preparation failure throws from `update()` before publication and leaves the prior revision current. - -## Synchronize asynchronously - -The same runtime can choose Worker preparation for any update. - -```ts -label.text = 'Prepare this away from the caller'; -const outcome = await runtime.updateAsync(); - -if (outcome.status === 'published') { - useRevision(outcome.value); -} -``` - -Promise-free callback form: - -```ts -label.text = 'Avoid a Promise for this hot path'; - -runtime.updateAsync({ signal: controller.signal }, (result) => { - if (!result.ok) { - handleUpdateError(result.error); - return; - } - - if (result.value.status === 'published') { - publish(result.value.value); - } -}); -``` - -```ts -interface AsyncTextUpdateOptions { - readonly signal?: AbortSignal; - readonly priority?: 'background' | 'normal' | 'urgent'; - readonly onProgress?: (progress: TextUpdateProgress) => void; -} - -interface TextUpdateProgress { - readonly revision: number; - readonly preparedParagraphs: number; - readonly totalParagraphs: number; - readonly stagedGlyphs: number; -} - -type TextUpdateCallback = (result: TextUpdateResult) => void; - -type TextUpdateResult = - | { readonly ok: true; readonly value: TextUpdateOutcome } - | { readonly ok: false; readonly error: TextPreparationError }; - -type TextUpdateOutcome = - | { readonly status: 'published'; readonly value: TextRuntimeRevision } - | { readonly status: 'superseded'; readonly revision: number; readonly byRevision: number } - | { readonly status: 'aborted'; readonly revision: number; readonly reason?: unknown }; - -type TextPreparationError = - | { - readonly kind: 'capacity-exceeded'; - readonly batch: ParagraphBatch; - readonly capacity: number; - readonly required: number; - readonly overflows: readonly GlyphCapacityOverflow[]; - } - | { - readonly kind: 'preparation-failed'; - readonly cause: unknown; - }; - -interface GlyphCapacityOverflow { - readonly resourceKey: GlyphBatchKey; - readonly required: number; -} -``` - -The callback form constructs no public Promise and runs exactly once asynchronously. Supersession and cancellation are -handled synchronization outcomes, not errors. The Promise resolves them and the callback returns them through its `ok` -branch. The Promise rejects only for an actual preparation failure; the callback reports the same failure through its -`error` branch. - -An asynchronous executor may stream completed paragraph work into unpublished staging storage and report bounded progress -through `onProgress`. Streaming never publishes a partial runtime or paragraph-batch revision; every affected batch becomes -current together only after the complete synchronization succeeds. - -Both forms snapshot dirty state when called. Later property mutations remain dirty for the next synchronization: - -```ts -label.text = 'A'; -const preparingA = runtime.updateAsync(); - -label.text = 'B'; // pending for the next update; not folded into A -``` - -A newer synchronization supersedes any older asynchronous candidate that has not published: - -```ts -label.text = 'A'; -const preparingA = runtime.updateAsync(); - -label.text = 'B'; -runtime.update(); // publishes B before returning - -const outcomeA = await preparingA; -// { status: 'superseded', revision: A, byRevision: B } -``` - -`B` is the correct final state. The superseded result only explains why the older request did not publish; callers may -ignore it when they do not need update diagnostics. - -## Dirty state selects the work - -```ts -type ParagraphDirtyChannel = - | 'text' - | 'font' - | 'features' - | 'content-box' - | 'paint' - | 'raster-pixel-ratio' - | 'origins' - | 'order' - | 'variant'; -``` - -```ts -const WorkByChannel = { - text: 'shape-layout-partition', - font: 'shape-layout-partition', - features: 'shape-layout-partition', - 'content-box': 'reflow-and-boundary-reshape', - paint: 'rewrite-instance-paint', - 'raster-pixel-ratio': 'reselect-resources-and-repack', - origins: 'rewrite-instance-origins', - order: 'rebuild-glyph-runs', - variant: 'rebuild-glyph-runs', -} as const; -``` - -Core keeps a dirty set rather than scanning every paragraph. Repeated writes to the same field coalesce. Paint, origin, -order, and render-variant changes do not reshape text. - -## Core produces real glyph batches - -One paragraph can resolve glyphs through several fonts. Those fonts use one technique but may bind different GPU -resources. Core partitions and packs them before the renderer sees the revision. - -```ts -interface PreparedParagraphBatchRevision { - readonly paragraphBatch: ParagraphBatch; - /** Contiguous and monotonic within this paragraph batch. */ - readonly revision: number; - readonly technique: Technique; - readonly paragraphs: readonly PreparedParagraph[]; - readonly glyphBatches: readonly PreparedGlyphBatch[]; - readonly glyphRuns: readonly PreparedGlyphRun[]; -} - -interface PreparedGlyphBatch { - readonly key: GlyphBatchKey; - readonly technique: Technique; - readonly font: LoadedFont; - readonly capacity: number; - readonly instanceCount: number; - readonly binding: RasterBindingOf; - readonly storage: GlyphBatchStorageOf; - readonly dirtyRanges: readonly GlyphRange[]; -} - -declare const rasterTechniqueIdBrand: unique symbol; -type RasterTechniqueId = string & { readonly [rasterTechniqueIdBrand]: true }; - -declare const rasterResourceIdBrand: unique symbol; -type RasterResourceId = string & { readonly [rasterResourceIdBrand]: true }; - -interface GlyphBatchKey { - readonly technique: RasterTechniqueId; - readonly resource: RasterResourceId; - readonly pipelineVariant: number; - readonly generation: number; - readonly chunk: number; -} - -interface PreparedGlyphRun { - readonly batch: GlyphBatchKey; - readonly paragraph: ParagraphId; - readonly renderVariant: Variant | undefined; - readonly start: number; - readonly count: number; -} -``` - -`rasterPixelRatio` is renderer-supplied physical density, not layout scale. It defaults to the batch value, which defaults -to `1`; a paragraph may override it. Changing it never reshapes, but techniques such as Bitmap may reselect a strike and -repack affected storage. Because selection is part of the prepared core revision, one paragraph batch cannot represent two -different density choices for the same paragraph and revision across two attached targets. Paragraph overrides may still -partition one batch across several strikes. Render the same logical paragraph simultaneously at different target densities -with separate batches, or update the value before the synchronization that prepares that render phase. - -Spans do not override `rasterPixelRatio`. Density describes the target-space realization of one laid-out paragraph, while -spans describe source-local shaping and paint. A visual subsection that truly needs another density is a separate paragraph -(and, when it belongs to another render target, a separate batch). - -`RasterTechniqueId` and `RasterResourceId` are opaque branded strings whose values are stable and unique within a runtime. -Core interns and freezes one `GlyphBatchKey` object for each live physical glyph batch and reuses that object in -`PreparedGlyphBatch.key`, `PreparedGlyphRun.batch`, and adjacent revisions until the physical batch retires. Integrations -may therefore use the object as a `Map` key. The tuple `(technique, resource, pipelineVariant, generation, chunk)` is also its stable -diagnostic and deterministic ordering value; consumers must not manufacture keys. - -Given the resolved font sequence `Inter -> Noto -> Inter`, core may retain one Inter buffer and one Noto buffer while -emitting three ordered glyph runs: - -```ts -revision.glyphRuns = [ - { batch: interBatch.key, paragraph: label.id, renderVariant: plain, start: 0, count: 8 }, - { batch: notoBatch.key, paragraph: label.id, renderVariant: warning, start: 0, count: 3 }, - { batch: interBatch.key, paragraph: label.id, renderVariant: plain, start: 8, count: 5 }, -]; -``` - -Core resolves batch → paragraph → span variant inheritance, then segments the ordered glyph sequence whenever the physical -batch, paragraph, or effective variant changes. The renderer does not inspect glyphs to rediscover technique, -raster-resource, capacity, source order, or variant boundaries. Each glyph batch also carries the technique-defined -`binding` that selects the required pages, buffers, or other decoded font data from `glyphBatch.font.data`. - -`PreparedGlyphRun` is not a promised draw call. It is the smallest ordered core-authored range an integration may need to -classify. The target may split a run, or coalesce adjacent compatible runs, when compiling engine draws. It must preserve -the supplied order and compositing semantics unless its documented depth/blend policy proves another ordering equivalent. -It may not redo shaping, fallback, resource selection, or slot allocation. Array position is the authoritative run order; -there is no duplicate numeric run-order field. Every live physical glyph slot appears in exactly one run. A technique that -needs several passes for one run expands them in its program and keeps those passes adjacent unless equivalent ordering is -proven. - -Render variants remain on the calling thread. `updateAsync()` snapshots immutable text/span input and its resolved variant -table under one candidate generation ID before posting shaping/layout input to a Worker. The Worker never receives renderer -objects or variants. On return, core maps source clusters against that same candidate's span table—not current desired -state—then publishes only if the candidate is still current. A newer synchronous or asynchronous publication supersedes -and discards the older candidate before variant mapping can become visible. A -variant boundary does not split a shaping cluster or ligature. The cluster receives the variant of the span containing its -first UTF-16 code unit. Exact partial-ligature styling requires an authored shaping boundary or a shader masking technique. - -## Core retains canonical instance storage - -Core must retain paragraph input, shaping/layout results, glyph allocation metadata, shaped origins, and optional origin -overrides. It also owns one canonical packed CPU representation for each prepared glyph batch. - -```ts -interface PreparedGlyphBatch { - readonly storage: GlyphBatchStorageOf; - readonly dirtyRanges: readonly GlyphRange[]; -} -``` - -`dirtyRanges` is the coalesced delta from the immediately preceding revision of this paragraph batch. When a target has -that exact predecessor, it uploads only those ranges. A newly attached target, or a target whose committed -`sourceRevision` is older than that predecessor, initializes every range referenced by `glyphRuns`; those are the live -instance ranges for the current revision. It may coalesce overlapping or adjacent upload ranges without altering the -ordered run sequence. - -The technique defines the canonical structure-of-arrays fields and writes changed slots into them. Those arrays are the -portable synchronization boundary. They remain available for multiple targets, late attachment, inspection, Worker result -integration, target recovery, and deterministic tests. - -Published array contents remain readable until the next revision of that paragraph batch publishes. A target must consume -or copy its selected ranges during its synchronous `stage()` call; pending engine work cannot retain a canonical typed-array -view and read it after that call returns. Core can therefore reuse its CPU shadow without allocating an immutable full-buffer -snapshot for every publication. - -On an adjacent revision, an integration synchronizes only `dirtyRanges`. When its engine layout matches, this is a direct -range copy or upload. When its layout differs, it maps only those canonical fields and ranges into its own interleaved or -technique-specific buffer. First and gapped synchronization use the live glyph-run ranges described above. The integration -still performs no shaping, source sorting, raster-resource partitioning, or slot allocation. It does compile the ordered -runs into its own minimum compatible draw sequence because only the integration knows its program, variant, pass, and -material compatibility. - -This CPU copy deliberately decouples core publication from inaccessible or in-flight GPU memory. The target owns its engine -buffers, upload commands, double/triple buffering, frame publication, fences, and retirement. - -## Move glyphs without reshaping +Baselines are distances from the paragraph box's top edge. Summary counts include retained non-rendering glyphs such as +spaces; `missingGlyphCount` counts positioned `.notdef` glyphs. ```ts -declare const glyphTopologyBrand: unique symbol; -type GlyphTopology = number & { readonly [glyphTopologyBrand]: true }; - -interface GlyphSnapshot { - readonly topology: GlyphTopology; - readonly glyphIds: Uint32Array; +interface ParagraphLayoutInspection extends ParagraphLayoutSummary { + readonly fontHandles: Uint32Array; + readonly glyphFontSlots: Uint16Array; + readonly glyphIds: Uint16Array; + readonly glyphStableIds: Uint32Array; readonly clusters: Uint32Array; - readonly fontSlots: Uint16Array; - readonly shapedX: Float32Array; - readonly shapedY: Float32Array; - readonly displayedX: Float32Array; - readonly displayedY: Float32Array; -} - -interface GlyphOriginUpdate { - readonly topology: GlyphTopology; - readonly start: number; - readonly x: ArrayLike; - readonly y: ArrayLike; -} -``` - -```ts -const snapshot = label.snapshotGlyphs(); -const x = snapshot.displayedX.slice(); -const y = snapshot.displayedY.slice(); - -simulateGlyphs(x, y, delta); - -label.setGlyphOrigins({ - topology: snapshot.topology, - start: 0, - x, - y, -}); - -runtime.update(); // writes origins only -``` - -`topology` identifies the committed glyph sequence to which indices apply. It changes whenever shaping, fallback, glyph -count/order, or font-slot assignment changes; paint, order, variant, transform, and origin-only updates preserve it. -`setGlyphOrigins()` rejects a stale topology synchronously and leaves desired state unchanged. A later reshape preserves an -override only when the resulting topology is identical; otherwise core clears the override and publishes the newly shaped -origins. - -Clear the override to return to the current shaped positions: - -```ts -label.clearGlyphOriginOverrides(); -runtime.update(); -``` - -Reshaping updates the authoritative target positions. The application may snapshot them again and interpolate from its -current displayed positions. - -## Three.js is a separate public surface - -Three.js applications use `FontLoader`, `TextGroup`, and `Text` from `@pmndrs/text/three`. That integration owns these core -objects privately and synchronizes them during Three's render lifecycle; it never asks an application to create core -paragraphs and wrap them in adapter objects. - -See the authoritative [Three.js text API](three-api.md). The mapping is intentionally direct: - -```ts -FontLoader -> cached TextRuntime/shaper initialization + loaded fonts -TextGroup -> technique-specific ParagraphBatch + Three renderer target -Text -> desired paragraph state + late-bound Paragraph + Object3D transform -``` - -## Implement another engine - -The engine consumes already partitioned storage and ordered glyph runs: - -```ts -for (const glyphBatch of revision.glyphBatches) { - const gpuBatch = target.ensureBatch({ - key: glyphBatch.key, - technique: glyphBatch.technique, - font: glyphBatch.font, - binding: glyphBatch.binding, - capacity: glyphBatch.capacity, - storage: glyphBatch.storage, - }); - - const ranges = isAdjacentTargetRevision - ? glyphBatch.dirtyRanges - : liveGlyphRunRanges(revision.glyphRuns, glyphBatch.key); - gpuBatch.upload(ranges); - gpuBatch.setCount(glyphBatch.instanceCount); + readonly glyphFontSizes: Float32Array; + readonly x: Float32Array; + readonly y: Float32Array; + readonly glyphFlags: Uint16Array; + readonly lineTextStarts: Uint32Array; + readonly lineTextEnds: Uint32Array; + readonly lineGlyphStarts: Uint32Array; + readonly lineGlyphCounts: Uint32Array; + readonly lineBaselines: Float32Array; + readonly lineAdvances: Float32Array; } - -const draws = program.compileRuns(revision.glyphRuns, revision.glyphBatches); -for (const draw of draws) target.draw(draw); ``` -Core owns shaping, fallback, layout, sorting, resource partitioning, slot allocation, overflow chunking, instance packing, -dirty ranges, and the ordered variant-bearing text runs. The engine owns compatible-run coalescing/splitting, final draw -planning, transforms, visibility, scene composition, GPU objects, render-pass placement, command encoding, frame -publication, fences, and resource retirement. +Inspection preserves font fallback identity, glyph IDs, UTF-16 cluster offsets, stable glyph identities, line membership, +and positioned geometry. It is a copied semantic view for measurement, hit testing, selection, and directed presentation +augmentation—not GPU instance storage. Repeated unchanged queries may reuse the same result object. -## Dispose +## Synchronization boundary -```ts -worldText.dispose(); -overlayText.dispose(); -inter.dispose(); -runtime.dispose(); -``` +There is one engine update export, `text_update(requestOffset, requestLength)`. The TypeScript host writes a complete frame +request into retained Wasm staging memory; Rust applies mutations, shapes and lays out affected paragraphs, packs canonical +instance records, and emits the render-plan command buffer plus coalesced dirty ranges. Renderer policy is compiled data, +not a JavaScript callback executed from Rust. -Dispose from the narrowest retained owner outward: paragraphs when individually finished, paragraph batches when a render -phase is finished, fonts after their paragraph leases are gone, and the runtime last. A successful dispose is idempotent; -using a disposed handle otherwise fails. +The low-level engine session and wire format are package-internal during this foundation stack. This prevents applications +from binding to an unstable ABI while the maintained Three implementation proves the policy and command-buffer model. +The [Rust engine plan](rust-layout-engine.md) is the authority for the ABI, memory-growth discipline, SIMD layout, and +follow-on publishing features. -`TextRuntime.dispose()` is the one intentional cascade root. It cancels asynchronous preparation and unpublished staging, -disposes every remaining paragraph batch and paragraph, releases loaded fonts after those leases are gone, notifies -attachments, and disposes the runtime-owned registry, shaper, and Worker. It invalidates every handle created by that -runtime and does not publish another revision. Targets release GPU resources only after their engine knows no in-flight -frame still references them. +## Removed pre-cutover surfaces -## Why these boundaries exist +The following experimental V0 surfaces are not part of the current API: -```ts -const Decisions = { - oneParagraphAPI: 'A label or icon is still a paragraph.', - explicitBatchTechnique: 'The technique fixes canonical buffer layouts and rejects incompatible text before shaping.', - fontStacksAreFonts: 'A FontStack is one ordered font selection with missing-glyph behavior.', - explicitParagraphBatches: 'Only the application knows where text render phases must remain separate.', - coreOwnedPhysicalBatching: 'Every target would otherwise duplicate grouping, sorting, packing, and dirty tracking.', - handleOwnedMutation: 'Repeated writes debounce naturally before a synchronization call.', - perUpdateScheduling: 'The same runtime must switch between immediate and Worker preparation.', - canonicalCpuStorage: 'Targets synchronize adjacent deltas or live ranges from one stable portable representation.', - orderedGlyphRuns: 'Fallback and render variants preserve source order without pretending every run is a draw.', -} as const; -``` +- `createParagraphEngine` and standalone JavaScript paragraph layout; +- `TextRuntime.createParagraphBatch`, `runtime.update`, and `runtime.updateAsync`; +- `analyzeBidi`, `shapeBatch`, and `reshapeRanges` exports; +- the text-preparation Worker protocol; +- `@pmndrs/text/typegpu` and its duplicate batch executor. -The old public `createParagraphEngine()` path, runtime-wide sync/Worker mode, mutation callback passed to `update()`, mixed- -technique logical batch, and renderer-owned reshaping or physical glyph repartitioning are explicitly not part of this API. +TypeGPU will be rebuilt against the Rust render plan rather than retaining the removed TypeScript batch model. Use the +[Three.js API](three-api.md) for the maintained renderer and `@pmndrs/text/r3f` for React. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 50dc66b4..a8e0357a 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -42,8 +42,8 @@ sources: title: External gpucat integration fitness plan generated: - by: anthropic-claude/opus-5 - at: '2026-08-08T02:40:00Z' + by: openai-codex/gpt-5.6 + at: '2026-08-10T02:15:49Z' --- # Decision register @@ -127,104 +127,202 @@ Rasters attach only when shaping hash, glyph count, glyph-ID width, raster key, ## Baking and loading -| ID | Decision | Status | -| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------: | -| D-030 | Node and Worker hosts share one portable bake core. | Accepted | -| D-031 | The loader is baked-first and dynamically imports Worker fallback. | Accepted | -| D-032 | Omitted `baked` keeps the baked-first probe; explicit `baked: null` skips discovery and enters the source/runtime path without adding a second boolean policy. | Accepted | -| D-033 | In-memory deduplication is required; persistent bake caching is later. | Deferred | -| D-158 | Each maintained engine integration pairs a technique with its program through a technique-scoped subpath: `/three/bitmap`, `/three/mtsdf`, `/three/slug`, and the same shape under `/typegpu`. That subpath re-exports the portable technique and registers its engine program, so a consumer writes one import rather than two and a bundler drops the techniques an application never names. `/three` and `/typegpu` keep only technique-agnostic surface. Registration cannot live in `/raster/*`, which must never import a renderer. Deleting the merged-v0 surface exposed why this needs stating: registering every built-in program at `/three` module scope collapsed the three measured runtime graphs to within three bytes of each other, defeating the package's `sideEffects: false` declaration, and no document had specified the split it broke. | Accepted | -| D-156 | Font metrics bake underline position/thickness and strikeout position/size in v1 even though no renderer draws decorations yet. Those values live in the source `post` and OS/2 tables and are absent from the artifact today, so adding them after release would bump the artifact version and invalidate every font already baked. Carrying them costs a few bytes and no public API, which makes text decoration a purely additive renderer feature later. | Accepted | -| D-157 | Hyphenated justification defers to later work, but v1 first proves the shaping and layout contract can represent a hyphen the line breaker inserts at a break. Such a glyph has no source cluster, while every glyph today maps back to a UTF-16 cluster in the paragraph text. Language patterns, break selection, and justification quality controls are additive; the cluster invariant is not, so it is settled before the API freezes. | Accepted | +| ID | Decision | Status | +| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------: | +| D-030 | Node and Worker hosts share one portable bake core. | Accepted | +| D-031 | The loader is baked-first and dynamically imports Worker fallback. | Accepted | +| D-032 | Omitted `baked` keeps the baked-first probe; explicit `baked: null` skips discovery and enters the source/runtime path without adding a second boolean policy. | Accepted | +| D-033 | In-memory deduplication is required; persistent bake caching is later. | Deferred | +| D-158 | Each maintained engine integration pairs a technique with its program through a technique-scoped subpath: `/three/bitmap`, `/three/mtsdf`, `/three/slug`, and the same shape under `/typegpu`. That subpath re-exports the portable technique and registers its engine program, so a consumer writes one import rather than two and a bundler drops the techniques an application never names. `/three` and `/typegpu` keep only technique-agnostic surface. Registration cannot live in `/raster/*`, which must never import a renderer. Deleting the merged-v0 surface exposed why this needs stating: registering every built-in program at `/three` module scope collapsed the three measured runtime graphs to within three bytes of each other, defeating the package's `sideEffects: false` declaration, and no document had specified the split it broke. | Accepted | +| D-156 | Font metrics bake underline position/thickness and strikeout position/size in v1 even though no renderer draws decorations yet. Those values live in the source `post` and OS/2 tables and are absent from the artifact today, so adding them after release would bump the artifact version and invalidate every font already baked. Carrying them costs a few bytes and no public API, which makes text decoration a purely additive renderer feature later. | Accepted | +| D-157 | Hyphenated justification defers to later work, but v1 first proves the shaping and layout contract can represent a hyphen the line breaker inserts at a break. Such a glyph has no source cluster, while every glyph today maps back to a UTF-16 cluster in the paragraph text. Language patterns, break selection, and justification quality controls are additive; the cluster invariant is not, so it is settled before the API freezes. | Accepted | | D-159 | Paragraph layout is tiered by what each product actually depends on, and a change enters at its own tier. Text analysis is decided by the text and its base direction; shaping adds the fonts, spans, and style topology; metrics add font size and spacing; the line plan adds the content box; geometry adds alignment. The batch previously collapsed all of it into one `needsShape` boolean and rebuilt the paragraph object per update, so its caches never answered and a width drag cost a cold build: at 25515 glyphs a resize, a reflow, a text edit, and a build from nothing all measured within noise of 131ms. A retained layout session and per-tier reuse separated them to 33.72ms and 27.98ms without changing a byte of layout output. Font fallback reads shaped glyph identity rather than laying the paragraph out to find `.notdef`, so selection cannot depend on where the text happened to wrap. | Accepted | -| D-160 | Layout performance claims come from `text:layout-benchmark`, which reports a median of warmed repetitions per invalidation class with its relative standard deviation, never an average across classes. Phase spans, not a sampling profiler, decide where the time goes: sampling reports self time, which fragments one phase across its callees and understated Unicode analysis as 7% where it was 24.6% inclusive, and V8 inlining moved the same chain's self time between the caller and the leaf across two runs of identical code. A sampling profile remains the right tool for finding a hot loop inside one phase. Every position axis accumulates in double precision and narrows once, because alignment and justification read the axis back; the mixed-direction golden caught that as a last-digit drift. | Accepted | -| D-034 | The integration proof generates one grayscale bitmap strike. | Accepted | -| D-035 | Raster modules and generators are optional imports. | Accepted | -| D-036 | Baked assets are data; baker surfaces are libraries/modules. | Accepted | -| D-066 | The CLI resolves baker modules through an imported or explicitly named package's flat `pmndrs.text` map and public ESM exports; package semver governs compatibility and the CLI never scans dependency directories. | Settled for V0 | -| D-071 | The Node baker statically discovers `defineFont` uses and literal raster descriptors without executing application code; dynamic font origins remain valid when an unambiguous local pathname can be resolved, otherwise runtime fallback remains authoritative. | Accepted | -| D-070 | Bitmap strike tuples are non-empty, duplicate-free static positive integer literals and are part of raster identity; a missing declared strike makes a baked raster incompatible. | Accepted | -| D-077 | The portable bake core ships one `wasm32-unknown-unknown` module behind a versioned JSON-described C ABI and direct linear-memory TypeScript shim; it ships no platform binaries, WASI dependency, Embind, or binding-generator runtime. | Settled for V0 | -| D-078 | The bake and shaping Wasm modules use `no_std + alloc`, aborting panics, and pinned ABI-private dynamic Talc 5.0.4. Across the optimized four-module corpus it saves 46,610 raw, 15,121 gzip, and 12,121 Brotli bytes relative to `dlmalloc` without changing initial-memory class or artifacts. A 128 MiB global arena is rejected because it saves no meaningful transfer bytes while raising initial memory to about 129 MiB and imposing a fixed ceiling. A request-local scratch arena remains eligible only with proven lifetime ownership outside persistent Worker state. Host code owns gzip/Brotli measurement. | Accepted | -| D-084 | Font format parsing and outline/metric interpretation use maintained Fontations `read-fonts` and `skrifa`; project code owns bake policy and artifact contracts, not a parallel OpenType parser or geometry engine. | Settled for V0 | +| D-160 | Layout performance claims come from `text:layout-benchmark`, which reports a median of warmed repetitions per invalidation class with its relative standard deviation, never an average across classes. Phase spans, not a sampling profiler, decide where the time goes: sampling reports self time, which fragments one phase across its callees and understated Unicode analysis as 7% where it was 24.6% inclusive, and V8 inlining moved the same chain's self time between the caller and the leaf across two runs of identical code. A sampling profile remains the right tool for finding a hot loop inside one phase. Every position axis accumulates in double precision and narrows once, because alignment and justification read the axis back; the mixed-direction golden caught that as a last-digit drift. | Accepted | +| D-034 | The integration proof generates one grayscale bitmap strike. | Accepted | +| D-035 | Raster modules and generators are optional imports. | Accepted | +| D-036 | Baked assets are data; baker surfaces are libraries/modules. | Accepted | +| D-066 | The CLI resolves baker modules through an imported or explicitly named package's flat `pmndrs.text` map and public ESM exports; package semver governs compatibility and the CLI never scans dependency directories. | Settled for V0 | +| D-071 | The Node baker statically discovers `defineFont` uses and literal raster descriptors without executing application code; dynamic font origins remain valid when an unambiguous local pathname can be resolved, otherwise runtime fallback remains authoritative. | Accepted | +| D-070 | Bitmap strike tuples are non-empty, duplicate-free static positive integer literals and are part of raster identity; a missing declared strike makes a baked raster incompatible. | Accepted | +| D-077 | The portable bake core ships one `wasm32-unknown-unknown` module behind a versioned JSON-described C ABI and direct linear-memory TypeScript shim; it ships no platform binaries, WASI dependency, Embind, or binding-generator runtime. | Settled for V0 | +| D-078 | The bake and shaping Wasm modules use `no_std + alloc`, aborting panics, and pinned ABI-private dynamic Talc 5.0.4. Across the optimized four-module corpus it saves 46,610 raw, 15,121 gzip, and 12,121 Brotli bytes relative to `dlmalloc` without changing initial-memory class or artifacts. A 128 MiB global arena is rejected because it saves no meaningful transfer bytes while raising initial memory to about 129 MiB and imposing a fixed ceiling. A request-local scratch arena remains eligible only with proven lifetime ownership outside persistent Worker state. Host code owns gzip/Brotli measurement. | Accepted | +| D-084 | Font format parsing and outline/metric interpretation use maintained Fontations `read-fonts` and `skrifa`; project code owns bake policy and artifact contracts, not a parallel OpenType parser or geometry engine. | Settled for V0 | The [architecture](architecture.md) owns loading behavior and dependency rules. The [API contract](api-shapes.md) owns host and Worker shapes. ## Raster -| ID | Decision | Status | -| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-----------------: | -| D-050 | The merged, unreleased v0 implementation contains Bitmap, MTSDF, and Slug; Bitmap alone was only the first integration proof. The target v1 must preserve all three behind the renderer-neutral contract. | Accepted | -| D-051 | Rasters never duplicate advances, kerning, or shaping behavior. | Accepted | -| D-052 | Direct-to-GPU means no reconstruction/repacking, not zero upload. | Accepted | -| D-053 | The MSDF raster uses linear MTSDF RGBA8; padding stays in raster bounds. | Accepted | -| D-054 | Deterministic unhinted bitmap oversampling is the baseline candidate. | Experiment | -| D-055 | Recommend MSDF generally, but require an explicit raster module. | Accepted | -| D-056 | Windfoil is research prior art, not a planned text backend. | Accepted | -| D-057 | Post-slice Slug includes color-emoji vector paint; safe OpenType-SVG and standalone-SVG icon baking lands in the large-coverage CJK/icon milestone. | Accepted | -| D-058 | Fill, opacity, outline, and hard shadow are baseline game-text styles. | Accepted | -| D-059 | Payload reports separate shaping, transport, decoded, and GPU bytes. | Accepted | -| D-061 | Slug bands compress exactly; curve compression remains quality-gated. | Accepted | -| D-064 | Merged v0 and target v1 do not support plain MSDF assets or parallel MSDF/MTSDF batches. | Accepted | -| D-065 | First-party raster packages use TSL internally; the core raster API is shader-system and backend agnostic. | Accepted | -| D-073 | Target v1 assigns one selected raster per font slot; per-glyph raster mixing is additive color/SVG work after the first release. | Accepted | -| D-075 | Latin remains the target v1 rendering and raster-coverage priority. Pre-render CJK shaping/layout conformance may harden universal core assumptions, but CJK raster paging and icon coverage remain a post-v1 milestone and do not expand the Latin-first renderer exit gate. | Accepted | -| D-076 | Raster page indexes are logical IDs; page payloads may be embedded or independently addressed, and raster modules own preparation, residency, eviction, and backend batching. | Accepted | -| D-091 | Bitmap plane bounds preserve the rasterizer's integer pixel placement with `planeUnitsPerEm = strike ppem`; the shared TSL vertex graph snaps projected quad edges to physical framebuffer pixels so native rendering maps one atlas texel to one device pixel. | Accepted | -| D-092 | Hinted grayscale strikes and optional four-phase grayscale packing remain measured research. LCD/ClearType subpixel rendering, panel-order assumptions, runtime hint interpreters, and distance-field reconstruction are out of scope. | Experiment | -| D-093 | Bitmap V0 renders fill and opacity only and rejects outline or shadow through the raster paint-validation seam; MTSDF owns those distance-based effects rather than silently degrading them. | Accepted | -| D-096 | Bitmap presentation transitions are optional `@pmndrs/text/raster/bitmap` helpers over copied glyph identities and instance origins. Shaping and layout commit discretely; only identity-matched glyph positions interpolate before the existing physical-pixel snap, and unused consumers pay no target-origin allocation. | Settled for V0 | -| D-097 | Milestone 8 owns a purpose-built `no_std + alloc` Rust MTSDF core under `packages/text/rust`, with repository-defined types, limits, reusable scratch storage, data-oriented scalar/SIMD experiments, typed errors, and the generated direct-memory C ABI/JSON boundary. The scalar path is a correctness oracle; if `simd128` wins the complete quality, full-font-time, and size comparison, it is the single default shipped Wasm kernel rather than a baker option. The core may retain proven design ideas from reviewed implementations but is not a copied Klyff fork. Pinned native Chlumsky `msdfgen` remains the canonical test-only oracle and port reference; Klyff, OxiText, Rust bindings, and UIKit/Zappar remain research evidence rather than product dependencies. | Accepted | -| D-099 | MTSDF V0 originally fixed one opinionated bake: 64 plane units per em, a full eight-pixel encoded distance range, four field-padding texels, one atlas-gap texel, 1024-pixel pages, dense 20-byte records, and lossless linear RGBA8 KTX2. The published baker composes the admitted scalar kernel with the shared Fontations provider and lossless artifact primitives; standalone generator evidence remains separately measurable but is not a second published Wasm. | Superseded by D-110 | -| D-149 | Text size is logical CSS geometry. A rendering integration supplies an explicit raster pixel ratio; bitmap targets `CSS size × ratio`, deterministically selects the nearest declared physical strike, and never changes paragraph geometry to compensate for DPR. The core installs no DOM or gesture listeners. | Accepted | -| D-150 | Bitmap density strikes remain independent grayscale record/texture sets rather than RGB(A)-channel packing. A combined artifact may carry several strikes, while Milestone 13 adds independently fetched and evictable strike pages. | Accepted | -| D-151 | Language delivery is exact-coverage-first and locale-aware. A family directory may route grapheme-safe runs to font-local units, but language labels alone never prove coverage and units never split contextual shaping runs. Compiler-produced shaping closure/remapping remains Milestone 17. | Accepted | -| D-103 | Explicit and fallback runtime raster baking accept normalized bounded coverage and raster options through the same Worker-only path. Coverage may be seeded by Unicode ranges, authored text, or exact font-local glyph IDs, but it reduces atlas generation only: it does not subset the shaping font, remap glyph IDs, or claim transitive shaping closure. | Accepted | -| D-104 | Every direct-memory Wasm ABI layout is represented by fixed-width `#[repr(C)]` Rust types. Build-only Rust generators derive published JSON and exact `as const` TypeScript contracts from `size_of`, `align_of`, and `offset_of!`; production hosts import those generated facts, and production Wasm embeds no duplicate contract or ABI-pointer bootstrap. WebAssembly direct memory uses its guaranteed little-endian order; portable GLB, KTX2, SFNT, and extension encodings retain their format-defined byte order. | Accepted | -| D-105 | Merged v0 retained the Three.js/TSL integration through Slug so real shader, resource, batching, and lifetime requirements could inform the abstraction. Target v1 extracts one renderer-neutral core beneath Bitmap, MTSDF, and Slug; Three.js, TypeGPU, Wayfare, and other engines become independently selectable integrations. Optional TypeGPU compute-baker research cannot enter unrelated runtime graphs. | Accepted | -| D-106 | Slug V0 artifacts retain exact R16UI reference grids. The Three.js 0.185.1 adapter may pair-pack those values into R32UI texels at decode time because its WebGL TSL backend does not declare an unsigned sampler for `UnsignedShortType`; this preserves reference identity and two-byte density plus at most one terminal padding value. Other adapters remain free to upload R16UI directly, and the exception does not redefine the portable artifact. | Accepted | -| D-107 | Repository TypeScript commands execute the installed native compiler through one bounded runner that first proves its kill/reap path with a synthetic allocator, supervises the native PID rather than a shell or Node shim, caps aggregate tracked RSS, enforces a wall-time limit, and reports no success while a compiler survives. TSL changes compile reduced operation fixtures and a narrow graph before package or application projects; free functions remain the first mitigation but exact-version pathological overloads use one proven concrete compatibility boundary. | Superseded by D-114 | -| D-108 | MTSDF V0 uploads only the authenticated base level and uses bilinear field sampling plus screen derivatives for reconstruction. Conventional GPU mip generation and trilinear cross-level sampling are rejected: averaging encoded MSDF channels is not a distance-field-preserving operation, and the primary MSDF paper plus official generators provide no affirmative mipmap guidance. Runtime, standalone validation, fixtures, and the inspector report the exact padded base texture-array allocation. Any future size-specific representation is an independently authored atlas layer or strike, not a conventional mip chain. | Accepted | -| D-109 | Slug V0 implemented a centered exact-distance outline in one specialized fill-plus-outline draw. Retained measurements later showed `2.44×–4.33×` fill-only GPU time, and generated-shader inspection found duplicated traversal, curve loads, closest-point refinement, and a derivative inside divergent control flow. | Superseded by D-111 | -| D-110 | MTSDF V0 exposes `emSize` and full `pixelRange` as authenticated integer bake options. `emSize` is limited to `1..=1022`, `pixelRange` to `1..=1020`, `planeUnitsPerEm` equals `emSize`, and field padding is `ceil(pixelRange / 2)`. Omitted or partial options resolve against the 64/8 compatibility defaults; explicit effective 64/8 canonicalizes to the legacy fieldless descriptor and raster key, while every non-default descriptor contains both effective values. The low-level Wasm ABI is unchanged. Passing 32/4 and 32/6 155-glyph subset bakes proves the control path, not a new recommended default; quality and payload benchmarking owns that decision. | Accepted | -| D-111 | Remove the dynamic exact-distance Slug outline rather than ship an expensive fallback. Slug V0 supports fill and opacity and rejects every runtime outline or shadow property. The generic text outline API remains because MTSDF owns it. | Accepted | -| D-112 | Research one bounded Slug outline approximation that reuses ordinary fill traversal and screen derivatives without closest-point solving or independent halo traversal. It ships only if its quality is no worse than the MTSDF outline corpus and its median GPU time is at most `1.15×` a same-expanded-quad fill control on both WebGPU and forced WebGL2; otherwise Slug remains fill-only. | Experiment | -| D-114 | Carry the upstream `NodeExtras` lookup-map rewrite as a version-pinned pnpm patch for `@types/three` 0.185.1. A focused compile-only regression owns every previously explosive TSL operation. Package and application scripts invoke the pinned compiler directly; the native-process memory guard and its repository-wide invocation requirement are removed because they contained a dependency type-graph defect now corrected at its declaration boundary. | Accepted | -| D-115 | The benchmark app uses Koota as an application-boundary state manager. Coherent singleton world traits own live controls and published telemetry; direct world reads/writes coordinate capture and renderer state. The world instance is exported from a dedicated HMR-stable module. Koota does not enter core text, shaping, layout, baking, raster, or public package APIs, and entities/queries are reserved for data that genuinely has collection lifecycle. | Accepted | -| D-116 | Interactive benchmark overlays use official shadcn components backed by Base UI rather than application-owned dismissal, focus, portal, or keyboard machinery. Repository semantic tokens theme those checked-in components. Koota remains the single owner of runtime control values; shadcn/Base UI owns interaction behavior only. | Accepted | -| D-117 | Each benchmark route owns one persistent render host per backend generation. The host owns the canvas, renderer, animation loop, GPU timing, telemetry history, viewport, and serialized scene/job lifecycle. React Suspense owns cold asset readiness; scene, technique, delivery, and font selections preload and commit with React transitions so the last complete scene remains visible until an atomic replacement is ready. Compatible font changes retain the active `Text` objects and registry. | Accepted | -| D-118 | Milestone 10 replaces `buildBatches`, optional retained updates, and separate repaint mutation with one required renderer-neutral `stageBatch(previous, layout, resource, fontSlot, paint, rasterPixelRatio)` transaction. A stage owns one unpublished target batch, may retain or replace the previous batch, cannot mutate committed state before publication, commits synchronously and infallibly, and aborts idempotently; committed batch disposal is also idempotent. The portable contract exposes no Three.js, TSL, WebGPU, WebGL, or first-party raster-kind union. Three.js object attachment is an adapter requirement enforced by `Text`, not part of `RasterDrawBatch`. | Accepted | -| D-119 | Once a font, shared shaper, decoded raster resource, and layout-required raster pages are resident, `Text.setProperties` shapes, lays out, plans paint, and stages synchronously while retaining the previous complete generation. The Three.js adapter publishes that candidate at the start of `updateMatrixWorld` or `updateWorldMatrix`, before child traversal; the React adapter explicitly invalidates its R3F root after a core-property update. `ready` remains an observation channel for cold work and queued publication, not a consumer coordination requirement for warm React updates. Synchronous validation, shaping, preparation, or staging faults throw from `setProperties` without cancelling an earlier candidate or live generation; asynchronous preparation and defensive commit-contract faults reject `ready`. A raster `prepare` implementation returns `void` when its requirement is resident and one shared idempotent Promise only for genuinely cold work. | Accepted | -| D-120 | First-party raster batches allocate deterministic 25% glyph-instance slack capped at 256 instances and track logical count separately from capacity. Bitmap, MTSDF, and Slug retain their complete parallel instance records when compatible content fits; shrinks and exact-capacity growth publish authoritative draw counts, while overflow and incompatible ordered Bitmap/Slug page-run topology replace transactionally. Dirty uploads use 32-instance buckets, at most eight disjoint ranges, and a logical full-range fallback; pending renderer ranges carry forward until consumed. This reuse is per batch and does not introduce automatic batching across independent `Text` objects. | Accepted | -| D-121 | The public extension proof is a private `glyphExample` package that owns its kind, descriptor, companion artifact, embedded/external records, baker, runtime generator, decoder, Three.js/TSL adapter, retained capacity, dirty uploads, overflow, abort, and disposal using only published `@pmndrs/text` entry points plus its renderer dependency. Portable batches stay renderer-neutral through `RasterObjectDrawBatch`; `ThreeRasterDrawBatch` documents the `Text` adapter requirement. Static discovery requires the imported factory export name, package manifest key, and default baker kind to match. | Accepted | -| D-122 | Framework-neutral `Text` is a composite `Object3D`, not a `Group`, so a caller-owned parent Group remains Three.js's primary `groupOrder`. `Text.renderOrder` is the secondary paragraph base and each drawable receives that base plus its first-glyph/page-run-local order. Three raster batches implement `setRenderOrderBase`, use neutral non-`Group` roots, and preserve the base across retained commits; the adapter applies it before cold publication, resynchronizes later caller changes during ordinary matrix traversal, and rejects a nested raster Group that would replace the inherited primary key. Nested React Text remains source/span composition and does not create nested scene objects. | Accepted | -| D-123 | The target v1 core has three retained public objects: `TextRuntime`, `ParagraphBatch`, and `Paragraph`. A paragraph batch declares exactly one raster technique, capacity policy, and application render-phase boundary within which core may order and submit paragraphs; it is not a one-draw promise. Every paragraph owns its complete font selection. A multiline block, label, or font-backed icon is always a paragraph. Separate public font-group, paragraph-engine, label, icon, text-item, and mixed-technique logical-batch lifecycles are rejected. | Accepted | -| D-124 | Paragraph handles own desired text, spans, content box, style, paint, finite order, and reversible glyph-origin overrides. Observable top-level setters and indexed methods mark dirty channels without shaping; nested option records are immutable replacements. Repeated writes coalesce naturally. `TextRuntime.update()` snapshots every dirty paragraph across all paragraph batches and synchronously shapes, lays out, partitions, packs, and atomically publishes the final desired state. `updateAsync()` snapshots the same state for asynchronous preparation. A no-op synchronous update returns the current revision without allocation. | Accepted | -| D-125 | Sync versus async is selected per synchronization call, not when creating the runtime. Runtime options only provision a synchronous shaper and optional lazily created asynchronous executor. `updateAsync()` has a Promise form and a callback form that creates no public Promise; both complete asynchronously. Worker results may stream into unpublished staging storage and report bounded progress, but publication remains atomic. Mutations after an update snapshot remain dirty for the next synchronization. A newer sync or async synchronization supersedes any unpublished older asynchronous generation, which can never replace newer state. Published, superseded, and aborted requests are resolved outcomes; only an actual preparation failure rejects the Promise or enters the callback error branch. | Accepted | -| D-126 | Core owns fallback resolution, paragraph sorting, technique/resource partitioning, stable instance slots, capacity growth/chunking, canonical instance packing, dirty ranges, resolved opaque render variants, and ordered `PreparedGlyphRun` values. One same-technique paragraph batch may produce several resource buffers and repeated ordered runs from one buffer. A run is not a promised draw. Engine programs may split or coalesce adjacent compatible runs and own final draw planning, but may not reshape, resort source text, reselect resources, or reallocate core slots; they preserve order unless a documented compositing policy proves another order equivalent. | Accepted | -| D-127 | Core retains one canonical technique-defined structure-of-arrays CPU representation for every prepared glyph batch and reports exact coalesced dirty ranges. Matching targets copy/upload those ranges 1:1; different engine layouts map only those fields and ranges. First or gapped synchronization initializes live ranges referenced by the current glyph runs. Targets never reshape, source-sort, resource-partition, or allocate core slots. The CPU shadow decouples core revisions from inaccessible or in-flight GPU memory and supports multiple or late targets; targets own engine staging, final draw compilation, GPU publication, fences, and retirement. | Accepted | -| D-128 | Bitmap, MTSDF, Slug, and any external raster remain distinct techniques and cannot coexist in one `FontStack` or `ParagraphBatch`. A renderer that deliberately combines data from two existing techniques is a new technique with its own artifacts, compatibility key, instance schema, resource bindings, and shaders. Different fonts within one technique normally remain distinct GPU resource batches; a technique may opt fonts into one physical key only when it actually provides a shared addressable GPU resource. | Accepted | -| D-129 | The Three.js surface is `FontLoader`, `TextGroup`, and transform-bearing `Text`; it privately owns every core runtime, paragraph batch, paragraph, revision, attachment, and target. First loader use lazily initializes one cached runtime/shaper. `TextGroup` declares one technique, one construction-time `ThreeRasterProgram`, and one render phase; every `Text` owns a same-technique `Font` or `FontStack`, and standalone text derives an implicit batch. `updateMatrixWorld()` reconciles membership, invokes allocation-free-when-clean runtime update, commits the staged target, runs ordinary world-matrix traversal, and writes changed glyph transforms before render-list construction. The target copies core ranges, the program compiles glyph runs into draw meshes, and WebGPURenderer performs GPU writes/draws. `TextGroup` remains an `Object3D`, preserving the nearest real Group's primary `groupOrder`; its mutable `renderOrder` is the secondary base across compiled draws. | Accepted | -| D-130 | `FontStack` is an immutable ordered logical font selection, not a plural eligibility group: its first concrete font is primary and later same-technique fonts resolve missing glyphs. Every text-facing `font` field accepts a concrete `Font` or `FontStack`; batches and `TextGroup` never declare fonts or fallback. Core exports typed `txt` and `span` template helpers that flatten nested fragments into immutable UTF-16 string/span snapshots without parsing markup. The Three entry point re-exports those helpers directly, and React nested `` composition uses the same composer. Plain strings remain valid and clear spans when assigned. | Accepted | -| D-131 | Paragraph and text counts are not public capacity dimensions. Optional `GlyphBufferCapacity` has only `size` and `policy`, applied as glyph-instance slots independently to each physical technique/resource buffer. Explicit batches default to lazy `{ size: 4_096, policy: 'chunk' }`; standalone Three text defaults to `{ size: 256, policy: 'grow' }`. Chunk preserves buffers and adds fixed chunks, grow transactionally doubles until pending glyphs fit, and fixed makes `size` a hard per-buffer limit. Fixed overflow is knowable only after shaping, fails before publication, and is retained by Three rather than escaping render. Paragraph metadata grows normally, and core preserves logical order through glyph runs across every resulting buffer. | Accepted | -| D-132 | Three.js exposes no `TextGroup.allocate()` or second text-creation path. `new Text(properties)` creates one retained late-bound object; inherited `Object3D.add()` / `remove()` are its only membership operations, and removal does not dispose it. Glyph-slot allocation is an internal synchronization result. Core retains `ParagraphBatch.add(properties)` because `Paragraph` is not independently constructible. | Accepted | -| D-133 | `span()` accepts a renderer-neutral `SpanStyle` alone, or a same-technique `Font` / `FontStack` first followed by styles and compatible font overrides. `SpanStyle` combines paragraph style and glyph paint. Inputs merge left-to-right; later scalar fields or fonts replace earlier ones, while nested features, outline, and shadow replace as units. The returned immutable tag is reusable; readonly tuples preserve inputs for later binding. A style-only tag remains technique-neutral and inherits its surrounding font. | Accepted | -| D-134 | A detached Three.js `Text` owns reusable desired state but no core paragraph, batch, or GPU target. Direct scene rendering creates a text-owned implicit batch; moving into a group publishes destination membership before GPU-safe retirement of that target. Explicit-group slots and buffers belong to `TextGroup`, so removal recycles membership without disposing shared capacity. Even while detached, `Text.dispose()` is permanent: it cancels work, clears caches/references, and prevents reattachment. It neither mutates the scene graph nor disposes group buffers or fonts. Group disposal releases group resources without disposing children or fonts. | Accepted | -| D-135 | Core `Paragraph` handles are permanently owned by their creating `ParagraphBatch`; batch disposal cascades through those handles, while paragraph disposal never disposes its batch, runtime, or fonts. Core moves desired state by immutable snapshot plus destination `add()`, never by transferring a handle. Paragraphs and Three `Text` objects lease every selected concrete font for their full retained lifetime, and font disposal fails while leases remain. Three `Text` owns its desired snapshot and leases independently of group binding, so disposing a populated `TextGroup` unbinds but does not dispose its text; each live compatible text may create fresh membership elsewhere. A disposed group left in the scene graph remains a terminal non-rendering boundary rather than falling through to an ancestor or implicit batch. | Accepted | -| D-136 | Fixed capacity forbids automatic growth, not an explicit owner-directed capacity change. Core `ParagraphBatch.setCapacity(capacity)` preserves the batch, every paragraph handle, subscriptions, and attachments; it clears a latched capacity failure only when the normalized value changes, stages replacement canonical storage at the next synchronization, publishes atomically, and leaves the prior revision live on failure. Existing attachments record that source; each target stages replacement engine buffers on its owner's next `prepare()` and retires old buffers after its fences. Three `TextGroup.setCapacity()` and standalone `Text.setCapacity()` preserve public object identity and forward to their effective or retained implicit batch. The setter records capacity intent; it does not promise immediate allocation. `TextGroup.clone()` and `copy()` are unsupported because recursive copying would silently duplicate identity-bearing text, refs, listeners, membership, and renderer state. | Accepted | -| D-137 | `ParagraphBatch.attach(target)` is the standard retained renderer coordinator, not a privileged preparation API. The public observer replays `current`, reports later revisions, and completes on disposal, so another coordinator needs no private shaping/allocation access. Publication only records the newest attachment source; the observing engine calls `attachment.prepare()` to stage its own target and `commit()` at its safe boundary. `attach()` owns technique validation, cancellation, retained target failure, and cascading disposal. `dirtyRanges` is an adjacent-revision delta: first or gapped synchronization initializes live ranges named by current glyph runs, while adjacent synchronization uploads only the delta. Targets consume or copy canonical ranges during synchronous `stage()` and never retain mutable views across later publications. | Accepted | -| D-138 | The next API splits the current combined `RasterModule` into a renderer-neutral `RasterTechnique` and engine-owned `ParagraphBatchTarget`. One portable technique owns artifact decoding, hash-validated external resource resolution, retained CPU page/table data, glyph-to-resource binding, canonical instance schema, and packing. Every prepared glyph batch exposes that typed binding, so targets create textures/buffers without rediscovering page or resource membership. A concrete technique definition infers and preserves its exact options, descriptor, decoded data, binding, and storage types; the common heterogeneous boundary exposes those associated values as `unknown` rather than erasing them with `any`, and requires narrowing before technique-specific work. GPU resource creation, shaders, pipelines/materials, scene/pass integration, submission, fences, and retirement remain outside core. An optional adapter-level `RasterProgram` may share shader/resource realization across engines using the same backend: TypeGPU programs can be reused where hosts prove compatible WebGPU device/pass interop, while TSL programs remain Three.js-specific. Bakers and portable technique entry points import no engine or shader backend. | Accepted | -| D-139 | Evaluate TypeGPU functions as an optional source for shared WebGPU raster logic. At `three@0.185.1`, `typegpu@0.11.9`, and `@typegpu/three@0.11.0`, `toTSL()` injects a nullary WGSL closure through Three's WebGPU builder; it is not native TSL conversion, has no forced-WebGL2 route, and has not carried Slug's sampleable resources. A TypeGPU `RasterProgram` may still serve direct WebGPU hosts. This remains an isolated experiment inside the maintained `/typegpu` and `/three` subpath work: acceptance requires exact-version compilation, real resource/loop/vertex proofs, generated-shader inspection, Bitmap and Slug parity, backend coverage, and measured transfer/graph/compilation cost. Core and portable techniques import neither TypeGPU nor TSL. | Experiment | -| D-140 | `renderVariant` is a generic core value resolved by batch → paragraph → span inheritance and copied onto ordered glyph runs after shaping. Core never imports TSL, TypeGPU, material, or effect types. Variants do not split shaping clusters, physical glyph storage, pipelines, or draws by definition; a cluster receives the variant covering its first UTF-16 code unit. Variant changes rebuild run planning without reshaping, while stable integration-owned parameter bindings may update outside core. | Accepted | -| D-141 | The reusable GPU seam is a backend-specific `RasterShader` containing the complete canonical Bitmap, MTSDF, or Slug stage algorithm—including required vertex expansion/snapping/dilation, resource access, and fragment evaluation—followed by a `RasterProgram` that owns resources, application variants, pipeline/material compatibility, and final draw compilation. Custom programs normally call the canonical shader and alter resolved output; full shader replacement is only a low-level escape hatch. Concrete shader and program helpers infer exact associated types; heterogeneous boundaries widen to `unknown`, never `any`, and require narrowing. | Accepted | -| D-142 | `TextEffect` is optional Three/TSL convenience layered over the default `ThreeRenderVariant`, not a core API and not the only customization surface. Effect definitions compose in order after canonical raster evaluation; definition identity determines graph compatibility while parameter values live in text/span-local sidecar bindings. A custom `ThreeRasterProgram` may define another variant type and batching policy while reusing the same technique shader. Native TSL effects remain Three-specific; TypeGPU-authored pure WebGPU math may adapt only within the exact capabilities proven for the pinned `toTSL()` bridge. | Accepted | -| D-143 | The direct TypeGPU engine accepts a caller-owned `TgpuRoot` and owns one hidden core runtime, paragraph-batch attachments, canonical-range synchronization, transform/visibility buffers, program revisions, and draw encoding. It owns no adapter/device request, canvas, scene graph, RAF, render-pass creation, queue submission, or device-loss recovery. Applications mutate retained TypeGPU paragraph handles, explicitly call `update()` or `updateAsync()`, and call `encode(pass, frame)` on any live batch. TypeGPU programs expose typed canonical technique shaders, exact variant codecs, resources, pipelines, and draw compilers; another WebGPU host may reuse them only after its device/pass interop and lifecycle are source-inspected and executed. | Accepted | -| D-144 | Target v1 maintains Three.js, React Three Fiber, and TypeGPU inside `@pmndrs/text` as the `/three`, `/r3f`, and `/typegpu` subpath exports. Those integrations may use package-owned implementation modules while preserving the one-way dependency from renderer-neutral core into no engine. The gpucat fitness fixture alone remains an external package that must consume documented public exports and reject deep imports. The reviewed gpucat surface fits core revisions, typed buffers, dirty ranges, textures, transforms, and ordered instanced draws without a core change; canonical Slug shader reuse remains a separate executable shader-package gate. | Accepted | -| D-145 | Explore TypeGPU as the authoritative complete-stage raster implementation without adding GPU-framework types to core. The experiment begins with the cheapest `@typegpu/three` capability gate and may end in one of three documented outcomes: TypeGPU authority across hosts, TypeGPU authority for WebGPU with native engine fallbacks, or an authoritative semantic raster/resource/stage specification with separately verified TypeGPU, TSL, and gpucat implementations. The currently documented Three bridge is WebGPU-only; native TSL cannot retire while the flagship Three package promises WebGL2. A reusable TypeGPU shader/program package is valuable independently of a full direct scene engine. | Experiment | -| D-152 | Portable raster identities remain branded strings, but public authors construct them without casts: `defineRasterTechnique()` validates and brands its literal technique ID, while `defineRasterResourceId()` validates and brands technique-authored physical resource identities. The implementation proof found that branded input-only declarations were otherwise impossible for an external package to satisfy safely. Heterogeneous canonical storage is a partial `PropertyKey` record because finite named-field interfaces cannot satisfy a total index signature; the concrete self-mapped storage constraint still requires every declared field to be an `ArrayBufferView`. | Proposed | -| D-153 | `RasterGlyphInput` carries the paragraph-local displayed `originX` and `originY` used for the candidate revision, and `RasterGlyphWriteInput` carries the exact technique binding already selected by core for that physical batch. The built-in technique implementation found that storage writers otherwise could not author renderer-ready origins or resource-relative UV/address fields without repeating layout and resource selection. Core remains responsible for layout, glyph overrides, and partitioning; the technique only packs the supplied candidate into its canonical storage. | Proposed | -| D-154 | `RasterTechnique.select()` returns `undefined` when a shaped glyph intentionally has no renderable raster record, including whitespace. The built-in implementation found that a mandatory selection would allocate invalid physical instances for absent records. Font fallback and missing-glyph policy finish before raster selection; omission is only the explicit no-instance result for the already-resolved glyph. | Proposed | -| D-155 | A `TypeGpuRasterProgram` owns an exact `createTarget()` factory rather than exposing an incomplete fixed list of font-resource, pipeline, and run-compilation methods. The first implementation proved that the earlier sketch had no operation capable of creating or partially updating program-specific instance buffers from canonical storage and `dirtyRanges`. The returned public target owns those buffers, textures/tables, variant sidecars, bind groups, pipelines, staged revisions, encoding, and retirement while core remains unchanged. Built-in programs implement the factory; advanced hosts may coordinate its returned target through the ordinary public attachment contract. | Proposed | +| ID | Decision | Status | +| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :-----------------: | +| D-050 | The merged, unreleased v0 implementation contains Bitmap, MTSDF, and Slug; Bitmap alone was only the first integration proof. The target v1 must preserve all three behind the renderer-neutral contract. | Accepted | +| D-051 | Rasters never duplicate advances, kerning, or shaping behavior. | Accepted | +| D-052 | Direct-to-GPU means no reconstruction/repacking, not zero upload. | Accepted | +| D-053 | The MSDF raster uses linear MTSDF RGBA8; padding stays in raster bounds. | Accepted | +| D-054 | Deterministic unhinted bitmap oversampling is the baseline candidate. | Experiment | +| D-055 | Recommend MSDF generally, but require an explicit raster module. | Accepted | +| D-056 | Windfoil is research prior art, not a planned text backend. | Accepted | +| D-057 | Post-slice Slug includes color-emoji vector paint; safe OpenType-SVG and standalone-SVG icon baking lands in the large-coverage CJK/icon milestone. | Accepted | +| D-058 | Fill, opacity, outline, and hard shadow are baseline game-text styles. | Accepted | +| D-059 | Payload reports separate shaping, transport, decoded, and GPU bytes. | Accepted | +| D-061 | Slug bands compress exactly; curve compression remains quality-gated. | Accepted | +| D-064 | Merged v0 and target v1 do not support plain MSDF assets or parallel MSDF/MTSDF batches. | Accepted | +| D-065 | First-party raster packages use TSL internally; the core raster API is shader-system and backend agnostic. | Accepted | +| D-073 | Target v1 assigns one selected raster per font slot; per-glyph raster mixing is additive color/SVG work after the first release. | Accepted | +| D-075 | Latin remains the target v1 rendering and raster-coverage priority. Pre-render CJK shaping/layout conformance may harden universal core assumptions, but CJK raster paging and icon coverage remain a post-v1 milestone and do not expand the Latin-first renderer exit gate. | Accepted | +| D-076 | Raster page indexes are logical IDs; page payloads may be embedded or independently addressed, and raster modules own preparation, residency, eviction, and backend batching. | Accepted | +| D-091 | Bitmap plane bounds preserve the rasterizer's integer pixel placement with `planeUnitsPerEm = strike ppem`; the shared TSL vertex graph snaps projected quad edges to physical framebuffer pixels so native rendering maps one atlas texel to one device pixel. | Accepted | +| D-092 | Hinted grayscale strikes and optional four-phase grayscale packing remain measured research. LCD/ClearType subpixel rendering, panel-order assumptions, runtime hint interpreters, and distance-field reconstruction are out of scope. | Experiment | +| D-093 | Bitmap V0 renders fill and opacity only and rejects outline or shadow through the raster paint-validation seam; MTSDF owns those distance-based effects rather than silently degrading them. | Accepted | +| D-096 | Bitmap presentation transitions are optional `@pmndrs/text/raster/bitmap` helpers over copied glyph identities and instance origins. Shaping and layout commit discretely; only identity-matched glyph positions interpolate before the existing physical-pixel snap, and unused consumers pay no target-origin allocation. | Settled for V0 | +| D-097 | Milestone 8 owns a purpose-built `no_std + alloc` Rust MTSDF core under `packages/text/rust`, with repository-defined types, limits, reusable scratch storage, data-oriented scalar/SIMD experiments, typed errors, and the generated direct-memory C ABI/JSON boundary. The scalar path is a correctness oracle; if `simd128` wins the complete quality, full-font-time, and size comparison, it is the single default shipped Wasm kernel rather than a baker option. The core may retain proven design ideas from reviewed implementations but is not a copied Klyff fork. Pinned native Chlumsky `msdfgen` remains the canonical test-only oracle and port reference; Klyff, OxiText, Rust bindings, and UIKit/Zappar remain research evidence rather than product dependencies. | Accepted | +| D-099 | MTSDF V0 originally fixed one opinionated bake: 64 plane units per em, a full eight-pixel encoded distance range, four field-padding texels, one atlas-gap texel, 1024-pixel pages, dense 20-byte records, and lossless linear RGBA8 KTX2. The published baker composes the admitted scalar kernel with the shared Fontations provider and lossless artifact primitives; standalone generator evidence remains separately measurable but is not a second published Wasm. | Superseded by D-110 | +| D-149 | Text size is logical CSS geometry. A rendering integration supplies an explicit raster pixel ratio; bitmap targets `CSS size × ratio`, deterministically selects the nearest declared physical strike, and never changes paragraph geometry to compensate for DPR. The core installs no DOM or gesture listeners. | Accepted | +| D-150 | Bitmap density strikes remain independent grayscale record/texture sets rather than RGB(A)-channel packing. A combined artifact may carry several strikes, while Milestone 13 adds independently fetched and evictable strike pages. | Accepted | +| D-151 | Language delivery is exact-coverage-first and locale-aware. A family directory may route grapheme-safe runs to font-local units, but language labels alone never prove coverage and units never split contextual shaping runs. Compiler-produced shaping closure/remapping remains Milestone 17. | Accepted | +| D-103 | Explicit and fallback runtime raster baking accept normalized bounded coverage and raster options through the same Worker-only path. Coverage may be seeded by Unicode ranges, authored text, or exact font-local glyph IDs, but it reduces atlas generation only: it does not subset the shaping font, remap glyph IDs, or claim transitive shaping closure. | Accepted | +| D-104 | Every direct-memory Wasm ABI layout is represented by fixed-width `#[repr(C)]` Rust types. Build-only Rust generators derive published JSON and exact `as const` TypeScript contracts from `size_of`, `align_of`, and `offset_of!`; production hosts import those generated facts, and production Wasm embeds no duplicate contract or ABI-pointer bootstrap. WebAssembly direct memory uses its guaranteed little-endian order; portable GLB, KTX2, SFNT, and extension encodings retain their format-defined byte order. | Accepted | +| D-105 | Merged v0 retained the Three.js/TSL integration through Slug so real shader, resource, batching, and lifetime requirements could inform the abstraction. Target v1 extracts one renderer-neutral core beneath Bitmap, MTSDF, and Slug; Three.js, TypeGPU, Wayfare, and other engines become independently selectable integrations. Optional TypeGPU compute-baker research cannot enter unrelated runtime graphs. | Accepted | +| D-106 | Slug V0 artifacts retain exact R16UI reference grids. The Three.js 0.185.1 adapter may pair-pack those values into R32UI texels at decode time because its WebGL TSL backend does not declare an unsigned sampler for `UnsignedShortType`; this preserves reference identity and two-byte density plus at most one terminal padding value. Other adapters remain free to upload R16UI directly, and the exception does not redefine the portable artifact. | Accepted | +| D-107 | Repository TypeScript commands execute the installed native compiler through one bounded runner that first proves its kill/reap path with a synthetic allocator, supervises the native PID rather than a shell or Node shim, caps aggregate tracked RSS, enforces a wall-time limit, and reports no success while a compiler survives. TSL changes compile reduced operation fixtures and a narrow graph before package or application projects; free functions remain the first mitigation but exact-version pathological overloads use one proven concrete compatibility boundary. | Superseded by D-114 | +| D-108 | MTSDF V0 uploads only the authenticated base level and uses bilinear field sampling plus screen derivatives for reconstruction. Conventional GPU mip generation and trilinear cross-level sampling are rejected: averaging encoded MSDF channels is not a distance-field-preserving operation, and the primary MSDF paper plus official generators provide no affirmative mipmap guidance. Runtime, standalone validation, fixtures, and the inspector report the exact padded base texture-array allocation. Any future size-specific representation is an independently authored atlas layer or strike, not a conventional mip chain. | Accepted | +| D-109 | Slug V0 implemented a centered exact-distance outline in one specialized fill-plus-outline draw. Retained measurements later showed `2.44×–4.33×` fill-only GPU time, and generated-shader inspection found duplicated traversal, curve loads, closest-point refinement, and a derivative inside divergent control flow. | Superseded by D-111 | +| D-110 | MTSDF V0 exposes `emSize` and full `pixelRange` as authenticated integer bake options. `emSize` is limited to `1..=1022`, `pixelRange` to `1..=1020`, `planeUnitsPerEm` equals `emSize`, and field padding is `ceil(pixelRange / 2)`. Omitted or partial options resolve against the 64/8 compatibility defaults; explicit effective 64/8 canonicalizes to the legacy fieldless descriptor and raster key, while every non-default descriptor contains both effective values. The low-level Wasm ABI is unchanged. Passing 32/4 and 32/6 155-glyph subset bakes proves the control path, not a new recommended default; quality and payload benchmarking owns that decision. | Accepted | +| D-111 | Remove the dynamic exact-distance Slug outline rather than ship an expensive fallback. Slug V0 supports fill and opacity and rejects every runtime outline or shadow property. The generic text outline API remains because MTSDF owns it. | Accepted | +| D-112 | Research one bounded Slug outline approximation that reuses ordinary fill traversal and screen derivatives without closest-point solving or independent halo traversal. It ships only if its quality is no worse than the MTSDF outline corpus and its median GPU time is at most `1.15×` a same-expanded-quad fill control on both WebGPU and forced WebGL2; otherwise Slug remains fill-only. | Experiment | +| D-114 | Carry the upstream `NodeExtras` lookup-map rewrite as a version-pinned pnpm patch for `@types/three` 0.185.1. A focused compile-only regression owns every previously explosive TSL operation. Package and application scripts invoke the pinned compiler directly; the native-process memory guard and its repository-wide invocation requirement are removed because they contained a dependency type-graph defect now corrected at its declaration boundary. | Accepted | +| D-115 | The benchmark app uses Koota as an application-boundary state manager. Coherent singleton world traits own live controls and published telemetry; direct world reads/writes coordinate capture and renderer state. The world instance is exported from a dedicated HMR-stable module. Koota does not enter core text, shaping, layout, baking, raster, or public package APIs, and entities/queries are reserved for data that genuinely has collection lifecycle. | Accepted | +| D-116 | Interactive benchmark overlays use official shadcn components backed by Base UI rather than application-owned dismissal, focus, portal, or keyboard machinery. Repository semantic tokens theme those checked-in components. Koota remains the single owner of runtime control values; shadcn/Base UI owns interaction behavior only. | Accepted | +| D-117 | Each benchmark route owns one persistent render host per backend generation. The host owns the canvas, renderer, animation loop, GPU timing, telemetry history, viewport, and serialized scene/job lifecycle. React Suspense owns cold asset readiness; scene, technique, delivery, and font selections preload and commit with React transitions so the last complete scene remains visible until an atomic replacement is ready. Compatible font changes retain the active `Text` objects and registry. | Accepted | +| D-118 | Milestone 10 replaces `buildBatches`, optional retained updates, and separate repaint mutation with one required renderer-neutral `stageBatch(previous, layout, resource, fontSlot, paint, rasterPixelRatio)` transaction. A stage owns one unpublished target batch, may retain or replace the previous batch, cannot mutate committed state before publication, commits synchronously and infallibly, and aborts idempotently; committed batch disposal is also idempotent. The portable contract exposes no Three.js, TSL, WebGPU, WebGL, or first-party raster-kind union. Three.js object attachment is an adapter requirement enforced by `Text`, not part of `RasterDrawBatch`. | Accepted | +| D-119 | Once a font, shared shaper, decoded raster resource, and layout-required raster pages are resident, `Text.setProperties` shapes, lays out, plans paint, and stages synchronously while retaining the previous complete generation. The Three.js adapter publishes that candidate at the start of `updateMatrixWorld` or `updateWorldMatrix`, before child traversal; the React adapter explicitly invalidates its R3F root after a core-property update. `ready` remains an observation channel for cold work and queued publication, not a consumer coordination requirement for warm React updates. Synchronous validation, shaping, preparation, or staging faults throw from `setProperties` without cancelling an earlier candidate or live generation; asynchronous preparation and defensive commit-contract faults reject `ready`. A raster `prepare` implementation returns `void` when its requirement is resident and one shared idempotent Promise only for genuinely cold work. | Accepted | +| D-120 | First-party raster batches allocate deterministic 25% glyph-instance slack capped at 256 instances and track logical count separately from capacity. Bitmap, MTSDF, and Slug retain their complete parallel instance records when compatible content fits; shrinks and exact-capacity growth publish authoritative draw counts, while overflow and incompatible ordered Bitmap/Slug page-run topology replace transactionally. Dirty uploads use 32-instance buckets, at most eight disjoint ranges, and a logical full-range fallback; pending renderer ranges carry forward until consumed. This reuse is per batch and does not introduce automatic batching across independent `Text` objects. | Accepted | +| D-121 | The public extension proof is a private `glyphExample` package that owns its kind, descriptor, companion artifact, embedded/external records, baker, runtime generator, decoder, Three.js/TSL adapter, retained capacity, dirty uploads, overflow, abort, and disposal using only published `@pmndrs/text` entry points plus its renderer dependency. Portable batches stay renderer-neutral through `RasterObjectDrawBatch`; `ThreeRasterDrawBatch` documents the `Text` adapter requirement. Static discovery requires the imported factory export name, package manifest key, and default baker kind to match. | Accepted | +| D-122 | Framework-neutral `Text` is a composite `Object3D`, not a `Group`, so a caller-owned parent Group remains Three.js's primary `groupOrder`. `Text.renderOrder` is the secondary paragraph base and each drawable receives that base plus its first-glyph/page-run-local order. Three raster batches implement `setRenderOrderBase`, use neutral non-`Group` roots, and preserve the base across retained commits; the adapter applies it before cold publication, resynchronizes later caller changes during ordinary matrix traversal, and rejects a nested raster Group that would replace the inherited primary key. Nested React Text remains source/span composition and does not create nested scene objects. | Accepted | +| D-123 | The target v1 core has three retained public objects: `TextRuntime`, `ParagraphBatch`, and `Paragraph`. A paragraph batch declares exactly one raster technique, capacity policy, and application render-phase boundary within which core may order and submit paragraphs; it is not a one-draw promise. Every paragraph owns its complete font selection. A multiline block, label, or font-backed icon is always a paragraph. Separate public font-group, paragraph-engine, label, icon, text-item, and mixed-technique logical-batch lifecycles are rejected. | Accepted | +| D-124 | Paragraph handles own desired text, spans, content box, style, paint, finite order, and reversible glyph-origin overrides. Observable top-level setters and indexed methods mark dirty channels without shaping; nested option records are immutable replacements. Repeated writes coalesce naturally. `TextRuntime.update()` snapshots every dirty paragraph across all paragraph batches and synchronously shapes, lays out, partitions, packs, and atomically publishes the final desired state. `updateAsync()` snapshots the same state for asynchronous preparation. A no-op synchronous update returns the current revision without allocation. | Accepted | +| D-125 | Sync versus async is selected per synchronization call, not when creating the runtime. Runtime options only provision a synchronous shaper and optional lazily created asynchronous executor. `updateAsync()` has a Promise form and a callback form that creates no public Promise; both complete asynchronously. Worker results may stream into unpublished staging storage and report bounded progress, but publication remains atomic. Mutations after an update snapshot remain dirty for the next synchronization. A newer sync or async synchronization supersedes any unpublished older asynchronous generation, which can never replace newer state. Published, superseded, and aborted requests are resolved outcomes; only an actual preparation failure rejects the Promise or enters the callback error branch. | Accepted | +| D-126 | Core owns fallback resolution, paragraph sorting, technique/resource partitioning, stable instance slots, capacity growth/chunking, canonical instance packing, dirty ranges, resolved opaque render variants, and ordered `PreparedGlyphRun` values. One same-technique paragraph batch may produce several resource buffers and repeated ordered runs from one buffer. A run is not a promised draw. Engine programs may split or coalesce adjacent compatible runs and own final draw planning, but may not reshape, resort source text, reselect resources, or reallocate core slots; they preserve order unless a documented compositing policy proves another order equivalent. | Accepted | +| D-127 | Core retains one canonical technique-defined structure-of-arrays CPU representation for every prepared glyph batch and reports exact coalesced dirty ranges. Matching targets copy/upload those ranges 1:1; different engine layouts map only those fields and ranges. First or gapped synchronization initializes live ranges referenced by the current glyph runs. Targets never reshape, source-sort, resource-partition, or allocate core slots. The CPU shadow decouples core revisions from inaccessible or in-flight GPU memory and supports multiple or late targets; targets own engine staging, final draw compilation, GPU publication, fences, and retirement. | Accepted | +| D-128 | Bitmap, MTSDF, Slug, and any external raster remain distinct techniques and cannot coexist in one `FontStack` or `ParagraphBatch`. A renderer that deliberately combines data from two existing techniques is a new technique with its own artifacts, compatibility key, instance schema, resource bindings, and shaders. Different fonts within one technique normally remain distinct GPU resource batches; a technique may opt fonts into one physical key only when it actually provides a shared addressable GPU resource. | Superseded by D-161 | +| D-129 | The Three.js surface is `FontLoader`, `TextGroup`, and transform-bearing `Text`; it privately owns every core runtime, paragraph batch, paragraph, revision, attachment, and target. First loader use lazily initializes one cached runtime/shaper. `TextGroup` declares one technique, one construction-time `ThreeRasterProgram`, and one render phase; every `Text` owns a same-technique `Font` or `FontStack`, and standalone text derives an implicit batch. `updateMatrixWorld()` reconciles membership, invokes allocation-free-when-clean runtime update, commits the staged target, runs ordinary world-matrix traversal, and writes changed glyph transforms before render-list construction. The target copies core ranges, the program compiles glyph runs into draw meshes, and WebGPURenderer performs GPU writes/draws. `TextGroup` remains an `Object3D`, preserving the nearest real Group's primary `groupOrder`; its mutable `renderOrder` is the secondary base across compiled draws. | Accepted | +| D-130 | `FontStack` is an immutable ordered logical font selection, not a plural eligibility group: its first concrete font is primary and later same-technique fonts resolve missing glyphs. Every text-facing `font` field accepts a concrete `Font` or `FontStack`; batches and `TextGroup` never declare fonts or fallback. Core exports typed `txt` and `span` template helpers that flatten nested fragments into immutable UTF-16 string/span snapshots without parsing markup. The Three entry point re-exports those helpers directly, and React nested `` composition uses the same composer. Plain strings remain valid and clear spans when assigned. | Accepted | +| D-131 | Paragraph and text counts are not public capacity dimensions. Optional `GlyphBufferCapacity` has only `size` and `policy`, applied as glyph-instance slots independently to each physical technique/resource buffer. Explicit batches default to lazy `{ size: 4_096, policy: 'chunk' }`; standalone Three text defaults to `{ size: 256, policy: 'grow' }`. Chunk preserves buffers and adds fixed chunks, grow transactionally doubles until pending glyphs fit, and fixed makes `size` a hard per-buffer limit. Fixed overflow is knowable only after shaping, fails before publication, and is retained by Three rather than escaping render. Paragraph metadata grows normally, and core preserves logical order through glyph runs across every resulting buffer. | Accepted | +| D-132 | Three.js exposes no `TextGroup.allocate()` or second text-creation path. `new Text(properties)` creates one retained late-bound object; inherited `Object3D.add()` / `remove()` are its only membership operations, and removal does not dispose it. Glyph-slot allocation is an internal synchronization result. Core retains `ParagraphBatch.add(properties)` because `Paragraph` is not independently constructible. | Accepted | +| D-133 | `span()` accepts a renderer-neutral `SpanStyle` alone, or a same-technique `Font` / `FontStack` first followed by styles and compatible font overrides. `SpanStyle` combines paragraph style and glyph paint. Inputs merge left-to-right; later scalar fields or fonts replace earlier ones, while nested features, outline, and shadow replace as units. The returned immutable tag is reusable; readonly tuples preserve inputs for later binding. A style-only tag remains technique-neutral and inherits its surrounding font. | Accepted | +| D-134 | A detached Three.js `Text` owns reusable desired state but no core paragraph, batch, or GPU target. Direct scene rendering creates a text-owned implicit batch; moving into a group publishes destination membership before GPU-safe retirement of that target. Explicit-group slots and buffers belong to `TextGroup`, so removal recycles membership without disposing shared capacity. Even while detached, `Text.dispose()` is permanent: it cancels work, clears caches/references, and prevents reattachment. It neither mutates the scene graph nor disposes group buffers or fonts. Group disposal releases group resources without disposing children or fonts. | Accepted | +| D-135 | Core `Paragraph` handles are permanently owned by their creating `ParagraphBatch`; batch disposal cascades through those handles, while paragraph disposal never disposes its batch, runtime, or fonts. Core moves desired state by immutable snapshot plus destination `add()`, never by transferring a handle. Paragraphs and Three `Text` objects lease every selected concrete font for their full retained lifetime, and font disposal fails while leases remain. Three `Text` owns its desired snapshot and leases independently of group binding, so disposing a populated `TextGroup` unbinds but does not dispose its text; each live compatible text may create fresh membership elsewhere. A disposed group left in the scene graph remains a terminal non-rendering boundary rather than falling through to an ancestor or implicit batch. | Accepted | +| D-136 | Fixed capacity forbids automatic growth, not an explicit owner-directed capacity change. Core `ParagraphBatch.setCapacity(capacity)` preserves the batch, every paragraph handle, subscriptions, and attachments; it clears a latched capacity failure only when the normalized value changes, stages replacement canonical storage at the next synchronization, publishes atomically, and leaves the prior revision live on failure. Existing attachments record that source; each target stages replacement engine buffers on its owner's next `prepare()` and retires old buffers after its fences. Three `TextGroup.setCapacity()` and standalone `Text.setCapacity()` preserve public object identity and forward to their effective or retained implicit batch. The setter records capacity intent; it does not promise immediate allocation. `TextGroup.clone()` and `copy()` are unsupported because recursive copying would silently duplicate identity-bearing text, refs, listeners, membership, and renderer state. | Accepted | +| D-137 | `ParagraphBatch.attach(target)` is the standard retained renderer coordinator, not a privileged preparation API. The public observer replays `current`, reports later revisions, and completes on disposal, so another coordinator needs no private shaping/allocation access. Publication only records the newest attachment source; the observing engine calls `attachment.prepare()` to stage its own target and `commit()` at its safe boundary. `attach()` owns technique validation, cancellation, retained target failure, and cascading disposal. `dirtyRanges` is an adjacent-revision delta: first or gapped synchronization initializes live ranges named by current glyph runs, while adjacent synchronization uploads only the delta. Targets consume or copy canonical ranges during synchronous `stage()` and never retain mutable views across later publications. | Accepted | +| D-138 | The next API splits the current combined `RasterModule` into a renderer-neutral `RasterTechnique` and engine-owned `ParagraphBatchTarget`. One portable technique owns artifact decoding, hash-validated external resource resolution, retained CPU page/table data, glyph-to-resource binding, canonical instance schema, and packing. Every prepared glyph batch exposes that typed binding, so targets create textures/buffers without rediscovering page or resource membership. A concrete technique definition infers and preserves its exact options, descriptor, decoded data, binding, and storage types; the common heterogeneous boundary exposes those associated values as `unknown` rather than erasing them with `any`, and requires narrowing before technique-specific work. GPU resource creation, shaders, pipelines/materials, scene/pass integration, submission, fences, and retirement remain outside core. An optional adapter-level `RasterProgram` may share shader/resource realization across engines using the same backend: TypeGPU programs can be reused where hosts prove compatible WebGPU device/pass interop, while TSL programs remain Three.js-specific. Bakers and portable technique entry points import no engine or shader backend. | Accepted | +| D-139 | Evaluate TypeGPU functions as an optional source for shared WebGPU raster logic. At `three@0.185.1`, `typegpu@0.11.9`, and `@typegpu/three@0.11.0`, `toTSL()` injects a nullary WGSL closure through Three's WebGPU builder; it is not native TSL conversion, has no forced-WebGL2 route, and has not carried Slug's sampleable resources. A TypeGPU `RasterProgram` may still serve direct WebGPU hosts. This remains an isolated experiment inside the maintained `/typegpu` and `/three` subpath work: acceptance requires exact-version compilation, real resource/loop/vertex proofs, generated-shader inspection, Bitmap and Slug parity, backend coverage, and measured transfer/graph/compilation cost. Core and portable techniques import neither TypeGPU nor TSL. | Experiment | +| D-140 | `renderVariant` is a generic core value resolved by batch → paragraph → span inheritance and copied onto ordered glyph runs after shaping. Core never imports TSL, TypeGPU, material, or effect types. Variants do not split shaping clusters, physical glyph storage, pipelines, or draws by definition; a cluster receives the variant covering its first UTF-16 code unit. Variant changes rebuild run planning without reshaping, while stable integration-owned parameter bindings may update outside core. | Superseded by D-167 | +| D-141 | The reusable GPU seam is a backend-specific `RasterShader` containing the complete canonical Bitmap, MTSDF, or Slug stage algorithm—including required vertex expansion/snapping/dilation, resource access, and fragment evaluation—followed by a `RasterProgram` that owns resources, application variants, pipeline/material compatibility, and final draw compilation. Custom programs normally call the canonical shader and alter resolved output; full shader replacement is only a low-level escape hatch. Concrete shader and program helpers infer exact associated types; heterogeneous boundaries widen to `unknown`, never `any`, and require narrowing. | Accepted | +| D-142 | `TextEffect` is optional Three/TSL convenience layered over the default `ThreeRenderVariant`, not a core API and not the only customization surface. Effect definitions compose in order after canonical raster evaluation; definition identity determines graph compatibility while parameter values live in text/span-local sidecar bindings. A custom `ThreeRasterProgram` may define another variant type and batching policy while reusing the same technique shader. Native TSL effects remain Three-specific; TypeGPU-authored pure WebGPU math may adapt only within the exact capabilities proven for the pinned `toTSL()` bridge. | Superseded by D-167 | +| D-143 | The direct TypeGPU engine accepts a caller-owned `TgpuRoot` and owns one hidden core runtime, paragraph-batch attachments, canonical-range synchronization, transform/visibility buffers, program revisions, and draw encoding. It owns no adapter/device request, canvas, scene graph, RAF, render-pass creation, queue submission, or device-loss recovery. Applications mutate retained TypeGPU paragraph handles, explicitly call `update()` or `updateAsync()`, and call `encode(pass, frame)` on any live batch. TypeGPU programs expose typed canonical technique shaders, exact variant codecs, resources, pipelines, and draw compilers; another WebGPU host may reuse them only after its device/pass interop and lifecycle are source-inspected and executed. | Superseded by D-234 | +| D-144 | Target v1 maintains Three.js, React Three Fiber, and TypeGPU inside `@pmndrs/text` as the `/three`, `/r3f`, and `/typegpu` subpath exports. Those integrations may use package-owned implementation modules while preserving the one-way dependency from renderer-neutral core into no engine. The gpucat fitness fixture alone remains an external package that must consume documented public exports and reject deep imports. The reviewed gpucat surface fits core revisions, typed buffers, dirty ranges, textures, transforms, and ordered instanced draws without a core change; canonical Slug shader reuse remains a separate executable shader-package gate. | Superseded by D-234 | +| D-145 | Explore TypeGPU as the authoritative complete-stage raster implementation without adding GPU-framework types to core. The experiment begins with the cheapest `@typegpu/three` capability gate and may end in one of three documented outcomes: TypeGPU authority across hosts, TypeGPU authority for WebGPU with native engine fallbacks, or an authoritative semantic raster/resource/stage specification with separately verified TypeGPU, TSL, and gpucat implementations. The currently documented Three bridge is WebGPU-only; native TSL cannot retire while the flagship Three package promises WebGL2. A reusable TypeGPU shader/program package is valuable independently of a full direct scene engine. | Experiment | +| D-152 | Portable raster identities remain branded strings, but public authors construct them without casts: `defineRasterTechnique()` validates and brands its literal technique ID, while `defineRasterResourceId()` validates and brands technique-authored physical resource identities. The implementation proof found that branded input-only declarations were otherwise impossible for an external package to satisfy safely. Heterogeneous canonical storage is a partial `PropertyKey` record because finite named-field interfaces cannot satisfy a total index signature; the concrete self-mapped storage constraint still requires every declared field to be an `ArrayBufferView`. | Proposed | +| D-153 | `RasterGlyphInput` carries the paragraph-local displayed `originX` and `originY` used for the candidate revision, and `RasterGlyphWriteInput` carries the exact technique binding already selected by core for that physical batch. The built-in technique implementation found that storage writers otherwise could not author renderer-ready origins or resource-relative UV/address fields without repeating layout and resource selection. Core remains responsible for layout, glyph overrides, and partitioning; the technique only packs the supplied candidate into its canonical storage. | Proposed | +| D-154 | `RasterTechnique.select()` returns `undefined` when a shaped glyph intentionally has no renderable raster record, including whitespace. The built-in implementation found that a mandatory selection would allocate invalid physical instances for absent records. Font fallback and missing-glyph policy finish before raster selection; omission is only the explicit no-instance result for the already-resolved glyph. | Proposed | +| D-155 | A `TypeGpuRasterProgram` owns an exact `createTarget()` factory rather than exposing an incomplete fixed list of font-resource, pipeline, and run-compilation methods. The first implementation proved that the earlier sketch had no operation capable of creating or partially updating program-specific instance buffers from canonical storage and `dirtyRanges`. The returned public target owns those buffers, textures/tables, variant sidecars, bind groups, pipelines, staged revisions, encoding, and retirement while core remains unchanged. Built-in programs implement the factory; advanced hosts may coordinate its returned target through the ordinary public attachment contract. | Proposed | +| D-161 | Raster technique is a loaded-font/resource binding and no longer a user-authored property of `FontStack`, `ParagraphBatch`, Three `Text`, or `TextGroup`; this supersedes the technique-homogeneity clauses of D-123, D-126, D-128, D-129, D-130, D-133, and D-137 while preserving their remaining ownership, lifecycle, and ordering rules. One immutable ordered stack may contain different techniques from the same runtime, and fallback chooses fonts from shaped glyph availability without consulting renderer support. A validated render-plan policy declares its supported technique IDs, program/resource/schema mappings, paint and compositing capabilities, batching rules, and augmentations. Every first-party engine policy supports the shipped Bitmap, MSDF, and Slug techniques; third-party engine policies may safely expose a subset and grow it independently. Binding rejects stacks containing an undeclared technique, and preparation rejects unsupported resolved capabilities before atomic publication. Core partitions resolved glyphs by technique, resource, and program while preserving logical/compositing order and revision-relative patches. A mixed stack requires no synthetic composite technique or cross-technique artifact. | Accepted | +| D-162 | Retained text storage uses 16-byte-aligned SoA lanes in ABI-private 64-cluster chunks. The standard Web shaper is one compile-time `simd128` artifact: straight-line record packing stays compiler-vectorizable source, while break masks, bidi transition masks, and integer-exact chunk summaries use explicit 128-bit kernels plus scalar tails. There is no runtime dispatch. `PMNDRS_TEXT_SHAPER_SIMD=0` is the build-time release valve for a same-ABI scalar artifact. The scalar implementation remains the byte oracle. The 25,515/100,602-glyph Node and Chromium packet rejected hand-written packing and 32/128-cluster chunks, admitted the explicit mask/summary kernels, found no warm allocation or memory growth, and kept the selected lab delta to 1,158 Brotli bytes; policy execution, boundary search, native SIMD, and end-to-end contribution remain required measurements when those stages exist. | Accepted | +| D-163 | Validated render policies execute in Rust over borrowed semantic SoA inputs and policy-declared physical buffers. Registration resolves store buffer IDs once; each call preflights field shape, output schema, capacity, and live direct-memory ownership before writes. The scalar interpreter is the byte oracle. The standard Wasm artifact dispatches each straight-line operation over four `simd128` records with scalar tails; compiler auto-vectorization is rejected because it remained within scalar variance. Across 25,515 glyphs, explicit SIMD improves the representative 17-operation policy from 1.174 to 0.428 ms p95 in Node and 1.113 to 0.438 ms in Chromium with identical output bytes and no warm allocation or growth. The production SIMD module is 530 raw bytes smaller and 62 Brotli bytes larger than the same-ABI scalar build. This closes the policy-execution measurement left open by D-162; boundary search, native SIMD, and end-to-end contribution remain open. | Accepted | +| D-164 | The V0 render plan is a portable display-list and resource transaction encoded field-wise in canonical little-endian bytes, not bytecode, a backend command buffer, or a raw Rust-struct image. Its 144-byte aligned header carries revision/publication identity plus policy handle, capability set, and deterministic validated-policy fingerprint. Compiler-mapped fixed records cover semantics (44 bytes), resources (40), buffers (36), patches (36), primitives (64), draws (60), retirements (24), and diagnostics (24). Resource kind is distinct from create/update/retain action; ordered-direct and stable-indirect are explicit buffer strategies. Write-patch payloads are checked spans inside the same immutable publication and are rebased to absolute result offsets; other patch operations carry no payload address. Draw packets carry numeric material, clip, and depth identities rather than renderer objects or callbacks. Validation completes before the inactive A/B arena is touched, and failure headers expose no policy or table state. | Accepted | +| D-165 | Render-policy registration remains one compiler-mapped direct-memory transaction: a 36-byte header addresses fixed 40-byte capability-set, 56-byte program, 16-byte physical-buffer, and 16-byte operation records. Capability-set ID participates in program selection and frame validation; exact programs override set-agnostic programs. Capability records own backend flags, binding/draw limits, update alignment, and upload-cost integers; programs own technique/resource support, requested semantic views, independent storage/draw compatibility keys, and ordered-direct or stable-indirect allocation; buffer records own scalar/vector shape, alignment, padded stride, usage, and capacity class. The draw key may include material while the storage key omits it, producing material-split draws over shared glyph buffers, or both may include material when a backend/schema requires physical partitioning. V0 outputs are independently bindable vector streams rather than aliased mutable interleaved fields: policy bytecode packs wider `vec2`/`vec4` records, preserving disjoint direct borrows and the WebGL-compatible shapes already used by MSDF and Slug. Unknown flags, unsupported allocation/capability combinations, malformed costs/strides, and undeclared update capability sets fail before publication or revision change. | Accepted | +| D-166 | Ordered-direct retained storage uses stable instance IDs plus explicit semantic content revisions as its invalidation authority; it never scans old/new physical bytes to discover changes. Preparation groups records by policy program/resource, maintains an allocation-free warm open-addressed identity set and reusable scratch, coalesces aligned writes with the registered integer cost model, and sends consecutive changed records through the SIMD policy executor. No-op, localized rewrite, suffix-moving insertion, tail retirement, checkpoint/growth, and abort are distinct transactions. CPU mirrors update only after plan serialization succeeds. Dirty updates publish complete compact resource/buffer bindings and ordered glyph-span/draw tables while physical payload stays range-minimal; one span covers consecutive compatible physical records and splits at the 65,535-record wire limit. Stable-indirect storage, session wiring, and performance claims remain unimplemented rather than inferred. | Accepted | +| D-167 | `material` replaces `renderVariant` as the single batch → paragraph/text → span rendering property and `material_id`/`materialId` names its numeric Rust/wire identity. Material changes never reshape or relayout. A registered policy independently declares whether material partitions draw packets and physical storage; every first-party policy splits draws, while capability-specific storage may remain shared or partition when its addressing/schema requires it. Rust never stores a renderer object or invokes a host callback. The renderer maps IDs to retained material/pipeline state and owns construction, caching, fences, and disposal. The bespoke `TextEffect`/effect-list vocabulary is cut. A Three material factory over canonical technique shaders remains the intended integration, but its exact public types stay explicitly open in the material-authority concept. | Accepted | +| D-168 | Stable-indirect storage assigns semantic identities to persistent physical record slots and represents logical order in fixed 64-entry chunks. Insertions and reorders do not move unchanged physical records; content revisions, not byte comparison, select record writes. Deleted record slots and removed order chunks enter publication-generation quarantine and cannot be reused until the renderer explicitly acknowledges the corresponding GPU-safe fence. `consumed_plan_revision` is not that acknowledgment: applying a plan does not prove queued GPU work completed. Prepare, commit, and abort remain transactional; abort restores tentatively claimed reusable slots/chunks. Local order edits preserve whole prefix/suffix chunks when capacity permits, while order-buffer growth republishes every live chunk because a new allocation has no assumed contents. The allocator and chunk reconciler are implemented and tested; full stable-indirect render-plan compilation, session acknowledgment wiring, and target timing remain open. | Accepted | +| D-169 | The stable-indirect render-plan compiler binds each policy-defined physical stream with one reserved `u32` logical-order buffer (`policy_buffer_id = 65,535`). Glyph primitives address consecutive order records; draws bind the order buffer and physical streams while preserving the independent storage/draw key contract. A localized insertion retains existing physical slots and, in the one-stream fixture, emits one 4-byte physical record plus one affected 16-byte order-chunk write; a pure reorder emits only the order write. The capability fragmentation budget also bounds physical order spans: exceeding it transactionally rebases only the order buffer into dense chunks, increments that buffer generation, retires the old generation after its fence, and leaves glyph-buffer generations unchanged. No-op, abort, mixed-resource ordering, shared/partitioned material storage, fence-gated slot reuse, wire validation, and settled scratch capacities are tested. Session wiring, the dedicated renderer-fence acknowledgment request field, and target-hardware planner timing remain open rather than inferred. | Accepted | +| D-170 | A render-plan frame may resolve policy programs using both ordered-direct and stable-indirect allocation. The dispatcher does not materialize strategy-specific glyph/field partitions: each compiler filters the same borrowed semantic input, then only renderer-facing records are merged. Homogeneous frames retain the one-compiler direct-view fast path. Ordered buffer IDs occupy `1..=0x7fff_ffff`; stable physical and order buffers occupy `0x8000_0000..=0xffff_ffff`. Mixed publication rebases payload spans, deduplicates and validates resources, filters a retirement when the other strategy keeps that resource generation live, and merges draws by their original global order token. Alternating strategies, cross-strategy resource continuity, zero-output no-op publication, and settled merge capacities are tested. Wasm session wiring and target timing remain open rather than inferred. | Accepted | +| D-171 | Every engine session owns one Rust render-plan dispatcher and pins the first committed policy handle/fingerprint; capability-set changes remain legal within that policy. The compiler-derived request is 124 bytes and carries `acknowledged_publication_generation` independently from `consumed_plan_revision`. Acknowledgment is monotonic, cannot name the pending publication, and survives an aborted update because it reports an already-completed renderer fence. Wasm prepares and views the Rust plan, validates/stages it in the inactive A/B arena, then commits planner and session revision together; every failure before commit aborts prepared planner state. Making both planners reachable increases optimized Wasm from 739,909 / 272,624 / 214,395 to 822,443 / 308,033 / 242,447 raw/gzip/Brotli bytes. Nonempty semantic input and Rust shaping/layout timing remain unimplemented rather than inferred. | Accepted | +| D-172 | V0 semantic update sections use compiler-mapped fixed records: 24-byte ordered UTF-16 replacements, 88-byte stable style upserts/removals, 52-byte flow constraints, 8-byte flow vertices, 56-byte regions, 48-byte exclusions, and 56-byte inline objects. Text payload stays UTF-16 so every mutation and output cluster uses the repository's canonical indexing without a host UTF-8 conversion. Regions and exclusions have an inline rectangle bounds fast path or bounded polygon vertices referenced inside the same request; the request declares all geometry before one Rust layout call. Style records carry stable identity separately from authored cascade order, stated-field presence, shaping, spacing, raster density, material, color, and decoration inputs, while language/features remain checked offset-addressed payloads. Declaring these layouts does not yet admit or retain nonempty style sections and carries no shaping/layout timing claim. | Accepted | +| D-173 | Text mutation admission is a borrowed, allocation-free decode over 24-byte ordered UTF-16 replacement records and offset-addressed little-endian payloads. The decoder rejects noncanonical empty offsets, unknown encoding/opcode, nonzero reserved fields, arithmetic/range/alignment failures, and payload overlap with the record table before engine mutation. Each session applies replacements sequentially to retained scratch, commits by swapping only after plan serialization/commit, and clears scratch on abort or any invalid later replacement; completed renderer-fence acknowledgment remains independent. Cold request growth uses `reserveSession` before re-pinning, while same-capacity edits neither grow Wasm memory nor allocate mutation objects. The reachable slice changes optimized Wasm from 822,469 / 306,502 / 242,707 to 825,298 / 308,030 / 243,323 raw/gzip/Brotli bytes (+2,829 / +1,528 / +616). This retains real text through `text_update`, but shaping/layout and nonempty plan output remain unimplemented and unmeasured. | Accepted | +| D-174 | Cold capacity is split by ownership instead of reserving the 25K-glyph envelope per text object. Every session creation prewarms retained active/pending UTF-16, Unicode, bidi, shaping-run, shaped-glyph, and fallback arrays to 1,024 text units by default, with shaped-glyph capacity at 2×; `createSession` and `reserveSession` accept an explicit text capacity and retain the resulting high-water mark. These arrays are session-owned because committed results must survive across multiple live sessions. The compiler-published `initialize()` export runs after Wasm instantiation and prewarms reusable module-global synchronous scratch: the 32,768-entry policy-gather arena, HarfRust's actual 32,768-codepoint buffer, and UTF-16 context scratch, covering the 25,515 target fixture without multiplying that largest reservation per session. Initialization settles 57 Wasm pages and is identity-stable when repeated. Policy registration cold-reserves its exact field lanes. Line and geometry-output workspaces remain open; legacy batch-result vectors are outside the final frame claim. Warm `text_update` may not lazily settle capacity within declared envelopes. | Accepted | +| D-175 | Font stacks are cold-registered Rust engine values containing one nonempty, duplicate-free ordered list of existing shaping-font handles. Registration is idempotent only for byte-equivalent order, and a registered stack retains its member fonts so disposal cannot leave a dangling fallback reference. Stack identity is separate from each font's render binding: technique, resource directory, glyph records, and packing lanes are owned once per loaded font rather than copied into every stack. The cold registry uses a compact vector rather than another generic tree map: the rejected map build measured 837,865 raw / 312,057 gzip / 246,478 Brotli bytes, while the selected build is 828,401 / 309,252 / 244,402. The direct-memory lifecycle is outside `text_update`; compiled Wasm with a real baked font proves registration, retention, exact missing-stack status, release, and final font disposal. This establishes fallback ownership but does not claim that fallback shaping or raster binding has landed. | Accepted | +| D-176 | Every render-policy program declares an exact ordered input-source list matching its F32 fields followed by its U32 fields. Each 4-byte compiler-mapped record names a numeric `semantic`, `glyph`, `resource`, or `strike` scope plus a typed lane index; there is no callback or renderer object. Sources are validated and retained at cold registration, participate in the deterministic policy fingerprint, and direct gather from layout state and per-font render bindings into the existing at-most-32-field SIMD policy executor. The request/program records are now 44/64 bytes. Rust and compiled-Wasm tests cover exact decoding, unknown tags, cardinality, fingerprint conflict, reserved data, and overlap. The reachable registration checkpoint changes optimized Wasm from 828,401 / 309,252 / 244,402 to 829,906 / 309,646 / 244,790 raw/gzip/Brotli bytes (+1,505 / +394 / +388); D-178 lands execution. | Accepted | +| D-177 | A shaping font owns one immutable normalized render binding: one technique/program variant, field-major glyph lanes, one scalable strike or strictly increasing physical ppem strikes, dense strike×glyph resource addresses and lanes, and field-major lanes over a strictly resource-ID-ordered directory. MSDF and Slug use the scalable strike; Bitmap selects nearest `fontSize × rasterPixelRatio` and keeps the lower exact tie. Missing glyph resources use one sentinel. This avoids a union record containing every built-in technique's fields, keeps four neighboring rows contiguous for policy gather, and makes cold resource uniqueness validation linear. Registration is a compiler-mapped direct-memory operation, requires the exact shaping glyph count, owns decoded state, is idempotent only for an equal binding, and is released with the font. Rust hostile-wire tests and a real-Inter compiled-Wasm lifecycle test pass. Reachability changes optimized Wasm from 829,906 / 309,646 / 244,790 to 838,060 / 312,606 / 246,732 raw/gzip/Brotli bytes (+8,154 / +2,960 / +1,942). Gather and frame timing remain open. | Accepted | +| D-178 | Policy gather uses one engine-global reusable workspace. Each glyph's selected program fills the same ordered field slots from semantic, font-wide glyph, selected-strike, and selected-resource sources; program-grouped plan execution makes a technique-union record unnecessary. Typed fields are contiguous 16-byte-aligned four-record blocks with scalar tails. `initialize()` reserves the policy-independent 32,768-entry, 60-byte `PlanGlyph` arena; policy registration reserves only that policy's maximum F32/U32 lane counts to the same capacity. Compiled Wasm memory settles 1,245,184→3,342,336 bytes at first initialization and 3,342,336→3,538,944 for a one-F32-lane policy; repeated initialization/registration does not grow. A Rust proof gathers all four scopes into a nonempty ordered plan with exact bytes and unchanged capacity. The production frame reaches the gather with empty layout input, so nonempty frame timing remains open. Reachability changes optimized Wasm 838,060 / 312,606 / 246,732→845,580 / 315,285 / 249,221 raw/gzip/Brotli bytes (+7,520 / +2,679 / +2,489). | Accepted | +| D-179 | Editorial flow geometry is a complete borrowed section of one `text_update`, never a host measurement callback. A nonempty geometry transaction contains at least one constraint and region and may contain bounded rectangle/polygon exclusions and text-anchored inline objects. Compiler-published enums define axis, wrap, inline/block alignment, overflow, writing mode, orientation, exclusion side, and baseline tags. Rust validates finite ordered bounds, polygon vertices, limits, unique identities, region/exclusion ranges, text offsets after pending mutations, reserved fields, and all table/payload overlap before state mutation. Sessions transactionally retain a semantic geometry fingerprint while layout will consume the borrowed records directly; pointer-only vertex offsets are excluded so equivalent repacking is deterministic. Compiled Wasm accepts a complete rectangle/exclusion/object request in one update and rejects a forged region reference without advancing the A/B publication. Optimized Wasm changes from 847,814 / 315,809 / 249,629 to 856,832 / 318,999 / 252,620 raw/gzip/Brotli bytes. Styles remain rejected until their retained mutation model lands. | Accepted | +| D-180 | The retained style ABI uses an explicit authored `cascadeOrder` independent from stable `styleId`; ID allocation therefore cannot change equal-range precedence. Its `fieldMask` is stated-property presence, not a dirty hint, so absent values inherit and explicit zero-valued declarations remain representable. The 88-byte record also carries `rasterPixelRatio` for Rust-owned bitmap strike selection, although target density remains a root target property rather than a per-span typography feature. Compiler-published style, field, decoration-style, and decoration-flag vocabularies prevent host-local tag drift. This decision fixes the wire contract only: nonempty style sections remain rejected until validation and transactional retained storage land. | Accepted | +| D-181 | Nonempty style mutations are admitted only with their Rust consumer. Decoding borrows canonical fixed records and monotonically packed offset payloads without allocation. Each session pre-reserves two flat 64-style/512-language-byte/128-feature arenas and reusable mutation/order/nesting scratch. Mutations collapse by stable ID and merge with committed ID-sorted state into a compact inactive arena; no per-style vector or stale replaced payload survives. Rust validates stated versus absent bytes, numeric domains, language/tags, UTF-16 feature/range boundaries, binary-searched font-stack reachability, one complete root, unambiguous equal-range cascade order, proper nesting, and all request-section aliasing before plan preparation. Payload admission is linear and retained validation is O(n log n), with one reusable-scratch sort. Commit swaps arenas and abort preserves committed state. A compiled-Wasm real-font transaction commits text plus root style and rejects root removal without revision advance or post-creation memory growth. Optimized Wasm changes from 856,831 / 319,003 / 252,236 to 888,423 / 332,740 / 262,748 raw/gzip/Brotli bytes. Layout consumption and latency remain open. | Accepted | +| D-182 | Rust resolves retained stated styles into a derived A/B segment arena before shaping. One containment sweep stores the fully resolved parent in pre-reserved scope scratch, applies each stated field once, restores parent values on close, and coalesces adjacent semantically equal segments. Stable identity is irrelevant to precedence; containment and explicit authored order govern equal ranges. Language and feature results reference retained compact payloads rather than copying per segment. Root font stack, logical size, and target density are required; line height may remain absent to select natural font metrics. A nested/equal-range proof emits five exact maximal segments and verifies inherited shaping, spacing, paint, material, language, and features. Optimized Wasm changes from 888,423 / 332,740 / 262,748 to 895,593 / 335,396 / 264,355 raw/gzip/Brotli bytes. Unicode/run intersection, shaping, layout, and nonempty plan output remain open. | Accepted | +| D-183 | Unicode analysis is derived session state inside the Rust frame transaction. The existing pinned Unicode 17 generator emits TypeScript tables and compact Rust Script/Script_Extensions partitions from one source; the Rust partition omits derivable starts. `unicode-segmentation` 1.13.3 runs under `no_std`, validates retained UTF-16 through a reusable UTF-8 scratch string, returns extended-grapheme boundaries to the public UTF-16 coordinate space, and feeds allocation-reusing contextual script itemization. Active and pending analysis arenas reserve with session text capacity, swap only on commit, abort with a failed frame, and are untouched when text is unchanged. Rust unit tests cover Emoji ZWJ, Indic/Kana scripts, shared marks, malformed surrogates, rollback, and capacity reuse; host/SIMD Clippy and compiled-Wasm lifecycle tests pass. Optimized Wasm changes from 895,593 / 335,396 / 264,355 to 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. | Accepted | +| D-184 | Bidi analysis and shaping-run itemization are derived A/B session state. `unicode-bidi` remains the UAX #9 algorithm and fills reusable level, class, paragraph, and equal-level-run arrays; text and root base-direction changes re-run it, while unchanged text/style skips it. Root direction selects the paragraph base level. A non-root stated LTR/RTL direction is retained as a distinct derived override, inherited through its scope, and forces only the intersected run's level parity. One forward interval sweep intersects maximal resolved styles, contextual script items, and equal-level bidi runs, excludes mandatory hard-break controls, and coalesces equal adjacent shaping records. Session text capacity pre-reserves active/pending bidi and run arrays. Unit tests cover output reuse, mixed Latin/Hebrew levels, root-only direction updates, nested override parity, hard-break exclusion, and transaction rollback; host/SIMD Clippy and focused compiled-Wasm tests pass. Optimized Wasm changes from 964,019 / 360,765 / 288,742 to 968,086 / 362,664 / 286,438 raw/gzip/Brotli bytes. HarfRust fallback shaping, layout, nonempty plan output, and complete-path timing remain open. | Accepted | +| D-185 | Frame shaping consumes retained runs through a borrowed HarfRust input rather than constructing the legacy owned batch request. Legacy exports and `text_update` share the module-global prewarmed UnicodeBuffer, UTF-16 context scratch, and reusable 128-feature vector. Frame language/features borrow the active retained style arena. Each shaped run appends its font/source identity and glyph ID, UTF-16 cluster, advances, offsets, and flags directly into a pre-reserved A/B session SoA arena that swaps only on commit. The production Wasm update borrows the one existing `ShaperRegistry`; it does not duplicate font bytes or serialize shaped output through the old ABI. A compiled real-Inter test proves shape-plan cache count changes 0→1 only after `text_update` and remains unchanged after an aborted frame. Rust unit tests, host/SIMD Clippy, and focused compiled-Wasm tests pass. Optimized Wasm changes from 968,086 / 362,664 / 286,438 to 973,367 / 364,517 / 287,942 raw/gzip/Brotli bytes. This checkpoint shapes only each stack's primary font; ordered fallback, layout, nonempty plan output, and complete-path timing remain open. | Accepted | +| D-186 | Ordered fallback is resolved inside the same Rust frame transaction from actual HarfRust `.notdef` output, never from `cmap`, raster coverage, or a host callback. Reusable flat spans name source run, UTF-16 range, stack index, and concrete font; reusable cluster records collapse multi-glyph clusters with missing status ORed across glyph zero. Records sort by source run/logical cluster to normalize RTL output, then one linear merge advances only missing ranges. Font index increases monotonically, bounding passes by stack depth. Final spans and shaped SoA commit together and abort together. A compiled Inter→Noto Devanagari `text_update` constructs exactly two HarfRust plans, causally proving primary and fallback shaping. Host/SIMD Clippy and focused compiled-Wasm tests pass. Optimized Wasm changes from 973,367 / 364,517 / 287,942 to 982,356 / 368,183 / 290,439 raw/gzip/Brotli bytes. Layout/gather still receive no glyphs, so nonempty plan output and complete-path timing remain open. | Accepted | +| D-187 | Unicode 17 line-break opportunities are retained Rust Unicode state, not host input. The pinned `@cto.af/linebreak` 4.0.3 property trie is generated into a compact scalar partition containing resolved line-break class and only the punctuation, East Asian, and unassigned-extended-pictographic flags its ordered UAX #14 rules consume. A specialized `no_std` Rust evaluator reuses flat scalar/break arrays, returns UTF-16 offsets, retains the upstream MIT notice, and commits/aborts with grapheme/script analysis. All 19,338 unchanged official `LineBreakTest` cases pass, plus focused required-break tests; host/SIMD Clippy passes. Optimized Wasm changes from 982,356 / 368,183 / 290,439 to 1,009,460 / 377,053 / 295,875 raw/gzip/Brotli bytes. Cluster measurement, composition, nonempty plan output, and complete-path timing remain open. | Accepted | +| D-188 | Measured clusters are retained A/B Rust SoA state derived from final fallback-shaped glyphs and Unicode analysis: grapheme UTF-16 starts/ends, ordered `f64` advances, compact safe/allowed/required/hard-break flags, resolved-style index, source-run/font identity, and one offset-to-cluster index. Glyph positions remain `i32` design units through shaping and are scaled once with cached per-font UPEM; letter spacing, word spacing, and zero-width hard breaks join that ordered accumulation. Optional UAX #14 breaks are admitted only when the next grapheme is a HarfRust-safe boundary. A synthetic kernel proof fixes exact `[9, 7, 9, 0]` advances for design widths plus letter/word spacing and proves unsafe suppression; compiled real-font primary/fallback updates reach the pass. Optimized Wasm changes from 1,009,460 / 377,053 / 295,875 to 1,014,577 / 379,510 / 295,708 raw/gzip/Brotli bytes. The Brotli decrease is compressor interaction, not a speed claim. Composition, nonempty plan output, and complete-path timing remain open. | Accepted | +| D-189 | The allocation-free Rust `layout_next_line` kernel consumes retained clusters through a grapheme cursor and one caller-supplied `f64` width. Word mode prefers safe UAX #14 opportunities and falls back to HarfRust-safe boundaries; character mode accepts every safe grapheme boundary; no-wrap ignores width but preserves required breaks; over-wide first clusters always make progress; and a terminal hard break emits the canonical trailing empty line. Focused tests prove these modes and cursor termination. The kernel is not yet connected to region-band resolution or frame output, so this checkpoint makes no end-to-end timing or production Wasm-size claim. | Accepted | +| D-190 | Validated flow snapshots become retained A/B Rust state during the production update transaction. Constraints, ordered regions, exclusions, and polygon vertices are copied into reusable session arrays; vertex offsets are rebased, so no request pointer survives `text_update`. The rectangle fast path subtracts intersecting exclusions through two reusable sorted slot vectors, applies each declared wrap side, and enforces `max_slots_per_band`. An exact fixture maps region `0..100` minus exclusion `20..40` to slots `[0..20, 40..100]`; a polygon fixture proves three vertices survive as owned values. Optimized Wasm changes from 1,014,577 / 379,510 / 295,708 to 1,016,720 / 384,593 / 297,049 raw/gzip/Brotli bytes. Polygon band intersection, line-cursor connection, nonempty plan output, and complete-path timing remain open. | Accepted | +| D-191 | Bounded simple polygons resolve through retained allocation-reusing sweep scratch. Region bands intersect normalized cross-sections at every in-band vertex boundary and within each linear-edge slab; polygon exclusions conservatively project vertices and band-edge intersections over the margin-expanded block range before applying the declared wrap side. Horizontal boundary segments participate before interval normalization. Exact fixtures cover a serialized triangle, a concave U region yielding `[0..40, 60..100]`, a diamond exclusion yielding `[0..20, 60..100]`, and explicit `ResultTooLarge` when two normalized slots exceed a one-slot envelope. The kernel is not yet called by frame line placement, so no production Wasm-size or complete-path timing claim is made. | Accepted | +| D-192 | Production horizontal frame layout now consumes retained clusters and flow geometry into transactional line/fragment A/B arrays. A session-global slot workspace remains allocation-free after its declared high water mark. A band composes every available slot on one baseline, derives ascent/descent/line-height from each cluster's actual fallback font and resolved style in `f64`, and retries at most once with the maximum metrics found by the first widest pass. Enlarging the band conservatively intersects region slots and expands exclusion projection, so the retry consumes a subset rather than exposing later styles. Exact tests produce two fragments around a hole, raise a 10 px estimate to a 20 px line/16 px baseline, and overflow sequentially through region IDs `[1, 2, 2, 2]` without balancing. Rebuilt frame ABI and real-font/fallback integration tests pass. Optimized Wasm changes from 1,016,720 / 384,593 / 297,049 to 1,039,404 / 392,671 / 303,705 raw/gzip/Brotli bytes. Vertical flow, final positioning, boundary reshaping, nonempty plan output, and complete-path timing remain open. | Accepted | +| D-193 | Stable render identity starts with transactional UTF-16 unit IDs, not mutable offsets or collision-prone content hashes. A/B unit-ID arrays undergo the same ordered replacements as text; only inserted units receive monotonic nonzero IDs, and abort restores both committed IDs and the allocator cursor so retry is deterministic. Each grapheme inherits its first unit's ID. An exact fixture preserves `[1,2,3,4]` through abort, then grows an edit to `[1,5,6,3,4,7]`, proving that the shifted `c/d` suffix retains IDs `3/4`; a later first-unit replacement yields `[8,5,6,3,4,7]`. Optimized Wasm changes from 1,039,404 / 392,671 / 303,705 to 1,041,582 / 393,214 / 304,815 raw/gzip/Brotli bytes. Glyph-range allocation, per-glyph revision, positioning, and nonempty plan output remain open. | Accepted | +| D-194 | Logical clusters retain flat shaped-glyph adjacency built by count, prefix sum, and fill. This preserves each shaped run's glyph order while allowing line positioning to traverse cluster slices directly, including RTL output; no per-glyph object, search, or map enters the hot path. An exact fixture maps shaped clusters `[2,1,0]` to logical glyph-index slices `[2]`, `[1]`, `[0]`, and rebuilding preserves every adjacency-array capacity. Optimized Wasm changes from 1,041,582 / 393,214 / 304,815 to 1,043,289 / 394,074 / 304,902 raw/gzip/Brotli bytes. Stable glyph allocation and positioning remain open. | Accepted | +| D-195 | The stable plan pool and glyph reconciliation share one retained exact identity index. Its open-address hash chooses only a probe position; full-key equality decides matches, duplicate keys fail, and epoch clearing makes same-capacity prepare allocation-free. A collision fixture proves these properties. The isolated refactor changes optimized Wasm from 1,043,289 / 394,074 / 304,902 to 1,043,094 / 394,035 / 307,259 raw/gzip/Brotli bytes. The +2,357 Brotli regression is accepted to avoid a second correctness implementation and will be remeasured once glyph reconciliation makes the shared consumer reachable. | Accepted | +| D-196 | Per-glyph stable IDs reconcile transactionally by stable cluster ID and glyph ordinal. Existing ordinals retain monotonic IDs; inserted clusters and added ordinals allocate new IDs; cluster abort also aborts the allocator cursor. A fixture maps prior cluster IDs `[[1,2],[3]]` to `[[4],[1],[3,5]]` and reproduces the same result on retry without capacity growth. Positioning, not shaping, will compare exact final content and own `content_revision`. Optimized Wasm changes from 1,043,094 / 394,035 / 307,259 to 1,044,797 / 395,222 / 307,795 raw/gzip/Brotli bytes. | Accepted | +| D-197 | Horizontal positioning is retained Rust state and directly feeds policy gather. UAX #9 L1/L2 visual order, slot-local alignment/justification, actual fallback metrics, HarfRust offsets, baseline shift, baked extents, and positive-down bounds execute with `f64` accumulation and one narrowing. Six F32 and four U32 semantic SoA lanes remain policy-readable. Exact final bits assign transactional content revisions; a one-pixel fixture proves no-op revision reuse and changed-content advancement. Compiled real-Inter `text_update` publishes nonempty resource/buffer/patch/primitive/draw tables, while an identical warm frame preserves Wasm memory identity and emits zero patches. Optimized Wasm changes from 1,044,797 / 395,222 / 307,795 to 1,057,210 / 400,071 / 311,492 raw/gzip/Brotli bytes. Complete-path 25,515-glyph timing remains unmeasured. | Accepted | +| D-198 | Retained frame invalidation compares exact committed and pending semantic state rather than treating every style or geometry transaction as a full pipeline change. Direction changes restart bidi; shaping inputs restart shaping; metric inputs rebuild measured clusters and flow; positioning/paint inputs rebuild positioned semantics; exact geometry equality with no inline objects skips flow; and an unchanged ordered-direct frame publishes an empty reuse transaction without scanning glyphs. The 25,515-glyph, 8-warmup/31-sample Node run measures Rust cold/no-op/font-size/full-column-resize/suffix-edit/localized-edit at 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. This proves the invalidation direction, not final renderer parity: the Rust lane still executes a one-F32 policy rather than canonical Bitmap's five buffers, so full Bitmap packing remains a required comparison before publishing speedup ratios. Optimized Wasm is 1,060,175 / 400,500 / 316,984 raw/gzip/Brotli bytes. The sequential benchmark process reaches 76.25 MiB after multiple disposed sessions; that is not a per-session requirement or an accepted memory target. | Accepted | +| D-199 | Renderer-parity performance evidence uses validated real baked records and the complete first-party GPU buffer schemas rather than a representative one-lane policy. Bitmap emits five buffers and 48 bytes per renderable instance; MTSDF emits seven vec4 buffers and 112 bytes; Slug emits five float vec4 plus two unsigned vec4 buffers and 112 bytes. Derived foreground channels and inverse font size are gathered on demand without retained glyph arrays. Absent raster records are intentionally omitted, matching the portable techniques. SIMD execution retains SoA inputs, transposes four output records in registers, and writes tightly packed vec2/vec4 records with contiguous 128-bit stores; the schema-identical scalar build remains the baseline. 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. This fails the sub-4 ms gate and therefore mandates dependency-directed physical-buffer execution and publication: resize recomputes geometry only, font size recomputes geometry plus Slug inverse scale, and static UV/color/band/address/count outputs remain retained. Optimized Wasm is 1,060,971 / 400,835 / 317,139 raw/gzip/Brotli bytes. | Accepted | +| D-200 | Physical render-plan writes are selected by validated policy dependencies, not a frame-wide dirty flag. Positioning stores exact six-F32/four-U32 change bits in a compact side lane without enlarging the 60-byte `PlanGlyph`; policy registration propagates those dependencies through the straight-line program once. Ordered-direct and stable-indirect compilers intersect glyph and buffer masks, while new, rebound, or conservatively described records rewrite every output. At 21,805 renderable instances, full-column resize emits 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 writes; font-size emits 340.7/340.7/681.4 KiB. Five-warmup/11-sample resize medians are 4.599/4.916/6.057 ms. The exact payload reduction is accepted, but the latency evidence does not establish packing dominance or a general speedup and still fails the sub-4 ms gate; layout composition is the next measured target. Optimized Wasm is 1,065,394 / 399,111 / 317,830 raw/gzip/Brotli bytes. | Accepted | +| D-201 | Order-preserving positioned-glyph reconciliation compares equal stable-ID slots directly and builds the exact identity index only when output order or membership changes. Reordered content retains the map fallback and an exact revision-preservation test. The canonical benchmark may isolate a named case and load an explicit temporary profiling Wasm, but its default workload and shipping artifact remain unchanged. A symbolized no-`std` profile identifies positioning as the largest sampled full-column-resize function and policy gather as the next largest. Current five-warmup/11-sample Bitmap/MTSDF/Slug resize medians are 4.414/4.984/5.196 ms; inter-run variance prevents a precise speedup attribution and the sub-4 ms gate remains open. Optimized Wasm is 1,065,543 / 399,248 / 318,131 raw/gzip/Brotli bytes. | Accepted | +| D-202 | Positioned layout admits a conservative trivial-LTR lane only when every retained bidi level is even and no shaping run carries a direction override. That lane traverses logical clusters directly and does not fill line-level, visual-cluster, or visual-level scratch; any odd level or override retains the complete UAX #9 L1/L2 path. Exact positioning tests cover the admitted and rejected predicates, while the mixed-direction package golden remains unchanged. On the unchanged 25,515-positioned/21,805-renderable workload, two adjacent eight-warmup/31-sample Bitmap baselines measured 5.197 and 5.162 ms; two optimized runs measured 4.849 and 4.878 ms with the same single 170.4 KiB patch. Post-change MTSDF and Slug medians are 5.355 and 6.001 ms. The sub-4 ms gate remains open. Optimized Wasm is 1,065,857 / 403,525 / 318,137 raw/gzip/Brotli bytes; the six-byte Brotli increase is the meaningful compressed-size comparison, while gzip changed materially from code-layout interaction. | Accepted | +| D-203 | Policy registration compiles input-to-buffer dependencies and reverse operation-to-buffer liveness. Position-only updates gather only source lanes reaching semantically changed buffers and skip scalar/SIMD operations reaching no active buffer; checkpoints, new glyphs, and non-positioning changes force complete evaluation. Consecutive records may reuse immutable font-binding and policy-program resolution, but glyph/resource selection remains per record. Isolated selective gathering regressed and isolated operation liveness was neutral; combined they reduced canonical Bitmap/MTSDF/Slug resize medians from the D-202 checkpoint of 4.878/5.355/6.001 ms to 4.207/4.833/5.615 ms. Resolution caching then measured 3.981 and 4.120 ms in two canonical Bitmap runs, 4.646 ms for MTSDF, and 5.622 ms for Slug. A 4.799 ms isolated Bitmap run keeps the JIT-sensitive sub-4 ms gate open. The accepted cost is 1,069,973 / 405,888 / 319,558 raw/gzip/Brotli bytes, +14,116 / +2,363 / +1,421 bytes over D-202. | Accepted | +| D-204 | The first production Three policy registers Bitmap, MTSDF, and Slug together. Technique, program, and resource identify storage; material, clip, depth, and order additionally identify draws, allowing material-directed draw splits without forcing duplicate physical storage. Public string technique and raster-resource identities lower to deterministic nonzero `u32` wire IDs with UTF-8 FNV-1a, and one runtime-scoped registry rejects collisions across the shared namespace before registration. First-party font bindings compile validated raster data directly into one field-major request allocation, preserve every bitmap strike, sort resources by wire ID, and remap record references accordingly. Exact real-fixture tests compare every production field against the established renderer-parity tables, and the combined policy registers in compiled Wasm. Three render-plan consumption and public third-party policy authoring remain open. | Accepted | +| D-205 | A render-binding handle identifies one loaded font/technique/resource combination independently from its shaping-font handle. Rust font stacks contain binding handles; each binding names the retained shaping font whose SFNT, plans, metrics, and extents it reuses. Fallback and cluster state retain both identities: shaping and positioning use the shaping handle, while policy gather uses the binding handle. Compiled Wasm proves two techniques can bind one shaping font and an Inter-to-Devanagari fallback emits the second binding's distinct technique in one Rust plan. The additional retained `u32` cluster lane measures 4.217/4.791/5.633 ms for Bitmap/MTSDF/Slug resize at 8 warmups/31 samples; 6–7% RSD does not distinguish this from D-203's 4.120/4.646/5.622 ms. Optimized Wasm is 1,070,580 / 402,114 / 319,662 raw/gzip/Brotli bytes, +607 / -3,774 / +104 bytes from D-203. | Accepted | +| D-206 | One Rust engine session corresponds to one renderer batch such as `TextGroup`, not one `Text`. A session retains multiple stable-ID paragraph states and publishes one shared render plan; paragraph mutations and removal are keyed explicitly, and each paragraph owns its text, styles, constraints, and sequential region flow. Glyph and content identities allocate from session-wide monotonic namespaces so one planner cannot alias equal paragraph-local ordinals. Rust appends each paragraph's positioned SoA directly into one pre-reserved policy-gather workspace; TypeScript never concatenates prose, merges glyph arrays, or makes one Wasm call per text. This preserves the accepted batch lifecycle and single frame crossing while keeping exclusions as one-call paragraph geometry. The append kernel has an exact two-layout allocation-free proof. Text, style, constraint, and inline-object records now carry nonzero paragraph IDs through the generated ABI; the transitional single-child state rejects mixed IDs within a transaction and rebinding across transactions. The optimized Wasm is size-neutral within compression/code-layout noise at 1,070,673 raw / 402,177 gzip / 319,722 Brotli bytes versus 1,070,685 / 402,154 / 319,914 before the keyed wire. Retained child state and shared-plan commit remain in progress. | Accepted | + +| D-207 | Paragraph lifecycle identity does not determine presentation order. The frame ABI carries a separate 12-byte paragraph-control record that explicitly upserts ordered batch membership or removes retained state. The decoder rejects zero and duplicate IDs, duplicate declared orders, noncanonical removals, forged section overlap, and counts above the frame paragraph limit. Text, style, constraint, and inline-object records continue to name their owning paragraph independently. The production compiler emits control records before paragraph-owned semantics; the transitional single-child state rejects removal until the retained-child consumer lands. The reachable validation path changes optimized Wasm from 1,070,673 / 402,177 / 319,722 to 1,073,123 / 403,431 / 320,917 raw/gzip/Brotli bytes. | Accepted | + +| D-208 | Batch-global and paragraph-local state have distinct Rust owners before multiple children are admitted. `EngineSession` retains revision/fence state, the pinned policy identity, and the shared render-plan compiler. `ParagraphState` owns text/style mutation scratch and every retained Unicode, bidi, shaping, cluster, flow, and positioned-glyph A/B arena. The first checkpoint preserves the existing single child and exact transaction behavior; 124 Rust unit tests pass. Optimized Wasm changes from 1,073,123 / 403,431 / 320,917 to 1,073,179 / 403,475 / 321,149 raw/gzip/Brotli bytes. | Accepted | + +| D-209 | Stable glyph IDs and semantic content revisions are session-wide monotonic namespaces because one shared planner cannot distinguish equal paragraph-local ordinals. Paragraph identity indexes remain child-local, but prepare borrows transaction-local counters initialized from the session; abort discards them and successful shared-plan commit advances the session counters. The single-child checkpoint preserves exact behavior and all 124 Rust unit tests. Optimized Wasm changes from 1,073,179 / 403,475 / 321,149 to 1,073,074 / 403,537 / 321,046 raw/gzip/Brotli bytes, a size-neutral code-layout movement. | Accepted | + +| D-210 | Multi-paragraph frame consumption uses forward-only borrowed spans, not per-record maps or copied semantic arrays. Validated text, style, constraint, and inline-object tables expose a cursor that consumes only contiguous records for the current paragraph, returns an empty borrowed view when absent, and leaves an exact final cursor check to reject skipped or repeated ownership. The existing single-child production transaction now uses the same path. An exact fixture covers present/absent spans in every keyed table. Optimized Wasm changes from 1,073,074 / 403,537 / 321,046 to 1,074,464 / 404,058 / 321,156 raw/gzip/Brotli bytes. | Accepted | + +| D-211 | Every retained paragraph is initialized and capacity-reserved through one functional `ParagraphState` path. It prewarms paired style/resolution arenas and reusable scratch, then applies one text-capacity policy to every active/pending Unicode-through-positioning arena. New batch children cannot accidentally omit a retained lane or duplicate setup logic. Optimized Wasm changes from 1,074,464 / 404,058 / 321,156 to 1,074,774 / 404,030 / 321,343 raw/gzip/Brotli bytes. | Superseded by D-230 | + +| D-212 | Paragraph transaction finalization has one complete ordered definition. Every preparation failure and explicit session abort invokes `ParagraphState::abort_all`; successful shared-plan publication invokes `commit_all`. This prevents a later child failure from leaving an earlier child's pending Unicode-through-positioning arena live when the retained session becomes multi-paragraph. Rebuilding optimized Wasm also exposed pre-control-record integration fixtures: they now assert the 136-byte frame header and write explicit paragraph IDs through text, style, constraints, and inline objects. Focused compiled-Wasm integration and all 125 Rust unit tests pass. Optimized Wasm changes from 1,074,774 / 404,030 / 321,343 to 1,073,248 / 404,463 / 321,189 raw/gzip/Brotli bytes; no latency claim is attached to this control-flow consolidation. | Accepted | + +| D-213 | One engine session retains multiple stable-ID paragraph states and emits one atomic, revisioned command-buffer delta for the renderer. Paragraph lifecycle records create, reorder, or remove children; absent semantic sections retain a child rather than clearing it, while present text/style/geometry spans remain forward-only borrowed views. Final paragraph order drives one allocation-free gather into the shared Rust policy/planner. Geometry retention compacts only each child's referenced regions, exclusions, and vertices, so a sibling's global table prefix cannot manufacture layout invalidation. A later-child failure aborts every child and lifecycle change; order collisions, unknown semantic owners, and paragraph limits fail before publication. A compiled-Wasm Three-coordinator proof creates two paragraphs with material groups `[7,8]`, sends a lifecycle-only reorder without resending semantic data, and observes `[8,7]` in the next Rust publication. The render plan is therefore a retained renderer-neutral command buffer, not a TypeScript batch or a native `GPUCommandBuffer`. 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. Public Three GPU realization remains open, so this checkpoint carries no end-to-end draw or latency claim. | Accepted | + +| D-214 | Renderer transform ownership is an explicit required draw key. Each gathered paragraph supplies its stable paragraph ID as `transform_id`; transform is forbidden from physical-storage keys, so compatible paragraphs may share buffers, but it is required in draw keys so no packet crosses renderer object transforms. The fixed draw record grows from 60 to 64 bytes and publishes `transformId`. Cluster `semantic_id` no longer splits otherwise compatible glyph spans: a single-semantic primitive retains that ID, while a span crossing semantics publishes zero until optional semantic tables describe finer inspection. The compiled-Wasm two-paragraph fixture changes from six cluster-split draws to exactly two `(materialId, transformId)` packets and preserves exact reversed ownership after lifecycle-only reorder. This is a correctness prerequisite for Three realization and removes pathological per-cluster draws rather than inferring ownership from draw order. Optimized Wasm changes from 1,082,551 / 407,787 / 324,499 to 1,082,773 / 407,870 / 324,551 raw/gzip/Brotli bytes. | Superseded by D-216 | + +| D-215 | The Three coordinator retains a runtime-scoped reverse resource registry keyed by the exact `reference_id` that Rust publishes. Registration walks each already-validated first-party font binding once: Bitmap records exact pages, MTSDF records its atlas data, and Slug records exact analytic pages under the same collision-checked wire identity used by policy/font-binding compilation. Command-buffer execution therefore resolves a resource in one map lookup and never searches loaded fonts, repeats glyph/resource partitioning, or decodes a renderer object in Rust. Equal resource identity reuses the first retained realization source; the same identity under incompatible techniques is rejected. Focused TypeScript and compiled-Wasm coordinator tests pass. Buffer/patch/draw realization remains the next slice. | Accepted | + +| D-216 | Transform realization is selected per policy program rather than imposed globally. A program including transform in its draw key receives transform-split packets with nonzero draw-level `transformId`; a program omitting it packs the region's stable compact `transformIndex` into policy buffer 15 and receives draw-level zero, allowing compatible glyphs to index a renderer-owned matrix sidecar in one draw. The two modes may coexist across programs and capability sets. Region transform slots are supplied with flow geometry but do not participate in layout geometry or change when an `Object3D` matrix changes. Visible overflow carries clip zero; only an actual clip/ellipsis region forms a clip boundary. Rust tests prove split `[1,2]` and indexed `[0]` outputs from the same two-transform input. The compiled-Wasm Bitmap executor copies Rust patches into retained Three storage, resolves resources directly, uploads changed transform matrices without a Wasm call, retains material splits, and collapses two same-material paragraphs into one six-instance draw with transform slots `[2,2,2,1,1,1]`. All 129 Rust tests and the focused compiled-Wasm/Three test pass. Optimized Wasm is 1,083,255 raw / 411,409 gzip / 324,539 Brotli bytes; no browser pixel or end-to-end latency claim is attached yet. | Accepted | + +| D-217 | MSDF command-buffer realization reuses the same retained buffer/patch/draw executor and transform sidecar as Bitmap. Policy buffers 1–7 are exact `vec4` streams for geometry, UVs, bounds, fill, outline, shadow, and effects; buffer 15 remains the transform index. The renderer resolves the Rust resource reference directly, builds one layered RGBA atlas from validated pages, and invokes the canonical `msdfShader` rather than repacking TypeScript technique storage. A compiled-Wasm fixture changes two retained paragraphs from Bitmap-first to MSDF-first fallback without resending text or geometry and observes one six-instance program-2 draw with all eight required buffers. Wasm bytes do not change because this slice is renderer-only. Live WebGPU/WebGL pixels, material factories, retirement-bounded caches, and public `Text` cutover remain open. | Accepted | + +| D-218 | Slug command-buffer realization consumes five Rust-packed `vec4` streams, two `uvec4` streams, and the shared transform-index stream without TypeScript glyph repacking. The executor resolves each validated analytic page once and preserves the WebGL-compatible packed-reference texture. Indexed batching applies one per-instance matrix to both final vertex placement and Slug's half-pixel dilation: the canonical shader accepts an exact full MVP and derives clip-position and clip-normal gradients without matrix-row extraction. The legacy row interface remains available for existing targets. A compiled-Wasm fixture changes the same two retained paragraphs from Bitmap to MSDF to Slug without resending text or geometry and observes one six-instance program-3 draw with policy buffers 1–7 and 15. Wasm bytes do not change because this slice is renderer-only. Live shader compilation/pixels, material factories, retirement-bounded caches, and public `Text` cutover remain open. | Accepted | + +| D-219 | Three material authority uses one public `defineTextMaterial(create)` definition interned by object identity into a nonzero `u32 materialId`; zero remains the canonical default. Rust treats the ID only as policy-directed draw compatibility and never invokes the factory. The Three factory receives a technique-discriminated canonical shader output, the exact renderer-local position after transform indirection, and a DRY `createDefaultMaterial()`. It runs once per compatible material/program/resource realization, must return a fresh `NodeMaterial`, and the adapter owns disposal. A compiled-Wasm fixture proves two IDs split draws over shared storage, reorder/coalescing causes no extra calls, and changing retained glyphs from Bitmap to MSDF to Slug invokes the selected factory once for each new technique realization. Public `TextGroup`/`Text`/span material properties and renderer-fence cache retirement remain cutover work. | Accepted | + +| D-220 | The Three command-buffer executor treats Rust retirements as authoritative residency boundaries. Buffer retirement removes the exact generation and every dependent material realization; resource retirement removes the exact plan resource and disposes its Bitmap texture, MSDF atlas, or Slug page only when no remaining plan resource references the same renderer resource. Slot-range and output-byte retirements require no Three allocation action. Draw realization compatibility excludes mutable `recordIndex`, `recordCount`, and render order: lifecycle-only reorder retains the same meshes/geometries/materials and updates their range uniforms/order, while coalescing retains one compatible draw and retires only the incompatible draw. The compiled-Wasm fixture checks exact live storage-plus-resource byte accounting after Bitmap → MSDF → Slug transitions and strict draw identity across reorder/coalescing. Native GPU fence evidence remains part of the live backend cutover gate. | Accepted | + +| D-221 | Three executes the transform realization selected by each policy program. Indexed packets have zero draw-level transform, require the Rust-packed `u32` buffer 15, share a renderer matrix sidecar, and may merge compatible instances across scene objects. Direct packets have a nonzero Rust draw `transformId`, omit the index/table attributes, and retain one mesh whose relative matrix follows that scene object without another Wasm call. Bitmap, MSDF, and Slug all select their matching position/MVP graph; the target neither invents a transform boundary nor merges across one. A compiled-Wasm fixture runs the same two-paragraph input through indexed and direct first-party policies and observes `[0,0]` versus `[1,2]`, exact attribute presence/absence, and scene-only matrix updates. A hybrid policy then publishes indexed Bitmap `[program 1, transform 0]` and direct MSDF `[program 2, transform 2]` together and updates both renderer transform realizations without Wasm. A public per-text batching switch is not introduced: policy/program selection remains renderer-integration authority. Live backend pixels and public `Text` cutover remain open. | Accepted | + +| D-222 | Public Three `TextGroup` and standalone `Text` now render through one retained Rust engine session and the Rust command-buffer executor rather than constructing a TypeScript `ParagraphBatch`, attaching a target, and negotiating `prepare`/`commit` revisions. A group owns one session for every descendant paragraph and realizes compatible children as one indexed draw beneath the shared group root; standalone text owns the same path with its own draw root. Authored `material` definitions are interned to `materialId` on root text, group inheritance, or spans and resolved only by the renderer. Scene-transform and group render-order changes remain renderer-local. Rendering requests no paragraph layout arrays, so Three's legacy `layout`, glyph snapshot, and glyph-origin override surface is removed; any future measurement, caret, selection, or hit-test surface must be a separate demand-shaped Rust query rather than a render-plan table. Focused compiled-Wasm integration proves shared two-paragraph batching, mixed-font span draws, retained custom-material realization, reparenting, and disposal. D-234 completes deletion of the old TypeScript paragraph/batch implementation. | Accepted | + +| D-223 | Raster technique is owned by each loaded font binding and is no longer repeated by `FontStack`, imperative Three `Text`, or `TextGroup`. `createFontStack` accepts unique live fonts from one runtime and preserves the union of their techniques in its type. The Rust engine resolves the actual binding per glyph; the renderer policy decides whether and how each technique is lowered, and the render plan supplies the resulting storage/draw partition. Three leases heterogeneous selections by runtime rather than enforcing one technique. A compiled-Wasm public fixture realizes one Bitmap-root paragraph with an MSDF span as two policy-selected draws and invokes the same `material` factory under both canonical technique contexts. | Accepted | + +| D-224 | The Three and R3F command-buffer surfaces complete D-167's naming cutover: `material` is the only authored renderer customization property from group/text/span input through numeric Rust `materialId` and renderer factory realization. Their `ThreeRenderVariant` generic, `renderVariant` properties, setters, comparison logic, and no-op binding hook are deleted. D-234 deletes the remaining legacy portable-core and TypeGPU variant state. | Accepted | + +| D-225 | A third-party Three raster integrates with the Rust command buffer through one declarative `registerThreeRasterPlanProgram` registration. Its static policy descriptor is compiled and validated before the engine session exists; it contains input, physical-buffer, scalar-operation, and batching-key data but no hot JavaScript callback. Its cold font compiler lowers validated package-owned raster fields and resource references into one Rust binding, while its renderer factory realizes a material from exact plan buffers only when a draw requires it. The external glyph-example package no longer owns a `ParagraphBatchTarget`, candidate revision, slack planner, dirty-range copier, mesh transaction, or renderer-side layout loop. Compiled-Wasm lifecycle coverage proves Rust-packed buffers and retained mesh/geometry identity. A hardware-browser proof produces two deterministic samples on both WebGPU and forced WebGL2 with the same RGBA SHA-256 `817495c4afe3a8f88d2af85d972f43be88b9f834ed0268d0d0b2e3de86ba9d46`. Indexed visibility remains renderer-local: hiding one public `Text` zeros only its matrix-sidecar slot and preserves the shared draw; direct-policy draws mirror that object's mesh visibility. | Accepted | + +| D-226 | Semantic layout inspection is an explicit demand-shaped Rust query, not renderer input and not a reason to retain the legacy TypeScript layout path. The query uses the existing `semanticViewMask` on `text_update`; zero remains the ordinary rendering request. Its records may share the immutable A/B publication lifetime, but `ThreeTextRenderPlanExecutor` ignores them. The first public `Text.measureLayout()` view emits one paragraph summary plus its line records, performs one extra crossing only on an uncached explicit request, and invalidates on a committed semantic update. Record-level Rust tests cover exact/at-most sizing and overflow, while a real compiled-Wasm Three fixture proves the query leaves `Text.layout` absent and retains the existing mesh. Per-glyph inspection, caret, selection, hit testing, accessibility, and diagnostics remain separate masks and do not enter the render plan by default. | Accepted | +| D-227 | Explicit per-glyph inspection and presentation motion do not restore a renderer-side layout or target transaction. `Text.inspectLayout()` requests the Rust sidecar mask only on demand and copies paragraph summary, lines, semantic glyph IDs, clusters, font identity, size, flags, and shaped origins; ordinary render updates still request none of it. The first-party policy deliberately augments each renderable instance with its session-global stable glyph ID in `u32` buffer 14. Three pairs that policy output with the existing origin stream, snapshots renderer-local displayed origins, and permits topology-guarded presentation overrides for Bitmap, MSDF, and Slug. Before applying any later Rust command-buffer delta, the executor restores the captured authoritative origins; the plan then patches, replaces, or retires resources normally. Thus Rust remains the sole render-state transition authority, while Three retains only resource/draw tables and reversible presentation state—no parallel candidate/current `ParagraphBatchTarget` state machine. Rust record tests and compiled-Wasm one- and two-paragraph fixtures prove semantic spaces remain inspectable, stable IDs address shared draws without collision, overrides do not mutate shaped targets, transform-only updates preserve them, and semantic updates retire them. The optimized shaper is 1,089,889 raw / 414,204 gzip / 325,805 Brotli bytes on the canonical Darwin arm64 host. | Accepted | +| D-228 | A Rust command buffer makes the generic Three paragraph-target transaction redundant. The deleted `ThreeBitmapTarget`, `ThreeMtsdfTarget`, `ThreeSlugTarget`, `RetainedThreeTargetRevision`, and old raster-program registry no longer stage candidate/current rendering state beside Rust. `ThreeTextRenderPlanExecutor` directly applies the authoritative plan and retains only GPU resources, draw/material caches, synchronization, and reversible presentation state. First-party technique modules no longer register renderer targets as import side effects. The composition proof uses ordinary Bitmap policy packing plus `defineTextMaterial`; custom material code changes the canonical shader output without owning attributes, geometry, layout, or plan transitions. Third-party techniques continue through declarative `registerThreeRasterPlanProgram`. | Accepted | +| D-229 | The frame request's 64 MiB `maxOutputBytes` safety limit is independent of the currently reserved A/B result arenas. When a cold publication exceeds an arena, Rust aborts the prepared update and reports the exact required result watermark without advancing engine, plan, or publication revisions. `TextEngineSession` may reserve once and retry that same request; it re-resolves the request pointer and recopies the bytes after the reserve because Wasm growth can detach every prior view. Warm updates remain one crossing. A compiled-Wasm Three fixture starts with only a result-header-sized arena and must still publish a nonempty plan. The live MTSDF paragraph-stress scene now publishes 11,510 glyphs in one draw instead of failing at 1,382,592 required bytes. An adjacent three-run hardware-WebGPU comparison rejected splitting MTSDF origin and size into two `vec2` bindings: packed and split median CPU submit were both approximately 0.50–0.52 ms, while packed median GPU time averaged 0.748 ms versus 0.767 ms split, so the canonical `vec4` binding remains. | Accepted | +| D-230 | A session prewarms exactly one reusable paragraph state; batch glyph capacity sizes shared request/result transport and plan storage, not every paragraph's Unicode-through-positioning arenas. Additional paragraphs grow from their actual content. Recycling clears every committed and pending semantic arena, identity counter, fingerprint, and preparation flag while retaining allocated vectors; a new paragraph therefore begins semantically empty even when it receives a removed paragraph's storage. Frame admission separately covers the larger of final paragraph count and lifecycle-record count, plus the larger of text-derived cluster capacity and actual text/style mutation records. This supersedes D-211's multiplication of one session capacity across every child, which made a 2,926-unit, 476-paragraph icon grid request 4,096 units in every paragraph and fail allocation. A Rust capacity-identity regression, public Three double-replacement fixture, all 201 package tests, all 131 Rust tests, and one complete 27-cell Bitmap/MTSDF/Slug WebGPU workload-transition sweep pass. Optimized Wasm is 1,090,859 raw / 411,106 gzip / 325,149 Brotli bytes. | Accepted | +| D-231 | Browser workflows are successful only when both the Vitexec process and its injected page/module execution are successful. The root runner forwards captured output but rejects `[error]` and `[page error]` even when Vitexec exits zero. Presentation readiness is causal and may wait up to 60 seconds for a cold product-scale workload, but every cell must report positive glyph and draw counts plus zero missing glyphs. Grouped workload telemetry traverses the realized command-buffer root once because executor meshes are siblings of authored entry nodes. The complete 27-cell WebGPU sweep now reports nonzero work and exposes rather than hides Icon Grid's 476-draw, 30.8–47.0 FPS batching-policy gap. | Accepted | +| D-232 | A renderer batch declares its compositing freedom once through `ordered` or `independent`; it does not provide a hot callback. `ordered` is the default and preserves authored draw order. `independent` permits the Rust planner to coalesce compatible interleaved technique/resource groups, and both ordered-direct and stable-indirect strategies implement that policy before emitting the renderer-neutral command buffer. Three and R3F expose the same `compositing` name on `TextGroup`; the benchmark icon grid selects independent mode while prose workloads remain ordered. The optimized shaper is 1,101,079 raw / 417,984 gzip / 328,164 Brotli bytes. | Accepted | +| D-233 | Three classifies desired-state changes by the retained Rust semantic section they can invalidate: text replacement sends text/style/geometry, style-family changes send style, content-box changes send geometry, and an empty update is a no-op. A requested semantic view mask rides on a pending mutation frame so layout measurement/inspection and render-plan publication use one `text_update`; one returned sidecar populates all retained paragraphs, while cached committed queries remain crossing-free. On identical old Rust, Chromium 149/WebGPU/DPR-2 Paragraph Stress changes from 14.295 ms committed-baseline median to 13.615 ms with measurement piggyback alone and 7.450 ms after semantic dirty tiers, at 11,510 glyphs and one draw. The full candidate measures 6.885 ms; these 13–16-sample telemetry histories support isolation and direction, not a portable threshold. Focused integration proves zero calls for empty updates, exactly one call for mutation plus measurement, and exact command-buffer output. | Accepted | +| D-234 | The Rust render-plan cutover has one blessed executable path. The TypeScript paragraph engine, paragraph batch/attachment transaction, preparation worker, direct shaping/bidi/reshape ABI and readback, and first-generation `/typegpu` target are deleted rather than retained as compatibility implementations. Public `TextRuntime` owns the internal shaper; package-owned hosts alone access its direct-memory engine exports. Three and R3F consume the Rust command buffer. TypeGPU is a later from-scratch consumer of the same render-plan/policy contract and may not restore renderer-side layout or candidate/current target state. After cleanup, optimized SIMD Wasm is 1,113,113 raw / 422,035 gzip / 333,171 Brotli bytes. With `three`, React, and R3F external as optional peers, renderer-neutral JS + Wasm totals 1,211,173 / 440,875 / 349,703 raw/gzip/Brotli bytes and the complete Three adapter JS + Wasm totals 1,454,561 / 479,863 / 381,897. | Accepted | + +| D-235 | Raster techniques stop at identity, artifact decoding, retained CPU resource ownership, and disposal. The obsolete TypeScript `RasterRuntime`, candidate/commit raster transaction, glyph `select`, storage allocation, and record writers are deleted; Rust policy programs are the only production instance packers and dirty-range publishers. A Mori 0.19.1 structural scan corroborates the removed parallel path and flags similar ordered-direct/stable-indirect draw emission for evidence-gated extraction, not deletion: the strategies have distinct slot, order-buffer, and retirement semantics. All 154 Rust engine tests, all 161 package integration tests, Unicode 17 bidi/line-break conformance, both TypeScript projects, lint, and formatting pass. The cleanup leaves Wasm unchanged and reduces measured renderer-neutral JS + Wasm from 461,917 to 460,901 gzip bytes and complete Three + Wasm from 501,815 to 498,922 gzip bytes, with Three, React, and R3F external. | Accepted | +| D-236 | Ordered-direct and stable-indirect retain distinct storage engines but share one non-generic, out-of-line primitive/draw command emitter after resolving physical addressing. A symbol-bearing optimized build attributes 33.3 KiB and 50.1 KiB of function bodies to the respective planners; that is an attribution bound, not a duplicate-byte claim. Exact range partitioning also replaces stable planning's quadratic changed-range × slot-write dependency scan, reducing the 22k-target font-size median from 350.136 to 7.982 ms. The combined artifact is 1,160,323 raw / 442,570 gzip / 348,361 Brotli bytes, 220 / 485 / 423 bytes below the pre-extraction artifact. Future transfer-size work compiles separate ABI-identical runtime profiles selected at initialization; provisional `lite`, `cjk`, and `full` membership must be established by final-artifact measurement, and the scalar/SIMD build switch remains orthogonal. | Accepted | +| D-237 | WebGPU aliases canonical Rust-plan typed arrays directly. Three's WebGL2 PBO setup replaces a storage attribute array with power-of-two-padded texture storage, so each later command-buffer dirty range is copied exactly once from canonical storage into that detached upload view before texture invalidation; padding remains untouched. This is a renderer upload adaptation, not a second layout, packing, or render-plan state machine. A simulated-PBO integration regression and all 48 Bitmap/MTSDF/Slug × WebGPU/WebGL2 Presentation workload cells pass. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/dirty-range-upload-research.md b/docs/planning/dirty-range-upload-research.md new file mode 100644 index 00000000..ae73d682 --- /dev/null +++ b/docs/planning/dirty-range-upload-research.md @@ -0,0 +1,279 @@ +--- +type: Engineering Research +title: Adaptive dirty-range uploads for retained text plans +description: Compares three-flatland's bucketed sprite uploads with the Rust text render-plan compiler and defines the evidence needed to tune partial versus full GPU updates. +status: draft +tags: [render-plan, gpu, wasm, rust, three, performance, dirty-ranges] +sources: + - id: flatland-tracker + resource: https://github.com/thejustinwalsh/three-flatland/blob/2935a89fcd9999e8a8b3d3b733f7f7302285cd60/packages/three-flatland/src/pipeline/BucketedDirtyTracker.ts + title: three-flatland BucketedDirtyTracker + - id: flatland-batch + resource: https://github.com/thejustinwalsh/three-flatland/blob/2935a89fcd9999e8a8b3d3b733f7f7302285cd60/packages/three-flatland/src/pipeline/SpriteBatch.ts + title: three-flatland SpriteBatch thresholds and buffer ownership + - id: flatland-flush + resource: https://github.com/thejustinwalsh/three-flatland/blob/2935a89fcd9999e8a8b3d3b733f7f7302285cd60/packages/three-flatland/src/ecs/systems/flushDirtyRangesSystem.ts + title: three-flatland end-of-frame dirty-range flush + - id: text-packing + resource: ../../packages/text/rust/shaper/src/engine/plan_packing.rs + title: Rust render-plan range coalescing and upload cost model + - id: text-ordered-plan + resource: ../../packages/text/rust/shaper/src/engine/ordered_plan.rs + title: Rust ordered-direct changed-range planning + - id: text-stable-plan + resource: ../../packages/text/rust/shaper/src/engine/stable_plan.rs + title: Rust stable-indirect physical and order-buffer planning + - id: text-three-target + resource: ../../packages/text/src/three/engine-plan-target.ts + title: Three render-plan executor and update-range forwarding + - id: three-webgpu + resource: https://github.com/mrdoob/three.js/blob/r185/src/renderers/webgpu/utils/WebGPUAttributeUtils.js + title: Three r185 WebGPU attribute uploads + - id: three-webgl-fallback + resource: https://github.com/mrdoob/three.js/blob/r185/src/renderers/webgl-fallback/utils/WebGLAttributeUtils.js + title: Three r185 WebGL fallback attribute uploads + - id: three-webgl + resource: https://github.com/mrdoob/three.js/blob/r185/src/renderers/webgl/WebGLAttributes.js + title: Three r185 legacy WebGL attribute uploads +generated: + by: openai-codex/gpt-5.6 + at: '2026-08-09T14:10:06Z' +--- + +# Adaptive dirty-range uploads for retained text plans + +## Conclusion + +The Flatland technique applies, but its core decision already exists in the Rust text render-plan compiler. The text +engine should not add `BucketedDirtyTracker` to the Three command-buffer executor or move dirty-range policy back to +TypeScript. Rust already receives the complete mutation transaction, derives exact changed record ranges, coalesces +small gaps, bounds fragmentation, and promotes expensive partial updates to one full live-record range before publishing +`PATCH_WRITE` commands.[^text-packing] + +The useful work is therefore narrower: + +1. calibrate the existing Rust integer cost model against the installed Three WebGPU and WebGL backends; +2. make the cost decision per physical buffer, including the stable-indirect order buffer, rather than treating every + program stream as if it had the same changed-range economics; +3. use a Flatland-style reusable tracker only for renderer-local matrix and presentation-origin edits, which never cross + `text_update`; and +4. remove avoidable host allocations while forwarding already-coalesced Rust patches to Three. + +No new policy-program opcode is required. The renderer declares integer capabilities; the renderer-neutral Rust plan +compiler owns the decision; the adapter translates the resulting byte ranges into the backend API. + +## What Flatland actually does + +Flatland accumulates unordered per-slot writes over a frame. Each physical buffer owns a `BucketedDirtyTracker` with two +fixed `Int32Array`s: one first-dirty slot and one last-dirty slot per bucket. A clean-to-dirty bucket transition increments +one counter; later writes in the same bucket only widen its local span. The 16,384-instance default batch uses 256-slot +buckets, so each tracker scans 64 bucket entries at flush.[^flatland-tracker][^flatland-batch] + +At the single end-of-frame flush, each buffer chooses one of two Three update shapes: + +- fewer than the threshold: one `addUpdateRange` for the first-to-last dirty span in each dirty bucket; +- threshold reached: clear all ranges and set `needsUpdate`, requesting one full-buffer update. + +The thresholds are buffer-specific: five dirty buckets for 16-float matrices and three for the 16-float interleaved core +and custom effect buffers.[^flatland-batch] These are call-count cutovers, not percentage thresholds. At maximum bucket +occupancy they correspond to 1,280 matrix instances or 768 interleaved instances, but even one changed slot in each of +three far-apart buckets selects the interleaved full upload. The ECS flush reads dirtiness before clearing the trackers so +the shadow pipeline can reuse the same signal without rescanning sprite data.[^flatland-flush] + +Because Flatland allocates each typed array at `maxSize`, its empty-range full path uploads capacity, not only the active +`mesh.count`. The text planner's explicit promoted range is materially different: it targets the live record span plus +required initialized alignment padding. Fixed buckets also upload clean slots between the first and last mutation inside +each bucket. Flatland's source describes the design as strictly dominating one global min/max range, but that is a design +claim contingent on correctly tuned thresholds; it is not true for every sparse distribution or low-occupancy batch. + +The implementation has exact unit tests for empty, one-slot, same-bucket, multi-bucket, threshold, reset, non-aligned +capacity, and different-stride cases. Its source comments claim approximately 5 ns per `markDirty`, a sub-microsecond +64-bucket walk, and mobile-WebGPU tuning. Those numbers are not accompanied by a retained microbenchmark artifact in the +surveyed commit, so they are design annotations rather than transferable evidence. The introducing commit reports a +holistic M2 result of approximately 27,000 effect-enabled sprites at 60 FPS, but it also interleaved buffers and removed a +per-frame ECS pass; it does not isolate dirty bucketing. + +The source's claim that full updates use `bufferData` is stale for Three r185 updates. Once a buffer exists, the surveyed +backends use full-span `bufferSubData` or `GPUQueue.writeBuffer`; allocation is a separate path. That does not invalidate +the range-versus-full decision, but it changes the mechanism attributed to the win. + +## What the Rust text path already does + +The text planner has more information than Flatland's mutation-time tracker. Ordered-direct storage scans stable IDs and +content revisions in physical order to produce exact contiguous record ranges. Stable-indirect storage sorts changed +physical slots, creates exact ranges, and separately writes changed 64-entry logical-order chunks. Both reuse retained +scratch vectors rather than allocating one tracker object per frame.[^text-ordered-plan][^text-stable-plan] + +`coalesce_ranges` then applies four renderer-declared controls:[^text-packing] + +| Capability | First-party Three value | Effect | +| --- | ---: | --- | +| update alignment | 4 bytes | expands record ranges to legal backend alignment | +| accepted gap | max(128, 256) bytes | merges neighboring ranges when uploading the gap is cheaper than another call | +| fragmentation budget | 8 ranges | collapses excess fragments to one first-to-last span | +| whole-buffer threshold | 7,500 basis points | selects `0..live_records` when modeled partial cost reaches 75% of live bytes; later alignment may include initialized capacity padding | + +This is the Flatland policy generalized from fixed buckets into exact ranges and an explicit byte/call cost model. It is +also already in the correct ownership layer: the renderer supplies capabilities as static data, while Rust makes one +deterministic decision over the whole transaction and publishes only the selected patches. + +The Three executor copies each patch payload into retained typed storage and forwards its scalar range with +`addUpdateRange`. In r185, WebGPU issues one `queue.writeBuffer` per range and performs no additional merge. WebGL fallback +issues one `bufferSubData` per range and performs no merge. Legacy `WebGLRenderer` sorts and merges adjacent or overlapping +ranges in place before `bufferSubData`.[^three-webgpu][^three-webgl-fallback][^three-webgl] Pre-coalescing in Rust therefore +matters most for WebGPU and TSL's WebGL fallback; relying on legacy WebGL's merge would not cover the shipped renderer. + +## Gaps in the current model + +### Costing is program-wide before buffer liveness is known + +The current coalescer computes `bytes_per_record` by summing every stream in the program, then selects one common range +shape. Semantic dependency masks are applied later, and each active physical buffer receives its own patch. This keeps +correct ranges and prevents inactive-buffer uploads, but the cost estimate can be wrong in both directions: + +- a position-only update may touch one narrow stream, so aggregate stride makes clean gaps look more expensive than they + are and can preserve too many calls; +- an update touching several streams pays one backend call per stream per range, while the model charges the range-call + penalty only once. + +Range selection should be evaluated per active physical buffer, or by an exactly equivalent active-buffer-weighted +model. This belongs in the Rust plan compiler after dependency liveness is known, not in policy bytecode and not in the +Three executor. + +### Stable order chunks bypass the adaptive decision + +Stable-indirect physical records use `coalesce_ranges`, but changed 64-entry order chunks currently become individual +patches. Sparse insertions benefit from that precision; broad edits can publish many order-buffer calls. The same +gap/call/full-live model should consume the changed order chunks before serialization, while preserving chunk retirement +and fence invariants. + +### Renderer-local writes need their own tracker + +Scene-transform synchronization and presentation-origin overrides intentionally do not call Wasm. They currently append +Three update ranges directly. Rust cannot coalesce changes it never sees. A small retained tracker in the Three adapter is +appropriate for these sidecars: + +- one tracker for the shared 16-float transform matrix buffer; +- one tracker per origin buffer touched by presentation overrides; +- reset exactly once after all renderer-local writes for the frame; +- backend-calibrated range/full cutover, with no glyph-layout or policy semantics. + +This is where Flatland's power-of-two buckets are directly reusable. It must not observe or reinterpret Rust +`PATCH_WRITE` commands. + +### Host range forwarding still allocates + +The executor constructs a new `Set` for touched buffer IDs on every plan and Three's `addUpdateRange` constructs one +`{start, count}` object per patch. Rust's fragmentation budget bounds this work but does not eliminate it. A retained +generation stamp can replace the per-apply `Set`; if Three's public contract permits it for the pinned version, retained +range objects can be mutated and the array length adjusted instead of appended. This is an allocation/GC hypothesis until +a Safari allocation trace and an end-to-end timing isolate it. + +### WebGL storage-buffer emulation is a distinct upload shape + +TSL's WebGL fallback represents storage attributes through a PBO-like `DataTexture` path. The builder can replace the +attribute array with power-of-two-padded storage, and the current executor only marks the retained PBO texture +`needsUpdate`; it does not forward the Rust ranges to texture update ranges. Consequently, attribute range reduction does +not by itself prove reduced WebGL texture traffic. WebGL fallback needs its own captured evidence and may require a +row-aware adapter translation or a deliberately conservative capability set. This backend detail must not change the +renderer-neutral patch ABI. + +## Proposed ownership + +| Concern | Owner | Reason | +| --- | --- | --- | +| semantic dependency and changed glyph records | Rust retained engine | only this layer knows what changed and why | +| exact range, gap, fragmentation, and full-live decision | Rust render-plan compiler | one deterministic decision before publication | +| cost constants and backend limits | registered renderer capability set | renderer knowledge expressed as validated data, not a callback | +| packing math | policy program executed by Rust | existing straight-line data transformation boundary | +| byte-range to Three update-range translation | Three executor | backend object and scalar-width knowledge | +| scene matrices and presentation-origin dirty tracking | Three executor | renderer-local data never seen by `text_update` | +| final GPU command submission | Three WebGPU/WebGL backend | outside the renderer-neutral plan | + +Adding bucket size or a fixed dirty-bucket count to the public policy now would overfit Flatland. The existing byte-cost +fields can express the decision more generally. A new capability field is justified only if the benchmark matrix shows +that exact ranges plus byte/call costing cannot reproduce a stable backend optimum. + +## Correctness invariants + +Any refinement must preserve these exact properties: + +1. Applying patches in order produces byte-identical retained buffers to applying one full canonical replacement. +2. No emitted range crosses its buffer generation or allocated byte length, and every range satisfies scalar/backend + alignment. Any aligned records beyond the live draw count retain initialized committed bytes. +3. Coalesced gaps contain committed bytes copied into the outgoing payload; clean bytes are never zeroed or left + uninitialized merely because a larger upload was selected. +4. A replacement allocation is fully initialized even when semantic dependency masks would omit unchanged fields. +5. A no-op frame emits no patch, changes no Three attribute version, and performs no GPU upload. +6. Every active buffer's update ranges are cleared exactly once before its first update and remain available until Three + consumes them. +7. An empty Three range list is legal only when the intended upload covers the entire allocated typed array; a promoted + live span otherwise remains explicit, including any required initialized alignment padding. +8. Stable-indirect order-buffer coalescing preserves logical order, chunk retirement, and fence-delayed slot reuse. +9. Renderer-local trackers never alter Rust buffer identity, command ordering, draw boundaries, or semantic state. +10. WebGPU, WebGL fallback, and any native consumer may choose different cost constants but must realize identical final + bytes and draws. + +## Benchmark and admission matrix + +The comparison must use the same 25,515-positioned-glyph workload and mutation definitions as the current layout evidence. +A smaller representative paragraph may be added, but it cannot replace the canonical worst case. + +Test these update distributions for Bitmap, MTSDF, and Slug: + +- no-op; +- one glyph, 32 adjacent glyphs, and one full 256-record bucket; +- 3, 5, and 9 far-apart single-glyph changes; +- every 64th and every 256th glyph; +- contiguous 1%, 5%, 10%, 25%, 50%, 75%, and 100% spans; +- width-only layout, font-size layout/resource selection, localized text edit, suffix edit, and paragraph reorder; +- stable-indirect insertion and broad order-buffer rewrite; and +- transform-only and presentation-origin-only renderer-local changes. + +Compare at least these planners: + +1. current exact-range cost model; +2. Flatland-equivalent 256-record buckets with 3/5-bucket cutovers; +3. exact per-buffer cost model with a sweep of range-call penalties and whole-live thresholds; +4. one min-to-max range; and +5. unconditional full-live upload. + +Capture per update: + +- `text_update`, range-planning, policy packing, publication, Three apply, render submit, and GPU time; +- patch count, update-range count, payload bytes, uploaded bytes, and full-live promotions per physical buffer; +- retained scratch high-water marks and warm allocations/GC; +- draw count and framebuffer/conformance hash; and +- p50, p95, maximum, and sample count on Chromium WebGPU, Safari WebGPU, and forced WebGL2 on the target computer. + +The selection criterion is end-to-end frame cost, not minimum uploaded bytes in isolation. Adopt a threshold only when an +adjacent A/B run improves median without worsening p95, preserves every correctness invariant, and repeats across the +three raster techniques. Backend-specific constants are acceptable; backend-specific render-plan semantics are not. + +## Not yet verified + +- Flatland's fixed thresholds have not been isolated against its full-upload control on this computer. +- The current text capability values have not been swept against actual `writeBuffer`, `bufferSubData`, or PBO texture + costs. +- The stride-specific Rust coalescing primitive is implemented with focused gap, fragmentation, full-live, and overflow + tests. Ordered-direct and stable-indirect physical storage now retain one reusable range vector per possible physical + buffer, select ranges through exact semantic dependency masks, align for that buffer's stride, and cost it separately. + Stable 64-entry order chunks now use the same cost model and preserve committed bytes inside widened gaps. Identical + per-buffer range shapes regroup into one multi-buffer packing job; the ungrouped prototype repeated cold policy + execution per stream and regressed Bitmap/MTSDF/Slug by roughly 1.2/2.2/2.4 ms before this correction. After grouping, + one canonical run measures 15.208/15.940/16.114 ms cold and 3.946/4.370/5.284 ms resize, versus two detached + `bbd87d3e` baselines of 15.061–15.867 ms cold and 4.101–5.027 ms resize across the three techniques. Standard resize + remains one patch with unchanged bytes and cannot prove the sparse-update benefit; the distribution matrix and browser + upload evidence remain open. +- Safari GC attributed to update-range objects has not been isolated from other known per-frame allocations. +- Partial WebGL fallback PBO texture upload has not been proven through Three r185. + +[^flatland-tracker]: The tracker stores first/last dirty slots in fixed typed arrays and scans the complete bucket table only at flush. +[^flatland-batch]: The audited snapshot fixes the bucket size at 256 and thresholds at 5 for matrices and 3 for interleaved/custom streams. +[^flatland-flush]: `flushDirtyRangesSystem` is scheduled after batch writes and reads `isDirty` before flushing. +[^text-packing]: `coalesce_ranges` implements gap merging, fragmentation collapse, and a basis-point whole-live threshold in `no_std + alloc` Rust. +[^text-ordered-plan]: Ordered-direct compilation derives changes from retained stable identity and content revision in physical order. +[^text-stable-plan]: Stable-indirect compilation retains physical slots and a separate 64-entry chunked logical-order buffer. +[^three-webgpu]: Three r185 WebGPU emits one `GPUQueue.writeBuffer` call for each declared update range. +[^three-webgl-fallback]: Three r185 WebGL fallback emits one `bufferSubData` call for each declared attribute range. +[^three-webgl]: Three r185 legacy WebGL merges overlapping or adjacent ranges in place before upload. diff --git a/docs/planning/engine-integration-contract.md b/docs/planning/engine-integration-contract.md index e4da02e5..d0b9e6c6 100644 --- a/docs/planning/engine-integration-contract.md +++ b/docs/planning/engine-integration-contract.md @@ -4,7 +4,7 @@ title: Engine integration contract description: Exact storage, batching, ordering, transform, publication, and lifetime boundary between core text preparation and an engine renderer. documentation_type: reference tags: [api, engine, rendering, batching, storage, revisions] -status: stable +status: deprecated sources: - id: core-api resource: core-api.md @@ -38,6 +38,10 @@ generated: at: '2026-08-07T03:25:58Z' --- +> Historical design record. The Rust command buffer superseded the target preparation and TypeScript storage contract +> described below. Use [the current package reference](../packages/text.md) and +> [Rust layout-engine design](rust-layout-engine.md) for the blessed integration boundary. + # Engine integration contract An engine integration receives already partitioned glyph storage and ordered, variant-bearing glyph runs. diff --git a/docs/planning/index.md b/docs/planning/index.md index 8d1a3254..44467dde 100644 --- a/docs/planning/index.md +++ b/docs/planning/index.md @@ -10,7 +10,7 @@ - [Raster technique and engine resource API](raster-technique-api.md) — authoritative split between portable baker/artifact/CPU technique data, reusable backend technique shaders, variant-aware programs, and engine GPU targets. - [TypeGPU raster programs and text engine](typegpu-api.md) — complete direct TypeGPU API for typed technique shaders, programs, variants, caller-owned render passes, transforms, synchronization, and disposal. - [TypeGPU-first shader authority](typegpu-first-shader-authority.md) — exploratory package shape and falsifiable proof ladder for sharing complete raster kernels with direct WebGPU hosts, Three.js, and gpucat without changing core. -- [Three material authority for text draws](three-material-authority.md) — **work in progress, follow-up.** Proposes render variants carrying a user material factory over the exported canonical shaders. Recorded so the proposal survives; maintainers have identified incorrect edges that are unresolved. +- [Three material authority for text draws](three-material-authority.md) — **work in progress.** Fixes the Rust `material_id` route and shared-storage/draw-split contract while leaving the exact Three material-factory types for a later design pass. - [Merged v0 raster and baker plugin guide](raster-baker-plugin.md) — build against the implemented combined runtime/renderer module before the target v1 extraction replaces it. - [Architecture](architecture.md) — system ownership, import boundaries, and runtime flow. - [Renderer-neutral core, batching, and engine integration](engine-integration-boundary.md) — WIP extraction and proof plan for the batched core API, Three.js migration, direct TypeGPU engine, and Wayfare adapter. @@ -41,13 +41,17 @@ - [Shaping compilation and execution research](shaping-compilation-research.md) — closed-corpus baking, semantic bytecode, per-font CPU/Wasm specialization, and WebGPU execution research. - [Language-aware font units and physical bitmap strikes](language-and-strike-bundles.md) — coverage-first language delivery, CJK units, DPR selection, and independent strike residency. - [Responsive editorial flow and mixed-raster composition](editorial-flow-layout.md) — post-v1 exclusion regions, responsive columns, and a bitmap/MTSDF/Slug benchmark. +- [Paragraph-scoped preparation and synchronous layout queries](paragraph-query-preparation.md) — one-paragraph + prepare/query, retained candidate adoption, and why it needs no third full buffer. ## Rendering analysis +- [Adaptive dirty-range uploads](dirty-range-upload-research.md) — three-flatland comparison, existing Rust upload-cost + model, backend behavior, remaining per-buffer work, and measurement gate. - [MTSDF generation research](mtsdf-generation-research.md) — primary literature, implementation/license survey, owned Rust boundary, and data-oriented optimization gates. - [Grayscale bitmap hinting research](bitmap-hinting-research.md) — native pixel placement, hinted strikes, and four-phase grayscale packing gates. - [Renderer capabilities](renderer-capabilities.md) — feature matrix and developer guidance. -- [Three.js text effect composition](text-effect-composition.md) — optional TSL convenience over generic core variants and canonical raster shaders. +- [Three.js text effect composition](text-effect-composition.md) — superseded bespoke-effects proposal; custom material authority is the selected direction. - [Implementation difficulty](implementation-difficulty.md) — relative correctness and performance effort. - [Payload budget](payload-budget.md) — serialized, decoded, and resident cost model. - [GPU compression and Rust container ownership](gpu-compression.md) — transport/GPU compression constraints plus the GLB/KTX2 serializer decision. diff --git a/docs/planning/paragraph-query-preparation.md b/docs/planning/paragraph-query-preparation.md new file mode 100644 index 00000000..4e24aee6 --- /dev/null +++ b/docs/planning/paragraph-query-preparation.md @@ -0,0 +1,340 @@ +--- +type: Engineering Research +title: Paragraph-scoped preparation and synchronous layout queries +description: Defines how one paragraph can be shaped and laid out on demand, measured without compiling a render plan, and adopted by the next frame transaction without a third full buffer. +status: draft +tags: + - layout + - shaping + - measurement + - wasm + - performance + - transactions +generated: + by: openai-codex/gpt-5.6 + at: '2026-08-09T14:25:40Z' +sources: + - id: rust-engine-state + resource: ../../packages/text/rust/shaper/src/engine/state.rs + title: Retained Rust paragraph and session transaction state + - id: rust-transport + resource: ../../packages/text/rust/shaper/src/engine/transport.rs + title: Wasm request and A/B publication transport + - id: wasm-entry + resource: ../../packages/text/rust/shaper/src/wasm.rs + title: Wasm frame entry point and publication transaction + - id: three-text + resource: ../../packages/text/src/three/text.ts + title: Three Text measurement and synchronization path + - id: rust-layout-engine + resource: rust-layout-engine.md + title: Rust text engine and retained render-plan ABI + - id: pretext + resource: https://github.com/chenglou/pretext + title: Pretext prepared paragraph and streaming line-layout API + - id: parley + resource: https://docs.rs/parley/latest/parley/ + title: Parley retained shaping, re-linebreaking, and reusable layout scratch + - id: harfbuzz-buffer + resource: https://harfbuzz.github.io/harfbuzz-hb-buffer.html + title: HarfBuzz paragraph-context shaping contract + - id: canvas-measure + resource: https://html.spec.whatwg.org/multipage/canvas.html#dom-context-2d-measuretext-dev + title: HTML Canvas measureText semantics +--- + +# Paragraph-scoped preparation and synchronous layout queries + +## Conclusion + +The engine should support a synchronous, paragraph-scoped prepare/query transaction whose result can be adopted by the +next full frame. It does **not** require a third Wasm result buffer or a third complete paragraph arena. + +The narrow design retains one speculative transaction per engine session. Each synchronous query targets one complete +paragraph; later queries may append other paragraph-keyed candidates to the same transaction and its single linear +identity reservation. A query runs only the target's invalid paragraph stages through positioning, returns the requested +measurement or inspection records, and leaves render-plan gathering, policy execution, packing, publication, GPU +patches, and retirement untouched. The next full update either adopts the exact candidate set or invalidates it and +reuses its allocations. + +This is a bounded follow-up on its own `feat/*` stack after the Rust/Three cutover merges, not an unreviewed change +inside that stack. The remaining realtime publishing features likewise start from the independently consumable merged +cutover; neither follow-up is a hidden merge prerequisite for it. +The current update function deliberately couples paragraph preparation, session-global identity allocation, plan +preparation, A/B publication, and commit. Splitting those phases safely requires a new transaction state and ABI tests; +a request flag that only suppresses output would leave committed layout and displayed render state on different +revisions. + +## What exists now + +Three's `Text.measureLayout()` asks its owning batch to synchronize. When properties are pending, the measurement mask +rides the same full frame update, which is correct and avoids shaping twice. That update nevertheless prepares the +session render plan and applies it before returning the measurement. When the batch is already committed, a cache miss +sends an otherwise empty update, and Rust emits measurement records for every paragraph in the session. + +Rust already retains the expensive products per paragraph: + +- text, resolved styles, Unicode analysis, bidi runs, shaping runs, shaped glyphs, clusters, geometry, flow layout, and + positioned glyphs each have committed and pending storage; +- unchanged stages are reused according to invalidation rather than rebuilt; +- session-global stable glyph IDs and content revisions have committed and pending counters; and +- a full update commits paragraph state, plan state, identities, and engine/plan revisions atomically only after the + inactive result slot has been staged successfully. + +The current architecture therefore has the right reusable products but the wrong transaction granularity for a +query-before-render workflow. + +## Three different kinds of buffering + +The word “buffer” currently covers three independent ownership problems: + +| Storage | Current purpose | Required change | +| ---------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | +| Paragraph committed/pending arenas | Compute a candidate without corrupting the last committed paragraph | Retain pending paragraph candidates across synchronous queries and adopt or abort them on the next frame | +| Wasm A/B result arenas | Keep one immutable publication readable while the next result is written | No third slot; return a copied synchronous query result without changing render-plan authority | +| Renderer/GPU staging | Keep submitted bytes alive until the renderer retires them | No query involvement; only a full frame publishes patches or retirements | + +Triple buffering the publication or GPU data would not solve candidate reuse. The useful “double buffer” is the +paragraph's committed/pending compute state, which already exists. + +```mermaid +flowchart LR + subgraph Compute["Rust paragraph compute state"] + C["Committed paragraph stages"] + P["Pending candidate stages"] + D["Small speculative-transaction descriptor"] + C -->|"prepare one complete paragraph"| P + D --- P + end + + subgraph Result["Wasm result lifetime"] + A["Plan slot A"] + B["Plan slot B"] + Q["Copied query response\nfrom inactive slot"] + end + + subgraph Render["Renderer-owned lifetime"] + G["Retained GPU buffers"] + S["Submission staging / fences"] + G --- S + end + + P -->|"query records only"| Q + P -->|"adopt during full frame"| PC["Policy gather + plan compiler"] + PC -->|"stage immutable plan"| A + PC -. "next generation" .-> B + A -->|"patches and draws"| G + B -->|"patches and draws"| G + Q -.-> X["Host-owned semantic copy only"] +``` + +The query branch terminates in host-owned semantic data: it does not become plan input or renderer state. + +## Proposed prepare/query transaction + +### Query request + +Add a paragraph-query operation to the existing frame ABI and keep one Wasm export. This is a versioned +`ENGINE_UPDATE_ABI_VERSION` change, not an interpretation of currently reserved paragraph-mutation bits. The request +contains: + +- session and paragraph identity; +- the expected committed engine revision; +- the target paragraph's pending text/style/geometry mutations; +- the demanded semantic mask and explicit output ceiling; and +- no renderer policy work, compositing change, paragraph reorder, removal, or publication acknowledgment. + +Each call prepares one complete paragraph. The session holds at most one speculative transaction, but that transaction +may contain multiple paragraph-keyed entries prepared by sequential queries. A repeated query for an already-pending +paragraph either proves the same input fingerprint and reuses it or aborts the speculative transaction before rebuilding +it; it never creates an independent identity-allocation branch. + +### Query execution + +Rust prepares the target paragraph through the least stage that satisfies the query. A multiline size query needs flow +layout and positioned line spans but does not need policy gathering or physical-record packing. A future natural-shape +query can stop earlier when its contract does not require line placement. + +Shaping remains paragraph-contextual. HarfBuzz recommends passing the complete paragraph while selecting a run range so +joining and combining behavior can see surrounding text. “One paragraph” is therefore the bounded reusable unit; an +arbitrary substring is not independently cacheable without an explicit narrowed-context correctness contract. + +The query returns: + +- a small copied semantic response for synchronous host ownership; and +- an opaque candidate token identifying the prepared set, committed base revision, speculative generation, and exact + normalized-input fingerprint. + +The operation does not advance engine revision, plan revision, renderer publication generation, stable-pool retirement, +or **committed** identity counters. Measurement reaches positioning, so the speculative transaction reserves glyph-ID +and content-revision ranges and records their high-water marks. Adoption seeds all other frame allocation after those +reservations; abort releases them without advancing committed counters. It does not make the candidate visible to +rendering. + +### Candidate state and paragraph modes + +The speculative transaction adds a bounded descriptor, not another complete buffer. It records: + +- committed base revision and monotonically changing speculative generation; +- ordered paragraph IDs and exact normalized mutation fingerprints; +- reserved glyph-ID and content-revision high-water marks; +- each paragraph's `positioned_changed` value and satisfied semantic mask; +- whether an entry updates a committed paragraph or owns a not-yet-committed `ParagraphState`; and +- the host font-stack and material leases that keep candidate resources alive. + +A full frame assigns every paragraph exactly one mode: + +- **prepare** — clear/rebuild pending stages from this frame's mutations; +- **adopt** — preserve a validated candidate's pending stages and carry its `positioned_changed` value into gathering; + or +- **leave committed** — require that no pending stage flag is set and use the committed state. + +At commit, a prepared flag may exist only for a paragraph that this frame prepared or validly adopted. Adopt plus an +ordinary mutation/removal for the same paragraph is invalid. This explicit mode prevents today's unconditional +`ParagraphState::prepare` call from erasing the candidate and prevents `commit_all` from publishing unvalidated pending +state. + +For a paragraph that has never committed, the candidate owns its `ParagraphState` without inserting it into the live +session or leaving `lifecycle_prepared` set across calls. Adoption moves that state into the ordinary paragraph lifecycle +inside the final transaction. This is the new paragraph's first state, not a third copy. Failure or supersession returns +its allocations to the session spare/high-water pool. + +### Query response + +The response uses the inactive Wasm result arena with a distinct query-result flag. It follows the existing failure +header discipline: it does not change the active render-plan slot or renderer publication generation, never grows memory +inside the query call, and reports required capacity for a reserve/retry. A cold retry may recompute after abort; the warm +path is the performance contract. + +The host copies the demanded semantic result immediately. It is valid only until the next call into that engine session +and is never retained as a Wasm view. A query does not acknowledge a renderer publication. Host-side stack/material +leases remain held until the candidate is adopted, aborted, superseded, or disposed. + +### Frame adoption + +The host associates the opaque token with the exact public property revisions it measured. If those properties remain +current, the next full update names the token and adopted paragraph IDs instead of resending and recomputing them. Rust +verifies the session, committed base revision, live speculative generation, paragraph set, and repeated input +fingerprint, then includes the reserved identities and positioned records in the ordinary session-wide gather and plan +transaction. All other frame allocations begin after the candidate's retained high-water marks, regardless of paragraph +iteration order, so identities cannot collide. + +Successful A/B plan publication commits the candidate set with every other frame mutation. A changed property, +intervening session commit, invalid token, failed frame, or explicit cancellation aborts the speculative transaction, +increments its generation even when the committed engine revision did not change, and leaves displayed paragraphs +unchanged. This prevents a host token from naming pending arenas destroyed by a failed frame. The allocations remain +available for the next preparation. + +```mermaid +sequenceDiagram + participant App + participant Three + participant Wasm as Rust/Wasm engine + participant Candidate as Speculative transaction + participant Plan as Plan compiler + A/B transport + participant GPU as Three/GPU state + + App->>Three: measureLayout(paragraph A) + Three->>Wasm: query A + pending properties + Wasm->>Candidate: prepare A; reserve identities + Candidate-->>Wasm: metrics + token + high-water marks + Wasm-->>Three: inactive-slot query result + Three-->>App: copied frozen measurement + + opt another paragraph is measured before the frame + App->>Three: measureLayout(paragraph B) + Three->>Wasm: query B + same transaction token + Wasm->>Candidate: append B after reserved high-water marks + Wasm-->>Three: copied B result + updated token + Three-->>App: copied frozen measurement + end + + Three->>Wasm: full update; adopt token + other mutations + Wasm->>Candidate: verify base, generation, set, fingerprints, leases + alt candidate is exact and plan staging succeeds + Candidate->>Plan: adopted positioned records + Plan->>Plan: gather, pack, stage inactive A/B slot + Plan-->>Wasm: publish and commit atomically + Wasm-->>Three: render-plan delta + Three->>GPU: apply patches and draws + else stale token, changed input, or frame failure + Wasm->>Candidate: abort; increment speculative generation + Wasm-->>Three: committed layout and plan remain visible + end +``` + +## Why one speculative transaction + +Paragraph data is locally retained, but glyph stable IDs and content revisions are allocated from session-global +cursors. Multiple independent speculative transactions would reserve competing ranges, require rebasing prepared glyphs +at commit, or introduce a second identity-allocation scheme. One transaction keeps a single linear reservation while +still allowing `measureLayout()` calls for A, then B, to retain both paragraph results for the final frame. + +This keeps allocation deterministic and bounds existing paragraphs to their committed plus already-present pending +high-water storage. The descriptor grows only by compact paragraph metadata. If evidence later requires concurrent +transactions, identity assignment should move to final adoption rather than multiplying complete buffers. + +```mermaid +stateDiagram-v2 + [*] --> Idle + Idle --> Prepared: query one committed or new paragraph + Prepared --> Prepared: append another paragraph query + Prepared --> Adopting: full frame names exact token + Prepared --> Aborting: property change / supersession / disposal + Adopting --> Committed: plan staged and published + Adopting --> Aborting: validation or publication failure + Aborting --> Idle: clear pending flags; generation++ + Committed --> Idle: release candidate leases and descriptor +``` + +## Public behavior + +The desired semantic split is: + +- `measureLayout()` with no pending change returns the frozen committed cache without crossing; +- `measureLayout()` with a pending change prepares only that paragraph, returns its pending measurement synchronously, + and adds it to the session's speculative token; +- sequential measurements of other pending paragraphs extend that same transaction instead of invalidating earlier + work; +- the next render synchronization adopts the token if every adopted property revision is still current; and +- inspection remains an explicit larger copy and never becomes render-plan input. + +This resembles Canvas `measureText()` in being a synchronous query that does not render or mutate a scene, while the +opaque retained candidate adds reuse Canvas does not expose. Pretext independently demonstrates the useful separation +between paragraph preparation and cursor-driven line layout; Parley demonstrates retaining shaped layout while +re-breaking and re-aligning it and reusing scratch allocations. + +## Performance and allocation gates + +The feature is admitted only if a benchmark with many retained paragraphs and one pending target proves all of the +following against the current full-frame measurement path: + +- the query shapes/layouts exactly one paragraph and executes zero policy programs; +- it serializes zero render resources, buffers, patches, primitives, draws, or retirements; +- adopting the candidate performs zero repeated analysis, shaping, flow layout, or positioning for that paragraph; +- settled queries allocate nothing after high-water reservation; +- the query plus later adopting frame is faster than the current measurement-carrying full update plus an unchanged + successor frame; and +- abort, supersession, output growth, and failed final publication preserve the last committed layout and plan bytes. + +The benchmark must report query latency and combined query-plus-frame latency for both one pending paragraph and N +sequentially measured pending paragraphs. The N-paragraph case must prove one speculative transaction preserves earlier +prepared results and unique identities. A faster single query that makes the eventual frame or common multi-measurement +workflow slower is not a win. + +## Implementation boundary + +Land this from the merged Rust/Three cutover on its own stack. The work is cohesive but not “easy” because it must: + +1. factor paragraph preparation from session plan preparation; +2. add one retained speculative-transaction descriptor and explicit prepare/adopt/leave-committed modes; +3. reserve speculative identity ranges without committing or colliding with other frame mutations; +4. keep not-yet-committed paragraphs and host resource leases alive without opening live session lifecycle state; +5. return a targeted inactive-slot response without masquerading as a render-plan publication; +6. teach Three to associate one token with exact paragraph property revisions; and +7. prove single- and multi-paragraph reuse, invalidation, failure atomicity, bounded memory, and combined latency in + compiled Wasm. + +No third output slot, third GPU buffer, host callback, second shaping implementation, or layout arrays in the command +buffer are part of this design. The required candidate descriptor and, for a new paragraph only, its first owned +`ParagraphState`, are explicit transaction state rather than hidden complete buffers. diff --git a/docs/planning/raster-technique-api.md b/docs/planning/raster-technique-api.md index 0e5d4e5f..22840fdb 100644 --- a/docs/planning/raster-technique-api.md +++ b/docs/planning/raster-technique-api.md @@ -4,7 +4,7 @@ title: Raster technique and engine resource API description: Canonical boundary between portable raster baking and decoding, core glyph packing, reusable shader backends, and engine-specific GPU targets. documentation_type: reference tags: [api, raster, baking, resources, shaders, engines, typegpu, tsl] -status: stable +status: deprecated sources: - id: core-api resource: core-api.md @@ -44,6 +44,10 @@ generated: at: '2026-08-07T04:49:05Z' --- +> Historical design record. The Rust render-plan cutover superseded the TypeScript glyph binding, storage, and packing +> interfaces described below. Use [the current package reference](../packages/text.md), +> [core API](core-api.md), and [Three API](three-api.md) for the blessed surface. + # Raster technique and engine resource API One raster should not be one engine plugin. The stable split is: diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 30881adb..4186b1ba 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -1,16 +1,19 @@ --- type: Design Proposal -title: Rust layout engine and the atomic frame ABI -description: Moves retained paragraph layout into the existing Rust shaper crate behind one frame-scoped entry point, so the logic that must be correct is written once and reused natively, and instance data reaches the GPU without leaving Wasm memory. +title: Rust text engine and retained render-plan ABI +description: Defines a Rust-owned shaping, layout, typography, and render-plan pipeline with one steady-state Wasm update transaction and renderer-directed incremental output. status: draft tags: - layout + - shaping + - typography + - rendering - wasm - performance - abi generated: - by: anthropic-claude/opus-5 - at: '2026-08-08T05:10:00Z' + by: openai-codex/gpt-5.6 + at: '2026-08-10T02:15:49Z' sources: - id: layout-benchmark resource: ../../packages/text/scripts/benchmark-paragraph-layout.mts @@ -18,200 +21,1613 @@ sources: - id: shaper-crate resource: ../../packages/text/rust/shaper/src/lib.rs title: HarfRust Wasm shaper crate - - id: paragraph - resource: ../../packages/text/src/paragraph.ts - title: TypeScript paragraph preparation and layout - - id: bitmap - resource: ../../packages/text/src/raster/bitmap-technique.ts - title: Portable Bitmap technique, canonical instance storage - - id: pbo - resource: https://github.com/mrdoob/three.js/blob/r185/src/renderers/webgl-fallback/nodes/GLSLNodeBuilder.js - title: Three.js WebGL fallback node builder, setupPBO + - id: vertical-writing + resource: vertical-writing.md + title: Vertical-writing research + - id: editorial-flow + resource: editorial-flow-layout.md + title: Editorial-flow research + - id: writing-modes + resource: https://www.w3.org/TR/css-writing-modes-4/ + title: CSS Writing Modes Level 4 + - id: css-text + resource: https://www.w3.org/TR/css-text-4/ + title: CSS Text Level 4 + - id: text-decoration + resource: https://www.w3.org/TR/css-text-decor-4/ + title: CSS Text Decoration Level 4 + - id: inline-layout + resource: https://www.w3.org/TR/css-inline-3/ + title: CSS Inline Layout Level 3 + - id: multicol + resource: https://www.w3.org/TR/css-multicol-2/ + title: CSS Multi-column Layout Level 2 + - id: jlreq + resource: https://www.w3.org/TR/jlreq/ + title: Requirements for Japanese Text Layout + - id: ruby + resource: https://www.w3.org/TR/css-ruby-1/ + title: CSS Ruby Annotation Layout Module Level 1 + - id: harfbuzz + resource: https://harfbuzz.github.io/harfbuzz-hb-buffer.html + title: HarfBuzz buffer and safe-concatenation contract + - id: icu4x + resource: https://docs.rs/icu_segmenter/latest/icu_segmenter/ + title: ICU4X segmenter Unicode-version documentation + - id: parley + resource: https://docs.rs/parley/latest/parley/layout/ + title: Parley retained rich-text layout - id: pretext resource: https://github.com/chenglou/pretext - title: Pretext, incremental per-line text layout + title: Pretext incremental per-line text layout + - id: paragraph-query-preparation + resource: paragraph-query-preparation.md + title: Paragraph-scoped preparation and synchronous layout queries + - id: webrender + resource: https://firefox-source-docs.mozilla.org/gfx/RenderingOverview.html + title: Firefox rendering overview and display lists + - id: staging + resource: https://docs.rs/wgpu/latest/wgpu/util/struct.StagingBelt.html + title: wgpu staging-belt upload model + - id: wasm-js-api + resource: https://webassembly.github.io/spec/js-api/ + title: WebAssembly JavaScript API + - id: wasm-simd + resource: https://webassembly.github.io/spec/core/ + title: WebAssembly core SIMD and relaxed-operation semantics + - id: rust-simd + resource: https://doc.rust-lang.org/core/arch/wasm32/index.html + title: Rust wasm32 SIMD intrinsics and target-feature model + - id: safari-simd + resource: https://webkit.org/blog/13966/webkit-features-in-safari-16-4/ + title: WebAssembly 128-bit SIMD in Safari 16.4 + - id: chrome-simd + resource: https://blog.chromium.org/2021/04/chrome-91-handwriting-recognition-webxr.html + title: WebAssembly SIMD enabled by default in Chrome 91 + - id: firefox-simd + resource: https://bugzilla.mozilla.org/show_bug.cgi?id=1625130 + title: Firefox WebAssembly SIMD shipping record + - id: worker-transfer + resource: https://html.spec.whatwg.org/multipage/workers.html + title: HTML Worker transfer semantics + - id: renderer-capabilities + resource: renderer-capabilities.md + title: Renderer capability matrix + - id: dirty-range-uploads + resource: dirty-range-upload-research.md + title: Adaptive dirty-range upload research + - id: payload-budget + resource: payload-budget.md + title: Font and raster payload budget + - id: mlreq + resource: https://www.w3.org/TR/mlreq/ + title: Mongolian Layout Requirements + - id: unicode-emoji + resource: https://www.unicode.org/reports/tr51/ + title: Unicode Emoji 17 + - id: opentype-colr + resource: https://learn.microsoft.com/en-us/typography/opentype/spec/colr + title: OpenType COLR color table + - id: opentype-cbdt + resource: https://learn.microsoft.com/en-us/typography/opentype/spec/cbdt + title: OpenType CBDT color bitmap table + - id: opentype-sbix + resource: https://learn.microsoft.com/en-us/typography/opentype/spec/sbix + title: OpenType sbix color bitmap table --- -# Rust layout engine and the atomic frame ABI +# Rust text engine and retained render-plan ABI -This proposal moves retained paragraph layout from TypeScript into the existing Rust shaper crate, behind a single -frame-scoped entry point, and makes canonical instance storage the engine's output rather than something JavaScript -assembles afterwards. +This proposal supersedes the narrower “move paragraph layout into Rust” draft. The unit moving into Rust is the complete +text engine: Unicode analysis, bidi, style itemization, shaping, cluster construction, line breaking and composition, +positioning, typography-derived geometry, and renderer-neutral render-plan compilation. TypeScript retains public API +lifecycle and renderer integration; it does not retain a second implementation of typography. -It is written after a measured TypeScript optimization pass, so it inherits that pass's evidence rather than -speculating. Read [the decision register](decision-register.md) entries D-159 and D-160 for the tiering and the -measurement discipline the numbers below depend on. +The engine publishes two related products: -## Why this is not only a performance change +1. a semantic layout snapshot for editing, accessibility, hit testing, native reuse, and diagnostics; and +2. a revisioned render plan describing resources, physical buffer views, minimal patches, ordered primitives, and draw + packets that the initial Three/TSL dual-backend adapter or a native adapter lowers into its own commands. -Three requirements decide the shape, and only one of them is speed. +The render plan is deliberately closer to a display list or submission transaction than a shaped-glyph array. It is +not a literal WebGPU command buffer: backend-specific pipelines, staging, fences, and command encoding remain with the +renderer. -**The logic must be written once.** Bidi resolution, cluster boundaries, break opportunities, shaping integration, and -line composition are the parts most expensive to get right and most expensive to get wrong. A native consumer that -reimplements them inherits a second set of bugs and a second conformance obligation. Rust compiles to both targets from -one source, so the correctness-critical core stops being a per-target liability. +## Decisions this proposal makes -**Layout must be per line, with a caller-supplied width.** An editorial page flows text around images and objects, so -each line has its own available width, computed by subtracting blocked intervals. A paragraph-scoped pass cannot express -that: there is no single width to pass it. Pretext demonstrates the working shape — prepare once, then -`layoutNextLine(prepared, cursor, maxWidth)` with a segment/grapheme cursor carried between calls, each call free to -take a different width.[^pretext] Per-line layout is therefore a feature requirement that happens to also be the -performance answer. +- Correctness-critical shaping and layout logic has one Rust implementation shared by native and Wasm consumers. +- A dirty text session performs one Wasm update transaction in steady state. Unchanged animation frames perform none. + Font registration, session creation, cold reservation, and transfer-buffer return are lifecycle operations, not + hidden typography crossings. +- Per-line widths are computed inside Rust from declarative flow regions, columns, exclusions, and inline objects. An + arbitrary host callback per line is incompatible with both a single crossing and native reuse. +- The engine retains semantic layout state and publishes a portable render plan. Rendering receives only policy-packed + physical records and commands. Measurement, caret, selection, hit testing, accessibility, and diagnostics are + separate demand-shaped views requested explicitly from the retained Rust state. +- Render implementations register a versioned render-plan policy describing formats, batching compatibility, + capabilities, patch preferences, and permitted augmentations. Stable policies are referenced by ID on updates rather + than serialized every frame. +- Each program owns an explicit typed gather recipe. Ordered 4-byte records name `semantic`, `glyph`, `resource`, or + `strike` source lanes for every F32 field followed by every U32 field. This is numeric policy data, not a callback; + registration validates and fingerprints it so retained input meanings cannot change under one handle. The per-font + render binding owns the latter three lane families and layout owns semantic lanes. Both policy and binding + registration are live, while gather execution waits for the Rust layout connection and therefore has no frame timing + claim yet. +- A loaded font owns its raster technique and resource binding. `FontStack`, `Text`, and `TextGroup` do not ask the user + to repeat a technique: an ordered stack may contain fonts from different techniques in the same runtime, and the + render policy declares which of those techniques its engine can lower. -**The frame must cross the boundary once.** Not once per operation — once. Today a text edit crosses three times -(`analyzeBidi`, `shapeBatch`, `reshapeRanges`) and a resize once. Three exports mean three opportunities for the host to -observe or mutate intermediate state, and they are the reason layout logic accreted on the JavaScript side: whatever -sits between two crossings has to live somewhere, and it lived in TypeScript. +Per-font render data is normalized without paying for a union of every built-in technique's fields. One immutable +binding owns a technique/program variant, a dense font-wide glyph table, one or more strikes, a dense strike×glyph +address table, and a resource directory. MSDF and Slug use one scalable strike identified by zero ppem; Bitmap uses +strictly increasing physical ppem strikes and selects the nearest to `fontSize × rasterPixelRatio`, retaining the lower +strike at an exact tie. Every strike×glyph row selects a resource or carries the missing sentinel. Glyph, selected-strike, +and selected-resource F32/U32 data are field-major SoA tables with at most 32 lanes per scalar kind, so four neighboring +glyphs remain directly gatherable by the policy executor. The cold compiler-mapped decoder rejects noncanonical strikes, +unsorted/invalid resources, nonfinite floats, invalid indices, field-shape mismatch, reserved data, overlap, and shaping +glyph-count mismatch before publication. -## What per-line layout does and does not buy +The gather is one engine-global reusable workspace rather than one table per program or paragraph. For each glyph, its +selected program's ordered source recipe fills the same `0..N` F32/U32 field slots; the plan compilers already group +execution by program, so no union schema is required. Each typed lane is stored as contiguous four-record blocks with +16-byte alignment and a scalar tail. The 32,768-entry 60-byte internal `PlanGlyph` arena is policy-independent and is +reserved by module `initialize()`. Policy registration reserves only the maximum F32/U32 lane counts declared by that +policy, each to the same record capacity. The production empty-frame path now passes through this gather before plan +compilation; a Rust proof gathers all four source scopes into a nonempty ordered plan and asserts exact packed bytes. +Nonempty shaping/layout frame input is still open and no end-to-end timing is inferred from the synthetic proof. -Layout cascades. If a break position moves on line three, every line after it moves. Per-line composition does not -remove that and no model does; it is inherent to flow. What changes is the cost of each cascaded line and the number of -lines that must be composed at all. +- Result publication uses A/B Wasm buffers for synchronous reads only. A retained or asynchronous result is copied into + a worker-owned transferable `ArrayBuffer`; root returns ownership of that same buffer to the worker on retirement so + pooling or garbage collection occurs on the worker rather than root. +- Incremental output is revision-relative. If a consumer cannot apply the advertised base revision, the engine emits a + checkpoint rather than allowing a partially updated buffer. +- Scalar Rust remains the correctness oracle, but the engine's chunking, storage, alignment, flags, summaries, scratch, + and render-policy execution are designed around 128-bit Wasm lanes before the port. SIMD viability is an entry gate, + not a cleanup experiment after an allocation-heavy scalar architecture has hardened. -| Property | Paragraph-scoped pass | Per-line with a cursor | -| --- | --- | --- | -| Cascade length after a break moves | lines after the change | lines after the change | -| Reshape cost per cascaded line | whole line | glyphs straddling the boundary | -| Per-line available width | one width for the paragraph | one width per line | -| Stop when the viewport is full | not expressible | stop at the last visible line | -| Resume after an edit | recompose the paragraph | resume from the last unaffected line | +## What the current code establishes -The last two are the asymptotic ones. Four columns of six thousand glyphs with forty visible lines each compose forty -lines per column, not the whole column. That is the case the roadmap's editorial showcase actually renders, and a -paragraph-scoped pass cannot reach it by any constant-factor tuning. +The current ownership split does not match the target architecture: -## What the TypeScript pass already established +- `paragraph.ts` owns Unicode segmentation, style and bidi orchestration, shaping-run preparation, cluster measurement, + UAX line-break consumption, greedy composition, ellipsis, visual reordering, positioning, alignment, and + justification. +- the Rust shaper owns HarfRust calls, bidi analysis, font registration, and its wire ABI, but no paragraph composition; +- `paragraph-batch.ts` packs every live glyph from slot zero and then byte-compares the full live storage to discover + dirty ranges; and +- renderer adapters repack or copy those canonical arrays again according to backend constraints. -Do not re-derive these; they are measured and they bound what remains. +That evidence changes several claims in the earlier draft: -Cost per invalidation class at 25,515 glyphs, identical workload, pre-optimization commit against the current tree: +- A current text edit crosses the Wasm boundary at most for bidi analysis and broad shaping. The redundant boundary + reshape crossing has already been removed and contract tests assert zero reshape calls. +- “Atomic entry point, no logic moved” is not a valid stage. TypeScript needs bidi output to construct shaping runs, and + future line composition determines the narrowed context used for boundary reshaping. Those operations cannot become + private internal calls while TypeScript still orchestrates the data dependency between them. +- Small memcpy measurements show that copying is not the dominant cost in the measured workload. They do not establish + that full-buffer rewriting, scanning, repeated upload calls, or suffix movement are free. +- Double buffering does not eliminate CPU-to-GPU transfer and does not make a detached JavaScript view valid after + `memory.grow()`. It protects immutable publication generations; renderer-owned staging protects GPU submission. +- Returning only instance data would discard the information native consumers, editing surfaces, selection, + accessibility, decorations, and alternative renderers need. -| Case | Before | After | -| --- | --- | --- | -| Resize | 103.54 ms | 21.54 ms | -| Reflow | 110.40 ms | 17.37 ms | -| Text edit | 109.66 ms | 39.53 ms | +On the current branch, the pinned `text:layout-benchmark -- --glyphs 22000` workload renders 25,515 glyphs and measured: -Three findings from that pass constrain this design. +| Invalidation | Median | p95 | Relative to 8.33 ms | +| ------------ | -------: | -------: | ------------------: | +| cold | 53.78 ms | 73.83 ms | 6.5× median | +| font size | 12.50 ms | 17.75 ms | 1.5× median | +| layout width | 9.38 ms | 13.69 ms | 1.1× median | +| text edit | 39.11 ms | 41.72 ms | 4.7× median | -**Marshalling is not the cost.** Copying the shaped result out of Wasm across eleven arrays costs 0.08 ms per update for -604 KiB; copying canonical storage into the Three attribute costs 0.043 ms for 1.22 MB. Bulk copies are fast. Retaining -data in Wasm is worth doing because it decides *where the compute happens*, not because the copies are expensive. Any -proposal that justifies itself by saved memcpy time is justifying itself wrongly. +The warm lanes showed 12.7–15.4% relative standard deviation in that run. These are a local baseline, not a universal +forecast, but they establish that the present full-paragraph warm path does not synchronously meet 120 Hz. -**Boundary reshaping was redundant as built.** It requested the whole run as shaping context, which is the context the -retained shape already used, so it returned the glyphs it already had. Measured byte-identical over 640 ranges and -20,280 glyphs across Latin word wrap, Arabic word wrap, and Arabic character wrap narrow enough to break inside joined -words. The capability matters only under a *narrowed* context, which is exactly what per-line composition and ellipsis -truncation introduce — so the Rust engine must reintroduce it deliberately, with a test that fails when it is absent. +## Architectural boundary -**Segmentation and line breaking are already absent from the warm paths.** The text-analysis tier is retained across any -change that alters neither text nor base direction, so a resize and a reflow do not touch them. Moving them to Rust -cannot improve those two classes by any amount. They move for the write-once requirement, not for frame budget. +### Rust core -## The ABI +Create a renderer-neutral `no_std + alloc` Rust text-engine core, separated from the Wasm transport. The Wasm artifact +uses the repository-pinned Talc 5.0.4 dynamic allocator, aborting panics, LTO, one codegen unit, stripping, and the +`wasm-opt -Oz` pass, matching the existing portable Wasm crates. Host-only measurement, fixture, compression, and oracle binaries may +use `std`; production semantic code may not acquire a `std`-only dependency. -One export per frame. +The core owns: +- retained document and paragraph state, revisions, cache dependencies, and invalidation; +- Unicode 17 grapheme, word, script, bidi, and line-break analysis; +- the existing immutable ordered `FontStack` and `.notdef`-driven fallback semantics, generalized so each selected font + carries its own raster technique and resource binding, plus style itemization, OpenType features, and horizontal and + vertical shaping of the existing static-font contract; +- cluster advances, safe boundary reshaping, line composition, justification, and positioning; +- horizontal and vertical writing-mode geometry; +- decorations, inserted-glyph provenance, inline-object placement, and interaction geometry; +- resource identities, stable instance identities, physical record compilation, dirty patches, and draw packets; and +- scalar and optional SIMD implementations behind identical semantic contracts. + +The same crate is called directly by native consumers. A thin Wasm crate validates the binary request, invokes the +core, and publishes a versioned binary result. Browser-specific typed-array pinning must not leak into the core. + +### TypeScript host + +TypeScript owns: + +- the ergonomic public API and conversion into explicit engine mutations; +- font and raster-resource lifecycle; +- registration of render-plan policies and backend capability sets; +- pinning and re-pinning Wasm memory views; +- transferable-buffer retirement: root transfers a retired buffer back to its originating worker rather than dropping + it on the root thread; +- lowering the renderer-neutral plan through the one Three/TSL policy used by both WebGPU and forced WebGL2; and +- renderer-owned upload staging, command encoding, fences, and transfer-buffer retirement. + +TypeGPU product integration is outside this stack. Its former paragraph-target adapter is deleted and will be rebuilt +from scratch against the Rust-emitted render plan and policy contract. The Three/TSL adapter and a minimal native plan +consumer prove those boundaries without adding another renderer dependency to the core engine. + +It does not decide bidi runs, break lines, position glyphs, synthesize decorations, or rebuild dirty ranges. + +## Retained update ABI + +The hot operation is one mutation transaction: + +```text +text_update(session_id: u32, request_offset: u32, request_len: u32) -> u32 +``` + +“One crossing” means one call for a dirty session update. It does not mean one call on every `requestAnimationFrame`, +and it does not forbid cold lifecycle exports whose outputs are retained. + +### Request + +The versioned request contains offsets to packed sections for: + +- ABI version, session ID, expected engine revision, and last consumed render-plan revision; +- ordered text mutations and stable style/span mutations; +- paragraph constraints and writing-mode changes; +- complete flow geometry for this call: regions and their shapes, exclusions, inline objects, viewport, and explicit + page/region constraints, all in region-local coordinates before entry; +- deterministic composition limits: maximum regions, lines, clusters, and output bytes for this update; +- a registered render-policy ID and capability-set ID; +- requested semantic views such as hit-test, caret, selection, accessibility, and diagnostics; and +- optional policy parameters whose schema was validated at registration. + +Stable fonts, policies, and capabilities are referenced by IDs. Repeating a large descriptor every frame would merely +move host work into serialization. + +The V0 compiler-mapped section records are 24-byte ordered UTF-16 replacements, 88-byte stable style upserts/removals, +52-byte flow constraints, 8-byte inline/block vertices, 56-byte regions, 48-byte exclusions, and 56-byte inline +objects. UTF-16 payload preserves the public cluster coordinate without a host UTF-8 conversion. Styles carry current +shaping fields plus word spacing, baseline shift, material, color, and decoration inputs; checked language and feature +payloads are offset-addressed. Constraint records carry the complete region range, viewport, and resume cursor for the +call. Rectangle bounds are inline; bounded polygons reference vertex records in the same request. Defining this wire +grammar does not make a section valid until its Rust decoder and retained transaction land. + +The retained style transaction uses two flat pre-reserved arenas per session rather than allocating a language or +feature vector per span. Stable IDs drive an allocation-reusing mutation merge; authored cascade order remains a +separate value used to validate nesting and later resolve inheritance. Payload compaction happens while building the +inactive arena, so replacing a style cannot accumulate dead language or feature bytes. Root target density is retained +for bitmap strike selection but rejected on non-root spans. Commit is an arena swap and abort does not touch committed +styles. + +Resolution is a separate derived A/B arena. One sweep of the validated containment order keeps the fully resolved parent +on a pre-reserved stack, applies only the fields stated by each opening scope, emits maximal segments at start/end +boundaries, and coalesces equal neighbors. Language and OpenType-feature values remain references into the compact +retained style arena. A root states font stack, logical size, and target density; absent line height deliberately means +natural font metrics rather than a fabricated multiplier. + +All offsets and lengths are range-checked before use. Enum tags, alignment, multiplication, and revision relationships +are validated at the Wasm boundary. Failure returns a typed result without exposing partially mutated state. + +### Result + +The result header contains: + +- ABI version, status, engine revision, render-plan revision, and required base revision; +- active A/B output slot and publication generation; +- request and output capacity watermarks for a later update; +- offsets and lengths for semantic layout tables; +- offsets and lengths for resource, buffer, patch, primitive, and draw-packet tables; and +- diagnostics, feature fallbacks, and performance counters requested for development builds. + +The engine commits a revision only after every section is valid. A failed update leaves the previously published +revision consumable. + +### Capacity and memory growth + +JavaScript cannot write an oversized request before calling the function that would grow its staging region. The ABI +therefore has an explicit cold lifecycle operation: + +```text +text_reserve( + session_id: u32, + request_capacity: u32, + result_capacity: u32, + text_capacity: u32 +) -> u32 ``` -text_update(request_offset: u32, request_len: u32) -> u32 // returns result offset + +The host computes the exact encoded request length before pinning. It calls `text_reserve` only when that length exceeds +the retained request arena or when a previous result watermark requires more result capacity. Reservation grows by the +declared settling policy, re-pins all views, and performs no typography. The normal sequence is: + +1. reserve request and result capacity at session creation, or call `text_reserve` before pinning when a later mutation + exceeds either watermark; +2. write the next mutation request into the retained staging arena; +3. call `text_update` once; +4. compare `memory.buffer` identity, re-pin all views if it changed, and validate the result header; +5. synchronously consume the published slot, or copy retained/asynchronous bytes into a worker-owned transfer buffer; +6. transfer that buffer to root with the plan revision and ownership token; and +7. when the renderer retires it, transfer the same buffer back to the worker for pooling or worker-side collection. + +In the pinned runtime, `memory.grow()` detaches fixed-length `ArrayBuffer` views even when the memory declares a maximum. +The invariant is therefore not “only one export may ever grow.” It is: no memory-growing call may occur between pinning +a published result and the consumer's synchronous read, and the update operation is the only hot-path grow point. Future +resizable Wasm buffers can be feature-detected without weakening this fallback. + +The Wasm A/B pair is never retained by root. The worker may reuse a Wasm slot as soon as its bytes have been consumed or +copied. Transfer buffers have an explicit ownership state machine—`worker-owned -> transferred-to-root -> retired -> +transferred-to-worker`—and are never accessed while detached. A bounded worker-side pool reuses returned capacities; +excess buffers become unreachable and are collected on the worker. Failure to return a buffer is observable backpressure, +not permission to grow an unbounded pool. GPU staging belts and submission fences remain backend responsibilities. +[^staging][^worker-transfer] + +Cold capacity separates retained per-session state from shared synchronous scratch. Session creation prewarms both +UTF-16 transaction buffers and the active/pending Unicode, bidi, shaping-run, shaped-glyph, and fallback arrays to 1,024 +UTF-16 units unless the caller supplies a larger text capacity; shaped-glyph capacity begins at twice that value. Cold +reserve can grow them before the request view is pinned, and committed high-water capacity is reused. Retained A/B state +cannot be one engine-global workspace because more than one live session must preserve its committed result. HarfRust's +consumed-and-returned shaping buffer and policy gather are module-global scratch reused by synchronous updates; those +scratch arrays prewarm once to 32,768 entries, covering the 25,515-glyph target fixture. A warm update inside declared +capacities may not lazily settle another allocation. + +Module initialization is explicit rather than an incidental side effect of the first operational export. The generated +ABI publishes `initialize()`, and the standard host calls it immediately after `WebAssembly.instantiate`; this eagerly +creates module-owned state before a font registration, session operation, or update can be observed. At the current +checkpoint it creates module state, reserves the 32,768-entry render-plan gather arena, reserves HarfRust's actual +32,768-codepoint internal info/position buffer, and reserves one 32,768-codepoint UTF-16 context scratch array. HarfRust +consumes that buffer by value and returns the same allocation through `GlyphBuffer::clear`; the registry restores it +after every successful segment and every fallible setup path instead of constructing a fresh buffer per segment. +Initialization grows the optimized module from 1,245,184 to 4,980,736 linear-memory bytes (57 pages), of which 25 pages +are the HarfRust/context addition, and repeated initialization preserves byte length and `memory.buffer` identity. +Policy-specific aligned field lanes settle at cold policy registration. The legacy exported batch-result vectors still +settle independently and are not evidence for the new frame path. Retained Unicode, bidi, shaping-run, shaped-glyph, and +fallback arrays now settle at session creation/reservation; line composition and geometry-output arrays have not landed +and are not included in the zero-allocation frame claim. + +## Rust layout pipeline + +Each update follows one dependency graph inside Rust: + +```text +mutations + -> retained Unicode analysis and style itemization + -> bidi runs and font fallback + -> shaping and clusters + -> flow-band and inline-slot construction + -> line breaking, narrowed boundary reshaping, and composition + -> axis-neutral positioning, baselines, justification, and overflow + -> decoration, inline-object, hit-test, caret, and selection geometry + -> semantic snapshot + -> policy-directed render-plan compilation +``` + +### Analysis and line breaking + +The final engine cannot leave Unicode line breaking in TypeScript. The current JavaScript implementation is Unicode 17 +and passes the repository's unmodified `LineBreakTest` gate. Published Rust segmenters do not yet provide the same +Unicode-version guarantee; for example, the current ICU4X line segmenter documents Unicode 15.1 data while its other +segmenters have advanced.[^icu4x] + +The frame engine now owns that exact Unicode 17 answer. A generator resolves the pinned `@cto.af/linebreak` 4.0.3 +property trie and the punctuation, East Asian, and future-emoji properties used by its rules into one compact Rust scalar +partition. The `no_std` evaluator ports the same UAX #14 rule order into reusable scalar/break arrays, preserves the +upstream MIT notice, reports canonical UTF-16 offsets, and passes all 19,338 cases in the repository's unchanged official +`LineBreakTest` fixture. TypeScript opportunities are no longer an input to Rust layout. + +### Per-line composition and editorial flow + +Pretext and Parley both support a retained prepared layout with line-by-line progress, but the public hot path here must +not call back to JavaScript for each width.[^pretext][^parley] Rust builds line bands from declarative regions and +subtracts exclusions to produce one or more available inline intervals. A line cursor carries the logical cluster, +fragment, region, column, and block-axis position. Public resume tokens allow pagination and viewport-limited layout +without exposing a mutable internal pointer. + +Columns are strictly sequential. Each region has caller-supplied fixed geometry; the engine fills it once in logical +order and advances the cursor to the next region when its block extent is exhausted. There is no target-height search, +retry, redistribution, or implicit balancing solver. A shorter final column is valid output. + +Sequential overflow through at least two supplied regions is required. Every region and exclusion is supplied before +the one `text_update` call, which completes shaping, band construction, exclusion subtraction, breaking, boundary +reshaping, positioning, and plan compilation without a measurement callback or host round trip. The measured envelope +may cap how many regions, vertices, exclusions, lines, and clusters one realtime transaction accepts; it may not remove +multi-region continuation. Missing the 4 ms gate blocks the milestone until the implementation or supported numeric +envelope changes. The engine never uses a wall-clock timer to stop mid-update. Partial/resume is an explicit overflow +result for requests outside that envelope, not the normal layout path. + +### Portable flow model and Three integration + +“Column” is not a core type. The core type is an ordered flow thread containing stable regions. Equal adjacent +rectangles render as columns; pages, panels, irregular shapes, and several text planes use the same model. + +```text +FlowThread { + regions: [FlowRegionId...] + limits: { max_regions, max_lines, max_clusters, max_output_bytes } +} + +FlowRegion { + id + writing_mode + local_shape: rectangle | bounded_simple_polygon + exclusions: [rectangle | bounded_simple_polygon...] + clip_bounds + geometry_revision +} ``` -Bidi, shaping, segmentation, line composition, boundary reshaping, and instance packing all become internal Rust calls. -`analyzeBidi`, `shapeBatch`, and `reshapeRanges` stop existing at the boundary. There is no partial-state seam for -layout logic to accrete on. +The exact polygon vertex, exclusion, and slot limits are selected by the Stage 0 performance packet. Shapes are in the +region's local 2D text plane. A region descriptor contains no Three object, world matrix, material, or GPU handle. + +The current `ParagraphContentBox` becomes shorthand for a flow thread containing one rectangular region. The portable +paragraph API gains a mutually exclusive flow descriptor for explicit multi-region composition. Its layout output +identifies the region for every line and fragment and reports region-local inline/block coordinates plus overflow and +resume state. + +The Three integration exposes `TextRegion`, an identity-bearing `THREE.Object3D` that owns one region's local shape, +exclusions, writing mode, clip bounds, and geometry revision. `Text` owns the ordered `FlowThread` story and references +its `TextRegion` objects explicitly. There is no semantic `TextFlow` scene object. `TextGroup` remains only a +rendering/batching owner; flow is never inferred from group child order. + +- changing a region object's world transform updates rendering only; +- changing local region shape, writing mode, or exclusion geometry sends one flow-geometry mutation to Rust; +- render plans carry a region's stable compact transform slot; an indexed Three/TSL program packs that slot per + instance and reads a small `vec4`-record region-transform buffer on both WebGPU and the WebGL PBO fallback, while a + policy program that cannot use indirection may instead include transform in its draw key; and +- disposing or reordering a region changes the flow-thread revision without changing text or broad shaping state. + +The canonical geometry inputs are rectangles and bounded simple polygons. Public helpers construct rectangles and +polygons and conservatively tessellate circles and curves when their geometry revision changes. For a 3D obstacle, the +Three layer projects a bounded silhouette into the target region's 2D plane and tessellates it before constructing the +update request. All resulting shapes are submitted with revisions in that same call. Rust owns line-band intersection, +exclusion subtraction, break choice, boundary reshape, and glyph placement. Projection and curve tessellation are scene +geometry integration, not a second typography implementation. Their cost is measured separately and vertex/exclusion +caps prevent arbitrary meshes from entering the realtime text transaction. Region or exclusion geometry may not depend +on the text measurement produced by that call; such a dependency would create the forbidden measurement-feedback loop. + +An edit resumes from the earliest invalidated safe boundary. Recomposition stops when new line state converges with a +retained later line, or at the requested viewport/page boundary. A changed break still cascades when its flow actually +changes; the architecture makes that cascade incremental rather than pretending it does not exist. + +Boundary reshaping uses HarfBuzz's safe-concatenation flags: walk backward to a safe cluster, shape only the narrowed +suffix through the proposed line end, retry farther back if the result begins unsafe, and splice the replacement. Tests +must contain a script and width for which the result is wrong when this step is removed.[^harfbuzz] + +Position accumulation, alignment, justification, and vertical block progression use `f64` internally and narrow once +when writing an explicitly `f32` render view. + +### Axis-neutral geometry and vertical text + +Vertical text is not horizontal text with a rotated transform. The layout model uses logical inline and block axes from +the beginning, then maps them to physical coordinates at output. It supports at least: + +- `horizontal-tb`, `vertical-rl`, and `vertical-lr` writing modes; +- `mixed`, `upright`, and `sideways` text orientation; +- vertical advances and origins from `vhea`, `vmtx`, and `VORG` where present; +- `vert`/`vrt2` OpenType behavior and Unicode vertical-orientation data; +- vertical baselines, column progression, punctuation placement, decoration orientation, and interaction geometry; and +- per-instance orientation so mixed Latin and CJK do not force separate semantic layouts. + +This follows the distinction in Writing Modes and JLREQ: glyph orientation, line direction, punctuation, ruby, and +column order are related but not interchangeable operations.[^writing-modes][^jlreq] + +## Publishing typography contract + +The engine API should expose explicit typed values rather than cloning CSS strings, but its common-feature semantics +need a published reference. The initial contract is organized as capabilities so unsupported combinations fail or +report a fallback instead of silently approximating them. + +| Area | Required engine behavior | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Fonts and runs | language and script, fallback, OpenType features, horizontal and vertical metrics, baselines; static fonts only | +| Span positioning | explicit baseline shift, superscript/subscript positioning, and OpenType `sups`/`subs` features without a second text stream | +| Spacing | letter spacing, word spacing, line height, paragraph space before/after, first-line and hanging indents, spacing in logical axes | +| Tabs | authored left/right/center/decimal stops and bounded leader glyphs; the decimal alignment character is explicit rather than supplied by a locale database | +| Breaking | word/character/no-wrap, whitespace handling, explicit soft hyphen and inserted-hyphen provenance | +| Alignment | logical start/end/center, script-aware justification opportunities, hanging punctuation | +| Writing modes | horizontal and both vertical progressions, mixed/upright/sideways orientation, vertical substitutions and origins | +| Decorations | underline, overline, and line-through; color, thickness, offset, solid/double/dotted/dashed/wavy style, skip spaces, and bounded ink-box skipping | +| Editorial flow | multiple regions and sequential columns, exclusions with multiple slots per band, inline objects, drop caps, forced breaks | +| Pagination | explicit page/column breaks and resume tokens; no balancing or widow/orphan/keep solver | +| CJK emphasis | emphasis marks and short horizontal runs in vertical text; ruby and warichu are out of scope | +| Emoji | Unicode 17 grapheme, variation-selector, modifier, flag, tag, and ZWJ behavior through the ordinary font-fallback and shaping path; optional color art remains a raster resource | +| Interaction | logical/visual ranges, cluster maps, caret stops, hit testing, selection geometry, accessibility reading order | + +Word spacing applies to identified word separators, while letter spacing operates between typographic character units +after shaping and bidi ordering; nonzero letter spacing also interacts with optional ligatures. Justification cannot be +implemented as uniform extra space between glyph records.[^css-text] + +Decorations are layout-derived primitives, not renderer decoration flags. The core determines line fragments, +continuity, metrics, vertical orientation, skip-space behavior, and optional pre-baked ink-box intersections; the render +plan carries the resulting segments or paths alongside glyph primitives. Per-frame outline-curve intersection is not a +realtime feature.[^text-decoration] + +The font baker must expose the data this contract consumes. Its prerequisites include underline and strike metrics, +vertical advances and origins, baseline data where available, glyph extents for skip-ink decisions, and retained feature +data required by vertical shaping. A declaration that these metrics “should be baked” is not implementation evidence. + +### Per-font shaping-payload cost + +Most retained features add engine behavior but no font bytes. The current baker already retains `GSUB`, `GPOS`, `GDEF`, +horizontal metrics, dense glyph extents, and optional `BASE`, `VORG`, `vhea`, and `vmtx` tables when present. + +Measured source-table sizes for the repository fixtures establish the relevant bound: + +| Capability | Per-font shaping-data effect | +| ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Word/letter spacing, breaking, regions, exclusions, and sequential flow | zero bytes | +| Tate-chu-yoko | zero bytes; uses existing glyphs and existing GSUB width features when available | +| Emphasis marks | zero bytes; uses an ordinary shaped/cached mark glyph | +| Underline and strike | at most four extracted `i16` metrics, eight raw numeric bytes before container overhead | +| Bounded ink-box skipping | zero bytes; reuses the existing eight-byte dense extent record per glyph | +| Vertical shaping, Inter and Amiri fixtures | zero bytes; source fonts have no vertical tables and use the declared fallback | +| Vertical shaping, Noto Sans CJK JP | `vmtx` 261,386 + `vhea` 36 + `VORG` 920 = 262,342 raw bytes, about 36.8 KiB when the three source tables are Brotli-compressed independently; already retained today | +| `BASE` in Noto Sans CJK JP | 240 raw bytes; already retained and not exclusively vertical | + +Vertical layout therefore adds no new per-font bytes to the current artifact contract; it begins consuming data already +preserved. Underline metrics must be extracted from `post`, not retained by adding the whole table: Inter's source +`post` table is 32,773 bytes because it includes glyph names, while the needed underline position and thickness are four +raw bytes. Strike metrics already live in the required `OS/2` table. + +The target Mac's installed Noto Sans Mongolian fixture has 1,598 glyphs. The exact tables retained by the current closed +shaping profile occupy 59,352 padded SFNT table bytes; with its SFNT directory, 8-byte dense extents, and availability +bits, the derived shaping payload is 72,524 raw bytes. This cost belongs only to that selected font. HarfRust 0.12 already +contains Mongolian script selection, Free Variation Selector handling, and shaping behavior, so accepting the font adds +no second shaper or language dictionary to the core. The vertical contract therefore includes Mongolian top-to-bottom, +left-to-right flow and conformance fixtures alongside CJK top-to-bottom, right-to-left flow.[^mlreq] + +The engine has no EFIGS/Cyrillic/CJK-only language whitelist. It accepts any static font whose shaping is expressible by +the retained OpenType tables and the pinned HarfRust engine, and applications may compose per-language or already +subsetted font assets in one fallback list. Conformance priority covers Latin/EFIGS, Cyrillic, Arabic, Indic, CJK, +Mongolian, and Unicode emoji. A script requiring another global shaper such as AAT or Graphite is rejected rather than +pulling that system into every Wasm artifact. + +### Emoji and color-font boundary + +Emoji text remains ordinary Unicode prose. Unicode 17 grapheme and line-break data keep RGI modifier, flag, tag, keycap, +variation-selector, and ZWJ sequences intact; HarfRust maps a supported sequence to the font's glyphs, and the normal +fallback list may select an emoji-only font.[^unicode-emoji] No emoji sequence table is copied into each font. + +Color artwork never enters the shared shaping SFNT, which continues to exclude `COLR`, `CPAL`, `SVG `, `CBDT`, `CBLC`, +and `sbix`. A separately imported color-bitmap baker consumes the source font and emits an optional `rgba8unorm`, sRGB +bitmap companion with its own selected glyph coverage, strikes, pages, records, hashes, and byte report. The first color +slice accepts OpenType-layout fonts with CBDT/CBLC or `sbix` bitmap glyphs and COLR/CPAL glyphs flattened at bake time; +it does not add AAT `morx` shaping or an SVG runtime. CBDT embeds PNG-backed strikes, `sbix` stores standard bitmap +graphics, and COLR describes layered or paint-graph vector compositions.[^opentype-cbdt] [^opentype-sbix] +[^opentype-colr] + +The base branch already implements immutable ordered `FontStack` values and resolves `.notdef` clusters through later +fonts before layout. This stack ports that behavior into Rust and preserves its fixtures; it does not build a second +fallback mechanism. It removes the old raster-homogeneity restriction: every member must belong to the same text runtime, +but each loaded font carries its own technique and resource binding. Fallback remains a shaping decision about glyph +availability, not a renderer eligibility decision. + +The Rust engine cold-registers stack identity as a nonempty, duplicate-free ordered list of already registered +shaping-font handles. Equivalent registration is idempotent; conflicting order fails, and a member font cannot be +disposed while any registered stack retains it. Technique/resource data is deliberately not duplicated in the stack. +During `text_update`, HarfRust output is collapsed to logical cluster records; only clusters containing an actual glyph +zero advance to the next registered font. Flat source-ordered spans are reshaped at most once per stack depth and then +retained with the final shaped SoA. Sorting by source-run/cluster restores logical order for RTL output before one linear +span merge. A compiled Inter-to-Noto-Devanagari test observes two plan constructions in the same update, proving the +primary `.notdef` pass and fallback pass both ran. Stack lifecycle is cold and cardinality is normally small, so the +selected registry uses a compact vector. A generic tree map measured 837,865 raw / 312,057 gzip / 246,478 Brotli bytes; +removing that monomorphization reduced the artifact to 828,401 / 309,252 / 244,402 without changing the ABI or lookup +result. + +RGBA8 costs four GPU bytes per texel versus one for the grayscale R8 bitmap path; selected coverage and independently +resident pages are therefore mandatory, and the payload report keeps color pages separate.[^renderer-capabilities] +[^payload-budget] Slug color-paint compilation is not required to ship emoji in this stack; Bitmap is the required color +path. An MSDF or Slug prose font may therefore fall back to an emoji-only Bitmap font without reshaping or changing line +breaks; the render plan partitions the resolved glyphs by their actual technique and resource. No synthetic composite +technique or cross-technique artifact is required. + +Exact outline skip-ink is cut because it would undermine the reduced shaping payload—for example, the Noto CJK source +`CFF ` table is 15,458,582 bytes. Static V0 also continues to reject variable-font axis/delta tables (`fvar`, `avar`, +`gvar`, `cvar`, `HVAR`, `VVAR`, and `MVAR`). Variable-font support is not part of this stack; adding it would require a +separate format, size, and runtime admission decision. + +### Cut publishing features and cost envelope + +“Annotations” does not mean arbitrary comments. It refers to specialized inline typography, principally: + +- **ruby:** a second, usually smaller text stream associated with base characters or words—for example Japanese + furigana, Chinese pronunciation, or an explanatory gloss—placed above/below horizontal text or beside vertical text; +- **emphasis marks:** dots, sesame marks, or another glyph placed beside individual CJK graphemes as typographic + emphasis; and +- **tate-chu-yoko:** a short horizontal run, commonly two to four date or page-number digits, fitted upright into one + vertical inline cell. + +Ruby would be the large feature. It needs base/annotation pairing, independent shaping, mono/group distribution, overhang and +collision rules, line-break coupling, vertical placement, and potentially multiple annotation levels.[^ruby] A heavily +annotated educational document can approach one annotation glyph per base glyph, so shaping work, semantic glyph state, +and render records can approach 2× before pairing and overhang work. In the current physical schemas, another rendered +glyph represents 48 bytes for Bitmap, 108 bytes for MSDF, or 92 bytes for Slug before backend repacking; incremental +plans pay only for changed records, but an initial retained snapshot pays the live total. + +Emphasis marks do not require a nested line breaker, but a fully emphasized range can add one mark primitive per +grapheme and therefore approach 2× primitive packing for that range. Tate-chu-yoko normally adds no characters: it +reshapes and fits a tagged short run as one vertical inline atom. Both belong in the first credible vertical release. + +The product scope is therefore: + +| Capability | When it is used | Initial scope | Cost control | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | +| Japanese line-start/end restrictions and tailoring | ordinary Japanese horizontal and vertical prose | include | compact rule/table delta; same line-break vectors plus tailored cases | +| Tate-chu-yoko | short dates, counters, page numbers, and occasional Latin inside vertical text | include | bounded tagged runs; no general nested layout | +| Emphasis marks | CJK emphasis where italics are inappropriate | include with decorations | cached mark shape; one extra primitive only where applied | +| Ruby | educational text, names, pronunciation guides, translations, manga, and specialist CJK publishing | cut | no base/annotation model or nested shaping stream | +| Warichu | compact Japanese parenthetical notes set as two small lines inside one line | cut | no nested inline line-layout model | +| Automatic language hyphenation | narrow justified columns in language-aware publishing | cut | no dictionaries, data-pack ABI, or language hyphenation algorithm; explicit soft hyphens remain | +| Balanced columns | final newspaper, magazine, and page composition | cut | columns fill sequentially; applications may choose region geometry externally | +| Widow/orphan and keep constraints | page/column finalization | cut | explicit page/column breaks and resume tokens remain | +| Automatic footnotes and sidenotes | page-coupled notes and scholarly annotations | cut | applications manually compose note text in independent regions; no second text channel or coupled page solver enters the engine | +| OpenType math layout | formulas, stretchy operators, fractions, scripts, and equation structure | cut | the `MATH` table is not a complete math-layout specification and would require a separate recursive box engine | +| Text on a path | labels following arbitrary curves | cut | no arc-length mapping, tangent placement, curve-aware interaction, or path-decoration system in the core | +| OpenType-SVG glyph paint | SVG-authored color glyphs and icons | cut | no XML/SVG parser, DOM, scripting, animation, filter, or external-resource runtime; color emoji uses the bounded bitmap companion | + +The cut features reserve no runtime tables, optional dictionaries, policy opcodes, semantic records, or implementation +stages. More generally, the core never automatically lays out a second authored text channel beside the selected prose. +Their documented cost explains the boundary; it is not a promise of later delivery. Reintroducing one requires a new +design decision and an independent size/performance admission proposal. + +## Render-plan policy + +A renderer implementer registers a policy once. The policy is data, not a hot JavaScript callback, so the same request +can be validated, executed in Wasm, and reused natively. Every first-party engine policy declares support for the shipped +Bitmap, MSDF, and Slug techniques; the initial Three policy is the first implementation of that invariant. A third-party +engine policy may declare only the techniques it implements and grow that set independently. Binding a `FontStack` +validates all of its technique IDs against that set, and a resolved glyph or requested paint capability that the selected +program cannot implement fails preparation before publication. + +The descriptor includes: + +- named independently bindable physical vector streams: scalar type, vector width, alignment, stride, capacity class, + and usage intent; +- a technique capability table mapping stable technique IDs to program IDs, accepted resource kinds, supported paint and + compositing features, and physical schemas; +- required semantic inputs and requested derived views; +- separate storage and draw compatibility keys assembled from declared resource, technique, material, clipping, depth, + and ordering fields; +- backend capabilities such as storage-buffer support, indirect draws, aliasable vector widths, maximum binding sizes, + and update alignment; +- an upload cost model: preferred coalescing gap, range/call penalty, whole-buffer threshold, and fragmentation budget; +- an allocation strategy chosen from ordered direct storage or stable pooled records with a chunked order/indirection + buffer; and +- validated augmentations that derive extra fields from semantic records without reimplementing layout. + +Augmentation is one versioned, typed, straight-line bytecode. It has semantic-field and constant loads, explicit +resource lookups, deterministic arithmetic and conversion, predicated selection, and physical-field stores. It has no +callback, backward branch, data-dependent loop, memory allocation, arbitrary address, or layout-mutating opcode. The +engine iterates the validated program over four-record SIMD lanes and executes scalar tails. Built-in Bitmap, MSDF, and +Slug policies use the same bytecode and verifier as external policies; a native builder emits that bytecode rather than +bypassing it with a second Rust policy trait. + +The compiler-mapped V0 registration ABI uses a 36-byte request header followed by 40-byte capability-set, 56-byte +program, 16-byte physical-buffer, and 16-byte operation records. Capability-set selection is part of program lookup: +an exact set-specific program wins over a set-agnostic program, and an update naming an undeclared set fails before its +revision changes. Capability sets own storage/indirect/aliasing flags, maximum binding and draw limits, update alignment, +and the integer upload-cost model. Programs own the resource-kind mask, semantic-view request, storage-key mask, draw-key +mask, and one of the two allocation strategies. Physical streams own explicit alignment, padded stride, usage, and +capacity class. +All reserved bits and fields are zero and unknown flags fail registration. + +The upload model is informed by three-flatland's fixed 256-instance dirty buckets and per-buffer ranged/full cutovers, +but the retained text engine already has a more precise input: exact changed record spans for the complete transaction. +The [dirty-range upload research](dirty-range-upload-research.md) therefore keeps range selection in the Rust plan +compiler, identifies per-physical-buffer costing and stable-order coalescing as the remaining planner work, and reserves +Flatland-style bucket trackers for renderer-local transform and presentation-origin writes that never cross +`text_update`. Its thresholds remain a benchmark candidate, not evidence that three or five ranges are optimal for text. + +V0 does not alias several logical stores into one mutable interleaved byte span. Augmentation instead combines semantic +fields into independently bindable `vec2`/`vec4` or integer-vector records, including the existing MSDF and Slug +WebGL-compatible packing. This keeps executor borrows disjoint, avoids another aliasing grammar in the native ABI, and +still lets a policy trade buffer count against record width. Adding interleaved field offsets would require a new ABI +version and measured binding-pressure evidence; it is not a latent V0 implementation choice. + +Augmentation examples include packing `origin + size` into `vec4`, adding atlas/material indices, emitting selection or +object IDs, quantizing fields, or requesting per-glyph bounds. It may not choose line breaks, mutate cluster order, or +change semantic positions. + +The policy program is the only bytecode in this design. It is a validated, forward-only packing expression executed by +Rust while compiling physical records; it has no loop, backward branch, arbitrary address, allocator, host callback, or +layout/shaping authority. The render plan below is fixed-record data, not executable bytecode. + +## Render-plan IR + +The render plan is a revisioned display-list and resource transaction, following the separation used by retained +renderers such as WebRender: rendering intent and resource changes are portable; backend command encoding is not. +[^webrender] + +The render plan contains: + +- **identity:** ABI, engine revision, plan revision, required base revision, policy/capability hashes, and output + generation; +- **resources:** stable IDs, generations, bounds, creation/update/retirement intent, and technique-specific references; +- **buffers:** stable buffer IDs, schemas, live lengths, capacities, and allocation generations; +- **patches:** allocate/resize, write range, fill, copy/relocate, and retire operations referencing exact payload spans in + the published Wasm generation; +- **primitives:** ordered glyph, decoration, inline-object, clip, and custom-policy primitive records carrying the + selected technique, resource, and program identities where applicable; +- **draw packets:** compatible primitive ranges, resource/buffer bindings, ordering tokens, and optional indirect + argument records; and +- **retirement:** the earliest generation after which resources, slots, and output bytes may be reused. + +An explicitly requested semantic query may share the immutable A/B publication and its lifetime, but it is a sidecar, +not a render-plan table and not an input to the renderer executor. The measurement view contains one paragraph summary +plus its line records. The separately requested inspection view adds semantic glyph identity, font, cluster, size, +flags, and shaped origins, including glyphs such as spaces that deliberately produce no render instance. Both masks are +zero on ordinary rendering updates. Caret, selection, hit testing, accessibility, and diagnostics must each prove a +bounded record shape before admission. + +The current semantic query measures committed state or rides a complete pending frame. A separate +[paragraph-scoped prepare/query design](paragraph-query-preparation.md) records the bounded follow-up for synchronously +measuring one pending paragraph, retaining its prepared result, and adopting it into the next full frame without +compiling a render plan during the query or adding a third full buffer. + +A policy may request a renderer augmentation without moving semantic layout into the plan. The first-party Three policy +writes each renderable glyph's session-global stable ID to one compact `u32` stream beside the technique's existing +origin stream. Three uses that directed identifier only to implement optional presentation motion over retained GPU +records. It restores authoritative target origins before every later plan application, so the Rust command buffer remains +the sole render-state transition and no renderer-side candidate/current target state machine is required. + +The initial adapter lowers this IR to Three attributes, TSL storage nodes, and draws. The same graph runs through +Three's WebGPU backend and forced WebGL2 backend. In Three 0.185.1, WebGL PBO setup replaces the supplied typed array with +a power-of-two-padded retained array and a `DataTexture`; the adapter therefore performs one explicit copy into that +retained array and applies later patches to it. WebGPU may consume re-pinned Wasm views where Three preserves them. +Bitmap uses `vec2`/`vec4` records and MSDF and Slug use `vec4`/`uvec4` records, all valid in the fallback. A minimal native +consumer proves schema, patch, revision, and retirement semantics without claiming another renderer integration. + +The V0 wire checkpoint uses a 144-byte, 16-byte-aligned result header followed by compiler-mapped little-endian tables: +44-byte semantic, 40-byte resource, 36-byte physical-buffer, 36-byte patch, 64-byte primitive, 64-byte draw, 24-byte +retirement, and 24-byte diagnostic records. Resource kind and create/update/retain action are separate. Buffer strategy +is an explicit ordered-direct or stable-indirect tag. Variable patch payload bytes are part of the same immutable +publication; write patches rebase their checked payload span to an absolute result offset. Other patch opcodes carry no +payload address. The header identifies the registered policy by handle and deterministic fingerprint. This fixes a +portable display-list/resource-transaction grammar; it does not expose Rust layout, padding, or native-endian struct +copies to consumers. + +### Minimal updates + +“Minimal” is policy-relative and measured. The objective includes bytes scanned, bytes rewritten, upload bytes, upload +calls, draw packets, memory overhead, fragmentation, and CPU/GPU time. Minimizing only dirty-byte count can lose when it +creates hundreds of tiny `queue.writeBuffer` calls. + +The policy chooses one of two initial allocation strategies: + +1. **Ordered direct records:** lowest shader and draw complexity; insertion can move the ordered suffix. +2. **Stable record pool plus chunked order/indirection:** local record patches and bounded order-chunk updates at the + cost of one indexed lookup. The Three/TSL policy implements the lookup over storage records on both WebGPU and the + WebGL PBO fallback. + +There is no third segmented-record mode in this stack. Chunking belongs to the stable-indirect order representation, +and its draw-packet boundaries are part of that one strategy rather than another allocator and policy surface. + +The engine assigns stable instance identities where shaped semantics remain equivalent across revisions. Invalidation +starts from edited text/style/flow dependencies, not from rewriting every live record and diffing all bytes afterward. +Patches are aligned and coalesced according to the registered backend cost model. Tests cover insertion, deletion, +replacement, style edits, and flow changes at the start, middle, and end of large retained paragraphs. + +If `required_base_revision` does not match the consumer, the engine returns a checkpoint containing complete live state. +Skipped render revisions can never be repaired by applying an adjacent delta blindly. + +The retained ordered-direct compiler implements physical storage behind an explicit prepare/view/commit-or-abort +lifecycle. Stable instance IDs and semantic content revisions—not physical byte comparison—select dirty records. +Capability alignment expands ranges at record granularity; gap/call costs, fragmentation budget, and the whole-buffer +threshold coalesce them. Consecutive changed inputs stay batched through the four-record SIMD policy executor. A no-op +produces no resource, buffer, patch, retirement, or payload records; a tail deletion changes live metadata without an +upload; a middle insertion rewrites only that resource/program batch's suffix. Checkpoint/growth allocates and writes +complete aligned storage. CPU mirrors change only on commit, so failed A/B serialization can abort preparation. A dirty +transaction republishes the complete compact binding and command tables while its fat physical payload stays delta- +minimal. Glyph primitives are spans over consecutive physical records, split by logical run, binding identity, or the +65,535-record wire limit; this avoids publishing one 64-byte command per glyph. Draw packets carry numeric material, +clip, and depth identities plus exact resource/buffer table ranges. No-op preparation publishes no table or payload. + +Storage and draw compatibility are deliberately independent. The standard shared-storage policy puts material in the +draw key but not the storage key, so different materials reference ranges in the same physical glyph buffers. A policy +may put material in both keys when a fallback or custom per-material schema requires separate physical buffers. This is +not left to the adapter after publication: focused tests prove both plans. The distinction matters for the pinned Three +implementation because its ordinary WebGPU and WebGL fallback render-object paths both submit `firstInstance = 0`; a +shared-buffer adapter must supply an explicit storage index base, while a partitioned policy avoids that requirement. + +Interleaved `A, A, B, A` resource tests prove three ordered spans over two deduplicated resources and buffers, and both +allocation strategies pass wire validation. Stable-indirect compiles persistent physical slots plus 64-entry `u32` +order chunks under the same transactional prepare/view/commit-or-abort lifecycle. Each physical buffer identifies its +order buffer; that buffer uses the compiler-generated reserved binding ID 65,535. A localized insertion in the focused +one-stream fixture writes one new 4-byte physical record and one 16-byte order range, while arbitrary reorder writes +only order bytes. Deleted slots and chunks stay in publication-fence quarantine until acknowledged. The registered +fragmentation budget bounds accumulated draw spans: when an edit would exceed it, Rust rebases only the order buffer to +dense chunks, increments and retires that buffer generation, and preserves the physical glyph-buffer generation. +Order-buffer growth likewise republishes every live chunk because a replacement allocation cannot assume prior bytes. +No-op, abort, mixed-resource order, shared/partitioned material storage, fence-gated reuse, fragmentation rebasing, and +settled nested scratch capacities have focused tests. One frame may contain programs using both allocation strategies: +the dispatcher filters the same borrowed glyph and semantic-field slices in each compiler instead of allocating copied +partitions, assigns ordered-direct buffers to the low `u32` ID half and stable-indirect buffers to the high half, rebases +patch payload spans, deduplicates shared resources, and merges draws by their original global order token. A program may +therefore change allocation strategy without retiring a resource that remains live through the other compiler. A +homogeneous frame delegates its plan view directly to one compiler and does not populate merge scratch; a mixed no-op +publishes nothing, and repeated same-shape mixed edits retain settled vector capacities. + +Each engine session now owns that dispatcher and pins the first committed policy handle/fingerprint while allowing +capability-set changes within the same validated policy. The compiler-derived update header is 124 bytes and carries a +dedicated `acknowledged_publication_generation`; it must advance monotonically and cannot name the publication currently +being prepared. `consumed_plan_revision` remains independent because host application does not prove GPU completion. +The Wasm update prepares the Rust plan, validates and serializes it into the inactive arena, commits compiler/session +state only after staging succeeds, and aborts preparation on every intervening failure. The acknowledgment itself +survives an aborted publication because it reports an already-completed renderer fence. Compiled-Wasm tests exercise +accepted and future acknowledgments plus A/B preservation. Host tests exercise compiler abort/retry directly. A +post-prepare Wasm failure is not constructible while the encoded plan is the minimum-size header; that ABI ordering +requires a regression test once Rust shaping/layout can produce nonempty plan output that exceeds a request limit. + +This makes the full retained plan compiler reachable: the optimized artifact changes from 739,909 / 272,624 / 214,395 +to 822,443 / 308,033 / 242,447 raw/gzip/Brotli bytes. The 82,534 raw / 35,409 gzip / 28,052 Brotli increase is shared +runtime code, not font-local shaping data, and is now an explicit size-optimization target. Ordered UTF-16 text +replacements now decode as borrowed records and commit into retained session scratch transactionally. Constraints, +regions, exclusions, polygon vertices, and inline objects are borrowed from the same request, fully validated before +mutation, checked against pending text offsets, and committed as a placement-independent semantic fingerprint. Styles +are retained and resolved into maximal derived segments. Retained text now drives transactional Unicode 17 +extended-grapheme segmentation and Script/Script_Extensions itemization in Rust, with malformed UTF-16 aborting the +same frame transaction. Bidi/run intersection, shaping, and layout are not yet connected, so the Wasm path still emits +an empty Rust plan. Rust shaping/layout → nonempty plan connection and its 25,515-glyph end-to-end timing remain open; +the TypeScript layout benchmark is baseline evidence only. + +The retained-text decoder, transaction buffers, and cold capacity control change the optimized artifact from +822,469 / 306,502 / 242,707 to 825,298 / 308,030 / 243,323 raw/gzip/Brotli bytes, a shared-runtime delta of +2,829 / 1,528 / 616 bytes. The per-session 1,024-unit default is runtime memory rather than binary payload. This size +checkpoint does not time shaping or layout because neither stage consumes the retained text yet. + +The Unicode-analysis checkpoint uses `unicode-segmentation` 1.13.3 under `no_std`, generated Unicode 17 script tables +shared with the TypeScript generator, and flat reusable session arrays. Session text reservation prewarms both active +and pending analysis arenas; unchanged text does not re-run analysis. The compact Rust script partitions omit +derivable starts. Optimized Wasm measures 964,019 / 360,765 / 288,742 raw/gzip/Brotli bytes versus the prior +895,593 / 335,396 / 264,355 checkpoint. This is shared runtime data and code, not per-font shaping payload. The number +does not claim layout or shaping latency because neither has consumed these products yet. + +#### Future optimized-runtime delivery research + +The complete optimized shaper currently contains 939,488 bytes of Wasm code and 218,958 bytes of initialized data before +transfer compression. The repository-owned generated Unicode 17 script, bidi, and line-break Rust sources total 150,408 +bytes, but source size is not binary contribution and the data section also contains unrelated constants. Consequently, +external tables alone cannot be claimed to reduce the runtime to a small basic-font-parser footprint; a feature-stripped +build must measure the executable floor. + +The preferred delivery experiment is now compile-time runtime profiles with one source tree and one public ABI. Each +profile is a separate Wasm asset selected during initialization, so a lite consumer does not download the full artifact. +Provisional profiles are `lite` (Latin/EFIGS-oriented), `cjk` (lite plus the CJK-relevant analysis/layout machinery), and +`full`; exact names and membership wait for section-level artifact measurements. Font-local GSUB/GPOS and selected glyph +coverage remain in baked font assets and are not language-whitelisted by the runtime profile. SIMD remains the published +default, with the existing compile-time scalar switch orthogonal to the language/runtime profile; no scalar artifact is +published until a target requires it. + +Three related experiments are reserved for a later stack: + +1. Replace compiled Unicode property arrays with a versioned, little-endian, aligned data-pack format. The core reserves + one retained region, validates the pack version, section directory, lengths, and digest once, and thereafter reads + bounded slices directly. The initial implementation should fetch a plain binary blob and copy it once into the + reserved Wasm region. A data-only Wasm side module that imports the core memory and initializes the region with active + data segments is a benchmark candidate, not an assumed improvement. +2. Measure the provisional compile-time profiles, including a build that omits stable-indirect planning when its declared + policy capability supports ordered-direct only. A symbol-bearing `-Oz` build currently attributes about 50.1 KiB of + optimized function bodies to stable planning plus separate stable order/pool support; this is an upper-bound signal, + not a measured stripped-artifact saving. Profile selection must reject an incompatible policy before any text update. +3. Measure an EFIGS/Cyrillic/default-shaper executable profile separately from optional complex-script profiles. HarfRust + 0.12 has no script-level Cargo feature gates, so isolating Arabic/Indic/Khmer and related specialized shaper code would + require an upstream feature design or a narrowly maintained fork. CJK/default OpenType shaping, Unicode line breaking, + emoji grapheme behavior, and font-local GSUB/GPOS data must not be conflated with those specialized code paths. + +Font-local OpenType payloads remain in each baked font or fallback font; applications already control their glyph and +font-stack subsets. Data-pack discovery should therefore be driven before font registration by declared Unicode/script +coverage. A cold unexpected script may report a stable missing-pack identifier and retry after asynchronous loading, but +steady-state `text_update` remains one synchronous call and never initiates I/O. Native consumers may memory-map the same +pack format, while browsers retain the initialized bytes in Wasm linear memory. Each experiment must report raw, gzip, +Brotli, compile/startup, retained-memory, cold-load, and hot-path results before changing the default package. + +Retained bidi and run-itemization now consume those products inside the same transaction. UAX #9 output is copied into +reusable active/pending level, class, paragraph, and equal-level-run arrays. Text or root base-direction changes re-run +bidi; unchanged text and style do not. Root direction selects paragraph base level, while a nested stated LTR/RTL value +is preserved separately as a run override and forces level parity only during one style×script×bidi interval sweep. +That sweep excludes mandatory hard-break controls and emits allocation-reusing shaping-run records. Optimized Wasm is +968,086 / 362,664 / 286,438 raw/gzip/Brotli bytes (+4,067 / +1,899 / -2,304 from retained Unicode). HarfRust fallback +shaping has not consumed the runs yet, so plan output and complete-path timing remain open. + +Primary-font HarfRust shaping now consumes retained runs during Wasm `text_update`. The legacy batch export and frame +engine share one borrowed run view, actual prewarmed `UnicodeBuffer`, UTF-16 context scratch, and reusable 128-feature +scratch vector. Frame language/features borrow the retained style arena; glyph IDs, clusters, advances, offsets, flags, +and source-run/font records append directly into a pre-reserved A/B shape arena without constructing or serializing a +`ShapeBatchRequest`. A compiled real-Inter test observes plan-cache count 0→1 only after `text_update` and no increase +after an aborted update. Optimized Wasm is 973,367 / 364,517 / 287,942 raw/gzip/Brotli bytes (+5,281 / +1,853 / ++1,504). Ordered fallback is not yet applied, and layout/gather still receive no glyphs, so nonempty plan output and +complete-path timing remain open. + +Ordered fallback now consumes the actual shaped result rather than consulting `cmap` or raster coverage. Reusable flat +span and logical-cluster arrays preserve the source run, UTF-16 range, selected stack index, and concrete font. Each pass +shapes current spans, marks a cluster missing when any constituent glyph is zero, restores logical order across RTL +output, and advances only that range to the next font. The pass count is bounded by stack depth; the final spans and +glyph SoA commit together, while any later frame failure discards both pending values. A compiled Inter-to-Noto +Devanagari update constructs exactly two HarfRust plans. Optimized Wasm is 982,356 / 368,183 / 290,439 raw/gzip/Brotli +bytes (+8,989 / +3,666 / +2,497). Layout/gather still receive no glyphs, so nonempty plan output and complete-path timing +remain open. + +Unicode 17 line breaking now runs with grapheme/script analysis inside the pending Unicode transaction. The generated +table stores resolved line-break class plus only the punctuation, East Asian, and unassigned-extended-pictographic flags +read by the rule program; it does not embed a generic Unicode property runtime. Scalar records and break results reserve +with session text capacity and retain their high-water marks. All 19,338 official `LineBreakTest` cases match at UTF-16 +offsets, and required CRLF/end breaks have focused tests. Optimized Wasm is 1,009,460 / 377,053 / 295,875 raw/gzip/Brotli +bytes (+27,104 / +8,870 / +5,436). Cluster measurement, composition, nonempty plan output, and complete-path timing remain +open. + +Retained cluster measurement now consumes final fallback-shaped glyphs and Unicode analysis without a host view. One A/B +SoA stores grapheme UTF-16 bounds, `f64` logical advances, compact safe/allowed/required/hard-break flags, resolved-style +index, source-run/font identity, and one offset-to-cluster index. Glyph advances remain `i32` design units until the +cluster pass scales them once with font size and cached UPEM; letter spacing, word spacing, and hard-break width are +applied in that same ordered accumulation. UAX #14 opportunities are admitted only at a HarfRust-safe next cluster. +Synthetic exact tests cover scaling, both spacing lanes, hard breaks, allowed/required flags, unsafe suppression, and +capacity reuse; real-font compiled Wasm reaches the pass for primary and fallback text. Optimized Wasm is 1,014,577 / +379,510 / 295,708 raw/gzip/Brotli bytes (+5,117 / +2,457 / -167). The Brotli change is recorded as compressor interaction, +not a performance claim. Line composition, nonempty plan output, and complete-path timing remain open. + +The first line-composition kernel now advances a grapheme cursor for one caller-supplied width without allocating. It +matches the retained TypeScript break order: word wrapping prefers safe UAX #14 opportunities, then safe HarfRust +boundaries; character wrapping admits every safe grapheme boundary; no-wrap still honors required breaks; and a final +hard break produces the trailing empty line. Width accumulation remains `f64`. This is a kernel proof only: region-band +resolution has not yet connected it to production frame output, so it carries no end-to-end timing or Wasm-size claim. + +Declarative flow input now crosses from validated compiler-mapped request bytes into retained A/B Rust state in the +production transaction. Constraints, ordered regions, exclusions, and rebased polygon vertices are owned by the session; +no request pointer survives the call. The rectangle band fast path reuses two bounded slot vectors and subtracts +intersecting exclusion rectangles according to their wrap side, rejecting output beyond `max_slots_per_band`. An exact +fixture resolves a `0..100` region around a `20..40` exclusion to `[0..20, 40..100]`. Polygon intersection and driving +the line cursor remain open. Optimized Wasm is 1,016,720 / 384,593 / 297,049 raw/gzip/Brotli bytes (+2,143 / +5,083 / ++1,341 from the last production Wasm checkpoint); compressed deltas are recorded as transport evidence only. + +The bounded polygon band kernel now reuses retained critical-block, crossing, section, and intersection vectors. Region +slots are conservatively intersected across vertex boundaries and intervening linear-edge slabs; horizontal boundary +segments are included before normalized interval intersection. Polygon exclusions project every vertex and band-edge +intersection over the margin-expanded band, then subtract that conservative inline range using the declared wrap side. +Focused fixtures cover a triangular serialized region, a concave U-shaped region that yields two slots, a diamond +exclusion, and rejection when the normalized answer exceeds the public slot envelope. The kernel is not yet called by +frame line placement, so this checkpoint makes no production Wasm-size or end-to-end timing claim. + +Horizontal frame layout now calls the retained band and line kernels in production. A flow-layout A/B arena owns lines +and same-baseline fragments; one reusable slot workspace serves the session. Each band starts from the next cluster's +actual fallback-font metrics, composes over every available slot, and performs at most one conservative retry when the +widest first pass discovers taller content. Enlarging a band only intersects more region cross-sections and projects +more exclusion coverage, so the retry cannot expose later clusters. Exact tests place two fragments around one hole, +retry 10 px text to a 20 px mixed-style line with 16 px baseline, and continue four lines through region IDs +`[1, 2, 2, 2]` without balancing. Rebuilt Wasm preserves the frame and real-font/fallback integration tests. Optimized +Wasm is 1,039,404 / 392,671 / 303,705 raw/gzip/Brotli bytes (+22,684 / +8,078 / +6,656). Vertical flow, positioning, +boundary reshaping, semantic/glyph gather, nonempty plan output, and complete-path timing remain open. + +Stable render identity now begins at retained text rather than mutable offsets. Parallel A/B UTF-16 unit-ID arrays apply +the exact ordered replacement operations as text, allocate nonzero monotonic IDs only for inserted units, and commit or +abort their allocator transactionally. Grapheme clusters inherit the first unit ID, so unchanged clusters retain +identity when earlier edits shift every following UTF-16 offset; edits within a cluster retain identity while later +content revision can change. An exact abort/retry fixture keeps `[1,2,3,4]`, then an offset-growing edit produces +`[1,5,6,3,4,7]`, proving shifted suffix preservation and deterministic retry. Optimized Wasm is 1,041,582 / 393,214 / +304,815 raw/gzip/Brotli bytes (+2,178 / +543 / +1,110). Per-cluster glyph ranges, glyph identity/revision, positioning, +and nonempty plans remain open. + +Each cluster now owns a slice of one flat shaped-glyph index array. Construction counts glyphs by logical cluster, +prefix-sums those counts, then fills the slices while retaining HarfRust's run-local glyph order. This makes RTL and +multi-glyph clusters directly traversable without a per-glyph search, object, or map and reuses all arrays at their high +water mark. An exact reverse-order fixture maps shaped cluster stream `[2,1,0]` to logical slices `[2]`, `[1]`, `[0]`. +Optimized Wasm is 1,043,289 / 394,074 / 304,902 raw/gzip/Brotli bytes (+1,707 / +860 / +87). + +The plan pool's allocation-reusing identity lookup is now a shared exact `u32 -> u32` scratch component. Epoch clearing +avoids zeroing the table on every prepare, open addressing resolves probe collisions with full-key equality, and growth +occurs only beyond the retained high water mark. This extraction prevents glyph reconciliation from carrying a second +identity-table implementation. Its isolated size is 1,043,094 / 394,035 / 307,259 raw/gzip/Brotli bytes (-195 / -39 / ++2,357); the Brotli regression is accepted for the single correctness implementation and will be remeasured with its +glyph-reconciliation consumer reachable. + +Glyph identity reconciliation now consumes that index inside the cluster transaction. Existing cluster identities reuse +the IDs of surviving glyph ordinals; new clusters and added ordinals consume monotonic nonzero IDs. The allocator cursor +commits only with the pending cluster arena, so abort/retry is exact. A fixture transforms prior per-cluster glyph IDs +`[[1,2],[3]]` into `[[4],[1],[3,5]]` after inserting a cluster and growing the final cluster, then reproduces the result +without growing scratch. Optimized Wasm is 1,044,797 / 395,222 / 307,795 raw/gzip/Brotli bytes (+1,703 / +1,187 / +536). +Final positioned-content comparison will own `content_revision`; shaping identity alone does not overclaim GPU equality. + +Horizontal positioning now completes the first nonempty production frame path. Reusable line-level scratch applies UAX +#9 L1 resets before L2 reorders logical cluster slices into visual order. Each editorial slot positions independently, +including direction-aware alignment and non-final-line space justification. HarfRust offsets and actual fallback-font +metrics accumulate in `f64`; baked glyph extents become positive-down primitive bounds after one `f32` narrowing, while +non-rendering glyphs advance the cursor without producing instances. Six retained F32 semantic lanes carry bounds, font +size, and raster ratio; explicit baseline-origin policy fields resolve through each renderable glyph's packed index into +the existing semantic-glyph records instead of duplicating two float arrays. Six U32 lanes carry foreground, cluster, +region, flow-thread, transform, and stable-glyph identity. Exact float bits plus all +integer and semantic fields determine a monotonic transactional `content_revision`. A unit fixture retains revisions +`[1,2]` across an identical rebuild and advances to `[3,4]` after shifting the slot one pixel. A compiled real-Inter +`text_update` publishes nonzero resource/buffer/patch/primitive/draw tables; the identical next call keeps the same Wasm +buffer and emits zero patches. Optimized Wasm is 1,057,210 / 400,071 / 311,492 raw/gzip/Brotli bytes (+12,413 / +4,849 / ++3,697). This proves plan reachability and minimal no-op updates, not the still-unmeasured 25,515-glyph latency target. + +## Performance contract + +Text does not own an 8.33 ms frame. The hard warm-update ceiling is p95 < 4.0 ms from mutation submission through a +validated plan ready for renderer lowering, including any required Wasm-to-transfer-buffer copy. The design target is +p95 ≤ 1.0 ms for localized edits and retained constraint changes that converge within the requested viewport. Renderer +lowering and CPU-side upload submission are reported separately and must also fit the application's total 4 ms UI +budget; neither target is justified by a best-case median. + +The first retained-edit checkpoint deliberately uses shaping-run boundaries as its conservative invalidation unit. If a +localized edit preserves the complete run topology and the affected run's font fallback, Rust copies every other shaped +run and reshapes the affected run from its original independent context. This is broader than an unsafe-concat window, +but it is exact for the same reason the cold path shapes those runs independently. On the unchanged 22,000-glyph Bitmap +fixture, production optimized SIMD Wasm improved from 16.223 ms to 9.372 ms median (31 measured updates after 40 warmups), +with 9.723 ms p95 and about 1 KiB of command-buffer writes. This isolates a roughly 6.85 ms shaping win. It also proves +that shaping alone cannot meet the contract: cluster rebuilding, line composition, positioning, and plan gathering +remain global, and the ordinary eight-warmup lane still catches a later 1,114,112-byte memory growth. Both are open +failures, not reasons to weaken the gates. + +The next checkpoint added the first one-line convergence proof for same-length localized edits. With geometry, font +metrics, limits, and ellipsis behavior unchanged, Rust recomposed the line containing the edit and initially required +the complete line record to match the retained line. A match permitted the prefix and suffix lines—and their positioned +glyph records—to be retained; any mismatch discarded the partial result and ran the complete composer. A 101-update +production run after 40 warmups improved the same workload +from the preceding 9.372 ms checkpoint to 7.668 ms median, an 18.2% end-to-end reduction, with 9.620 ms p95 and five +roughly 1.2 KiB command-buffer patches. The optimized Wasm grows from 1,131,457 to 1,138,394 raw bytes (+6,937). This +remains above budget. Cluster reconstruction, revision assignment, and plan gathering remain broad, and the retained +high-water mark is still 80.38 MiB. + +The existing transaction's two flat UTF-16/identity buffers now remain synchronized after an equal-length commit or +abort, copying only the proven changed identity range instead of cloning the paragraph before its next edit. A +101-update rerun measured 7.633 ms median / 9.358 ms p95, effectively flat against 7.668 / 9.620 ms, while optimized +Wasm shrank 520 raw bytes to 1,137,874. This cleanup removes known redundant copying without attributing the remaining +latency to memcpy. Length-changing edits still require the chunk-local gap storage below. + +Positioning now carries the exact previous/new glyph span for the line accepted by the convergence proof. Retained +prefix and suffix glyphs keep their content revisions and zero change masks; only the recomposed span performs semantic +field comparison and identity lookup. On the same 101-update optimized workload this narrows 7.633 ms median / 9.358 ms +p95 to 6.894 / 9.314 ms. The 9.7% median reduction admits the range optimization, while the effectively unchanged p95 +does not support a tail-latency claim. Optimized Wasm grows 1,693 raw bytes to 1,139,567. + +The incremental shaping splice now identifies its affected source run for cluster aggregation. When grapheme and glyph +topology are exact, Rust copies retained cluster lanes, rebuilds only that run's advances, bindings, adjacency, safety, +break flags, and stable glyph identities, and also recomputes the predecessor break whose legality depends on the +run's first safe boundary. Any topology mismatch uses the cold builder. A field-for-field oracle proves the retained +result equals an independent cold rebuild. The same 101-update optimized workload improves from 6.894 / 9.314 ms +median/p95 to 5.881 / 8.406 ms, with five roughly 1.2 KiB patches. Optimized Wasm grows 7,633 raw bytes to 1,147,200. +This admits the retained aggregation path but remains above the 4 ms p95 contract. + +The composer now continues across old line bands until the new cursor reaches an old line's ending cluster with the +same height and baseline. Only that ending state controls whether the following line may be retained: requiring the +recomposed line's start and fragments to equal the old line made later convergence impossible after a boundary moved. +An exact three-line fixture transfers advance across the edited boundary, changes the second line from clusters +`[3,6)` to `[3,5)`, and proves the next recomposed line reaches cluster 9 before the suffix is retained. Equal-length +ASCII-letter replacement also reuses Unicode and bidi state because UTF-16 length, grapheme boundaries, Latin script, +UAX #14 class, and bidi class cannot change under that exact guard; spaces, punctuation, non-ASCII text, and structural +edits continue through complete analysis. On the unchanged production benchmark, 40 warmups and 101 measured updates +improve the preceding 5.881 / 8.406 ms median/p95 to 2.607 / 6.184 ms. RSD falls from the previously observed 137.6% +split to 42.4%, while five roughly 1.2 KiB patches remain. The 1,147,266-byte optimized Wasm is 66 bytes larger than +the retained-cluster checkpoint. The median meets the 4 ms ceiling, but the break-sensitive p95 does not; retained +policy gathering, plan compilation, and chunk-local insert/delete storage remain open. + +Policy gathering now retains its validated field-major inputs under an exact session revision, policy fingerprint, and +capability-set key. A one-byte source-selection lane lets zero-change positioned glyphs reuse the prior plan record +without font-resource selection or policy interpretation. A changed glyph rereads only inputs reachable from its +semantic change mask. Stable glyph identity may change without invalidating physical topology; technique, program, +resource, transform, material, clip, and depth keys remain exact fallback boundaries. When selection topology changes, +the gather keeps the verified prefix, truncates at the first mismatch, and completely gathers only that suffix and later +paragraphs, so a new allocation still has every input it needs without a second prefix scan. Cache state promotes only +with the engine transaction, abort cannot publish it, and font-binding/policy disposal invalidates it. An oracle changes +identity and one policy field in place, then changes material and proves the suffix rebuild matches a complete input. +On the unchanged 40-warmup/101-update production run, median/p95 improve from 2.607 / 6.184 ms to 1.314 / 5.863 ms; +five roughly 1.2 KiB patches remain. RSD is 76.2%, so the fast class is near the 1 ms design target but break-sensitive +edits still miss the p95 contract. Optimized Wasm grows 5,856 raw bytes to 1,153,122, and the retained high-water mark is +80.19 MiB. Ordered-plan compilation and chunk-local insert/delete storage remain open. + +The renderer's 25% instance slack is not the edit-storage design. Editing requires the selected ABI-private +64-cluster semantic chunks to reserve a small bounded gap so insert, delete, and replacement operations move only the +affected chunk before summaries and downstream line state resume. The current production text, shape, cluster, and +position arenas still use flat transactional A/B vectors, so they copy or scan far more state than a localized edit +requires. That implementation gap must close before this stack can claim the retained-storage decision or the edit +latency target. + +The benchmark reports phases and retained-output costs separately: + +- Unicode/style invalidation, shaping, composition, positioning, semantic geometry, plan compilation; +- bytes scanned, rewritten, published, copied by the adapter, and submitted to the GPU; +- patch count, upload call count, draw-packet count, allocations, memory growth, and output-generation pressure; and +- scalar-oracle versus SIMD-kernel time on the same data and output contract; and +- root/worker ownership transitions, transfer-buffer pool hits, copies, transferred bytes, returns, and backpressure. + +At minimum, benchmark these deterministic workloads: + +- the existing 25,515-glyph fully visible cold, font-size, width, and text-edit cases; +- localized insert/delete/replace/style edits at paragraph start, middle, and end; +- 100,000 retained glyphs with a bounded visible window and convergence after a local edit; +- mixed-direction Arabic/Latin, Indic shaping, CJK horizontal and vertical layout; +- decorations and word/letter spacing; +- multi-column flow with exclusions and an inline object; and +- renderer policies for ordered, indirect, and segmented storage on supported backends. + +No-op frames do no Wasm work. After warm capacity is established, primary warm cases perform zero allocator calls and +zero memory growth. The fully visible 25,515-glyph font-size, width, and text-edit lanes and the retained/windowed +100,000-glyph localized-edit lane must all remain below the 4 ms p95 ceiling. The 1 ms target is reported for every lane +and is a required design objective, but it does not become an asserted result until measured. Cold initialization and +genuinely document-wide structural changes are reported separately with allocation, growth, and tail-latency evidence. + +### Realtime feature admission + +No finite implementation can guarantee a frame time for unbounded text or geometry. The product guarantee is therefore +defined over explicit input and work bounds selected from the Stage 0 measurements. Each request caps regions, lines, +clusters, exclusions per region, slots per band, policy operations, semantic-output bytes, patch bytes, and total output +bytes. Exceeding a cap produces a valid partial plan, an overflow reason, and a resume cursor; it never starts an +unbounded recovery or silently changes typography. The previous complete revision remains renderable until its +replacement is complete. + +A realtime feature must satisfy all of these: + +- its work is linear or better in invalidated/visible chunks, emitted fragments, or another explicitly capped input; +- it performs no nested layout, global optimization, unbounded backtracking, runtime dictionary loading, outline-curve + intersection, arbitrary host callback, or convergence loop without a deterministic iteration cap; +- its warm path performs zero allocations and memory growth at the admitted capacity; +- expensive semantic views are absent unless requested; and +- its isolated and combined worst-case corpora remain under p95 4 ms, with p95 1 ms as the design target. + +The admitted feature candidates are horizontal and vertical shaping, Unicode breaking, word/letter spacing, +underline/overline/line-through, bounded ink-box skipping, tate-chu-yoko with a short-run cap, emphasis marks, bounded +exclusions and inline objects, and bounded sequential-region flow. A candidate that misses the budget is narrowed or +cut; architectural generality is not a reason to ship it. + +## SIMD-shaped engine design + +SIMD is part of the storage and algorithm design, not a loop replacement at the end. The scalar implementation remains +an exact oracle and tail path, but production data structures must make contiguous lanes available from the first Rust +milestone. + +### Data layout + +- Store hot glyph and cluster fields as 16-byte-aligned SoA arrays: glyph IDs, clusters, design-unit advances and + offsets, style/font slots, bidi levels, break flags, and stable identities. Physical render records remain + policy-directed AoS/SoA outputs. +- Partition retained text into fixed-capacity chunks whose live capacity is a multiple of 16 clusters. The Stage 0 + packet compares 32-, 64-, and 128-cluster chunks; the chosen size is then an ABI-private invariant. +- Give each chunk summaries needed to skip it without visiting every cluster: total advance by uniform scale run, + required/safe/allowed break masks, first/last candidate positions, bidi transition mask, text range, and revision. +- Retain HarfRust's `i32` design-unit advances and offsets until scaling is required. Do not eagerly convert the whole + paragraph to `f64` arrays. Cluster grouping is performed once while HarfRust output is already monotone within a run. +- Reuse HarfRust `GlyphBuffer::clear()` allocations, UTF decoding buffers, feature records, run tables, line cursors, + patch builders, policy VM registers, and both Wasm output arenas. Clearing live lengths must not drop capacities. +- Use slabs or generational arenas for variable-count lines, fragments, decorations, and inline objects. No object or + hash-map entry is allocated per glyph or cluster in a warm update. + +### Kernel map + +| Kernel | Lane shape | Planned treatment | +| --------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------ | +| Built-in Bitmap/MSDF/Slug packing | four independent `f32`/`u32` records | explicit four-lane load/transform/store; strongest first admission candidate | +| Declarative policy transforms | four independent semantic records | vector bytecode/graph execution so dispatch is amortized across four records | +| Bidi-level transitions | sixteen `u8` levels | compare shifted contiguous levels and extract a bitmask | +| Break and cluster flags | sixteen `u8` flags | vector masks plus `bitmask`/trailing-zero candidate selection | +| Patch verification/coalescing | sixteen bytes or four words | `v128` compare on already invalidated spans; never scan the whole live buffer as the primary algorithm | +| Glyph-to-cluster aggregation | repeated cluster IDs and scatter writes | reshape once into cluster-contiguous runs; core Wasm SIMD has no gather/scatter instruction | +| Line-width search | chunk summaries plus ordered boundary scan | skip summary blocks; preserve scalar addition order at the exact width boundary | +| Decoration bounds/packing | four independent segments | vectorize bounds and physical packing after line fragmentation is fixed | + +Line-breaking correctness forbids changing floating-point association. Core WebAssembly SIMD provides deterministic +`v128` integer and float operations, but relaxed-SIMD operations permit implementation-dependent results and are not +used.[^wasm-simd] Exact integer scans are preferred; ordered `f64` accumulation remains scalar where changing order +could move a break. Both oracle and production paths narrow only when writing a declared lower-precision render field. + +Rust 1.97's `core::simd` remains nightly-only. Wasm kernels therefore use stable `core::arch::wasm32` intrinsics with +`simd128`; native kernels use target-specific intrinsics behind the same chunk/kernel interface.[^rust-simd] The kernel +interface has compile-time SIMD and scalar backends, selected by one checked build feature; exactly one backend is +linked into an artifact. The standard Web artifact enables SIMD. The scalar backend is the differential oracle and +tail implementation and also keeps a same-ABI, no-SIMD compatibility artifact buildable without maintaining a second +semantic engine. That artifact is not published or selected at runtime until concrete consumer demand justifies it. + +### Up-front viability packet + +Before the semantic port chooses its retained structs, build a test-only kernel lab over captured arrays from the real +25,515- and 100,000-glyph workloads. It compares the same data layout under scalar, compiler-generated, and explicit +SIMD kernels for packing, flag scanning, chunk summaries, boundary search, and policy execution in Node, Chromium, and +representative native targets. + +The packet must: + +- prove exact semantic and byte output, including mixed direction, vertical data, partial chunks, and unaligned request + offsets; +- inspect the optimized Wasm to prove the intended vector instructions survive Binaryen; +- record p50/p95/p99, instructions or phase time where available, warm allocations, memory growth, raw/Brotli bytes, + and end-to-end contribution; +- demonstrate zero warm allocation and growth for the tested kernels; and +- select chunk size, alignment, summary layout, policy execution shape, and browser capability baseline before those + types become production API. + +A kernel is admitted when it improves its phase p95 by at least 20% and either improves the complete hot update by at +least 5% or removes enough latency/allocations to meet the 1/4 ms budgets, without regressing another primary workload +by more than 2%. Its production Brotli delta is capped at the smaller of 12 KiB or 5% of the engine module unless a +separate decision records stronger end-to-end evidence. These thresholds intentionally reject the repository's prior +MTSDF pattern of double-digit code growth for low-single-digit bounded gains. + +The first packet checkpoint selects 64-cluster, 16-byte-aligned SoA storage. Across real 25,515- and 100,602-glyph +arrays, scalar, compiler-vectorized, and selected hybrid artifacts produced identical horizontal, vertical, partial-tail, +and four-byte-aligned output hashes in Node 24.18.0 and Chromium 149 without warm allocation or memory growth. Compiler +vectorization, not hand-written shuffles, owns straight-line record packing. Explicit `i8x16` break/bidi masks and +integer-exact `i64x2` summaries exceeded the 20% phase threshold; 64-cluster summaries won the large workload against +32 and 128. The production policy executor resolves output-buffer indices at registration, preflights all semantic SoA +inputs and physical outputs before writing, and dispatches validated straight-line bytecode over four records per SIMD +iteration with scalar tails. Its representative 17-operation program over 25,515 glyphs measured 1.174→0.428 ms p95 +in Node and 1.113→0.438 ms in Chromium; compiler auto-vectorization remained within scalar variance. All three artifacts +produced the same output bytes. The selected lab artifact now adds 4,185 raw / 1,096 Brotli bytes over scalar, while the +standard production `+simd128` build is 530 raw bytes smaller and 62 Brotli bytes larger than its same-ABI scalar +release-valve build. The optimized disassembly contains the intended vector loads, stores, arithmetic, comparisons, +bitmasks, shuffles, and integer lanes. Boundary search, representative native SIMD, and end-to-end contribution remain +open parts of the packet because their production stages do not exist yet; they are not inferred from these admitted +kernels. + +## Implementation stack + +Each stage is a small Conventional Commit series with unchanged fixtures and an independently reviewable invariant. +The first stack proves the Wasm boundary, policy, display-list/render-plan contract, and complete current Rust semantic +pipeline before adding new publishing features. The single ABI is its final cutover, not its first commit. + +Current checkpoint evidence: the retained Rust transaction reaches real Inter shaping, fallback, measured clusters, +horizontal editorial flow, positioning, policy gather, and a nonempty render plan. Exact invalidation lets unchanged +ordered-direct frames publish an empty reuse transaction and lets font-size changes reuse Unicode, bidi, and shaping. +The original one-F32 diagnostic over 25,515 positioned glyphs measured +13.693/0.001/4.090/3.374/13.927/13.986 ms median for +cold/no-op/font-size/full-column-resize/suffix-edit/localized-edit. The unchanged TypeScript comparison measures +55.25/11.90/8.36/38.55 ms for cold/font-size/width/suffix-edit. + +The renderer-parity benchmark now validates the real baked Bitmap, MTSDF, and Slug fixtures and emits their canonical +GPU schemas: five Bitmap buffers totaling 48 bytes per instance, seven MTSDF vec4 buffers totaling 112 bytes, and five +Slug float vec4 plus two unsigned vec4 buffers totaling 112 bytes. The same stress text positions 25,515 glyphs and +selects 21,805 renderable raster instances, matching the portable techniques' deliberate omission of absent records. +Policy validation now propagates semantic input dependencies through the straight-line program once and stores a mask +per physical output. Positioning records exact eight-F32/six-U32 dependency change bits in a compact side lane without +enlarging the 64-byte `PlanGlyph`; ordered-direct and stable-indirect planning intersect the two masks. New or rebound +records remain +conservative full writes. For 21,805 renderable instances, full-column resize now emits one position/geometry patch: +170.4 KiB for Bitmap and 340.7 KiB for MTSDF or Slug, down from cold-plan writes of 1,022.1, 2,384.9, and 2,384.9 KiB. +Font-size emits 340.7/340.7/681.4 KiB because Bitmap size and Slug inverse scale also change. Static UV, color, bounds, +effects, band, address, and count outputs remain retained. + +Five-warmup/11-sample Bitmap/MTSDF/Slug medians are now 5.812/6.274/7.443 ms for font-size and +4.599/4.916/6.057 ms for full-column resize. Run-to-run latency does not establish a general speedup despite the exact +payload reduction; it confirms that packing and copying were not the dominant cost. The sub-4 ms gate still rejects the +current path, so layout composition is the next measured optimization target. Memory right-sizing, incremental text +edits, Three consumption, and deletion of the duplicate TypeScript path remain foundation-stack gates. + +A symbolized temporary no-`std` Wasm profile can now isolate the canonical column-resize case without changing its +default measurement sequence. Positioning is the largest sampled Rust function, followed by policy gather. Positioned +reconciliation now checks the common equal-length/equal-stable-ID order before building the exact identity index; bidi +or flow reordering retains the map fallback. The current five-warmup/11-sample resize medians are +4.414/4.984/5.196 ms for Bitmap/MTSDF/Slug. The inter-run variance is too large to assign a precise causal speedup to +this small change, and all three remain above the median gate. + +The common visually LTR lane now proves every retained bidi level is even and that no run has a direction override once +per positioning pass. It then walks logical clusters directly, avoiding per-line L1 scratch copies and visual +cluster/level writes; any odd level or override takes the unchanged complete L1/L2 path. On the same workload, two +adjacent eight-warmup/31-sample Bitmap baselines measured 5.197 and 5.162 ms, while two optimized runs measured 4.849 +and 4.878 ms with the same one-patch 170.4 KiB output. Post-change MTSDF and Slug medians are 5.355 and 6.001 ms. This +retains a measured positioning reduction without claiming the still-open sub-4 ms gate. Optimized Wasm is 1,065,857 +raw / 403,525 gzip / 318,137 Brotli bytes; Brotli changes by six bytes from D-201, while gzip is sensitive to the new +code layout. + +Policy registration now closes the dependency chain in both directions: forward propagation records which F32/U32 +input lanes reach each physical buffer, and reverse liveness records which physical buffers consume each operation. +For a positioned update, the frame-level semantic-change union selects active buffers, gather reads only their required +lanes, and scalar/SIMD execution skips operations that reach no active buffer. New glyphs, checkpoints, and changes +outside retained positioning force all inputs. Consecutive glyphs reuse their last resolved font binding and immutable +policy program without caching selection results. + +The mechanisms must be evaluated together. Selective gather alone measured 5.027/5.685/6.443 ms for +Bitmap/MTSDF/Slug resize and operation liveness alone measured 4.880/5.329/5.985 ms, against the preceding +4.878/5.355/6.001 ms checkpoint. Combined they measured 4.207/4.833/5.615 ms. Adding binding/program resolution +caching measured 3.981 and 4.120 ms in two canonical full-sequence Bitmap runs, 4.646 ms for MTSDF, and 5.622 ms for +Slug. A separate case-isolated Bitmap process measured 4.799 ms, so process/JIT tiering prevents treating the single +3.981 ms result as closure of the sub-4 ms gate. Optimized Wasm is 1,069,973 raw / 405,888 gzip / 319,558 Brotli +bytes, an increase of 14,116 raw / 2,363 gzip / 1,421 Brotli bytes over D-202. + +The first production cutover slice now shares the already initialized `RuntimeShaper` Wasm instance with a typed +text-engine host. The host owns raw policy/font-binding/font-stack registration and session disposal, invokes cold +reservation before pinning when the exact request outgrows its arena, writes directly into retained request memory, and +returns a borrowed view over the published A/B slot. It performs no layout, shaping, or render-plan interpretation in +TypeScript and does not create a second module instance. A compiled-Wasm integration test observes revisions 1/2 and +slots A/B while proving publication B leaves A byte-stable. The Three adapter is not switched by this slice; production +request/policy compilation and plan lowering remain open and are not claimed by the host proof. + +The next production slice compiles one Three policy containing the complete first-party Bitmap, MTSDF, and Slug +program set and compiles validated raster artifacts into the corresponding immutable field-major font bindings. The +compiler allocates one final binding request rather than materializing per-field arrays. Exact fixture comparison covers +every emitted lane, and the combined policy registers in the real Wasm module. Technique, program, and resource form +storage identity; `material_id`, clip, depth, and order additionally form draw identity, preserving renderer authority +to share storage or split physical buffers without placing a callback in Wasm. String technique and resource IDs map +deterministically to nonzero `u32` values with UTF-8 FNV-1a, while a runtime-scoped registry rejects collisions across +both domains. This is production policy/binding compilation, not the Three cutover: the public adapter still consumes +legacy paragraph batches until frame-request compilation and render-plan lowering land. + +Production frame serialization now covers the complete current request ABI in one final allocation: text mutations; +root and range styles; language and feature payloads; typography, paint, material, and decoration fields; constraints; +sequential rectangle or polygon regions; exclusion holes; inline objects; policy bytes; and revision/fence state. It +contains no shaping, layout, batching, or record-packing logic. For the existing rectangle stress case, the production +bytes are exactly equal to the established benchmark helper, including UTF-16 surrogate handling. A broad structural +fixture covers every variable table and the vertical/polygon/decorated lanes. The remaining proof is deliberately +scoped: normalize actual public Three state into this descriptor, submit a rich request to Rust, then lower the returned +plan; structural serialization alone is not that cutover. + +Render-binding identity is now independent from shaping-font identity. A registered binding handle names one loaded +font/technique/resource combination and points to the retained shaping handle it shares; font stacks contain binding +handles. Fallback carries both identities through the shape and cluster arenas, metrics/extents use the shaping handle, +and policy gather uses the binding handle. This makes same-face multi-technique registration and mixed-technique +fallback representable without retaining the SFNT twice. Compiled Wasm proves Inter missing Devanagari advances to a +second binding and emits that binding's different technique in the Rust plan. The cost is one additional retained `u32` +cluster lane. On the unchanged 25,515-positioned/21,805-renderable workload, 8-warmup/31-sample column-resize medians +are 4.217/4.791/5.633 ms for Bitmap/MTSDF/Slug versus 4.120/4.646/5.622 ms at D-203; 6–7% RSD and lower run minima do +not establish a regression. Optimized Wasm is 1,070,580 raw / 402,114 gzip / 319,662 Brotli bytes, +607 / -3,774 / +104 +bytes from D-203. + +The production Three ownership layer is lazy and runtime-scoped. It keeps the all-technique policy and first-party +binding compilers out of renderer-neutral `TextRuntime`, allocates monotonic binding/stack/session handles, and +reference-counts exact ordered stack sequences. Last release disposes the Rust stack; reverse fallback order has a +distinct identity; retired handles are not immediately reused. A real Inter fixture registers Bitmap and MTSDF against +one shaping handle and proves the lifecycle in compiled Wasm. The coordinator is not yet referenced by the public Three +entry, so this slice establishes cold ownership without claiming the batch/session cutover or a shipping graph change. + +The first consumption slice retains a validated plan view instead of decoding Rust records into host objects. One +`DataView` covers the Wasm memory buffer and survives normal A/B slot changes; only `memory.grow()` replaces it. Table +offsets, counts, strides, alignment, and publication bounds are validated once per publication, after which the Three +lowerer can apply patches and draws by fixed offsets. A real compiled-Wasm fixture produces nonempty resource, buffer, +patch, primitive, and draw tables through this view. GPU realization and the public Three import remain open. + +The Three cutover must also preserve the existing batch meaning: one `TextGroup` contains multiple independent +paragraphs. Current Rust constraints describe multiple region flows over one retained prose stream, so assigning one +session to each `Text` would multiply boundary calls and physical buffers rather than preserve batching. One engine +session therefore retains paragraph-keyed child state and one shared planner/publication. Stable glyph/content IDs come +from session-wide monotonic namespaces, and Rust appends each child's positioned SoA into one pre-reserved gather +workspace. The append kernel is allocation-free after `begin(total_records)` and has an exact two-layout proof; +paragraph-keyed mutation/removal and atomic child commit remain the next implementation slice. Adjacent rebuilt-Wasm +Bitmap column-resize medians are 4.083 and 4.078 ms at 8 warmups/31 samples with 5.8%/6.1% RSD, so this slice makes no +speedup claim and shows no material regression. Optimized Wasm is 1,070,685 / 402,154 / 319,914 raw/gzip/Brotli bytes. + +After the public Three command-buffer cutover and deletion of its parallel target transaction, the canonical +`--glyphs 22000` workload was revalidated with five warmups and 11 samples. The benchmark now sums glyph primitive +`recordCount` values from the published Rust plan and rejects a workload below 95% of the requested target; the former +`primitiveCount` label counted primitive-table rows and misleadingly reported one. The unchanged fixture produces 25,515 +positioned TypeScript glyphs and 21,805 Rust renderable records. TypeScript cold/font-size/width/text medians are +61.29/11.40/8.33/39.15 ms. Complete Rust `text_update` plus Bitmap plan publication measures +14.77/4.95/3.78/14.43 ms for cold/font-size/column-resize/suffix-edit, MTSDF measures +15.37/5.35/4.35/14.84 ms, and Slug measures 15.43/6.11/5.08/15.61 ms. All three beat the TypeScript implementation; +none closes the required resize p95 below 4 ms (4.16/4.95/5.68 ms). This scope includes request copying into retained +Wasm memory and publication of exact plan bytes, but not Three patch application or GPU submission. The sequential +process reaches 86.50/100.88/101.69 MiB Wasm high-water marks; isolated warm sessions still reach 64.75/77.75/78.56 +MiB. Both latency and memory right-sizing remain foundation gates rather than accepted release costs. + +The first product-scale command-buffer scene exposed a missing cold-capacity transition rather than a layout error: +MTSDF paragraph stress required a 1,382,592-byte publication while the retained result arena was smaller. The request's +64 MiB output safety ceiling is now independent of arena capacity. Rust reports the exact watermark after aborting the +prepared update; the host reserves both result slots, re-pins and recopies the request, and retries once. A compiled-Wasm +test forces this path from a header-sized arena, and the live scene publishes 11,510 glyphs in one draw. Warm frames retain +the required one crossing. A three-run settled WebGPU comparison also rejected a policy experiment that split MTSDF +origin and size into separate `vec2` buffers: CPU submission was unchanged, while the additional binding slightly worsened +median GPU time, so the existing packed `vec4` remains. + +The first multi-workload retained-session sweep then exposed two independent paragraph-lifecycle bugs. Host limits had +treated `maxParagraphs` as only the final live count even though the wire table contains removals plus insertions, and +Rust recycled one removed paragraph's allocation without clearing its semantic contents. Both are now exact: admission +covers actual lifecycle and semantic mutation record counts, while a recycled state clears every committed and pending +semantic arena but retains its vector capacities. The same sweep exposed that the 4,096-glyph group capacity had been +multiplied into a 4,096-unit reserve for each of 476 icon-grid paragraphs. A session now prewarms one reusable paragraph; +additional children allocate from their actual content rather than multiplying a batch budget. A Rust regression proves +semantic reset plus capacity identity, a public Three fixture performs two atomic child-set replacements, and a complete +27-cell Bitmap/MTSDF/Slug WebGPU workload-transition sweep exits cleanly. The optimized shaper is 1,090,859 raw / 411,106 +gzip / 325,149 Brotli bytes. Benchmark draw/glyph telemetry for grouped command-buffer draws is corrected separately and +is not part of this transition proof. + +Ellipsis now closes the first deliberate narrowed-context shaping case. A flow thread requests it only when text remains +after its final visible region/line or a complete no-wrap line exceeds its final slot. Rust selects U+2026 through the +font stack, repeatedly evaluates the candidate width while trimming clusters, and reshapes only the final contiguous +same-font tail with pre-context clipped to the line and post-context clipped to the truncation boundary. The retained +whole-paragraph shape remains authoritative everywhere else, so normal reflow retains the zero-boundary-reshape +invariant. Source replacement and ellipsis glyphs live in a paragraph-transactional boundary arena and enter the same +positioning, identity, semantic, policy, and render-plan pipeline as ordinary glyphs. The Amiri public Three regression +first proves broad and narrowed glyph identities differ at the chosen unsafe joining boundary and then proves the Rust +output matches the narrowed result; a separate no-wrap regression covers complete-but-too-wide lines. The checkpoint +passes 136 Rust library tests and all 204 package tests. Same-machine detached `4adbbebc` comparison over two complete +22,000-target-glyph Bitmap runs measures baseline/current column-resize medians of 4.041/4.056 and 4.102/4.189 ms, while +cold medians are 15.490/15.569 and 15.212/15.358 ms. These small mixed-direction differences and the noisier current +samples establish performance adjacency, not a speedup or material regression. The complete checkpoint also contains +the lazy Three origin-index and Bitmap resource-selection fixes, so its optimized Wasm movement from +1,101,079 / 414,917 / 328,164 to 1,114,718 / 420,714 / 333,743 raw/gzip/Brotli bytes is an aggregate checkpoint cost, +not an ellipsis-only attribution. + +### Foundation stack — Wasm, policy, render plan, and complete current semantics + +### Stage 0 — contracts and measurement + +- record the accepted architecture in the decision register and update the affected package concepts; +- define the binary schema, revisions, A/B publication and transferable-buffer ownership state machines, semantic + tables, and render-plan IR; +- add phase, patch, upload, allocation, and generation-pressure instrumentation; +- add edit-locality, vertical, decoration, spacing, exclusion, and bounded-region benchmark fixtures without changing + goldens; +- execute the up-front SIMD viability packet and select chunk, alignment, summary, and policy-execution layouts; +- add reproducible `simd` and `scalar` artifact builds with identical schemas and ABI, make SIMD the standard package, + and test that an artifact contains only its selected kernel backend; and +- set the measured hard caps for clusters, lines, exclusions, slots, regions, policy operations, and output bytes. + +### Stage 1 — engine shell, frame ABI, and retained ownership + +- separate renderer-neutral Rust core from the Wasm transport; +- implement validated mutation transactions, stable revisions, request reservation, A/B Wasm publication, worker-owned + transferable buffers, root retirement, and deterministic partial/resume results behind a test-only entry point; +- add aligned retained chunks, packed flags, chunk summaries, generational arenas, and zero-allocation warm scratch; +- test growth, detachment, malformed requests, failed updates, skipped revisions, detached-buffer misuse, missing returns, + bounded-pool backpressure, and worker-side collection; and +- keep the shipping hot path unchanged while the Rust dependency chain is incomplete. + +### Stage 2 — policy and retained render-plan proof + +- land the typed straight-line policy bytecode and semantic/resource/buffer/patch/primitive/draw IR; +- compile captured current semantic fixtures into stable instance identities, ordered-direct or stable-indirect storage, + invalidation-directed dirty patches, and capability-shaped draw packets; +- prove built-in Bitmap, MSDF, and Slug policies through scalar kernels before adding SIMD; +- lower the same plan through the one Three/TSL adapter on WebGPU and forced WebGL2 plus a minimal native consumer; and +- prove ordered MSDF-to-Slug and MSDF/Slug-to-Bitmap fallback, per-technique buffers and patches, atomic multi-program + publication, the first-party policy accepting all three built-ins, and a restricted third-party policy rejecting an + unsupported stack before rendering. + +### Stage 3 — complete current shaping and layout in Rust + +- port Unicode 17 grapheme and line-break analysis, bidi orchestration, style itemization, the existing ordered + `FontStack` fallback semantics while removing its raster-homogeneity constraint, shaping-run construction, cluster + measurement, horizontal line composition, reordering, positioning, alignment, justification, and existing overflow + behavior; +- write semantic results directly into the retained plan compiler rather than returning shaped glyph arrays to + TypeScript; +- retain the TypeScript engine temporarily only as a differential test oracle on deterministic fixtures; +- use the unchanged official Unicode vectors, fallback fixtures, mixed-direction goldens, and packed-consumer bytes; and +- implement only SIMD kernels admitted by the Stage 0 scalar-versus-SIMD evidence, with scalar paths and tails kept + test-visible. + +### Stage 4 — atomic cutover and foundation performance gate + +- cut the public hot path to one `text_update` call after byte and semantic parity is established; +- remove TypeScript shaping and layout orchestration and the old analysis/shape/reshape exports together; +- apply retained patches through both Three/TSL backends, including WebGL2's required retained PBO copy; and +- pass the complete 25,515-glyph target-hardware gate before any additional publishing feature enters the stack. + +### Following stacks — publishing features on the proven foundation + +### Stage 5 — spacing, decorations, and interaction + +- implement word- and letter-spacing semantics, tabs/indents, inserted hyphens, script-aware justification, and hanging + punctuation; +- bake and consume underline/strike/vertical/baseline metrics; +- emit underline, overline, line-through, hit-test, caret, selection, and accessibility geometry; and +- verify direction changes, ligature boundaries, fallback fonts, line fragmentation, and vertical decoration orientation. -**The request is written in place.** JavaScript holds a pinned view over a retained staging region and writes the -frame's mutations into it: text deltas, style and span changes, constraints, and the per-line width intervals that carry -hole punching. Then it calls `text_update` once. +### Stage 6 — editorial and vertical layout -**Growth never costs a second crossing in steady state.** The result header carries the capacity the next frame will -need. Growth happens at the top of the next single call. A frame that grows toward the watermark may need a second call; -growth doubles and then backs off to fit, so this settles and does not recur. +- implement axis-neutral horizontal/vertical composition and text orientation; +- add Japanese line tailoring, bounded tate-chu-yoko, emphasis marks, regions, exclusion subtraction, multiple inline + slots, inline objects, drop caps, explicit breaks, and sequential fill; +- add retained cursor/resume/convergence behavior and safe narrowed reshaping at line boundaries; and +- resolve every declared region and exclusion in the same update call, flowing sequentially into at least a second + fixed region without balancing or a host measurement round trip. -**Detachment has one discipline, because `memory.grow()` detaches every view regardless of a declared maximum** -— verified in the pinned Node runtime: with `maximum` set, `buffer.resizable` is `false` and an existing view is -detached by a grow. Double buffering inside Wasm does not prevent this; detachment is a property of the memory, not of -a buffer slot. The rule is therefore: +### Stage 7 — emoji color raster and integration cleanup -1. `text_update` is the only export that may grow memory. -2. After it returns, compare `memory.buffer` identity against the retained one. -3. Re-pin every view if it changed. -4. Upload. No Wasm call may occur between re-pin and upload. +- add the optional Bitmap color companion and selected-page accounting without changing shaping-data size; +- verify emoji-only Bitmap fallback from MSDF and Slug prose through the heterogeneous `FontStack` contract; +- remove the differential TypeScript implementation after all gates pass; +- update roadmap, package concepts, decision register, and API reference; and +- finish with package checks, repository checks, benchmarks, browser conformance, and a clean worktree. -Double buffering remains in the design for its actual purpose: the engine writes the next frame's instance data into the -free buffer while the active one is still being read by the renderer, and swaps. That is what makes the retained result -safe to alias, once the detachment discipline above holds. +## Hard gates for every implementation stage -**The result is instance data, not shaped glyphs.** The engine writes canonical technique storage directly — for Bitmap -that is `origins`, `sizes`, `uvOrigins`, `uvSizes` as tightly packed pairs and `colors` as quads, which is already the -GPU attribute layout[^bitmap] — plus the coalesced dirty ranges describing what changed. +- never regenerate a golden or official Unicode fixture to accept a behavior change; +- `mise exec -- pnpm --filter @pmndrs/text check` passes Rust and TypeScript tests, lint, format, types, official Unicode + vectors, browser consumers, and packaging; the current closure checkpoint contains 158 Rust library tests and 165 + Node integration tests plus three deterministic fuzz-smoke tests, but executable manifests—not frozen counts—remain + authoritative as coverage grows; +- the benchmark application's complete `check` passes its current executable manifest; the closure checkpoint contains + 111 Vitest cases and 16 isolated headless Chromium targets, with the manifest authoritative rather than these counts; +- the mixed-direction Amiri golden and packed-consumer contract remain exact until an explicitly versioned render-plan + contract replaces the latter; +- `text:rust-layout-benchmark -- --glyphs 22000` measures the packaged release Wasm's complete `text_update` and render + plan for every current technique. Historical TypeScript tables remain labeled evidence; deleted code is not rebuilt as + a second implementation merely to manufacture a live baseline; +- Unicode segmentation and line breaking pass the repository's unchanged official vectors; +- scalar and SIMD paths produce identical declared output bytes; and +- each adapter proves patch application from the stated base revision and checkpoint recovery after a skipped revision. -## What the GPU can and cannot alias +## Resolved product direction -Verified against the pinned Three.js, not assumed. +- Render-plan policies are validated declarative bytecode built through typed host/native builders; no arbitrary + JavaScript packing callback or second native policy implementation executes in the hot path. +- Loaded fonts own technique and resource binding. User-facing `FontStack`, Three `Text`, and `TextGroup` carry no + separately authored technique; every first-party engine policy supports Bitmap, MSDF, and Slug, while third-party + policies may declare and safely enforce a subset. +- Ruby, warichu, automatic language hyphenation and dictionaries, balanced columns, and widow/orphan/keep solvers are + cut from the product scope. They reserve no base-runtime code or data. +- The hard warm-update ceiling is p95 < 4 ms on both the fully visible 25,515-glyph lanes and the 100,000-glyph + retained/windowed localized-edit lane. The design target is p95 ≤ 1 ms. +- Sequential multi-region overflow is required, resolves all declared regions and exclusions in one update, and does + not balance columns. Partial/resume remains only the explicit response to a declared out-of-envelope request. +- The standard Web artifact requires `simd128`. This excludes Safari before 16.4, Chromium before 91, Firefox before + 89 on x86/x64 or 90 on arm64, and Firefox on arm32/mips64.[^safari-simd] [^chrome-simd] [^firefox-simd] Initialization + reports a typed unsupported-capability error when module validation fails; it does not user-agent sniff or silently + load another module. +- A checked build-time feature can produce a schema- and ABI-identical scalar artifact from the same engine and kernel + interface. It is retained as a release valve and published only when an actual consumer requires the older browser + tail; SIMD remains the default build and package. -**WebGPU aliases correctly**, for `itemSize` 2 and 4. `itemSize` 3 makes Three reallocate every update. Bitmap's layout -qualifies as authored; MTSDF and Slug repack into `vec4` and need their canonical layouts checked against the same -constraint before they can alias. +[^css-text]: + [CSS Text Level 4](https://www.w3.org/TR/css-text-4/) defines the relevant spacing, hanging-punctuation, + and justification concepts. The engine API need not duplicate CSS syntax. -**WebGL2 cannot alias under the PBO path.** `GLSLNodeBuilder.setupPBO` assigns `attribute.array = newArray` with -power-of-two padding and hands that array to a `DataTexture` it retains by reference.[^pbo] A view over Wasm memory is -copied once and dropped, and later re-pinning is silently ineffective. +[^harfbuzz]: + [HarfBuzz buffer flags](https://harfbuzz.github.io/harfbuzz-hb-buffer.html) define safe-to-insert and + unsafe-to-concatenate boundaries used for narrowed reshaping. -This is not a blocker. One copy on WebGL2 replaces the four the current pipeline performs, and WebGPU — the flagship -backend and the native path — gets the direct alias. The design should not contort to make WebGL2 zero-copy. +[^safari-simd]: + [WebKit's Safari 16.4 release notes](https://webkit.org/blog/13966/webkit-features-in-safari-16-4/) + record the addition of WebAssembly 128-bit SIMD. -## Staging +[^chrome-simd]: + [The Chromium 91 release announcement](https://blog.chromium.org/2021/04/chrome-91-handwriting-recognition-webxr.html) + records WebAssembly SIMD becoming enabled by default. -Each stage lands independently, proves byte-identical layout against the pinned goldens, and reports -`text:layout-benchmark` before and after. A stage that does not move its measured number is reported as such and kept or -dropped on its merits, not on expectation. +[^firefox-simd]: + [Mozilla's WebAssembly SIMD shipping record](https://bugzilla.mozilla.org/show_bug.cgi?id=1625130) + records Firefox 89 for x86/x64, Firefox 90 for arm64, and no planned arm32 or mips64 implementation. -**Stage 1 — the atomic entry point, no logic moved.** Introduce `text_update` and the retained staging region. Bidi, -shaping, and reshaping become internal calls behind it; the three current exports are removed. Layout stays in -TypeScript, driven through the new single crossing. This isolates the ABI change from every logic change, so a -regression here is unambiguously the ABI. +[^icu4x]: + [`icu_segmenter` documentation](https://docs.rs/icu_segmenter/latest/icu_segmenter/) currently documents + Unicode 15.1 line-break data, so it cannot replace this repository's Unicode 17 gate without new evidence. -**Stage 2 — instance packing in Rust.** Canonical storage becomes engine output. This is the phase with the most -favourable ratio: a fixed-width transform, no data dependence between glyphs, and it is where SIMD pays first. Gate on -the packed-consumer hash staying byte-identical. +[^jlreq]: + [JLREQ](https://www.w3.org/TR/jlreq/) documents Japanese vertical composition, punctuation, ruby, emphasis, + and line-start/end restrictions. -**Stage 3 — per-line composition with a cursor.** `layout_next_line(cursor, max_width)` with the caller supplying width -per line. Line breaking initially consumes host-supplied break opportunities so the Unicode conformance gate is -untouched. Boundary reshaping returns here, under a narrowed context, with a test that fails if it is absent — this is -where hole punching and correct ellipsis truncation become expressible. +[^parley]: + [Parley layout](https://docs.rs/parley/latest/parley/layout/) demonstrates retained rich-text layout, + decorations, cursor/selection data, and line-breaking iteration in a current Rust implementation. -**Stage 4 — text analysis in Rust.** Grapheme segmentation moves first; `unicode-segmentation` is Unicode 17 and passes -the official grapheme vectors. Line breaking waits: no published Rust crate passes Unicode 17 `LineBreakTest`, and this -repository gates on that file passing unchanged. Until ICU4X publishes a conforming UAX #14 segmenter, break -opportunities stay host-supplied through the Stage 3 ABI, which costs nothing because the tier is already retained -across the warm paths. +[^pretext]: + [Pretext](https://github.com/chenglou/pretext) demonstrates a prepared text object advanced by a cursor and + a different width for each line; its Canvas measurement model is not adopted here. -## Where SIMD pays +[^ruby]: + [CSS Ruby Annotation Layout Level 1](https://www.w3.org/TR/css-ruby-1/) defines base/annotation pairing, + levels, positioning, merging, and distribution that make ruby a coupled second layout stream. -Named so the claim can be checked rather than assumed. In descending expected value: instance packing, a fixed-width -transform over independent glyphs; cluster advance prefix sums; per-line width accumulation; break-candidate scanning -over packed flags; and bidi level run detection. Each should be measured against a scalar Rust baseline before it is -described as a win — a Rust rewrite that is not vectorized is the honest comparison point, not the TypeScript one. +[^rust-simd]: + [Rust's stable `wasm32` architecture documentation](https://doc.rust-lang.org/core/arch/wasm32/index.html) + documents `simd128` intrinsics, compilation requirements, and the lack of in-module runtime feature detection. -## Risks +[^staging]: + [`wgpu::util::StagingBelt`](https://docs.rs/wgpu/latest/wgpu/util/struct.StagingBelt.html) is an example of + renderer-owned reuse for many buffer writes; it is separate from Wasm result publication. -The engine that must not regress is 1,800 lines of subtle bidi, cluster, and line-composition logic behind goldens that -have already caught one real precision regression and one crash during this work. The staging exists so that each -landing has a small blast radius. +[^text-decoration]: + [CSS Text Decoration Level 4](https://www.w3.org/TR/css-text-decor-4/) defines the decoration + dimensions and skip behavior used as the semantic reference. -Two facts specifically limit the achievable win and should be restated whenever this plan is quoted: text analysis is -already absent from resize and reflow, and boundary reshaping as previously built was redundant. Both were removed in -TypeScript. The Rust engine inherits an already-tightened baseline, so its case rests on write-once correctness, -per-line composition, and vectorized packing — not on the original numbers. +[^webrender]: + [Firefox's rendering overview](https://firefox-source-docs.mozilla.org/gfx/RenderingOverview.html) + separates retained display-list intent from renderer-specific scene building and submission. -[^pretext]: [Pretext](https://github.com/chenglou/pretext) exposes `layoutNextLine(prepared, cursor, maxWidth)` and a - range-returning variant, carrying a segment/grapheme cursor between calls and keeping prepared segment widths valid - across them. +[^wasm-simd]: + [The WebAssembly core specification](https://webassembly.github.io/spec/core/) defines fixed-width + `v128` operations and records that relaxed operations may have implementation-dependent results. -[^bitmap]: `createStorage` returns `Float32Array(capacity * 2)` for origins, sizes, and both UV pairs, and - `Float32Array(capacity * 4)` for colors. +[^worker-transfer]: + [The HTML Worker model](https://html.spec.whatwg.org/multipage/workers.html) defines worker + `postMessage` transfer lists used to move `ArrayBuffer` ownership between worker and root. -[^pbo]: `setupPBO` replaces the attribute array with a padded copy and constructs the `DataTexture` over that copy. +[^writing-modes]: + [CSS Writing Modes Level 4](https://www.w3.org/TR/css-writing-modes-4/) defines logical inline/block + directions and distinguishes writing mode from glyph orientation. diff --git a/docs/planning/text-effect-composition.md b/docs/planning/text-effect-composition.md index 8d1371fc..9cabb0e9 100644 --- a/docs/planning/text-effect-composition.md +++ b/docs/planning/text-effect-composition.md @@ -1,148 +1,31 @@ --- -type: API Specification +type: Research Concept title: Three.js text effect composition -description: Optional TSL convenience for composing parameterized effects after canonical raster technique shaders while core carries only opaque render variants. -documentation_type: reference -tags: [rendering, effects, tsl, threejs, webgpu, variants] -status: stable +description: Superseded proposal for a bespoke TSL effect layer; custom material authority is the selected direction. +documentation_type: explanation +tags: [rendering, effects, tsl, threejs, superseded] +status: deprecated sources: - - id: raster-contract - resource: ../../packages/text/src/raster.ts - title: Raster module contract - - id: mtsdf-runtime - resource: ../../packages/text/src/raster/msdf.ts - title: MTSDF runtime material and paint implementation - - id: text-runtime - resource: ../../packages/text/src/three/text.ts - title: Framework-neutral Text lifecycle - - id: tsl-skill - resource: ../../.agents/skills/tsl/SKILL.md - title: Repository TSL implementation guidance + - id: material-authority + resource: three-material-authority.md + title: Three material authority - id: core-api resource: core-api.md - title: Core render variants and glyph runs - - id: three-api - resource: three-api.md - title: Three.js text API - - id: typegpu-api - resource: typegpu-api.md - title: TypeGPU raster programs and text engine + title: Core material routing generated: by: openai-codex/gpt-5.6 - at: '2026-08-07T03:25:58Z' + at: '2026-08-08T00:00:00Z' --- # Three.js text effect composition -`TextEffect` is optional Three/TSL program authoring sugar. It is not a core concept. Core carries an integration-defined -`renderVariant` from batch, paragraph, and span state into ordered glyph runs; the selected Three program interprets it. +This proposal is superseded. The package will not add `TextEffect`, `defineTextEffect`, effect lists, an effect parameter +schema, or an effect graph registry. Those concepts duplicate renderer material systems and do not provide ordinary +lighting, depth, shadow, or pipeline authority. -```ts -const chromatic = defineTextEffect(slugShader, { - parameters: { phase: 'f32' }, - compose(base, parameters, context) { - return { - ...base, - color: chromaticColor(base.color, context.paintIndex, parameters.phase), - }; - }, -}); +The selected Rust route carries numeric `material_id` values from the resolved text/span cascade into policy-shaped draw +packets. The exact Three material-factory API remains under design in [Three material authority](three-material-authority.md). +Reusable TSL functions remain ordinary application helpers for constructing a material; they are not a second text-core +rendering vocabulary. -const text = new Text({ - font, - text: 'Spectrum', - renderVariant: { - effects: [chromatic.bind({ phase: phaseUniform })], - }, -}); -``` - -## Complete helper surface - -```ts -type ThreeEffectParameterSchema = Readonly>; -type ThreeNodeFor = Type extends 'f32' - ? ReturnType - : Type extends 'vec2f' - ? ReturnType - : Type extends 'vec3f' - ? ReturnType - : ReturnType; -type ThreeEffectParametersOf = { - readonly [Key in keyof Schema]: ThreeNodeFor; -}; - -interface ThreeTextEffectDefinition { - readonly shader: Shader; - readonly parameters: Schema; - compose( - base: ThreeRasterFragmentOutputOf, - parameters: ThreeEffectParametersOf, - context: ThreeRasterFragmentContextOf, - ): ThreeRasterFragmentOutputOf; - bind(parameters: ThreeEffectParametersOf): ThreeTextEffectBinding; -} - -interface ThreeTextEffectBinding { - readonly effect: ThreeTextEffectDefinition; - readonly parameters: ThreeEffectParametersOf; -} - -declare function defineTextEffect( - shader: Shader, - definition: Omit, 'shader' | 'bind'>, -): ThreeTextEffectDefinition; - -interface ThreeRenderVariant { - readonly effects?: readonly ThreeTextEffectBinding[]; -} -``` - -The shader argument is what makes the callback contextual: `base`, `context`, and the return type come from that exact -shader, while the literal schema maps every parameter key to its TSL node type. No type parameter is expected to infer only -from a callback parameter position. The heterogeneous binding list is erased only after construction; the standard program -narrows it by retained effect-definition identity before composing or writing parameters. - -## Composition boundary - -Every effect composes after the program's canonical technique shader: - -```ts -let output = slugShader.fragment(context); // canonical curve traversal and coverage -for (const binding of variant.effects ?? []) { - output = composeKnownEffect(output, binding, context); -} -return output; -``` - -Bitmap, MTSDF, and Slug retain atlas/curve sampling, coverage reconstruction, clipping, outline constraints, and -technique-specific validation. An effect changes resolved output; it does not replace the hard raster algorithm. A custom -`ThreeRasterProgram` may bypass this helper and define its own variant contract while still calling the same exported -technique shader. - -## Batching contract - -Effect-definition identity and declaration order determine graph compatibility. Parameter values do not. The standard -program may therefore place bindings for many texts and spans into indexed sidecar storage and draw them together through -one material. A different ordered definition list requires another material/pipeline variant and may split the draw plan. - -Core only preserves variant boundaries and text order. It neither assigns TSL material keys nor forces one draw per effect. -Changing a paragraph/span binding rebuilds core glyph runs without reshaping. Updating a stable uniform or sidecar binding -may require no core call and no instance-buffer rewrite. - -## Required invariants - -- effects compose in declaration order over the previous resolved output; -- graph identity is definition identity plus ordered composition, never current parameter values; -- parameters remain text/span-local even when materials and pipelines are shared; -- semantic context is explicit and small: resolved output, paint/span index, glyph index, and normalized local coordinates; -- unsupported semantic inputs fail while staging and do not replace the live target revision; -- effect bindings and material variants have deterministic leases and disposal; -- TypeGPU-authored pure WebGPU math may adapt only within capabilities proven for the pinned `toTSL()` bridge, while native - TSL effects remain Three-specific; and -- proof measures graph construction, first pipeline creation, parameter updates, upload changes, CPU submit, GPU time, and - untouched-text bundle/pipeline cost. - -The API is complete only after two chained effects, shared graph/independent parameters, Bitmap and Slug composition, -disposal, pinned WebGPURenderer output, and single-draw multi-variant batching have causal tests. This is an integration -feature layered on the accepted core variant contract; failure of the convenience helper cannot remove core customization. +This file remains only to make the rejected direction and its replacement discoverable. diff --git a/docs/planning/three-api.md b/docs/planning/three-api.md index 6c202325..fb8acca1 100644 --- a/docs/planning/three-api.md +++ b/docs/planning/three-api.md @@ -1,1219 +1,261 @@ --- type: API Specification title: Three.js text API -description: Target v1 API for the package-owned Three.js integration subpath that loads fonts, declares scene-local text batches, retains transform-bearing Text objects, and synchronizes hidden core work inside the Three.js render lifecycle. +description: Reference for loading fonts, batching Text objects, querying committed Rust layout, and defining Three.js materials. documentation_type: reference -tags: [api, threejs, fonts, text, batching, lifecycle, rendering] +tags: [api, threejs, fonts, text, batching, materials, layout] status: stable sources: - id: core-api resource: core-api.md title: Core text API - - id: engine-contract - resource: engine-integration-contract.md - title: Engine integration contract - - id: effect-composition - resource: text-effect-composition.md - title: Optional Three.js effect composition - - id: typegpu-api - resource: typegpu-api.md - title: TypeGPU raster programs and text engine + - id: rust-engine + resource: rust-layout-engine.md + title: Rust text engine and render-plan ABI - id: current-loader - resource: ../../packages/text/src/loader.ts - title: Current font loader + resource: ../../packages/text/src/three/font-loader.ts + title: Current Three.js font loader - id: current-text resource: ../../packages/text/src/three/text.ts title: Current Three.js Text lifecycle + - id: current-material + resource: ../../packages/text/src/three/material.ts + title: Current Three.js material factory - id: three-object3d resource: https://threejs.org/docs/pages/Object3D.html title: Three.js Object3D - id: three-loader resource: https://threejs.org/docs/pages/Loader.html title: Three.js Loader - - id: three-group - resource: https://threejs.org/docs/pages/Group.html - title: Three.js Group - - id: three-buffer-attribute - resource: https://threejs.org/docs/pages/BufferAttribute.html - title: Three.js BufferAttribute generated: by: openai-codex/gpt-5.6 - at: '2026-08-07T04:31:24Z' + at: '2026-08-09T18:30:00Z' --- # Three.js text API -`@pmndrs/text/three` is the package-owned engine integration over renderer-neutral core contracts. Core never imports -Three.js; this subpath owns Three.js loading, scene objects, renderer resources, programs, and lifecycle. - -Three.js owns the core API internally. A Three.js application never creates a `TextRuntime`, -`ParagraphBatch`, `Paragraph`, prepared revision, or glyph run. +`@pmndrs/text/three` is the maintained renderer integration. `Text` and `TextGroup` are Three.js `Object3D` subclasses; +scene traversal collects desired mutations, calls the Rust engine, consumes its render-plan command buffer, uploads dirty +ranges, and updates draw proxies. ```ts -FontLoader - -> LoadedFont[] - -> TextGroup // explicit batch for one scene render phase - -> Text[] // transform-bearing Three.js objects - -> renderer.render(scene, camera) // membership, shaping, packing, and uploads synchronize here +import { FontLoader, Text, TextGroup, defineTextMaterial } from '@pmndrs/text/three'; +import { bitmap } from '@pmndrs/text/three/bitmap'; +import { msdf } from '@pmndrs/text/three/msdf'; +import { slug } from '@pmndrs/text/three/slug'; ``` -## The complete public surface - -```ts -import * as THREE from 'three/webgpu'; -import * as TSL from 'three/tsl'; -import type { - FontSelection, - FormattedText, - GlyphBatchKey, - GlyphRange, - ParagraphBatchTargetError, - PreparedGlyphBatch, - PreparedGlyphRun, - RasterBindingOf, - TextInput, - TextPreparationError, -} from '@pmndrs/text'; - -type TextError = TextPreparationError | ParagraphBatchTargetError; - -interface ThreeRenderVariant { - readonly effects?: readonly ThreeTextEffectBinding[]; -} - -type ThreeEffectParameterType = 'f32' | 'vec2f' | 'vec3f' | 'vec4f'; -type ThreeEffectParameterSchema = Readonly>; -type ThreeEffectParametersOf = { - readonly [Key in keyof Schema]: Schema[Key] extends 'f32' - ? ReturnType - : Schema[Key] extends 'vec2f' - ? ReturnType - : Schema[Key] extends 'vec3f' - ? ReturnType - : ReturnType; -}; - -interface ThreeTextEffectDefinition< - Shader extends AnyThreeRasterShader, - Schema extends ThreeEffectParameterSchema, -> { - readonly shader: Shader; - readonly parameters: Schema; - compose( - base: ThreeRasterFragmentOutputOf, - parameters: ThreeEffectParametersOf, - context: ThreeRasterFragmentContextOf, - ): ThreeRasterFragmentOutputOf; - bind(parameters: ThreeEffectParametersOf): ThreeTextEffectBinding; -} - -interface ThreeTextEffectBinding< - Shader extends AnyThreeRasterShader = AnyThreeRasterShader, - Schema extends ThreeEffectParameterSchema = ThreeEffectParameterSchema, -> { - readonly effect: ThreeTextEffectDefinition; - readonly parameters: ThreeEffectParametersOf; -} - -declare function defineTextEffect< - Shader extends AnyThreeRasterShader, - const Schema extends ThreeEffectParameterSchema, ->( - shader: Shader, - definition: Omit, 'shader' | 'bind'>, -): ThreeTextEffectDefinition; - -type ThreeProgramVariantKey = PropertyKey | object; - -declare const threeRasterShaderTypes: unique symbol; -interface ThreeRasterShaderTypeMap { - readonly vertexContext: VertexContext; - readonly vertexOutput: VertexOutput; - readonly fragmentContext: FragmentContext; - readonly fragmentOutput: FragmentOutput; -} - -interface AnyThreeRasterShader { - readonly technique: Technique; - readonly [threeRasterShaderTypes]?: ThreeRasterShaderTypeMap; -} +Import only the technique modules an application uses. Each module registers the matching Three policy program and +material implementation. -interface ThreeRasterShader< - Technique extends AnyRasterTechnique, - VertexContext, - VertexOutput, - FragmentContext, - FragmentOutput, -> extends AnyThreeRasterShader { - readonly [threeRasterShaderTypes]?: ThreeRasterShaderTypeMap< - VertexContext, - VertexOutput, - FragmentContext, - FragmentOutput - >; - vertex(context: VertexContext): VertexOutput; - fragment(context: FragmentContext): FragmentOutput; -} - -type ThreeRasterShaderTypesOf> = NonNullable< - Shader[typeof threeRasterShaderTypes] ->; -type ThreeRasterFragmentContextOf> = - ThreeRasterShaderTypesOf['fragmentContext']; -type ThreeRasterFragmentOutputOf> = - ThreeRasterShaderTypesOf['fragmentOutput']; - -interface ThreeMtsdfVertexContext { - readonly localPosition: ReturnType; - readonly glyphIndex: ReturnType; - readonly viewport: ReturnType; - readonly modelViewProjection: THREE.Node; - readonly instance: ThreeMtsdfInstanceNodes; - readonly resources: ThreeMtsdfResourceNodes; -} - -interface ThreeMtsdfInstanceNodes { - readonly origin: ReturnType; - readonly fontSize: ReturnType; - readonly glyphRecord: ReturnType; - readonly paintIndex: ReturnType; -} - -interface ThreeMtsdfResourceNodes { - readonly atlas: THREE.Node; - readonly emSize: ReturnType; - readonly pixelRange: ReturnType; -} - -interface ThreeDerivativeNodes { - fwidth(value: THREE.Node): THREE.Node; -} - -interface ThreeRasterVertexOutput { - readonly position: ReturnType; - readonly techniqueVaryings: Readonly>; -} - -interface ThreeRasterFragmentOutput { - readonly color: ReturnType; - readonly coverage: ReturnType; -} - -interface ThreeMtsdfFragmentContext { - readonly localPosition: ReturnType; - readonly glyphIndex: ReturnType; - readonly paintIndex: ReturnType; - readonly screenScale: ReturnType; - readonly derivatives: ThreeDerivativeNodes; - readonly instance: ThreeMtsdfInstanceNodes; - readonly resources: ThreeMtsdfResourceNodes; -} - -interface ThreeProgramMaterialContext< - Technique extends AnyRasterTechnique, - Shader extends AnyThreeRasterShader, -> { - readonly renderer: THREE.WebGPURenderer; - readonly shader: Shader; - readonly font: LoadedFont; - readonly binding: RasterBindingOf; - readonly pipelineVariant: number; -} - -interface ThreeProgramVariantWriteContext { - readonly runs: readonly PreparedGlyphRun[]; - readonly ranges: readonly GlyphRange[]; -} - -interface ThreeProgramRunContext { - readonly glyphBatches: readonly PreparedGlyphBatch[]; - readonly glyphRuns: readonly PreparedGlyphRun[]; -} - -interface ThreeProgramDraw { - readonly object: THREE.Object3D; - readonly batch: GlyphBatchKey; - readonly start: number; - readonly count: number; -} - -interface ThreeRasterProgram< - Technique extends AnyRasterTechnique, - Variant, - Shader extends AnyThreeRasterShader = AnyThreeRasterShader, -> { - readonly technique: Technique; - readonly shader: Shader; - readonly cacheLimits: { - readonly pipelines: number; - readonly materializedVariants: number; - }; - supportsVariant(value: unknown): value is Variant; - variantKey(value: Variant | undefined): ThreeProgramVariantKey; - createMaterial(context: ThreeProgramMaterialContext): THREE.NodeMaterial; - writeVariants(context: ThreeProgramVariantWriteContext): void; - compileRuns(context: ThreeProgramRunContext): readonly ThreeProgramDraw[]; - dispose(): void; -} - -declare function defineThreeRasterProgram< - Technique extends AnyRasterTechnique, - Variant, - Shader extends AnyThreeRasterShader, ->(program: ThreeRasterProgram): ThreeRasterProgram; - -interface FontLoaderOptions { - readonly runtimeBake?: RuntimeFontBake; - readonly createWorker?: () => TextPreparationWorker; -} - -declare class FontLoader extends THREE.Loader, LoadedFontRequest> { - constructor(manager?: THREE.LoadingManager, options?: FontLoaderOptions); - - load( - request: LoadedFontRequest, - onLoad: (font: LoadedFont) => void, - onProgress?: (event: ProgressEvent) => void, - onError?: (error: unknown) => void, - ): void; - - loadAsync( - request: LoadedFontRequest, - onProgress?: (event: ProgressEvent) => void, - ): Promise>; - - dispose(): void; -} - -declare class TextGroup extends THREE.Object3D { - constructor(options: TextGroupOptions); - - readonly technique: Technique; - readonly capacity: GlyphBufferCapacity; - readonly program: ThreeRasterProgram; - readonly textCount: number; - readonly disposed: boolean; - readonly error: TextError | undefined; - onError: ((error: TextError) => void) | undefined; - renderVariant: Variant | undefined; - - add( - ...children: CompatibleTextChildren - ): this; - setCapacity(capacity: GlyphBufferCapacity): void; - retry(): void; - clone(recursive?: boolean): never; - copy(source: THREE.Object3D, recursive?: boolean): never; - dispose(): void; -} - -declare class Text extends THREE.Object3D { - constructor(properties: StandaloneTextProperties); - - readonly textGroup: TextGroup | undefined; - readonly bound: boolean; - readonly disposed: boolean; - readonly layout: ParagraphLayout | undefined; - readonly error: TextError | undefined; - onError: ((error: TextError) => void) | undefined; - - font: FontSelection; - get text(): string; - set text(value: TextInput); - spans: readonly TextSpan[]; - contentBox: ParagraphContentBox; - style: ParagraphStyle; - paint: GlyphPaintInput; - rasterPixelRatio: number; - renderVariant: Variant | undefined; - - set(properties: TextUpdate): void; - setSpan(index: number, span: TextSpan): void; - removeSpan(index: number): void; - - snapshotGlyphs(): GlyphSnapshot; - setGlyphOrigins(update: GlyphOriginUpdate): void; - clearGlyphOriginOverrides(): void; - - setCapacity(capacity: GlyphBufferCapacity): void; - retry(): void; - dispose(): void; -} - -type SameType = [Left] extends [Right] ? ([Right] extends [Left] ? true : false) : false; - -type CompatibleTextChildren< - Technique extends AnyRasterTechnique, - Variant, - Children extends readonly THREE.Object3D[], -> = { - readonly [Index in keyof Children]: Children[Index] extends Text - ? SameType extends true - ? SameType extends true - ? Children[Index] - : never - : never - : Children[Index]; -}; - -export { txt, span } from '@pmndrs/text'; -export { defineTextEffect, defineThreeRasterProgram }; -export type { - FormattedText, - GlyphBufferCapacity, - SpanFormat, - SpanStyle, - SpanTag, - TextPreparationError, - UnboundSpanTag, -} from '@pmndrs/text'; -export type { ThreeRasterProgram, ThreeRasterShader, ThreeRenderVariant, ThreeTextEffectBinding }; -``` - -There is deliberately no universal four-field raster context. Each first-party shader exports its exact resource, -instance, vertex-context/output, and fragment-context/output types. Bitmap includes viewport/device-pixel snapping inputs; -MTSDF includes atlas access, `emSize`, `pixelRange`, derivatives, and screen scale; Slug includes curve/header/reference -resources, band bases, dilation inputs, and dependent-load accessors. The associated type map carries those exact types into -`createMaterial()` and `defineTextEffect()`. Adding a technique means defining those semantics, not widening a shared -context with optional fields. - -First-party programs keep bounded material/pipeline and materialized-variant caches. Factory options declare the limits, -eviction retires resources through renderer-safe disposal, and `program.dispose()` releases every remaining entry. A fresh -object-valued variant each frame therefore cannot grow the cache without bound. Custom programs own and document the same -policy. - -## Load fonts with the Three.js loader +## Load a font ```ts -import { createFontStack } from '@pmndrs/text'; -import { FontLoader } from '@pmndrs/text/three'; -import { mtsdf } from '@pmndrs/text/raster/mtsdf'; - const loader = new FontLoader(); - -const [inter, noto, iconFont] = await Promise.all([ - loader.loadAsync({ - input: { baked: '/fonts/Inter.font.glb' }, - raster: { technique: mtsdf }, - }), - loader.loadAsync({ - input: { baked: '/fonts/NotoSans.font.glb' }, - raster: { technique: mtsdf }, - }), - loader.loadAsync({ - input: { baked: '/fonts/Icons.font.glb' }, - raster: { technique: mtsdf }, - }), -]); - -const uiFont = createFontStack(inter, noto); -``` - -The first load in a Three font-cache domain lazily creates the single core text runtime and shaping engine. Concurrent loads -share that initialization Promise, and later loaders in the same domain reuse the resolved runtime and shaper. The cache -domain is integration-owned; Three users do not construct a core registry or runtime. `loadAsync()` does not resolve until -the font, selected technique data, and synchronous shaper are ready. Loading remains an explicit application wait; shaping -ordinary warm edits does not become a readiness Promise. - -The callback `load()` and Promise-returning `loadAsync()` follow the standard Three.js loader pattern and participate in the -provided `LoadingManager`. The loaded font is a Three-surface handle; it does not expose the hidden core runtime or core font -handle. - -Constructing a `Text` acquires a lease on every concrete font in its `Font` or `FontStack`, even while the object is -detached. Changing `text.font` acquires the complete replacement selection before releasing the old leases. -`LoadedFont.dispose()` fails while a live `Text` lease remains, so disposing a group or moving text can never silently drop -fallback data or replace a glyph with missing-glyph output. A `FontStack` value alone owns no lease; using a stack with a -successfully disposed member for a new `Text` is rejected. - -## Create an explicit batch with `TextGroup` - -```ts -import { TextGroup } from '@pmndrs/text/three'; - -const worldText = new TextGroup({ - technique: mtsdf, -}); - -scene.add(worldText); -``` - -```ts -interface TextGroupOptions { - readonly technique: Technique; - readonly program?: ThreeRasterProgram; - readonly capacity?: GlyphBufferCapacity; - readonly renderOrder?: number; - readonly renderVariant?: Variant; -} - -interface GlyphBufferCapacity { - readonly size: number; - readonly policy: 'grow' | 'chunk' | 'fixed'; -} -``` - -## Select a program and render variant - -`technique` remains construction-only because it fixes decoded resources and canonical glyph-buffer layout. `program` is -also construction-only because it fixes the accepted variant type, technique shader, Three attributes, node material, -pipeline compatibility, and final draw compiler. Fonts remain per `Text` and are never declared on the group. - -```ts -const gradientSlug = createThreeSlugProgram({ - fragment({ shader, context }) { - const base = shader.fragment(context); - return { ...base, color: gradient(base.color, context.localPosition) }; - }, -}); - -const labels = new TextGroup({ - technique: slug, - program: gradientSlug, - renderVariant: { gradient: 'ui-default' }, -}); - -const label = new Text({ - font: uiFont, - text: 'Warning', - renderVariant: { gradient: 'warning' }, -}); -labels.add(label); -``` - -The first-party default program is selected when `program` is omitted. A group, text, and manual span may each set a -variant; inheritance is group → text → span. The hidden core batch carries those exact values through ordered glyph runs. -The Three program decides whether adjacent variants use one material/draw with indexed sidecar parameters or require -separate draw proxies. A variant is not automatically a material and is not automatically a draw boundary. - -The standard programs accept `ThreeRenderVariant`, whose optional `effects` list is produced by the effect helpers: - -```ts -const chromatic = defineTextEffect(slugShader, { - parameters: { phase: 'f32' }, - compose(base, parameters, context) { - return { ...base, color: chromaticColor(base.color, context.paintIndex, parameters.phase) }; - }, -}); - -const animated = chromatic.bind({ phase: phaseUniform }); -const effectLabels = new TextGroup({ technique: slug }); // standard ThreeRenderVariant program -const effectLabel = new Text({ - font: uiFont, - text: 'Warning', - renderVariant: { effects: [animated] }, -}); -effectLabels.add(effectLabel); - -effectLabel.setSpan(0, { - start: 0, - end: 7, - renderVariant: { effects: [animated] }, -}); -``` - -Effects compose after the canonical Bitmap, MTSDF, or Slug shader has resolved coverage and base output. Definitions with -the same ordered graph identity share a material program; binding values are stored per text/span and do not create a new -pipeline. This is an optional TSL authoring convenience, not a core API and not a requirement for custom programs. A custom -program may define a completely different `Variant` type while still reusing the exported canonical technique shader. - -React Three Fiber expresses the same span variant through nested text: - -```tsx - - Normal animated - -``` - -An optional TypeGPU-authored pure-WebGPU function may enter a Three program only through capabilities proven for a pinned -`@typegpu/three` version. At the reviewed 0.11.0 bridge, `toTSL()` injects a nullary WGSL closure through Three's WebGPU -builder, has no WebGL2 path, and has not carried the real Slug resources. Three still owns accessors, material, blend/depth -state, render-list integration, draw compilation, and lifecycle. Native TSL remains the only specified complete Three -program; any adapted TypeGPU shader is an experimental program implementation, not a different core technique. - -### Use the default or preallocate explicitly - -An explicit `TextGroup` defaults to `{ size: 4_096, policy: 'chunk' }`. Storage is allocated lazily for each physical -font-resource buffer, so an empty group allocates no glyph arrays or GPU buffer. Text objects and their metadata are not -capacity-limited. - -```ts -const denseText = new TextGroup({ - technique: mtsdf, - capacity: { size: 20_000, policy: 'chunk' }, +const font = await loader.loadAsync({ + input: { baked: '/fonts/inter-msdf.font.glb' }, + raster: { technique: msdf, options: { /* technique options */ } }, }); ``` -`size` counts glyph-instance slots per physical buffer, not texts and not total glyphs across the `TextGroup`. `chunk` -allocates another buffer without replacing published storage, `grow` transactionally replaces the full buffer with a -buffer whose capacity doubles until the pending glyphs fit, and `fixed` makes `size` a hard per-buffer limit. The readonly -`capacity` property exposes the normalized explicit or default value. +`FontLoader` extends `THREE.Loader`, participates in its `LoadingManager`, and accepts an optional `AbortSignal` on the +request. Loaders sharing a manager share one text runtime. Disposing a loader releases its runtime only after every font +loaded through that domain has also been disposed. -`add()` validates text lifetime, font lifetime, and technique compatibility. It does not shape, so it cannot know whether a -fixed physical buffer will overflow. That check occurs during the owning group's pre-render synchronization, after fallback, -shaping, and layout reveal exact per-resource glyph counts. - -Resize explicitly when a fixed group needs a larger allocation: +Source-font loading requires a caller-supplied runtime baker: ```ts -const overflow = labels.error; -if (overflow?.kind !== 'capacity-exceeded') throw new Error('No fixed-capacity overflow to resize'); - -labels.setCapacity({ size: overflow.required, policy: 'fixed' }); +const loader = new FontLoader(undefined, { runtimeBake }); ``` -`setCapacity()` preserves the public `TextGroup`, every nested `Text`, and every bound core `Paragraph`. It forwards the -normalized capacity to the existing hidden `ParagraphBatch`, clears an unchanged capacity-overflow latch, and schedules a -transactional canonical-storage and target-storage replacement for the next synchronization. The previous complete draw -objects remain live until the replacement commits; renderer fences then retire them normally. No scene reparenting, -listener transfer, ref replacement, or cleanup is required. - -`fixed` prevents automatic growth; it does not make the configured size permanently immutable. `setCapacity()` may also -switch policies or shrink deliberately. Passing the current normalized capacity is a no-op. A shrink that cannot hold the -desired generation reports the ordinary typed overflow while preserving the prior complete draw. - -`TextGroup.clone()` and `TextGroup.copy()` are unsupported and throw. A group owns identity-bearing text membership, -subscriptions, attachment state, and renderer resources that cannot follow ordinary recursive `Object3D` copy semantics -safely. Construct a separate group and add intentionally distinct `Text` objects when a second independently renderable -tree is required. +No runtime baker is pulled into the default Three bundle. -A `TextGroup` is one author-declared text render phase and one hidden core paragraph batch. Its technique fixes the -canonical instance layout and shader family before any text is attached. Every `Text` owns its font selection, which must -use that technique. Core may produce several physical resource batches and ordered variant-bearing glyph runs beneath one -`TextGroup`; the selected program compiles those runs into Three draw objects. - -The `add()` override preserves normal `Object3D` children while conditionally rejecting any directly supplied -`Text` tuple member. Runtime ancestry validation remains mandatory for JavaScript, React reconciliation, -and text nested below arbitrary containers. - -`TextGroup` deliberately extends `THREE.Object3D`, not `THREE.Group`. Three carries the nearest real ancestor Group's -`renderOrder` through non-Group descendants as `groupOrder`; another Group would replace it, including with its default -value of `0`. The integration does not insert a hidden Group. - -`TextGroup.renderOrder` is the secondary render-order base for the batch. The integration maps the program's ordered -physical draws to consecutive native Three render orders beginning at that base. `Text.renderOrder` remains the paragraph -sorting value inside core; it cannot create a Three render-list boundary inside one GPU batch. +## Create text ```ts -parent.renderOrder = 100; -parent.add(textGroup); // physical draws use groupOrder 100 - -textGroup.renderOrder = 10; // physical draws begin at secondary order 10 -``` - -A nested `TextGroup` starts a new batch and render-order domain; it never joins its nearest outer `TextGroup`. - -Create separate `TextGroup` instances when text belongs to different scenes, render phases, or renderer lifetimes: - -```ts -const mainSceneText = new TextGroup(worldOptions); -const minimapSceneText = new TextGroup(minimapOptions); - -mainScene.add(mainSceneText); -minimapScene.add(minimapSceneText); -``` - -One Three object can have only one parent, so one `TextGroup` cannot be present in two scenes simultaneously. The group binds -to the first renderer that draws it. Rendering it through a different renderer fails before drawing; create a separate -`TextGroup` so attributes, materials, upload ranges, fences, and retirement remain renderer-owned. Standalone implicit -batches follow the same rule. - -## Add and remove text through the scene graph - -```ts -import { Text } from '@pmndrs/text/three'; - const label = new Text({ - font: inter, - text: 'Player 1', + font, + text: 'Hello world', + style: { fontSize: 32, lineHeight: 1.2, language: 'en' }, + contentBox: { + width: { mode: 'at-most', size: 420 }, + wrap: 'word', + overflow: 'clip', + }, + paint: { color: '#ffffff' }, }); -worldText.add(label); - -label.position.set(0, 2, 0); -label.rotation.y = Math.PI / 4; -label.scale.setScalar(2); +scene.add(label); ``` -There is no `TextGroup.allocate()` shortcut. Construction creates one retained, late-bound `Text`; inherited -`Object3D.add()` and `Object3D.remove()` are the only membership operations. Adding binds the object to the batch before -the next synchronization. Removing releases its internal paragraph membership without disposing the public object, so it -can be added elsewhere. - -A `Text` joins its nearest `TextGroup` ancestor. Ordinary `Object3D` containers may appear between them. A nested -`TextGroup` stops membership discovery: - -```ts -worldText.add(container); -container.add(label); // label still belongs to worldText +A standalone `Text` owns an implicit batch of one. It binds lazily when attached to a traversed scene graph; construction +does not shape or allocate renderer buffers. -worldText.add(overlayText); -overlayText.add(icon); // icon belongs to overlayText, never worldText -``` +`Text` accepts either a plain string with explicit `spans`, or a formatted value built with `txt` and `span`. Span values +may override font selection, shaping style, paint, and material. -## A standalone `Text` is a batch of one +## Batch text ```ts -const title = new Text({ - font: uiFont, - text: 'Standalone title', +const group = new TextGroup({ + capacity: { size: 4096, policy: 'grow' }, + compositing: 'ordered', }); -scene.add(title); -``` - -```ts -type StandaloneTextProperties = TextProperties< - Technique, - Variant -> & - Readonly<{ - capacity?: GlyphBufferCapacity; - }>; -``` - -When a render-attached `Text` has no `TextGroup` ancestor, it owns an implicit paragraph batch containing only itself. Its -required font selection supplies that implicit batch's technique. Adding that same object to a `TextGroup` retires the -implicit batch, validates its font selection against the explicit group technique, and creates new paragraph membership in -the group. - -An unattached or detached `Text` remains unbound and owns no implicit batch. `textGroup.remove(text)` therefore leaves only -the reusable public object and desired state. Adding it directly to a scene later creates its implicit batch before that -scene's first shaping and render; adding it to another `TextGroup` creates membership there instead. The public `Text`, -transform, desired properties, and glyph overrides remain the same object throughout. - -The standalone `capacity` value configures only that implicit batch and defaults to `{ size: 256, policy: 'grow' }` to -avoid reserving a full explicit-group chunk for every isolated label. While the object is inside a `TextGroup`, the parent -group's technique and capacity policy are authoritative; the `Text` always retains its own font selection. -`text.setCapacity()` changes the retained implicit-batch capacity without replacing the `Text`; while grouped, that setting -is retained but inactive until the text becomes standalone again. - -## Bind late; render on the first frame - -Constructing an unattached `Text` stores desired state only. It creates no core paragraph, performs no shaping, allocates no -glyph slots, and creates no GPU object. - -```ts -const score = new Text({ font: inter, text: '0' }); - -score.text = '1'; -score.text = '2'; -score.text = '3'; - -hudText.add(score); -renderer.render(scene, camera); // shapes and renders only "3" -``` - -Membership is resolved before the first shaping call. `Object3D` `added`, `removed`, `childadded`, and `childremoved` -events mark scene membership dirty synchronously. Because those events do not bubble through every arbitrary ancestor -change, `Text` and `TextGroup` perform a final ancestry reconciliation at the start of `updateMatrixWorld()`. - -The integration uses `updateMatrixWorld()` as its automatic synchronization hook. `TextGroup` owns a private -`ThreeTextBatchBinding`; this is the object that holds the core `ParagraphBatch`, its `ParagraphBatchAttachment`, the -`ThreeParagraphBatchTarget`, the internal draw meshes, and the map from each child `Text` to its core `Paragraph`. -It is implementation machinery, not another public API. - -The implementation sequence is: - -```ts -class TextGroup extends THREE.Object3D { - readonly #binding: ThreeTextBatchBinding; - - override updateMatrixWorld(force?: boolean): void { - this.#binding.reconcileMembership(this); - this.#binding.applyPendingMembership(); - - // Runtime-wide: shape, lay out, sort, partition, allocate, pack, and publish. - // Returns runtime.current without allocating when no desired state is dirty. - this.#binding.runtime.update(); - - // Stage only this observed renderer target from the latest core publication. - this.#binding.prepareCurrentRevision(); - - // Commit this target revision. This installs - // the exact internal meshes needed by the program-compiled draw sequence. - this.#binding.commitPreparedRevision(); - - // Three computes this group, every child Text, and every newly installed mesh. - super.updateMatrixWorld(force); - - // Core glyph origins are paragraph-local. Compose them with the now-current - // Text transforms, copy only changed transform slots, and mark those attribute - // ranges for WebGPURenderer. - this.#binding.writeGlyphTransforms(); - } -} -``` - -`applyPendingMembership()` is where scene membership becomes core membership. For each newly bound object it calls -`paragraphBatch.add(text.desiredState)` and records the returned `Paragraph`; for each departure it calls -`paragraph.dispose()`. It applies desired-state setters to already bound paragraphs before `runtime.update()`. No shaping -happens in `Text.text`, `Text.set()`, `Text.setSpan()`, or the scene-graph event handlers. - -`runtime.update()` publishes one atomic `TextRuntimeRevision` and updates the attachment's latest source revision. It never -calls a target or allocates renderer resources. The currently traversed binding then calls: - -```ts -attachment.prepare(); -// coordinator calls threeTarget.stage(attachment.current, attachment.source) -``` - -`ThreeParagraphBatchTarget.stage()` performs the engine-layout work: it creates or reuses the required Three -`BufferAttribute` storage, selects `PreparedGlyphBatch.dirtyRanges` when its committed target revision is the immediate -predecessor, otherwise selects every live range for that batch from the prepared glyph runs, copies those ranges, sets the -corresponding Three update ranges and `needsUpdate`, and asks the selected `ThreeRasterProgram` to compile ordered -compatible runs into internal meshes or draw proxies. It does not shape, sort paragraph source order, repartition physical -storage, or upload directly to a GPU queue. A ready -stage is still unpublished until `commitPreparedRevision()` calls `attachment.commit()` at this render boundary and swaps -the binding's live internal draw objects. - -The standard Three target is intentionally synchronous: `stage()` must return `{ status: 'ready', stage }` before -`prepareCurrentRevision()` returns. Font bytes, raster pages, and optional program modules are loaded explicitly before a -`Text` can bind; Three `NodeMaterial` and buffer objects are created synchronously, while WebGPURenderer performs physical -pipeline compilation/upload later in its normal render path. A custom target that returns `pending` remains valid under the -core attachment contract, but cannot provide this integration's same-observing-frame guarantee and is not accepted by the -standard `TextGroup` binding. - -With Three's default `scene.matrixWorldAutoUpdate = true`, the complete WebGPURenderer 0.185.1 call chain is: - -```ts -renderer.render(scene, camera) - -> Renderer._renderScene(scene, camera) - -> scene.updateMatrixWorld() - -> TextGroup.updateMatrixWorld(force) - -> binding.reconcileMembership(textGroup) - -> binding.applyPendingMembership() - -> ParagraphBatch.add(...) / Paragraph.dispose() / Paragraph setters - -> TextRuntime.update() - -> publish TextRuntimeRevision - -> attachment records latest source revision - -> binding.prepareCurrentRevision() - -> ParagraphBatchAttachment.prepare() - -> ThreeParagraphBatchTarget.stage(previous, preparedBatch) // must be ready - -> binding.commitPreparedRevision() - -> ParagraphBatchAttachment.commit() - -> install the staged internal draw meshes - -> Object3D.updateMatrixWorld(force) - -> Text.updateMatrixWorld(force) // transform only when grouped - -> drawMesh.updateMatrixWorld(force) - -> binding.writeGlyphTransforms() - -> BufferAttribute.addUpdateRange(...) - -> BufferAttribute.needsUpdate = true - -> Renderer._projectObject(...) // build and sort the render list - -> drawMesh.onBeforeRender(...) - -> Renderer._renderObjectDirect(...) - -> Geometries.updateForRender(...) - -> Attributes.update(...) - -> WebGPUBackend.updateAttribute(...) // actual dirty-range GPU write - -> backend.draw(...) // one program-compiled draw -``` - -Names beginning with `Renderer._` are shown to locate the integration in Three.js 0.185.1's implementation; they are not -APIs the package calls or overrides. The supported hook is the public `Object3D.updateMatrixWorld()` override. The internal -draw meshes use ordinary Three render-list and buffer-update behavior. - -If an application sets `scene.matrixWorldAutoUpdate = false`, Three deliberately skips `scene.updateMatrixWorld()` and -therefore skips this automatic text synchronization. That application has opted into manual scene updates and must call -`scene.updateMatrixWorld()` before `renderer.render(scene, camera)`; it does not call a text-specific update method. - -Publishing the target revision before `super.updateMatrixWorld()` ensures newly installed meshes receive a world matrix in -the same traversal. Writing transform attributes after it ensures they read completed `Text.matrixWorld` values. Both -happen before `_projectObject()` builds the render list, so resident text added immediately before `renderer.render()` is -present, transformed, uploaded, and drawn in that call; no preparatory frame is required. - -`super.updateMatrixWorld()` visits every child `Text` exactly once. A grouped `Text` still performs that normal transform -update but skips its standalone preparation branch because its nearest `TextGroup` owns the paragraph membership and draw -objects. Joining or leaving a group never changes `matrixAutoUpdate` or `matrixWorldAutoUpdate`; caller-authored Three matrix -policy survives unchanged. A detached text owns no preparation, while a directly rendered standalone text resumes its own -implicit-batch branch. That branch uses the same method order through a private one-paragraph `ThreeTextBatchBinding`. - -## Moving between batches is remove plus add - -```ts -overlayText.add(label); -``` - -Three.js removes `label` from its old parent before adding it to `overlayText`. The integration responds by staging two core -operations: - -```ts -oldParagraph.dispose(); -const nextParagraph = overlayParagraphBatch.add(label.desiredState); +group.add(title, body, iconLabel); +scene.add(group); ``` -It does not move a core paragraph handle between batches. Pending removal and allocation publish in the same pre-render -synchronization, so the old batch cannot leave ghost glyphs while the new batch renders the object. Cached shaping and -layout may be reused when their inputs are unchanged, but the destination receives new batch slots. - -The old paragraph slot and glyph instances belong to the old batch, not to `label`. Removal makes those slots reusable and -updates the old batch's logical counts and glyph runs. It does not dispose or shrink a shared buffer merely because one -text left. The old `TextGroup` retains that capacity until a later transactional replacement or `TextGroup.dispose()`. -The destination group owns any new physical storage it needs. Moving from a standalone implicit batch also retires that -text-owned target storage according to the renderer's in-flight-frame rules. +All descendant `Text` objects that belong to the same runtime participate in one retained Rust engine session and one +render plan. Compatible Bitmap, MSDF, and Slug records may share backing storage while the plan emits the draw boundaries +required by technique, font resource, material, clipping, and compositing policy. -That standalone-to-group transition is transactional. The integration validates and stages destination membership first, -publishes the new complete group revision, then retires the previous implicit target only after no in-flight frame can use -it. It never destroys the old target first and risks a missing frame or unrecoverable destination failure. +`compositing: 'ordered'` preserves authored draw order. `independent` allows Rust to reorder compatible work when the +application asserts that blending order is irrelevant. -Removal marks old membership dirty synchronously. Slot recycling and the updated glyph-run list publish at the old -group's next render synchronization. If the old group remains visible, that synchronization occurs before Three builds the -next render list. If the entire group is removed and will never render again, the application disposes the group rather -than waiting for another synchronization. +Capacity policy controls the instance arena: -Changing parents during an active Three.js traversal is unsupported, matching Three.js scene-graph expectations. Scene -membership changes must complete before `renderer.render()` enters world-matrix traversal. +| Policy | Behavior | +| --- | --- | +| `grow` | Grow retained storage to fit the group. | +| `chunk` | Use bounded chunks when the group exceeds the initial size. | +| `fixed` | Reject an update that exceeds the declared capacity. | -## Dispose a group; retain its text +The default group capacity is 4,096 glyphs with `chunk` policy. A standalone `Text` defaults to 256 glyphs with `grow` +policy. `setCapacity()` changes the retained capacity policy without changing text semantics. -`TextGroup.dispose()` is terminal for the group, not recursive destruction of its scene children: +## Update retained values ```ts -groupA.add(label); -renderer.render(scene, camera); +label.text = 'Updated'; +label.style = { ...label.style, fontSize: 36 }; +label.contentBox = { width: { mode: 'exact', size: 500 }, wrap: 'word' }; +label.paint = { color: '#ffd166' }; -groupA.dispose(); - -groupA.disposed; // true -label.disposed; // false -label.bound; // false -label.textGroup; // undefined, even while label.parent is still groupA - -groupB.add(label); // Three reparents the same object -renderer.render(scene, camera); // new paragraph membership renders in groupB +label.set({ text: 'Final value', paint: { color: '#ffffff' } }); ``` -Disposal synchronously invalidates `groupA` as a text-batch boundary, unbinds every direct or nested member `Text`, cancels -the group's unpublished preparation, and begins retirement of its core paragraph batch and renderer targets. Existing -children keep their transforms, desired state, glyph-origin override state, and font leases. The group contributes no -further text draws and rejects new text membership, but disposal does not mutate Three parent/child relationships. -While a live `Text` remains below the disposed group in the scene graph, that disposed group stays a terminal non-rendering -batch boundary: ancestry reconciliation must not fall through to an outer `TextGroup` or create an implicit standalone -batch. The caller moves the text explicitly when it should render elsewhere. +Setters change desired state. The nearest `TextGroup` applies all pending descendant changes together on its next +`updateMatrixWorld()` traversal. Reassigning a value that normalizes to the current state is a no-op. Transform-only +changes update the transform buffer and do not reshape or recompose text. -`groupB.add(label)` validates the live text, every leased font, and technique compatibility before calling Three's -reparenting operation. Failure leaves `label` unchanged and unbound; success creates a new core paragraph handle and group-B -slots before its first render. No group-A paragraph handle or GPU allocation transfers to group B, and group-A resources -retire independently according to their renderer fences. +One group traversal performs at most one mutating `text_update` transaction for that group's pending values. Calling a +layout query with pending mutations may perform that synchronization earlier; the following traversal observes the +committed revision and does not repeat the semantic work. -## Three.js owns synchronization +For editor-style changes, use UTF-16 ranges instead of rebuilding or diffing the paragraph in application code: ```ts -renderer.setAnimationLoop(() => { - renderer.render(scene, camera); -}); +label.insertText(cursor, 'a'); +label.deleteText(selectionStart, selectionEnd); +label.replaceText(selectionStart, selectionEnd, pastedText); ``` -Applications do not call a core update and then copy the result into Three. The integration coalesces desired-state and -membership changes, invokes the shared runtime's `update()` from each encountered standalone `Text` or `TextGroup`, then -prepares only that encountered owner's attachment and continues normal matrix traversal. Core specifies that a no-op -`update()` returns the current revision without allocation or notification. The first call after a mutation therefore -prepares every dirty paragraph across every paragraph batch; later calls in the same frame, scene, or render pass are cheap -revision checks unless their own membership reconciliation introduced new dirty work. Publication alone never stages, -cancels, aborts, allocates, uploads, or commits another scene's or renderer's target. That attachment reconciles its stale -candidate and prepares the latest source only when its owner is actually traversed. +These operations update the ordinary `text` value and queue narrow replacements for the same next-frame transaction. +Multiple operations before traversal remain one Wasm call. Direct `label.text = next` remains the simple declarative API; +the adapter derives its smallest common-prefix/common-suffix replacement without allocating a second scan buffer. +Offsets match JavaScript and DOM selection APIs and cannot split a surrogate pair. Existing spans shift with edits; +inserted text inherits a span only when inserted strictly inside it, so span-boundary affinity does not become hidden +mutable state. -`WebGPURenderer.render(scene, camera)` updates and projects only the supplied scene or object root. Three does not first -update every scene known to the application. When an application renders several scenes, each scene traversal naturally -encounters its own text owners and calls the same shared runtime. No application-level text update is required: the first -encounter after a mutation performs the work, and every later encounter observes the published revision. Warm edits are -current in the render call that observes them. Loading and raster-page misses remain explicit readiness work owned by -`FontLoader` and the loaded font handle rather than being silently started as ordinary shaping. +Errors are retained on `text.error` and the owning `group.error`, then forwarded to `onError`. They do not escape Three.js +scene traversal. A renderer-side failure leaves the Rust publication unconsumed; `retry()` or the next group traversal +reapplies its owned bytes before another engine delta is requested. -## Change retained text at runtime +## Query committed layout ```ts -label.text = 'First value'; -label.text = 'Second value'; -label.text = 'Player 2'; - -label.contentBox = { - width: { mode: 'at-most', size: 360 }, - wrap: 'word', -}; +const summary = label.measureLayout(); +const layout = label.inspectLayout(); ``` -Those writes update desired state only. The parent batch shapes the final values once during its next render-loop -synchronization. Nested records are immutable replacement values; direct deep mutation is unsupported. - -```ts -interface TextBaseProperties { - readonly font: FontSelection; - readonly contentBox?: ParagraphContentBox; - readonly style?: ParagraphStyle; - readonly paint?: GlyphPaintInput; - readonly rasterPixelRatio?: number; - readonly renderVariant?: Variant; -} - -type TextContentProperties = - | Readonly<{ - text: string; - spans?: readonly TextSpan[]; - }> - | Readonly<{ - text: FormattedText; - spans?: never; - }>; - -type TextProperties = TextBaseProperties< - Technique, - Variant -> & - TextContentProperties; - -type TextUpdate = - | (Partial> & - Readonly<{ - text?: string; - spans?: readonly TextSpan[]; - }>) - | (Partial> & - Readonly<{ - text: FormattedText; - spans?: never; - }>); - -interface TextSpan { - readonly start: number; - readonly end: number; - readonly font?: FontSelection; - readonly style?: ParagraphStyle; - readonly paint?: GlyphPaintInput; - readonly renderVariant?: Variant; -} -``` +`measureLayout()` requests an allocation-light `ParagraphLayoutSummary`. `inspectLayout()` additionally copies per-line +and per-glyph semantic arrays. Neither query is part of the ordinary render plan, and rendering never materializes layout +arrays merely to draw. -`font` and every span font must match the effective batch technique. Changing `font` to another same-technique `Font` or -`FontStack` is a retained update. Assigning an incompatible selection throws without changing current desired or rendered -state. +Queries apply pending mutations for the containing group because the requested result must describe one coherent Rust +revision. Every paragraph updated by that transaction becomes reusable by the next render traversal. Repeating a query on +an unchanged committed layout returns the retained result object without another Wasm crossing. -## Compose typed spans +The complete field semantics are defined by the [core layout-query reference](core-api.md#layout-query-values). -The Three entry point re-exports core's renderer-neutral `txt` and `span` tags. It does not add formatting methods to the -`Text` class or parse a markup language. +## Define a material ```ts -import { Text, span, txt } from '@pmndrs/text/three'; - -const emphasis = span(noto, { color: '#ffddff' }); - -const label = new Text({ - font: uiFont, - text: txt`Fast ${emphasis`accurate`} text`, +const material = defineTextMaterial((context) => { + const value = context.createDefaultMaterial(); + // Customize the technique-specific TSL graph or material properties. + return value; }); -label.text = 'Plain text'; -label.text = txt`Player ${span(noto)`Two`}`; -``` - -`txt` returns one immutable typed literal containing the flattened string and computed UTF-16 spans. `span()` accepts a -style by itself, or a `Font` / `FontStack` followed by styles and same-technique font overrides, merging left to right into -a reusable typed tag. TypeScript validates fonts, style and paint fields, property names, and technique. Assignment of a -plain string clears spans, while assignment of a literal replaces text and spans atomically. Explicit `spans`, `setSpan()`, -and `removeSpan()` remain the lower-level imperative form. - -React Three Fiber uses the same composer internally: - -```tsx - - Fast accurate text - +const label = new Text({ font, text: 'Custom', material }); ``` -The nested React form and `txt` literal above must produce the same source string and span ranges. A nested React `` -is inline paragraph data; `label.add(new Text(...))` remains an ordinary spatial Three child and a separate paragraph. +The factory is renderer-owned. Rust carries a numeric `materialId` through style resolution and draw planning; it does not +execute the factory. Three invokes `create()` when it needs a material for a concrete Bitmap, MSDF, or Slug pipeline. +Material creation runs while Three holds borrowed plan-backed attributes. It must return synchronously and must not query +or update text; the coordinator rejects such reentrancy before another Wasm call can detach those views. -Three-native state remains Three-native: +`ThreeTextMaterialContext` is a discriminated union on `technique`. Each branch provides the concrete technique shader, +the final policy-selected position node, and `createDefaultMaterial()`. -```ts -label.position.x += 1; -label.visible = false; -label.layers.set(2); -label.renderOrder = 10; -``` - -Transforms never reshape. `Text.renderOrder` maps to the paragraph ordering value inside the effective batch. Visibility, -layers, and transform changes update instance visibility/transform storage without changing shaping. `TextGroup.renderOrder` -sets the secondary Three render-order base for the batch's ordered physical draws. The nearest real Three Group owns -their primary `groupOrder`. - -## Structural, rebuilding, and hot changes +A material on a span overrides the text material; a text material overrides the group material. Equal material objects +share identity. Different materials may still share instance buffers—the render plan determines draw segmentation, while +the Three executor decides which GPU resources can be shared safely. -### Construction-only batch identity - -Technique defines compatibility and has no setter: +## Mix fallback techniques ```ts -new TextGroup({ - technique, // canonical instance layout and shader family - program, // accepted variant type, attributes, material/pipeline, and draw compiler - capacity, // initial physical glyph-buffer size and overflow policy +const prose = await loader.loadAsync({ + input: { baked: '/fonts/inter-msdf.font.glb' }, + raster: { technique: msdf, options: {} }, }); -``` - -Changing technique or program requires a new `TextGroup`. Capacity is deliberately mutable through `setCapacity()` because storage -replacement must preserve the group, its text identities, and its core paragraph handles. - -For standalone `Text`, `setCapacity()` changes its implicit batch without changing the public object. Its font selection is -mutable; changing technique rebuilds the implicit batch. Inside an explicit `TextGroup`, changing to a different technique -is rejected and requires moving the retained `Text` to a compatible group. -The renderer identity becomes fixed on first draw and is also structural. `renderOrder` remains mutable. - -### Retained changes that rebuild internal storage - -These operations retain public objects but may allocate new internal glyph slots, chunks, attributes, or materials: - -```ts -destination.add(text); // remove old paragraph allocation, add new allocation -textGroup.setCapacity(nextCapacity); // preserve handles; replace canonical and target storage transactionally -text.font = anotherFont; // reshape and possibly change physical resource batch -text.spans = nextSpans; // reshape and possibly change raster-resource glyph runs -text.rasterPixelRatio = next; // select resources and rebuild affected target storage -text.renderVariant = nextVariant; // rebuild run/draw compatibility without reshaping -``` - -Glyph overflow follows the owning group's `grow`, `chunk`, or `fixed` policy. All fallible replacement work stages before -publication; failure preserves the last complete revision. - -### Text errors do not escape rendering - -A synchronous core preparation failure is caught by the Three adapter before it can escape `renderer.render()`. An -asynchronous failure enters the same adapter state. The owner is the effective `TextGroup`, or the standalone `Text` for an -implicit batch: - -```ts -labels.onError = (error) => { - if (error.kind === 'capacity-exceeded') { - console.error(`Text needs ${error.required} glyph slots; the fixed limit is ${error.capacity}.`); - } -}; - -renderer.render(scene, camera); -labels.error; // typed preparation or target failure, or undefined after a successful revision -``` - -The integration sets `error` during synchronization and defers `onError` until after the active Three traversal. Core -preparation failure or retained `attachment.error` preserves the last complete target revision; a first-render failure -submits nothing for that owner. The failed desired generation stays retained, but Three does not retry an identical failure -every frame. A relevant text, font, content-box, membership, or explicit `setCapacity()` change schedules new core work; -`retry()` requests one explicit attempt against unchanged state. Successful publication clears `error`. Capacity recovery -means resizing explicitly, reducing demand, or removing or moving text. No failure can partially publish or escape the -render call. - -While a `Text` is grouped, the group is the synchronization owner: read `text.textGroup.error` and use the group's callback. -The `Text` properties report and observe only its implicit standalone batch and are inactive while grouped. One failed -generation schedules one deferred callback, not one callback per render frame. `text.retry()` delegates to that effective -group while grouped and to the retained implicit attachment while standalone; `group.retry()` retries only that group's -attachment. - -### Hot retained changes - -These never recreate the `Text` or `TextGroup`: - -```ts -text.text = nextText; -text.contentBox = nextContentBox; -text.style = nextStyle; -text.paint = nextPaint; -text.renderOrder = nextOrder; -text.position.copy(nextPosition); -text.visible = nextVisible; -text.setGlyphOrigins(nextOrigins); -``` - -Dirty channels determine whether the hidden update shapes, reflows, rewrites paint/origins/transforms, or only rebuilds the -glyph-run plan. - -## Manual glyph motion - -```ts -const snapshot = label.snapshotGlyphs(); -const x = snapshot.displayedX.slice(); -const y = snapshot.displayedY.slice(); - -simulateGlyphs(x, y, delta); - -label.setGlyphOrigins({ - topology: snapshot.topology, - start: 0, - x, - y, +const emoji = await loader.loadAsync({ + input: { baked: '/fonts/emoji-slug.font.glb' }, + raster: { technique: slug, options: {} }, }); -``` -Clear overrides to return to shaped positions: - -```ts -label.clearGlyphOriginOverrides(); +const font = createFontStack(prose, emoji); +const label = new Text({ font, text: 'Status 🌍' }); ``` -The next Three render-loop synchronization writes the changed origins without reshaping. Later content changes may reshape -the authoritative targets; the application can snapshot again and interpolate from its current displayed values. +The font stack carries resource and technique identity. The user-facing text API does not repeat a technique selector. +Rust resolves missing-glyph fallback, and the render plan partitions the selected glyphs by the capabilities and resources +declared by the active Three policy. -## Dispose ownership explicitly +## Directed glyph presentation ```ts -label.dispose(); -worldText.dispose(); -inter.dispose(); -noto.dispose(); -loader.dispose(); +const snapshot = label.snapshotGlyphOrigins(); +if (snapshot !== undefined) { + const x = snapshot.shapedX.slice(); + x[0] += 4; + label.setGlyphOrigins({ layout: snapshot.layout, x, y: snapshot.shapedY }); +} ``` -`remove()` changes membership; `dispose()` ends ownership. Use the explicit destroy sequence when a text will never be -reused: - -```ts -label.removeFromParent(); -label.dispose(); -``` +Origin overrides are presentation-only. They use stable glyph identities from the inspected layout and never mutate +authoritative Rust shaping or layout. A semantic text/style/geometry revision retires incompatible overrides. +`clearGlyphOriginOverrides()` restores shaped positions. -`Text.dispose()` is idempotent and permanent. It releases the current core paragraph membership, renderer-neutral cached -state, and any implicit standalone batch and target. It does not dispose explicit-group buffers or loaded fonts, and it -does not mutate the caller-owned scene graph; a disposed object still parented in Three is skipped but remains referenced -until the caller removes it. When grouped, disposal stages the same old-membership cleanup as `remove()` and the group -publishes that cleanup before its next render. When already detached and unbound, disposal still cancels pending work, -clears retained shaping/layout state and font references, marks the object permanently disposed, and prevents future -attachment. Mutating or adding a disposed `Text` throws. +## Ownership and disposal -`TextGroup` owns its hidden paragraph batch, canonical batch storage, renderer-specific targets, materials, attributes, -and subscriptions. Removing a child only frees/recycles logical slots inside those shared resources. `TextGroup.dispose()` -permanently releases the group-owned resources, but does not dispose or remove child `Text` objects; callers may remove -those retained children and add them to a live compatible group. A disposed group rejects text attachment and cannot be -reactivated. +- `Text.dispose()` unbinds the object and releases its font leases. +- `TextGroup.dispose()` releases the group session and GPU resources but does not dispose descendant `Text` objects. +- `LoadedFont.dispose()` releases font and raster resources after all text leases are gone. +- `FontLoader.dispose()` releases its claim on the manager-scoped runtime. -`LoadedFont.dispose()` fails while any live `Text` lease remains. After the final text is disposed or changes font, font -disposal releases that loaded-font ownership. `FontStack` itself owns no lifecycle and cannot keep a disposed concrete font -valid. `FontLoader.dispose()` releases its cache-domain ownership; loaded fonts and their shared shaping state remain valid -until their own final owners are gone. +Dispose text objects before their loaded fonts. A disposed `TextGroup` can be removed while its still-live `Text` children +are moved into another group. -Renderer-specific GPU resources retire according to the renderer target's in-flight-frame rules. Disposal is idempotent. +## React Three Fiber -## Required conformance cases +`@pmndrs/text/r3f` exports ``, ``, and `useFont`. Components preserve the Three ownership and batching +semantics above. Nested R3F `` values flatten into formatted spans; an outer text requires a font, while nested spans +may override it. The maintained renderer target is `@react-three/fiber/webgpu`, which inherits Three's WebGL fallback. -The implementation is not complete until tests prove: +## Deliberately absent surfaces -- an unattached `Text` performs no shaping or GPU allocation; -- direct `scene.add(text)` renders through an implicit batch of one on its first render; -- `TextGroup` exposes no duplicate creation or allocation shortcut; `new Text()` plus ordinary `add()` is the only explicit-group path; -- direct and nested descendants join the nearest `TextGroup`, while nested `TextGroup` boundaries do not merge; -- a detached `Text` owns desired state but no paragraph batch or GPU resources, and direct scene attachment creates its implicit batch before first render; -- add/remove/reparent events plus pre-render ancestry reconciliation cannot leave stale or duplicate membership; -- moving a `Text` performs an atomic old allocation removal and new allocation creation without ghost glyphs; -- removing one text recycles its slots without shrinking or disposing shared group buffers; -- disposing a populated group unbinds but does not dispose its direct or nested text, and each retained text can bind to a live compatible group; -- text left parented below a disposed group remains unbound and cannot fall through to an outer group or implicit standalone batch; -- disposed text rejects mutation and attachment, text disposal does not dispose group/font resources, and group disposal does not dispose child text/fonts; -- font disposal fails while paragraph or text leases remain, and group disposal or reparenting cannot create missing glyphs by releasing font data; -- fixed capacity is checked after shaping rather than by `add()`, preserves the last complete revision, never throws from Three traversal, reports once, and retries only after a relevant change; -- `setCapacity()` preserves the group, every public `Text`, every core paragraph handle, and existing target attachments while replacing canonical and GPU storage transactionally; -- `TextGroup.clone()` and `copy()` are rejected rather than silently duplicating identity-bearing text, listener, and renderer state; -- simultaneous scene placements use separate groups, ordinary reparenting can move one group between scenes, and attempting - to draw one group through a second renderer fails before encoding; -- construction-only incompatibilities fail without mutating the current group; -- runtime setters coalesce and select the narrowest dirty work; -- automatic synchronous preparation renders warm edits in the observing frame; -- a same-technique `FontStack` produces the core-authored minimum physical batches and exact ordered glyph runs; -- mixed-technique group additions and font stacks fail before shaping without replacing live text; -- font-bound, font-stack-bound, style-only, reusable-tag, and readonly-tuple `span()` forms normalize identically, while mixed-technique format lists fail; -- `txt`/`span`, explicit spans, and nested React `` produce the same UTF-16 source/span snapshot; -- WebGPU and forced WebGL2 execute the same Bitmap, MTSDF, and Slug behavior on Three.js 0.185.1. +There is no effects/variant API, JavaScript layout callback, public TypeGPU batch, or user-authored command parser in this +surface. Custom visual behavior uses `material`; renderer-directed batching uses the compiled Rust policy and render plan. +TypeGPU will be rebuilt against that plan in a later stack. diff --git a/docs/planning/three-material-authority.md b/docs/planning/three-material-authority.md index 66689357..26a8ab12 100644 --- a/docs/planning/three-material-authority.md +++ b/docs/planning/three-material-authority.md @@ -1,10 +1,10 @@ --- type: Research Concept title: Three material authority for text draws -description: Proposes that a render variant carry an optional user material factory over the exported canonical shaders, so applications compose text materials with ordinary Three.js idioms instead of a bespoke effects vocabulary. -documentation_type: explanation -status: draft -tags: [planning, threejs, tsl, materials, render-variants, follow-up] +description: Defines user-owned material factories carried from text and span properties through numeric Rust render-plan material identities. +documentation_type: reference +status: stable +tags: [planning, threejs, tsl, materials, render-plan] sources: - id: three-api resource: three-api.md @@ -15,110 +15,165 @@ sources: - id: raster-technique resource: raster-technique-api.md title: Raster technique and engine resource API - - id: variant-run-split - resource: ../../packages/text/src/paragraph-batch.ts - title: Ordered run compilation and variant splitting - - id: bitmap-target - resource: ../../packages/text/src/three/bitmap-target.ts - title: Three Bitmap target and its program-owned material - - id: program-registry - resource: ../../packages/text/src/three/program-registry.ts - title: Three raster program registry + - id: ordered-plan + resource: ../../packages/text/rust/shaper/src/engine/ordered_plan.rs + title: Rust retained ordered-plan compiler + - id: render-plan + resource: ../../packages/text/rust/shaper/src/engine/render_plan.rs + title: Rust render-plan records + - id: plan-executor + resource: ../../packages/text/src/three/engine-plan-target.ts + title: Three command-buffer executor and material realization generated: - by: anthropic-claude/opus-5 - at: '2026-08-07T19:05:00Z' + by: openai-codex/gpt-5.6 + at: '2026-08-08T00:00:00Z' --- # Three material authority for text draws -**Status: work in progress.** This records a proposal made during the target-v1 Three slice so it is not lost. Maintainers -have identified incorrect edges in it that are not yet resolved. Treat every section as a starting position for that -discussion rather than an accepted design, and do not implement from it without settling the open questions below. +Applications may supply a material factory at batch, text, or span scope. `material` is one cascaded rendering property; +the nearest authored value wins. It replaces the former generic `renderVariant` name and the unimplemented `TextEffect` +proposal. The Rust engine carries only a numeric `material_id`, while the Three integration owns the corresponding +factory, `NodeMaterial`, bindings, cache, and disposal. -## The problem +This boundary keeps layout and shaping renderer-neutral. Rust never stores a JavaScript object, invokes a host callback, +or interprets a Three material. It uses `material_id` only as policy-directed draw compatibility data and writes it into +the fixed render-plan draw record as `materialId`. -Target-v1's Three targets construct their own material and never expose it: +## Public construction shape ```ts -new THREE.MeshBasicNodeMaterial({ depthTest: false, depthWrite: false, side: THREE.DoubleSide, transparent: true }) +type ThreeTextMaterialContext = + | { + readonly technique: 'pmndrs.bitmap'; + readonly shader: ThreeBitmapShaderOutput; + readonly position: Node<'vec3'>; + createDefaultMaterial(): THREE.NodeMaterial; + } + | { + readonly technique: 'pmndrs.msdf'; + readonly shader: ThreeMsdfShaderOutput; + readonly position: Node<'vec3'>; + createDefaultMaterial(): THREE.NodeMaterial; + } + | { + readonly technique: 'pmndrs.slug'; + readonly shader: ThreeSlugShaderOutput; + readonly position: Node<'vec3'>; + createDefaultMaterial(): THREE.NodeMaterial; + }; + +interface ThreeTextMaterial { + create(context: ThreeTextMaterialContext): THREE.NodeMaterial; +} + +declare function defineTextMaterial( + create: (context: ThreeTextMaterialContext) => THREE.NodeMaterial, +): ThreeTextMaterial; + +interface TextGroupOptions { + readonly material?: ThreeTextMaterial; +} + +interface TextProperties { + readonly material?: ThreeTextMaterial; +} + +interface TextSpan { + readonly material?: ThreeTextMaterial; +} ``` -That single choice decides more than it appears to. `MeshBasicNodeMaterial` is unlit, so text cannot receive lighting. -Without depth write or depth test, text never enters the depth buffer, so it cannot cast shadows, receive them, or -participate in depth-composited effects such as depth of field. Text renders as a depth-less transparent overlay, which -is a reasonable default for labels and heads-up displays and is part of what makes the pinned Bitmap conformance exact, -but it prevents text from behaving like an ordinary object in a three-dimensional scene. - -Colour-space composition already works: a third party can register a program and compose over the exported canonical -shaders, which is how the composed-program proof masks its own paint over `bitmapShader`. The gap is that doing so -requires implementing an entire `ThreeRasterProgram` — owning buffers, geometry, pipeline, and draw compilation — which -is heavy ceremony for an application that only wants a different material. - -## The proposal - -Three.js attaches materials to objects, and overriding an object's material is ordinary practice. Text should accept a -user-defined material through that same instinct rather than through a bespoke effects vocabulary. - -The routing already exists. Core treats a render variant as opaque render intent and splits ordered runs by it: the -run-merge condition requires `Object.is(previousRun.variant, variant)`, so two variants already produce two runs and -therefore two draws. Nothing new is needed to dispatch a per-variant material. - -So the render variant carries an optional material factory, invoked where each program currently constructs its own -material. That call happens per glyph batch, because each batch binds different pages and textures — which is already -the shape the exported shaders take. +`position` is the exact renderer-local position after policy-selected transform indirection. This is distinct from the +canonical shader's paragraph-local position and prevents a custom material from accidentally bypassing indexed +transforms. The discriminant narrows the exact Bitmap, MSDF, or Slug shader output without a universal union record in +Rust or Wasm. ```ts -const etched = { - material: (text) => { - const material = new MeshStandardNodeMaterial(); - material.colorNode = mix(base, sheen, text.coverage); - material.opacityNode = text.coverage; - return material; - }, -}; - -new TextGroup({ technique: slug, renderVariant: etched }); +const etched = defineTextMaterial(({ technique, shader, position }) => { + if (technique !== 'pmndrs.slug') return createFallbackMaterial({ shader, position }); + const material = new THREE.MeshStandardNodeMaterial({ transparent: true }); + material.positionNode = position; + material.colorNode = mix(shader.color, sheen, shader.coverage); + material.opacityNode = shader.opacity; + return material; +}); + +const text = new Text({ font, text: 'Etched', material: etched }); +text.setSpan(0, { start: 0, end: 3, material: warning }); ``` -Composing a variant of an existing technique then needs no raster module. `registerThreeRasterProgram` remains for -implementing a genuinely new technique, not for restyling one that exists. - -Lighting, shadows, and depth of field stop being this package's concern under this shape. They follow from the material -the application chose — a standard material with depth write enabled participates in depth and lighting like any other -object — rather than from flags this package invents and maintains. - -## Factory rather than subclass - -Export a factory returning exactly the material each program builds internally, so an application starts from that -material and changes one node instead of reconstructing it. A subclassable base class would bind applications to a -package-owned class and work against the ecosystem idiom, which is to assign nodes to a material the application chose. -A factory also keeps the internal path and the application path on the same code, the same property that makes the -exported canonical shaders trustworthy: deleting the export breaks the built-in target. - -## Consequence for the effect placeholder - -`ThreeRenderVariant.effects` is declared and never read. Under this proposal a variant carrying a material is the -effect, so the placeholder becomes unnecessary and should be removed rather than implemented. That also settles the -deferred `TextEffect` helper in the direction of not building it. - -## Constraint - -The default path must stay byte-identical. The pinned Bitmap conformance frame depends on the exact current material -state, so application materials are opt-in per variant and the default variant keeps the program-owned material. - -## Open questions and known incorrect edges - -Maintainers have flagged that this proposal has edges that are wrong as written. Those are not yet enumerated here, and -enumerating them is the first task when this is picked up. Known open items so far: - -- Whether glyph coverage drives a shadow-casting depth prepass cleanly is empirical and unproven. It should be spiked - against a standard material before any of this is committed to. -- The relationship between a per-variant material and paint resolved per glyph is unspecified. Core resolves paint into - canonical instance storage, so a material that ignores the resolved paint would silently discard authored colour, and - the interaction between authored spans and an application material needs a stated rule. -- Whether a material factory can be reconciled with the requirement that programs own variant compatibility and final - draw compilation is unexamined. Two variants that differ only by material may or may not be safely coalescable. -- A separate request asked for text usable as a sampled function, `getTextNode({ coordinates })`, which is a different - model from instanced glyph geometry. Rendering a group to a render target satisfies it today with no package change, - and that route should be documented; an analytic evaluation at arbitrary paragraph coordinates is conceivable for - MSDF and Slug but not for Bitmap, whose atlas sampling and pixel snapping are per quad. +`createDefaultMaterial()` is the DRY path for changing ordinary material state while retaining the package's canonical +placement, coverage, color, and opacity nodes. Creating another `NodeMaterial` is the low-level path for lighting, +shadows, depth writes/tests, and other standard Three behavior. Neither path may replace or duplicate the technique's +glyph coverage algorithm unless the application registers a complete custom raster plan program. + +The construction function, runtime-scoped identity registry, command-buffer executor, and public `TextGroup`/`Text`/span +`material` properties are implemented through the atomic Rust-session path. + +## Identity and render-plan route + +The Three integration interns each live `ThreeTextMaterial` by object identity and assigns a nonzero `u32 material_id`; +zero means the built-in default material. The frame request carries the resolved ID on Rust-owned material segments. +Rust resolves the ordinary batch → text → span cascade, maps clusters to one material ID, and preserves that ID through +glyph primitives into draw packets. Transform ownership is separately policy-selectable. A program may include +transform in its draw key and receive a nonzero draw-level `transformId`, or omit that key and request the first-party +`u32 transformIndex` stream. The latter indexes a renderer-owned region matrix table and permits one compatible draw +across distinct `TextRegion`/`Text` transforms. Transform never enters first-party physical-storage identity. + +The registered policy has independent storage and draw key masks. Every first-party policy includes material in its draw +key. A capability-specific policy may omit it from the storage key, producing different material draws over ranges in +the same physical glyph buffers, or include it when per-material schemas or backend addressing make distinct buffers +preferable. A third-party policy may omit material from the draw key only when it packs per-instance material selection +and its renderer can draw those materials together; the draw-level `materialId` is then zero and the policy-owned +physical record is authoritative. + +Material assignment changes run/draw planning and any policy-requested material sidecar; it never reshapes text or +recomputes line layout. Stable material-owned uniforms may change outside the core update. Replacing the factory object +changes material identity and schedules render-plan recompilation. + +## Construction, caching, and lifetime + +Factories run only when the Three adapter needs a compatible material/resource realization: first use, a new material +ID, an incompatible resource binding, or a retired realization. They never run in Rust, per glyph, or merely because a +frame was requested. The adapter retains a bounded cache keyed by technique, material ID, program, and resource-binding +compatibility. + +The adapter owns and disposes every material returned by a factory. A factory must return a fresh unowned material for +each invocation; returning a material already owned by another scene object is rejected. Removing a material ID from the +live plan retires it only after the renderer-safe publication/fence boundary. Disposing a material definition still +referenced by a live batch, text, or span is an integration lifecycle error. + +## Paint and batching rules + +Fill, opacity, outline, and shadow remain Rust-resolved per-glyph paint. The canonical shader consumes those values before +the application material composes its output. A custom material may intentionally ignore canonical color, but it cannot +silently disable required glyph placement, clipping, or coverage. Bitmap still rejects outline/shadow; MTSDF retains its +bounded distance-based implementation; Slug remains fill-only until a separately measured shared-traversal design lands. + +Material identity is always independent from shaping/layout and is policy-selectable at the two rendering boundaries. +Adjacent glyph spans with the same draw key form one draw span. Different material IDs may reference the same physical +buffers at different record ranges, or a storage key containing material may partition those records. Rust ordered-plan +tests prove both outcomes rather than leaving the adapter to reinterpret the published plan. + +## Rejected effects layer + +There is no `TextEffect`, effect list, graph-composition registry, or effect parameter schema. Those types duplicated the +material system, introduced a second shader vocabulary, and still could not express ordinary Three lighting/depth/shadow +behavior without material authority. Reusable functions may help applications build `NodeMaterial` graphs, but they are +ordinary Three/TSL code outside the text core contract. + +## Required implementation evidence + +- batch, text, and nested-span material cascade reaches exact `materialId` draw records without shaping/layout work; +- two materials over one resource share physical glyph buffers and produce ordered draw spans; +- Bitmap, MTSDF, and Slug factories consume the exact canonical shaders used by the command-buffer executor; +- WebGPURenderer and its WebGL2 fallback preserve placement and coverage for default and custom materials; +- replacement, failure, cache eviction, renderer retirement, and disposal preserve the previous complete frame; +- a lit/depth-writing material proves standard Three lighting, depth, and shadow participation where Three supports it; +- untouched text pays no material-factory call, allocation, pipeline rebuild, or extra package import; and +- package raw/minified/gzip/Brotli and first-pipeline costs are reported before the API is marked implemented. + +The numeric `material_id` route, material-directed draw compatibility, shared physical glyph storage, rejection of a +second effects vocabulary, and the exact Three factory types above are implemented inputs to the Rust plan. The remaining +evidence bullets are release gates for broadening material behavior, not authority for a parallel renderer target. diff --git a/docs/planning/version-contract.md b/docs/planning/version-contract.md index 3f2af8c9..8c4943a2 100644 --- a/docs/planning/version-contract.md +++ b/docs/planning/version-contract.md @@ -11,6 +11,9 @@ sources: - id: harfbuzz resource: https://github.com/harfbuzz/harfbuzz/releases/tag/13.0.0 title: HarfBuzz 13.0.0 + - id: harfbuzz-r3f-assets + resource: https://github.com/harfbuzz/harfbuzz/releases/tag/14.2.0 + title: HarfBuzz 14.2.0 - id: harfbuzz-utilities-build resource: https://github.com/harfbuzz/harfbuzz/blob/a0fc099681a69ae40665fbea74982a2e9d7a5260/util/meson.build title: HarfBuzz 13.0.0 utility build definition @@ -82,9 +85,10 @@ These values are exact fixture and provenance inputs. “Latest” is never a va | Surface | Pin | Source identity | | ------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | Rust toolchain | `1.97.1` | `rust-toolchain.toml` | -| HarfBuzz build system | Meson `1.11.1` + Ninja `1.13.2` | exact workload-scoped `apps/benchmarks/mise.toml` pins; used only to build the authenticated HarfBuzz oracle utilities from source | +| HarfBuzz build system | Meson `1.11.1` + Ninja `1.13.2` | exact workload-scoped `apps/benchmarks/mise.toml` pins; used only to build the authenticated HarfBuzz oracle utilities from source | | HarfRust | `0.12.0` | tag commit `60b28ea22b5261710018d69c168a762bcb28794c` | | HarfBuzz oracle | `13.0.0` | tag commit `a0fc099681a69ae40665fbea74982a2e9d7a5260` | +| R3F example asset subsetter | HarfBuzz `14.2.0` | authenticated release archive; used only to reproduce the checked Inter Latin and Font Awesome globe assets | | MTSDF quality oracle | Chlumsky `msdfgen` `1.13.0` | tag `v1.13`, commit `1874bcf7d9624ccc85b4bc9a85d78116f690f35b`; source archive SHA-256 `93cd1ad8918c1a78c5c96e82d4f4c77f0eb86c2e7e8579a0967e54196c4b7167` | | Unicode | `17.0.0` | versioned UCD and UAX data | | Unicode Script/Script_Extensions data | `@unicode/unicode-17.0.0` `1.6.17` | build-only generated range-table source | @@ -111,7 +115,7 @@ These values are exact fixture and provenance inputs. “Latest” is never a va | KTX2 Rust model/parser | `ktx2` `0.5.0` | compile-time R8 DFD generation plus native artifact validation | | KTX2 JavaScript parser | `ktx-parse` `1.1.0` | package-owned artifact and runtime page validation | -GLib development metadata is a native build-host prerequisite for the HarfBuzz benchmark workload, not a root contributor requirement or fixture identity input. HarfBuzz 13.0.0 gates its `hb-shape` and `hb-subset` targets on `HAVE_GLIB`; the provisioner therefore requires `-Dglib=enabled`, and the pinned Ubuntu 24.04 CI job installs `libglib2.0-dev` explicitly and prints the resolved `glib-2.0` version. Every unrelated optional HarfBuzz backend is disabled explicitly so host-installed FreeType, Cairo, ICU, CoreText, or experimental raster/vector dependencies cannot change the source-build graph. GLib owns the utility frontend, while the authenticated HarfBuzz source and exact generated-byte comparison remain authoritative for fixture semantics. Contributors may supply the documented versions directly; the nested mise config is the reproducible installation option and does not create a second task surface. +GLib development metadata is a native build-host prerequisite for the HarfBuzz benchmark workload, not a root contributor requirement or fixture identity input. HarfBuzz 13.0.0 and 14.2.0 gate their `hb-shape` and `hb-subset` targets on `HAVE_GLIB`; the shared provisioner therefore requires `-Dglib=enabled`, authenticates each version's source archive independently, and the pinned Ubuntu 24.04 CI job installs `libglib2.0-dev` explicitly and prints the resolved `glib-2.0` version. The 13.0.0 build remains the shaping oracle and CJK fixture authority; 14.2.0 is isolated to the R3F example's checked asset subsets. Every unrelated optional HarfBuzz backend is disabled explicitly so host-installed FreeType, Cairo, ICU, CoreText, or experimental raster/vector dependencies cannot change the source-build graph. GLib owns the utility frontend, while the authenticated HarfBuzz source and exact generated-byte comparison remain authoritative for fixture semantics. Contributors may supply the documented versions directly; the nested mise config is the reproducible installation option and does not create a second task surface. ## Generated contract diff --git a/docs/roadmap/roadmap.md b/docs/roadmap/roadmap.md index 4fbae35a..2442e21d 100644 --- a/docs/roadmap/roadmap.md +++ b/docs/roadmap/roadmap.md @@ -22,10 +22,13 @@ sources: - id: 'engine-integration-plan' resource: '../planning/engine-integration-boundary.md' title: 'Renderer-neutral extraction plan' + - id: 'paragraph-query-preparation' + resource: '../planning/paragraph-query-preparation.md' + title: 'Paragraph-scoped preparation and synchronous layout queries' generated: - by: anthropic-claude/opus-5 - at: '2026-08-07T19:05:00Z' + by: openai-codex/gpt-5.6 + at: '2026-08-09T14:25:40Z' --- # Canonical implementation roadmap @@ -153,6 +156,9 @@ These rows replace the former separate backlog. Each is intended to become one f | 11.13 | ⬜ | Prove the shaping and layout contract can represent a break-inserted hyphen glyph that has no source cluster, and fix the contract if it cannot. Language patterns, break selection, and justification quality controls remain later work. | M | 11.6 | | 11.14 | ⬜ | Add the professional typography the editorial showcase requires: `wordSpacing`, first-line indent, paragraph space before/after, and justification controls covering minimum/maximum word-space ratio, letter-space expansion, and last-line policy. | L | 11.12–11.13 | | 11.15 | ⬜ | Settle Three material authority, so applications supply their own `NodeMaterial` and gain lighting, shadows, and depth-composited effects without implementing a raster program. Resolve the open edges in the [material authority concept](../planning/three-material-authority.md) first; it is a recorded proposal, not an accepted design. | M | 11.6 | +| 11.16 | ✅ | Replace duplicate TypeScript shaping, layout, packing, and dirty-plan work with one retained Rust/Wasm frame transaction, validated renderer policy, and incremental render plan; land the Rust, policy/plan, and Three adapter PRs as one coordinated stack after exact Bitmap/MSDF/Slug, benchmark-app, size, and browser parity. | XL | 11.6 | +| 11.17 | ⬜ | Add paragraph-scoped synchronous prepare/query and candidate adoption: measure one pending paragraph per call without compiling a render plan, retain one session transaction with linear identity reservation, and reuse its paragraph-keyed results in the next full frame without a third full buffer. | L | 11.16 | +| 11.18 | ⬜ | Complete the Rust engine's realtime publishing set over that proven path: spacing, decorations, interaction geometry, horizontal and vertical writing, one-call exclusions and sequential regions, bounded CJK tailoring, and optional color-emoji fallback, excluding every explicitly cut unbounded solver or second authored text stream. | XL | 11.16 | ## Milestone 0 — accept contracts and versions diff --git a/package.json b/package.json index 04dc8ce1..31e7328a 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "dev": "pnpm --filter @pmndrs/text-benchmarks dev", "build": "pnpm --filter './packages/*' build && pnpm --filter './apps/*' build", "test": "pnpm --filter './packages/*' --if-present test && pnpm --filter './apps/*' --if-present test", - "check": "tsc -p .claude/tsconfig.json && node --test .claude/hooks/sync-agent-config.test.ts && pnpm --filter './packages/*' check && pnpm --filter './apps/*' check && oxfmt --check .claude/hooks .claude/settings.json .claude/tsconfig.json package.json docs/log.md && ruby .agents/skills/open-knowledge-format/scripts/validate_okf.rb docs --workspace-root .", + "check": "tsc -p .claude/tsconfig.json && node --test .claude/hooks/sync-agent-config.test.ts && pnpm --filter './packages/*' check && pnpm --workspace-concurrency=1 --filter './apps/*' check && oxfmt --check .claude/hooks .claude/settings.json .claude/tsconfig.json package.json docs/log.md && ruby .agents/skills/open-knowledge-format/scripts/validate_okf.rb docs --workspace-root .", "scripts": "node apps/benchmarks/scripts/workflows.mts" }, "devDependencies": { diff --git a/packages/font-baker/src/index.ts b/packages/font-baker/src/index.ts index 55a3f588..80f2bc68 100644 --- a/packages/font-baker/src/index.ts +++ b/packages/font-baker/src/index.ts @@ -2,6 +2,7 @@ import { FONT_BAKER_VERSION, FONT_FORMAT_VERSION } from './contract.js'; import { fontBakerAbi, type FontBakerAbi } from './generated/font-baker-abi.js'; export { FONT_BAKER_VERSION, FONT_FORMAT_VERSION } from './contract.js'; +export { fontBakerAbi } from './generated/font-baker-abi.js'; export interface FontBakeDescriptorV0 { readonly formatVersion: 0; @@ -113,8 +114,7 @@ export async function createFontBaker(source: FontBakerWasmSource): Promise number; diff --git a/packages/font-baker/tests/integration/wasm-package.test.mjs b/packages/font-baker/tests/integration/wasm-package.test.mjs index 0f024e67..11910992 100644 --- a/packages/font-baker/tests/integration/wasm-package.test.mjs +++ b/packages/font-baker/tests/integration/wasm-package.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; -import { FontBakeError, createFontBaker, createFontBakerFromInstance, readFontBakerAbi } from '../../dist/index.js'; +import { FontBakeError, createFontBaker, createFontBakerFromInstance, fontBakerAbi } from '../../dist/index.js'; const [wasm, rustReleaseWasm] = await Promise.all([ readFile(new URL('../../dist/font_baker.wasm', import.meta.url)), @@ -23,10 +23,8 @@ test('the distributed module is the pinned size-optimized zero-import release mo test('the published and generated TypeScript ABI contracts are identical', async () => { const module = await WebAssembly.compile(wasm); assert.deepEqual(WebAssembly.Module.imports(module), []); - const instance = await WebAssembly.instantiate(module, {}); - const generated = readFontBakerAbi(instance); - assert.equal(generated.versions.binaryen, '129.0.0'); - assert.deepEqual(generated, publishedAbi); + assert.equal(fontBakerAbi.versions.binaryen, '129.0.0'); + assert.deepEqual(fontBakerAbi, publishedAbi); assert.equal( WebAssembly.Module.exports(module).some(({ name }) => name.includes('abi_')), false, diff --git a/packages/glyph-example-raster/src/index.ts b/packages/glyph-example-raster/src/index.ts index 8e5a4f84..fbd5ad03 100644 --- a/packages/glyph-example-raster/src/index.ts +++ b/packages/glyph-example-raster/src/index.ts @@ -7,9 +7,4 @@ export { type GlyphExampleDescriptor, type GlyphExampleOptions, } from './contract.js'; -export { - glyphExample, - type GlyphExampleBinding, - type GlyphExampleData, - type GlyphExampleGlyphBatchStorage, -} from './raster.js'; +export { glyphExample, type GlyphExampleData } from './raster.js'; diff --git a/packages/glyph-example-raster/src/raster.ts b/packages/glyph-example-raster/src/raster.ts index 68ab60a2..25d81509 100644 --- a/packages/glyph-example-raster/src/raster.ts +++ b/packages/glyph-example-raster/src/raster.ts @@ -1,9 +1,5 @@ import type { - GlyphPaint, - GlyphRange, JsonValue, - RasterGlyphInput, - RasterGlyphWriteInput, RasterResourceId, RasterResourceSource, RasterTechnique, @@ -27,39 +23,23 @@ import { const RECORD_STRIDE = 4; -/** - * The physical batch payload this technique needs while packing. Only the inset varies per decoded raster, so the - * decoded data owns one frozen binding and every selection returns that same object identity. - */ -export interface GlyphExampleBinding { - readonly inset: number; -} - export interface GlyphExampleData { readonly resource: RasterResourceId; - readonly binding: GlyphExampleBinding; + readonly inset: number; readonly colors: Uint8Array; readonly glyphCount: number; } -export interface GlyphExampleGlyphBatchStorage { - readonly origins: Float32Array; - readonly sizes: Float32Array; - readonly colors: Float32Array; -} - /** - * A third-party portable raster technique. It owns decoding, selection, and canonical instance packing and never - * mentions a renderer; the Three program in `./three.js` turns the packed storage into draws. + * A third-party portable raster technique. It owns identity, decoding, and resource lifetime and never mentions a + * renderer; the Three program in `./three.js` declares the Rust packing policy and realizes its command-buffer draws. */ export const glyphExample: RasterTechnique< RasterTechniqueId & 'studio.glyph-example', typeof GLYPH_EXAMPLE_KIND, GlyphExampleOptions | undefined, GlyphExampleDescriptor, - GlyphExampleData, - GlyphExampleBinding, - GlyphExampleGlyphBatchStorage + GlyphExampleData > = defineRasterTechnique({ id: 'studio.glyph-example', kind: GLYPH_EXAMPLE_KIND, @@ -82,104 +62,16 @@ export const glyphExample: RasterTechnique< } return { resource: defineRasterResourceId(`studio.glyph-example/${font.shapingHash}/${raster.rasterKey}`), - binding: Object.freeze({ inset: extension.descriptor.inset }), + inset: extension.descriptor.inset, colors, glyphCount: font.glyphCount, }; }, - select(input: RasterGlyphInput) { - assertGlyphId(input.data, input.glyphId); - if (!Number.isFinite(input.fontSize) || input.fontSize <= 0) { - throw new TypeError('glyph-example font sizes must be positive finite values'); - } - return { resource: input.data.resource, pipelineVariant: 0, binding: input.data.binding }; - }, - createStorage(capacity: number): GlyphExampleGlyphBatchStorage { - if (!Number.isSafeInteger(capacity) || capacity < 0) { - throw new RangeError('glyph-example storage capacity must be a non-negative safe integer'); - } - return { - origins: new Float32Array(capacity * 2), - sizes: new Float32Array(capacity * 2), - colors: new Float32Array(capacity * 4), - }; - }, - writeStorage( - storage: GlyphExampleGlyphBatchStorage, - range: GlyphRange, - input: RasterGlyphWriteInput, - ): void { - assertWriteRange(storage, range, input.glyphs.length); - if (input.binding !== input.data.binding) { - throw new TypeError('glyph-example write binding does not belong to its data'); - } - for (let index = 0; index < input.glyphs.length; index += 1) { - writeGlyph(storage, range.start + index, input.data, input.binding, input.glyphs[index]!); - } - }, - validatePaint(paint: GlyphPaint): void { - for (const entry of paint.palette) { - if (entry.color.length !== 4 || entry.color.some((value) => !Number.isFinite(value) || value < 0 || value > 1)) { - throw new TypeError('glyph-example fill color must contain four finite linear values in [0, 1]'); - } - if (entry.outline !== undefined || entry.shadow !== undefined) { - throw new TypeError('glyph-example supports fill color and opacity only'); - } - } - }, dispose(data: GlyphExampleData): void { data.colors.fill(0); }, }); -function writeGlyph( - storage: GlyphExampleGlyphBatchStorage, - instance: number, - data: GlyphExampleData, - binding: GlyphExampleBinding, - glyph: RasterGlyphInput, -): void { - assertGlyphId(data, glyph.glyphId); - if (!Number.isFinite(glyph.fontSize) || glyph.fontSize <= 0) { - throw new TypeError('glyph-example font sizes must be positive finite values'); - } - if (!Number.isFinite(glyph.originX) || !Number.isFinite(glyph.originY)) { - throw new TypeError('glyph-example positions must be finite values'); - } - if (glyph.paint.color.length !== 4) throw new TypeError('glyph-example paint must resolve four linear components'); - const inset = binding.inset * glyph.fontSize; - const vectorOffset = instance * 2; - storage.origins[vectorOffset] = glyph.originX + inset; - storage.origins[vectorOffset + 1] = glyph.originY - glyph.fontSize * 0.8 + inset; - storage.sizes[vectorOffset] = Math.max(glyph.fontSize * 0.05, glyph.fontSize * 0.65 - inset * 2); - storage.sizes[vectorOffset + 1] = Math.max(glyph.fontSize * 0.05, glyph.fontSize - inset * 2); - const record = glyph.glyphId * RECORD_STRIDE; - const colorOffset = instance * 4; - for (let channel = 0; channel < 4; channel += 1) { - storage.colors[colorOffset + channel] = (data.colors[record + channel]! / 255) * glyph.paint.color[channel]!; - } -} - -function assertGlyphId(data: GlyphExampleData, glyphId: number): void { - if (!Number.isSafeInteger(glyphId) || glyphId < 0 || glyphId >= data.glyphCount) { - throw new RangeError('glyph-example layout references an unavailable glyph'); - } -} - -function assertWriteRange(storage: GlyphExampleGlyphBatchStorage, range: GlyphRange, glyphCount: number): void { - const capacity = storage.colors.length / 4; - if ( - !Number.isSafeInteger(range.start) || - !Number.isSafeInteger(range.count) || - range.start < 0 || - range.count < 0 || - range.count !== glyphCount || - range.start > capacity - range.count - ) { - throw new RangeError('glyph-example storage write range is outside its capacity'); - } -} - function decodeExtension( font: RegisteredFont, raster: RegisteredRaster, diff --git a/packages/glyph-example-raster/src/three.ts b/packages/glyph-example-raster/src/three.ts index 53c5512e..6161e37c 100644 --- a/packages/glyph-example-raster/src/three.ts +++ b/packages/glyph-example-raster/src/three.ts @@ -1,323 +1,168 @@ -import type { - GlyphBatchKey, - ParagraphBatchTarget, - ParagraphBatchTargetUpdate, - ParagraphId, - PreparedGlyphBatch, - PreparedParagraphBatchRevision, -} from '@pmndrs/text'; -import { registerThreeRasterProgram, type ThreeRasterTargetOwner } from '@pmndrs/text/three'; -import { add, instanceIndex, min, mul, positionLocal, step, storage, sub, uniform, uv, vec3 } from 'three/tsl'; +import { registerThreeRasterPlanProgram, threePolicyAbi, type ThreePlanProgramBuffer } from '@pmndrs/text/three'; +import { add, min, mul, positionLocal, step, storage, sub, uv, vec3 } from 'three/tsl'; import * as THREE from 'three/webgpu'; -import { glyphExample } from './raster.js'; - -/** - * A third-party Three program for a third-party technique. It is registered by technique identifier, so nothing in - * `@pmndrs/text` needs to know this package exists: core packs the canonical storage and this program owns every - * engine resource — geometry, attributes, material, and the meshes it publishes into the caller's scene. - */ -registerThreeRasterProgram(glyphExample, (owner) => new ThreeGlyphExampleTarget(owner)); - -interface GlyphExampleResource { - readonly key: GlyphBatchKey; - readonly capacity: number; - readonly gpuBytes: number; - readonly material: THREE.MeshBasicNodeMaterial; - update(batch: PreparedGlyphBatch): void; - geometry(count: number): THREE.InstancedBufferGeometry; - dispose(): void; -} - -interface RunIdentity { - readonly paragraph: ParagraphId; - readonly batch: GlyphBatchKey; -} - -/** - * One committed generation of draws. Retention is the point of this class: while core reports the same batch capacities - * and the same run topology, a warm revision transfers its meshes and buffers to its successor instead of rebuilding - * them, which is what keeps object and geometry identity stable across text updates. - */ -class ThreeGlyphExampleRevision { - readonly sourceRevision: number; - readonly draws: readonly THREE.Mesh[]; - readonly #resources: ReadonlyMap; - readonly #runIdentities: readonly RunIdentity[]; - #transferred = false; - #disposed = false; - - constructor( - sourceRevision: number, - draws: readonly THREE.Mesh[], - resources: ReadonlyMap, - runIdentities: readonly RunIdentity[], - ) { - this.sourceRevision = sourceRevision; - this.draws = draws; - this.#resources = resources; - this.#runIdentities = runIdentities; - } - - get gpuBytes(): number { - if (this.#disposed || this.#transferred) return 0; - let bytes = 0; - for (const resource of this.#resources.values()) bytes += resource.gpuBytes; - return bytes; - } - - setRenderOrderBase(base: number): void { - for (let index = 0; index < this.draws.length; index += 1) this.draws[index]!.renderOrder = base + index; - } - - canReuse(next: PreparedParagraphBatchRevision): boolean { - if (this.#disposed || this.#transferred || next.glyphBatches.length !== this.#resources.size) return false; - for (const batch of next.glyphBatches) { - const resource = this.#resources.get(batch.key); - if (resource === undefined || resource.capacity !== batch.capacity) return false; - } - if (next.glyphRuns.length !== this.#runIdentities.length) return false; - return next.glyphRuns.every((run, index) => { - const identity = this.#runIdentities[index]; - return identity?.paragraph === run.paragraph && identity.batch === run.batch; +import { glyphExample, type GlyphExampleData } from './raster.js'; + +const opcodes = threePolicyAbi.opcodes; +const scalar = threePolicyAbi.scalarTypes; +const semantic = threePolicyAbi.semanticF32Fields; +const semanticU32 = threePolicyAbi.semanticU32Fields; +const batch = threePolicyAbi.batchFields; + +registerThreeRasterPlanProgram({ + technique: glyphExample, + policy: { + f32InputCount: 12, + u32InputCount: 1, + inputs: [ + { scope: 'semantic', field: semantic.inlineStart }, + { scope: 'semantic', field: semantic.blockStart }, + { scope: 'semantic', field: semantic.fontSize }, + { scope: 'semantic', field: semantic.foregroundRed }, + { scope: 'semantic', field: semantic.foregroundGreen }, + { scope: 'semantic', field: semantic.foregroundBlue }, + { scope: 'semantic', field: semantic.foregroundAlpha }, + ...Array.from({ length: 5 }, (_, field) => ({ scope: 'glyph' as const, field })), + { scope: 'semantic', field: semanticU32.transformIndex }, + ], + buffers: [ + { id: 1, scalar: scalar.f32, vectorWidth: 2 }, + { id: 2, scalar: scalar.f32, vectorWidth: 2 }, + { id: 3, scalar: scalar.f32, vectorWidth: 4 }, + { id: threePolicyAbi.transformBufferId, scalar: scalar.u32, vectorWidth: 1 }, + ], + operations: glyphExampleOperations(), + storageKeyMask: batch.technique | batch.program | batch.resource, + drawKeyMask: + batch.technique | batch.program | batch.resource | batch.material | batch.clip | batch.depth | batch.order, + }, + compileFont(compiler) { + const data = compiler.font.data as GlyphExampleData; + const { resources } = compiler.resources([data.resource]); + compiler.retain(data.resource, data); + compiler.compile({ + techniqueId: compiler.techniqueId, + programVariant: 0, + glyphCount: compiler.font.font.glyphCount, + strikes: [0], + resources, + resourceIndex: () => 0, + glyphF32: { + rows: data.glyphCount, + fields: [ + () => data.inset, + (row) => data.colors[row * 4]! / 255, + (row) => data.colors[row * 4 + 1]! / 255, + (row) => data.colors[row * 4 + 2]! / 255, + (row) => data.colors[row * 4 + 3]! / 255, + ], + }, + glyphU32: compiler.emptyTable(data.glyphCount), + strikeF32: compiler.emptyTable(data.glyphCount), + strikeU32: compiler.emptyTable(data.glyphCount), + resourceF32: compiler.emptyTable(resources.length), + resourceU32: compiler.emptyTable(resources.length), }); - } - - transfer( - next: PreparedParagraphBatchRevision, - renderOrderBase: number, - ): ThreeGlyphExampleRevision { - if (!this.canReuse(next)) throw new Error('glyph-example revision is not compatible for reuse'); - for (const batch of next.glyphBatches) this.#resources.get(batch.key)!.update(batch); - for (let index = 0; index < next.glyphRuns.length; index += 1) { - const run = next.glyphRuns[index]!; - const draw = this.draws[index]!; - draw.userData.pmndrsTextRunStart = run.start; - (draw.geometry as THREE.InstancedBufferGeometry).instanceCount = run.count; - draw.renderOrder = renderOrderBase + index; - } - this.#transferred = true; - return new ThreeGlyphExampleRevision(next.revision, this.draws, this.#resources, this.#runIdentities); - } - - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - if (this.#transferred) return; - for (const draw of this.draws) { - draw.removeFromParent(); - draw.geometry.dispose(); - } - for (const resource of this.#resources.values()) resource.dispose(); - } -} - -class ThreeGlyphExampleTarget implements ParagraphBatchTarget< - typeof glyphExample, - Variant, - ThreeGlyphExampleRevision -> { - readonly technique: typeof glyphExample = glyphExample; - readonly #owner: ThreeRasterTargetOwner; - #committed: ThreeGlyphExampleRevision | undefined; - #disposed = false; - - constructor(owner: ThreeRasterTargetOwner) { - this.#owner = owner; - } - - get gpuBytes(): number { - return this.#committed?.gpuBytes ?? 0; - } - - stage( - previous: ThreeGlyphExampleRevision | undefined, - next: PreparedParagraphBatchRevision, - ): ParagraphBatchTargetUpdate { - if (this.#disposed) throw new Error('glyph-example Three target has been disposed'); - if (previous?.canReuse(next) === true) return this.#warmStage(previous, next); - const resources = new Map(); - const draws: THREE.Mesh[] = []; - try { - for (const batch of next.glyphBatches) resources.set(batch.key, createResource(batch)); - 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('glyph-example run references an unknown physical batch'); - const mesh = new THREE.Mesh(resource.geometry(run.count), resource.material); - mesh.name = 'pmndrs.text.glyph-example'; - mesh.userData.pmndrsTextRunStart = run.start; - mesh.frustumCulled = false; - mesh.renderOrder = this.#owner.renderOrderBase + index; - draws.push(mesh); - } - } catch (error) { - discard(draws, resources.values()); - throw error; + }, + createMaterial(context) { + if (context.materialId !== 0) { + throw new TypeError('glyph-example does not implement custom text materials'); } - let finished = false; - return { - status: 'ready', - stage: { - sourceRevision: next.revision, - commit: () => { - if (finished) throw new Error('glyph-example stage is no longer active'); - finished = true; - for (let index = 0; index < draws.length; index += 1) { - this.#owner.objectForParagraph(next.glyphRuns[index]!.paragraph).add(draws[index]!); - } - this.#committed = new ThreeGlyphExampleRevision(next.revision, draws, resources, runIdentities(next)); - return this.#committed; - }, - abort: () => { - if (finished) return; - finished = true; - discard(draws, resources.values()); - }, - }, - }; - } - - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - this.#committed = undefined; + const origins = floatBuffer(context.buffers, 1, 2); + const sizes = floatBuffer(context.buffers, 2, 2); + const colors = floatBuffer(context.buffers, 3, 4); + const origin = storage(origins.attribute, 'vec2', origins.attribute.count).setPBO(true).element(context.instance); + const size = storage(sizes.attribute, 'vec2', sizes.attribute.count).setPBO(true).element(context.instance); + const color = storage(colors.attribute, 'vec4', colors.attribute.count).setPBO(true).element(context.instance); + const unit = uv(); + const edgeDistance = min(min(unit.x, sub(1, unit.x)), min(unit.y, sub(1, unit.y))); + const material = new THREE.MeshBasicNodeMaterial({ + depthTest: false, + depthWrite: false, + side: THREE.DoubleSide, + transparent: true, + }); + material.positionNode = context.transformPosition( + vec3(add(origin.x, mul(positionLocal.x, size.x)), add(origin.y, mul(positionLocal.y, size.y)).negate(), 0), + ); + material.colorNode = color.rgb; + material.opacityNode = mul(color.a, sub(1, step(0.08, edgeDistance))); + return material; + }, +}); + +function glyphExampleOperations() { + const operations: Array<{ + opcode: number; + target?: number; + operand0?: number; + operand1?: number; + immediate0?: number; + }> = []; + for (let field = 0; field < 12; field += 1) { + operations.push({ opcode: opcodes.loadF32, target: field, operand0: field }); } - - #warmStage( - previous: ThreeGlyphExampleRevision, - next: PreparedParagraphBatchRevision, - ): ParagraphBatchTargetUpdate { - let finished = false; - return { - status: 'ready', - stage: { - sourceRevision: next.revision, - commit: () => { - if (finished) throw new Error('glyph-example stage is no longer active'); - finished = true; - this.#committed = previous.transfer(next, this.#owner.renderOrderBase); - return this.#committed; - }, - abort: () => { - finished = true; - }, - }, - }; + operations.push({ opcode: opcodes.loadU32, target: 31, operand0: 0 }); + operations.push({ opcode: opcodes.multiplyF32, target: 12, operand0: 7, operand1: 2 }); + operations.push({ opcode: opcodes.addF32, target: 13, operand0: 0, operand1: 12 }); + operations.push({ opcode: opcodes.constantF32, target: 14, immediate0: f32Bits(0.8) }); + operations.push({ opcode: opcodes.multiplyF32, target: 15, operand0: 2, operand1: 14 }); + operations.push({ opcode: opcodes.subtractF32, target: 16, operand0: 1, operand1: 15 }); + operations.push({ opcode: opcodes.addF32, target: 17, operand0: 16, operand1: 12 }); + operations.push({ opcode: opcodes.constantF32, target: 18, immediate0: f32Bits(0.05) }); + operations.push({ opcode: opcodes.multiplyF32, target: 19, operand0: 2, operand1: 18 }); + operations.push({ opcode: opcodes.constantF32, target: 20, immediate0: f32Bits(0.65) }); + operations.push({ opcode: opcodes.multiplyF32, target: 21, operand0: 2, operand1: 20 }); + operations.push({ opcode: opcodes.constantF32, target: 22, immediate0: f32Bits(2) }); + operations.push({ opcode: opcodes.multiplyF32, target: 23, operand0: 12, operand1: 22 }); + operations.push({ opcode: opcodes.subtractF32, target: 24, operand0: 21, operand1: 23 }); + operations.push({ opcode: opcodes.lessThanF32, target: 25, operand0: 24, operand1: 19 }); + operations.push({ opcode: opcodes.selectF32, target: 26, operand0: 25, operand1: 19, immediate0: 24 }); + operations.push({ opcode: opcodes.subtractF32, target: 27, operand0: 2, operand1: 23 }); + operations.push({ opcode: opcodes.lessThanF32, target: 28, operand0: 27, operand1: 19 }); + operations.push({ opcode: opcodes.selectF32, target: 29, operand0: 28, operand1: 19, immediate0: 27 }); + storeF32(operations, 1, 0, 13); + storeF32(operations, 1, 1, 17); + storeF32(operations, 2, 0, 26); + storeF32(operations, 2, 1, 29); + for (let channel = 0; channel < 4; channel += 1) { + operations.push({ opcode: opcodes.multiplyF32, target: 12, operand0: 3 + channel, operand1: 8 + channel }); + storeF32(operations, 3, channel, 12); } + operations.push({ + opcode: opcodes.storeU32, + operand0: 31, + operand1: 0, + immediate0: threePolicyAbi.transformBufferId, + }); + return operations; } -function createResource(batch: PreparedGlyphBatch): GlyphExampleResource { - const origins = instanceAttribute(batch.storage.origins, 2); - const sizes = instanceAttribute(batch.storage.sizes, 2); - const colors = instanceAttribute(batch.storage.colors, 4); - const material = createMaterial(origins, sizes, colors); - return { - key: batch.key, - capacity: batch.capacity, - gpuBytes: origins.array.byteLength + sizes.array.byteLength + colors.array.byteLength, - material, - update(next) { - upload(origins, next.storage.origins, 2, next.dirtyRanges); - upload(sizes, next.storage.sizes, 2, next.dirtyRanges); - upload(colors, next.storage.colors, 4, next.dirtyRanges); - }, - geometry(count) { - const geometry = unitQuad(); - geometry.instanceCount = count; - geometry.setAttribute('_glyphExampleOrigins', origins); - geometry.setAttribute('_glyphExampleSizes', sizes); - geometry.setAttribute('_glyphExampleColors', colors); - return geometry; - }, - dispose() { - material.dispose(); - }, - }; -} - -function upload( - target: THREE.StorageInstancedBufferAttribute, - source: Float32Array, - itemSize: number, - ranges: readonly { readonly start: number; readonly count: number }[], +function storeF32( + operations: Array<{ opcode: number; operand0?: number; operand1?: number; immediate0?: number }>, + buffer: number, + lane: number, + register: number, ): void { - if (ranges.length === 0) return; - const values = target.array as Float32Array; - target.clearUpdateRanges(); - for (const range of ranges) { - const start = range.start * itemSize; - const count = range.count * itemSize; - values.set(source.subarray(start, start + count), start); - target.addUpdateRange(start, count); - } - target.needsUpdate = true; - const pbo = (target as THREE.StorageInstancedBufferAttribute & { pbo?: THREE.DataTexture }).pbo; - if (pbo !== undefined) pbo.needsUpdate = true; -} - -function instanceAttribute(source: Float32Array, itemSize: number): THREE.StorageInstancedBufferAttribute { - const value = new THREE.StorageInstancedBufferAttribute(new Float32Array(source), itemSize); - value.setUsage(THREE.DynamicDrawUsage); - value.needsUpdate = true; - return value; + operations.push({ opcode: opcodes.storeF32, operand0: register, operand1: lane, immediate0: buffer }); } -function runIdentities( - revision: PreparedParagraphBatchRevision, -): readonly RunIdentity[] { - return revision.glyphRuns.map((run) => ({ paragraph: run.paragraph, batch: run.batch })); -} - -function discard(draws: readonly THREE.Mesh[], resources: Iterable): void { - for (const draw of draws) { - draw.removeFromParent(); - draw.geometry.dispose(); +function floatBuffer( + buffers: ReadonlyMap, + id: number, + vectorWidth: number, +): ThreePlanProgramBuffer { + const buffer = buffers.get(id); + if (buffer === undefined || buffer.scalarType !== scalar.f32 || buffer.vectorWidth !== vectorWidth) { + throw new TypeError(`glyph-example draw requires f32x${vectorWidth} policy buffer ${id}`); } - for (const resource of resources) resource.dispose(); -} - -/** - * Core packs one physical batch that several runs may share, so each mesh reads its own slice through the run-start - * uniform rather than assuming its run begins at instance zero. - */ -function createMaterial( - origins: THREE.StorageInstancedBufferAttribute, - sizes: THREE.StorageInstancedBufferAttribute, - colors: THREE.StorageInstancedBufferAttribute, -): THREE.MeshBasicNodeMaterial { - const material = new THREE.MeshBasicNodeMaterial({ - depthTest: false, - depthWrite: false, - side: THREE.DoubleSide, - transparent: true, - }); - const runStart = uniform(0, 'uint').onObjectUpdate( - ({ object }) => (object?.userData.pmndrsTextRunStart as number | undefined) ?? 0, - ); - const instance = instanceIndex.add(runStart); - const origin = storage(origins, 'vec2', origins.count).setPBO(true).element(instance); - const size = storage(sizes, 'vec2', sizes.count).setPBO(true).element(instance); - const color = storage(colors, 'vec4', colors.count).setPBO(true).element(instance); - const unit = uv(); - const edgeDistance = min(min(unit.x, sub(1, unit.x)), min(unit.y, sub(1, unit.y))); - const frame = sub(1, step(0.08, edgeDistance)); - // Canonical storage is positive-down, so the program negates Y to reach Three's Y-up paragraph space. The technique - // never states a renderer convention; converting it is exactly the program's job. - material.positionNode = vec3( - add(origin.x, mul(positionLocal.x, size.x)), - add(origin.y, mul(positionLocal.y, size.y)).negate(), - 0, - ); - material.colorNode = color.rgb; - material.opacityNode = mul(color.a, frame); - return material; + return buffer; } -function unitQuad(): THREE.InstancedBufferGeometry { - const geometry = new THREE.InstancedBufferGeometry(); - geometry.setAttribute('position', new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0], 3)); - geometry.setAttribute('uv', new THREE.Float32BufferAttribute([0, 0, 1, 0, 0, 1, 1, 1], 2)); - geometry.setIndex([0, 1, 2, 2, 1, 3]); - return geometry; +function f32Bits(value: number): number { + const bytes = new ArrayBuffer(4); + const view = new DataView(bytes); + view.setFloat32(0, value, true); + return view.getUint32(0, true); } diff --git a/packages/glyph-example-raster/tests/glyph-example.test.ts b/packages/glyph-example-raster/tests/glyph-example.test.ts index 6e8926ef..d731d9ca 100644 --- a/packages/glyph-example-raster/tests/glyph-example.test.ts +++ b/packages/glyph-example-raster/tests/glyph-example.test.ts @@ -5,9 +5,8 @@ import { join } from 'node:path'; import { FontRegistry, + createTextRuntime, rasterBake, - type GlyphPaint, - type RasterGlyphInput, type RasterKey, type RasterResolverContext, type RasterResourceResolverContext, @@ -15,10 +14,13 @@ import { type Sha256Hex, } from '@pmndrs/text'; import { bakeFont } from '@pmndrs/text/bake'; +import { Text, TextGroup } from '@pmndrs/text/three'; +import * as THREE from 'three/webgpu'; import { afterEach, describe, expect, test, vi } from 'vitest'; import glyphExampleBaker from '../src/baker.js'; -import { GLYPH_EXAMPLE_KIND, glyphExample, glyphExampleDescriptor, type GlyphExampleData } from '../src/index.js'; +import { GLYPH_EXAMPLE_KIND, glyphExample, glyphExampleDescriptor } from '../src/index.js'; +import '../src/three.js'; const source = new URL('../../../apps/benchmarks/fixtures/fonts/inter-v4.1/Inter-Regular.ttf', import.meta.url); const temporaryDirectories: string[] = []; @@ -64,7 +66,7 @@ describe('public external raster proof', () => { const data = await glyphExample.decode(font, raster); expect(raster.kind).toBe(GLYPH_EXAMPLE_KIND); expect(data.colors.byteLength).toBe(font.glyphCount * 4); - expect(data.binding.inset).toBe(glyphExampleDescriptor({ paletteSeed: 7 }).inset); + expect(data.inset).toBe(glyphExampleDescriptor({ paletteSeed: 7 }).inset); expect(resolve).toHaveBeenCalledOnce(); expect(resolveResource).toHaveBeenCalledOnce(); expect(resolve.mock.calls[0]?.[0].reference.kind).toBe(GLYPH_EXAMPLE_KIND); @@ -75,67 +77,6 @@ describe('public external raster proof', () => { } }); - test('selects one shared resource and packs canonical instances without a renderer', async () => { - const { font, data } = await loadEmbedded(); - try { - const selection = glyphExample.select(glyph(data, 1)); - expect(selection).toEqual({ resource: data.resource, pipelineVariant: 0, binding: data.binding }); - expect(glyphExample.select(glyph(data, 2))?.binding).toBe(data.binding); - - const storage = glyphExample.createStorage(4); - glyphExample.writeStorage( - storage, - { start: 1, count: 2 }, - { data, binding: data.binding, glyphs: [glyph(data, 1), glyph(data, 2)] }, - ); - // Canonical storage is Float32Array, so every expectation compares at single precision. - const inset = data.binding.inset * 16; - expect(Array.from(storage.origins.subarray(0, 2))).toEqual([0, 0]); - expectClose(Array.from(storage.origins.subarray(2, 4)), [inset, 12 - 16 * 0.8 + inset]); - expectClose(Array.from(storage.sizes.subarray(2, 4)), [16 * 0.65 - inset * 2, 16 - inset * 2]); - expectClose(Array.from(storage.colors.subarray(4, 8)), glyphColor(data, 1)); - expectClose(Array.from(storage.colors.subarray(8, 12)), glyphColor(data, 2)); - - expect(() => - glyphExample.writeStorage( - storage, - { start: 0, count: 1 }, - { data, binding: { ...data.binding }, glyphs: [glyph(data, 1)] }, - ), - ).toThrow(/binding does not belong/); - expect(() => - glyphExample.writeStorage( - storage, - { start: 4, count: 1 }, - { data, binding: data.binding, glyphs: [glyph(data, 1)] }, - ), - ).toThrow(/outside its capacity/); - expect(() => glyphExample.select(glyph(data, font.glyphCount))).toThrow(/unavailable glyph/); - - glyphExample.dispose(data); - } finally { - font.dispose(); - } - }); - - test('rejects paint the package cannot render', async () => { - const { font, data } = await loadEmbedded(); - try { - expect(() => - glyphExample.validatePaint?.({ - paintIndices: Uint16Array.of(0), - palette: [{ color: [1, 1, 1, 1], outline: { color: [0, 0, 0, 1], width: 1 } }], - }), - ).toThrow(/fill color and opacity only/); - expect(() => - glyphExample.validatePaint?.({ paintIndices: Uint16Array.of(0), palette: [{ color: [1, 1, 2, 1] }] }), - ).toThrow(/four finite linear values/); - glyphExample.dispose(data); - } finally { - font.dispose(); - } - }); - test('honors cancellation before decoding and leaves no decoded data', async () => { const baked = await bakeFixture({ artifact: 'embedded', pages: 'embedded' }); const core = baked.execution.outputs.find(({ role }) => role === 'font'); @@ -151,16 +92,63 @@ describe('public external raster proof', () => { ); font.dispose(); }); -}); -async function loadEmbedded(): Promise<{ readonly font: RegisteredFont; readonly data: GlyphExampleData }> { - const baked = await bakeFixture({ artifact: 'embedded', pages: 'embedded' }); - const core = baked.execution.outputs.find(({ role }) => role === 'font'); - assert.ok(core); - const font = await new FontRegistry().registerAsset(await readFile(core.file)); - const raster = await font.loadRaster(rasterSelection(font)); - return { font, data: await glyphExample.decode(font, raster) }; -} + test('publishes and retains external draws through the Rust command buffer', async () => { + const baked = await bakeFixture({ artifact: 'embedded', pages: 'embedded' }); + const core = baked.execution.outputs.find(({ role }) => role === 'font'); + assert.ok(core); + const registry = new FontRegistry(); + const runtime = await createTextRuntime({ + registry, + wasm: await readFile(new URL('../../text/dist/text_shaper.wasm', import.meta.url)), + }); + const font = await runtime.loadFont({ + input: { baked: dataUrl(await readFile(core.file)) }, + raster: { technique: glyphExample, options: { paletteSeed: 7 } }, + }); + const text = new Text({ font, text: 'PUBLIC RASTER', style: { fontSize: 48 } }); + const group = new TextGroup({ renderOrder: 200 }); + group.add(text); + const scene = new THREE.Scene(); + scene.add(group); + scene.updateMatrixWorld(); + + expect(group.error).toBeUndefined(); + const draw = group.children.find((child): child is THREE.Mesh => child instanceof THREE.Mesh); + expect(draw).toBeDefined(); + expect(draw?.renderOrder).toBe(200); + const geometry = draw?.geometry as THREE.InstancedBufferGeometry; + expect(geometry.getAttribute('_pmndrsText_1')).toBeDefined(); + expect(geometry.getAttribute('_pmndrsText_2')).toBeDefined(); + expect(geometry.getAttribute('_pmndrsText_3')).toBeDefined(); + expect(geometry.getAttribute('_pmndrsText_15')).toBeDefined(); + expect(geometry.instanceCount).toBeGreaterThan(0); + const sizes = geometry.getAttribute('_pmndrsText_2'); + const expectedWidth = Math.max(48 * 0.05, 48 * 0.65 - font.data.inset * 48 * 2); + const expectedHeight = Math.max(48 * 0.05, 48 - font.data.inset * 48 * 2); + for (let instance = 0; instance < geometry.instanceCount; instance += 1) { + expect(sizes.getX(instance)).toBeCloseTo(expectedWidth, 5); + expect(sizes.getY(instance)).toBeCloseTo(expectedHeight, 5); + } + const colors = geometry.getAttribute('_pmndrsText_3'); + for (let instance = 0; instance < geometry.instanceCount; instance += 1) { + expect(font.data.colors.some((_, offset) => glyphColorMatches(font.data.colors, offset, colors, instance))).toBe( + true, + ); + } + + text.text = 'PLUGIN UPDATE'; + scene.updateMatrixWorld(); + expect(group.error).toBeUndefined(); + expect(group.children.find((child) => child instanceof THREE.Mesh)).toBe(draw); + expect(draw?.geometry).toBe(geometry); + + group.dispose(); + text.dispose(); + font.dispose(); + runtime.dispose(); + }); +}); /** The baked artifact advertises its own raster key, so the test never reimplements key derivation. */ function rasterSelection(font: RegisteredFont): { readonly rasterKey: RasterKey; readonly kind: 'glyphExample' } { @@ -183,19 +171,21 @@ async function bakeFixture(packaging: { }); } -function glyph(data: GlyphExampleData, glyphId: number): RasterGlyphInput { - return { data, glyphId, fontSize: 16, originX: 0, originY: 12, rasterPixelRatio: 1, paint: paint().palette[0]! }; -} - -function expectClose(actual: readonly number[], expected: readonly number[]): void { - expect(actual).toHaveLength(expected.length); - for (const [index, value] of expected.entries()) expect(actual[index]).toBeCloseTo(value, 6); -} - -function glyphColor(data: GlyphExampleData, glyphId: number): readonly number[] { - return Array.from(data.colors.subarray(glyphId * 4, glyphId * 4 + 4), (value) => value / 255); +function dataUrl(bytes: Uint8Array): string { + return `data:model/gltf-binary;base64,${Buffer.from(bytes).toString('base64')}`; } -function paint(): GlyphPaint { - return { palette: [{ color: [1, 1, 1, 1] }], paintIndices: Uint16Array.of(0) }; +function glyphColorMatches( + records: Uint8Array, + offset: number, + attribute: THREE.BufferAttribute | THREE.InterleavedBufferAttribute, + instance: number, +): boolean { + if (offset % 4 !== 0 || offset > records.length - 4) return false; + return ( + Math.abs(attribute.getX(instance) - records[offset]! / 255) < 1e-6 && + Math.abs(attribute.getY(instance) - records[offset + 1]! / 255) < 1e-6 && + Math.abs(attribute.getZ(instance) - records[offset + 2]! / 255) < 1e-6 && + Math.abs(attribute.getW(instance) - records[offset + 3]! / 255) < 1e-6 + ); } diff --git a/packages/text/package.json b/packages/text/package.json index 834db4a2..5ce13c33 100644 --- a/packages/text/package.json +++ b/packages/text/package.json @@ -14,11 +14,7 @@ "!dist/internal/raster-baker-profile.js" ], "type": "module", - "sideEffects": [ - "./dist/three/bitmap.js", - "./dist/three/msdf.js", - "./dist/three/slug.js" - ], + "sideEffects": false, "exports": { ".": { "types": "./dist/index.d.ts", @@ -44,10 +40,6 @@ "types": "./dist/r3f.d.ts", "import": "./dist/r3f.js" }, - "./typegpu": { - "types": "./dist/typegpu.d.ts", - "import": "./dist/typegpu.js" - }, "./raster/bitmap": { "types": "./dist/raster/bitmap-technique.d.ts", "import": "./dist/raster/bitmap-technique.js" @@ -128,14 +120,12 @@ "oxlint": "1.75.0", "react": "19.2.8", "three": "0.185.1", - "typegpu": "0.11.9", "unicode-property-value-aliases": "3.9.0" }, "peerDependencies": { "@react-three/fiber": ">=10.0.0-alpha.2 <11", "react": ">=19.2 <19.3", - "three": ">=0.185.1", - "typegpu": ">=0.11.9 <0.12" + "three": ">=0.185.1" }, "peerDependenciesMeta": { "@react-three/fiber": { @@ -143,9 +133,6 @@ }, "react": { "optional": true - }, - "typegpu": { - "optional": true } }, "pmndrs": { diff --git a/packages/text/rust/shaper/Cargo.lock b/packages/text/rust/shaper/Cargo.lock index 65171a09..ded0955d 100644 --- a/packages/text/rust/shaper/Cargo.lock +++ b/packages/text/rust/shaper/Cargo.lock @@ -149,6 +149,7 @@ dependencies = [ "serde_json", "talc", "unicode-bidi", + "unicode-segmentation", ] [[package]] @@ -285,6 +286,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + [[package]] name = "zmij" version = "1.0.23" diff --git a/packages/text/rust/shaper/Cargo.toml b/packages/text/rust/shaper/Cargo.toml index 82ef7d70..cb1d63fd 100644 --- a/packages/text/rust/shaper/Cargo.toml +++ b/packages/text/rust/shaper/Cargo.toml @@ -18,12 +18,15 @@ required-features = ["std"] [features] default = ["std"] std = ["serde_json/std"] +kernel-lab = [] +simd128 = [] [dependencies] harfrust = { version = "0.12.0", default-features = false, features = ["libm"] } read-fonts = { version = "0.41.0", default-features = false, features = ["libm"] } serde_json = { version = "1.0.145", default-features = false, features = ["alloc"] } unicode-bidi = { version = "0.3.18", default-features = false } +unicode-segmentation = { version = "=1.13.3", default-features = false, features = ["no_std"] } [build-dependencies] serde_json = "1.0.145" diff --git a/packages/text/rust/shaper/LICENSES/cto-af-linebreak-MIT.txt b/packages/text/rust/shaper/LICENSES/cto-af-linebreak-MIT.txt new file mode 100644 index 00000000..74bf620b --- /dev/null +++ b/packages/text/rust/shaper/LICENSES/cto-af-linebreak-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023-present Joe Hildebrand + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/text/rust/shaper/evidence/kernel-layout-v0.json b/packages/text/rust/shaper/evidence/kernel-layout-v0.json new file mode 100644 index 00000000..9a5ea0c9 --- /dev/null +++ b/packages/text/rust/shaper/evidence/kernel-layout-v0.json @@ -0,0 +1,163 @@ +{ + "schemaVersion": 0, + "generatedBy": "text:kernel-lab + text:kernel-lab-browser", + "recordedAt": "2026-08-08", + "target": { + "machine": "Apple M2 Pro, 10 cores, 32 GiB", + "node": "24.18.0", + "chromium": "149.0.7827.55", + "rust": "1.97.1", + "wasmOptimizer": "binaryen 129.0.0" + }, + "workloads": [ + { + "glyphs": 25515, + "outputSha256": "e79081ca22e1b77d1efa89722e62219f1d956e10086f44c81f4bdf0374f4e2de" + }, + { + "glyphs": 100602, + "outputSha256": "221b06997b4c0ded29a8bd21d42745111400910c95f5baf625a2210db93417f5" + } + ], + "correctness": { + "scalarAutoAndSelectedHashesEqual": true, + "horizontalVerticalAndPartialChunksCovered": true, + "alignedAndFourByteAlignedOutputsEqual": true, + "warmMemoryGrowth": false, + "warmAllocationCalls": 0, + "allocationEvidence": "The measured kernel entry points create only borrowed slices over caller-owned memory and contain no allocation path; the warm memory buffer identity also remains unchanged." + }, + "artifactBytes": { + "kernelLab": { + "scalar": { "raw": 734374, "brotli": 213921 }, + "auto": { "raw": 735101, "brotli": 214366 }, + "selectedHybrid": { "raw": 738559, "brotli": 215017 } + }, + "production": { + "scalar": { "raw": 726102, "gzip": 269893, "brotli": 210951 }, + "simd128": { "raw": 725572, "gzip": 269260, "brotli": 211013 } + } + }, + "optimizedSelectedInstructions": { + "v128Loads": 698, + "v128Stores": 767, + "f32x4Operations": 26, + "i8x16Bitmasks": 3, + "i8x16Shuffles": 35, + "i64x2Operations": 28 + }, + "p95Milliseconds": { + "node": { + "25515": { + "scalar": { + "pack": 0.043354, + "breakMasks": 0.01269, + "bidiMasks": 0.010704, + "policy": 1.174357, + "chunk64": 0.015559 + }, + "auto": { + "pack": 0.013313, + "breakMasks": 0.011764, + "bidiMasks": 0.012434, + "policy": 1.187443, + "chunk64": 0.008818 + }, + "selectedHybrid": { + "pack": 0.013273, + "breakMasks": 0.001506, + "bidiMasks": 0.002633, + "policy": 0.427859, + "chunk64": 0.003832 + } + }, + "100602": { + "scalar": { + "pack": 0.169114, + "breakMasks": 0.049176, + "bidiMasks": 0.04191, + "policy": 4.498917, + "chunk64": 0.064013 + }, + "auto": { + "pack": 0.061651, + "breakMasks": 0.047813, + "bidiMasks": 0.05031, + "policy": 4.622422, + "chunk64": 0.034167 + }, + "selectedHybrid": { + "pack": 0.059286, + "breakMasks": 0.005646, + "bidiMasks": 0.010376, + "policy": 1.698078, + "chunk64": 0.014919 + } + } + }, + "chromium": { + "25515": { + "scalar": { + "pack": 0.04375, + "breakMasks": 0.014063, + "bidiMasks": 0.010938, + "policy": 1.1125, + "chunk64": 0.01875 + }, + "auto": { "pack": 0.01875, "breakMasks": 0.0125, "bidiMasks": 0.014063, "policy": 1.075, "chunk64": 0.009375 }, + "selectedHybrid": { + "pack": 0.01875, + "breakMasks": 0.003125, + "bidiMasks": 0.003125, + "policy": 0.4375, + "chunk64": 0.00625 + } + }, + "100602": { + "scalar": { "pack": 0.175, "breakMasks": 0.053125, "bidiMasks": 0.04375, "policy": 4.35, "chunk64": 0.0625 }, + "auto": { "pack": 0.0625, "breakMasks": 0.04375, "bidiMasks": 0.05, "policy": 4.3, "chunk64": 0.0375 }, + "selectedHybrid": { + "pack": 0.075, + "breakMasks": 0.009375, + "bidiMasks": 0.0125, + "policy": 1.75, + "chunk64": 0.01875 + } + } + } + }, + "endToEndCheckpoint": { + "command": "pnpm scripts run text:layout-benchmark -- --glyphs 22000", + "warmup": 8, + "samples": 31, + "newKernelsWired": false, + "cases": { + "cold": { "glyphs": 25515, "medianMs": 55.05, "p95Ms": 69.71 }, + "fontSize": { "glyphs": 25515, "medianMs": 12.1, "p95Ms": 14.82 }, + "layoutWidth": { "glyphs": 25515, "medianMs": 8.3, "p95Ms": 11.08 }, + "text": { "glyphs": 25507, "medianMs": 38.59, "p95Ms": 41.28 } + }, + "interpretation": "This remains the TypeScript layout pipeline, so the admitted kernels make no end-to-end performance claim yet." + }, + "selection": { + "retainedChunkClusters": 64, + "packing": "compiler-vectorizable straight-line scalar source compiled with +simd128", + "breakAndBidiMasks": "explicit simd128 with scalar tails", + "chunkSummaries": "explicit simd128 integer sums and masks with scalar tails", + "policyExecution": "validated straight-line bytecode dispatched once over four-record simd128 lanes with scalar tails and registration-time output-buffer resolution", + "standardArtifact": "simd128", + "compatibilityDefine": "PMNDRS_TEXT_SHAPER_SIMD=0", + "runtimeDispatch": false, + "rejected": [ + "hand-written four-record packing, because it did not beat compiler vectorization and regressed the 100k Node result", + "compiler auto-vectorization for policy execution, because it remained within scalar variance", + "32-cluster chunks, because their large-workload summary time exceeded 64", + "128-cluster chunks, because their large-workload summary time exceeded 64" + ], + "remainingPacket": [ + "boundary search after retained line cursors and summaries exist", + "representative native SIMD implementation and measurement", + "end-to-end hot-update contribution after the Rust semantic pipeline is wired" + ] + } +} diff --git a/packages/text/rust/shaper/src/abi_contract.rs b/packages/text/rust/shaper/src/abi_contract.rs index c1113dcd..bd845b7e 100644 --- a/packages/text/rust/shaper/src/abi_contract.rs +++ b/packages/text/rust/shaper/src/abi_contract.rs @@ -2,6 +2,56 @@ use alloc::string::{String, ToString}; use core::mem::{align_of, offset_of, size_of}; use serde_json::json; +use crate::engine::frame::{ + ALIGN_CENTER, ALIGN_END, ALIGN_JUSTIFY, ALIGN_START, AXIS_AT_MOST, AXIS_EXACT, + AXIS_UNCONSTRAINED, BASELINE_ALPHABETIC, BASELINE_MIDDLE, BASELINE_TEXT_BOTTOM, + BASELINE_TEXT_TOP, BLOCK_ALIGN_CENTER, BLOCK_ALIGN_END, BLOCK_ALIGN_START, DECORATION_DASHED, + DECORATION_DOTTED, DECORATION_DOUBLE, DECORATION_FLAGS_MASK, DECORATION_LINE_THROUGH, + DECORATION_NONE, DECORATION_OVERLINE, DECORATION_SKIP_INK, DECORATION_SOLID, + DECORATION_UNDERLINE, DECORATION_WAVY, DEFAULT_SESSION_TEXT_CAPACITY, EXCLUSION_WRAP_BOTH, + EXCLUSION_WRAP_INLINE_END, EXCLUSION_WRAP_INLINE_START, EXCLUSION_WRAP_LARGEST, + ORIENTATION_MIXED, ORIENTATION_SIDEWAYS, ORIENTATION_UPRIGHT, OVERFLOW_CLIP, OVERFLOW_ELLIPSIS, + OVERFLOW_VISIBLE, PARAGRAPH_MUTATION_REMOVE, PARAGRAPH_MUTATION_UPSERT, RESULT_FLAG_CHECKPOINT, + SEMANTIC_F32_BLOCK_EXTENT, SEMANTIC_F32_BLOCK_ORIGIN, SEMANTIC_F32_BLOCK_START, + SEMANTIC_F32_FONT_SIZE, SEMANTIC_F32_FOREGROUND_ALPHA, SEMANTIC_F32_FOREGROUND_BLUE, + SEMANTIC_F32_FOREGROUND_GREEN, SEMANTIC_F32_FOREGROUND_RED, SEMANTIC_F32_INLINE_EXTENT, + SEMANTIC_F32_INLINE_ORIGIN, SEMANTIC_F32_INLINE_START, SEMANTIC_F32_INVERSE_FONT_SIZE, + SEMANTIC_F32_RASTER_PIXEL_RATIO, SEMANTIC_U32_CLUSTER_ID, SEMANTIC_U32_FLOW_THREAD_ID, + SEMANTIC_U32_FOREGROUND_RGBA, SEMANTIC_U32_REGION_ID, SEMANTIC_U32_STABLE_GLYPH_ID, + SEMANTIC_U32_TRANSFORM_INDEX, SEMANTIC_VIEW_LAYOUT_INSPECTION, SEMANTIC_VIEW_MASK, + SEMANTIC_VIEW_MEASUREMENT, SHAPE_POLYGON, SHAPE_RECTANGLE, STYLE_FIELD_BASELINE_SHIFT, + STYLE_FIELD_DECORATION, STYLE_FIELD_DIRECTION, STYLE_FIELD_FEATURES, STYLE_FIELD_FONT_SIZE, + STYLE_FIELD_FONT_STACK, STYLE_FIELD_FOREGROUND, STYLE_FIELD_LANGUAGE, + STYLE_FIELD_LETTER_SPACING, STYLE_FIELD_LINE_HEIGHT, STYLE_FIELD_MASK, STYLE_FIELD_MATERIAL, + STYLE_FIELD_RASTER_PIXEL_RATIO, STYLE_FIELD_WORD_SPACING, STYLE_FLAG_ROOT, + STYLE_MUTATION_REMOVE, STYLE_MUTATION_UPSERT, TEXT_ENCODING_UTF16_LE, + TEXT_MUTATION_REPLACE_UTF16, WRAP_CHARACTER, WRAP_NONE, WRAP_WORD, WRITING_HORIZONTAL_TB, + WRITING_VERTICAL_LR, WRITING_VERTICAL_RL, +}; +use crate::engine::policy::{ + ALLOCATION_ORDERED_DIRECT, ALLOCATION_STABLE_INDIRECT, BATCH_CLIP, BATCH_DEPTH, BATCH_MATERIAL, + BATCH_ORDER, BATCH_PROGRAM, BATCH_RESOURCE, BATCH_TECHNIQUE, BATCH_TRANSFORM, + BUFFER_USAGE_COPY_DST, BUFFER_USAGE_STORAGE, BUFFER_USAGE_VERTEX, CAP_ALIAS_VEC2, + CAP_ALIAS_VEC4, CAP_INDIRECT_DRAWS, CAP_ORDERED_DIRECT, CAP_STABLE_INDIRECT, + CAP_STORAGE_BUFFERS, INPUT_GLYPH, INPUT_RESOURCE, INPUT_SEMANTIC, INPUT_STRIKE, OP_ADD_F32, + OP_CONSTANT_F32, OP_CONSTANT_U32, OP_CONVERT_U32_TO_F32, OP_LESS_THAN_F32, OP_LOAD_F32, + OP_LOAD_U32, OP_MULTIPLY_F32, OP_SELECT_F32, OP_STORE_F32, OP_STORE_U16, OP_STORE_U32, + OP_SUBTRACT_F32, ScalarType, +}; +use crate::engine::render_plan::{ + BUFFER_ORDERED_DIRECT, BUFFER_STABLE_INDIRECT, BufferRecord, DiagnosticRecord, DrawRecord, + PATCH_ALLOCATE_OR_RESIZE, PATCH_COPY, PATCH_FILL, PATCH_RETIRE, PATCH_WRITE, + POLICY_BUFFER_ORDER, PRIMITIVE_CLIP, PRIMITIVE_DECORATION, PRIMITIVE_GLYPH, + PRIMITIVE_INLINE_OBJECT, PRIMITIVE_POLICY, PatchRecord, PrimitiveRecord, + RESOURCE_ACTION_CREATE, RESOURCE_ACTION_RETAIN, RESOURCE_ACTION_UPDATE, RETIRE_BUFFER, + RETIRE_OUTPUT_BYTES, RETIRE_RESOURCE, RETIRE_SLOT_RANGE, ResourceRecord, RetirementRecord, +}; +use crate::engine::semantic_view::{ + SEMANTIC_CARET, SEMANTIC_CLUSTER, SEMANTIC_FRAGMENT, SEMANTIC_GLYPH, SEMANTIC_INSERTED_GLYPH, + SEMANTIC_LINE, SEMANTIC_PARAGRAPH_MEASUREMENT, SEMANTIC_RUN, SEMANTIC_SELECTION, + SemanticRecord, +}; + pub const ABI_VERSION: u32 = 0; pub const SHAPER_VERSION: &str = env!("CARGO_PKG_VERSION"); pub const HARFRUST_VERSION: &str = "0.12.0"; @@ -9,93 +59,363 @@ pub const HARFRUST_COMMIT: &str = "60b28ea22b5261710018d69c168a762bcb28794c"; pub const UNICODE_VERSION: &str = "17.0.0"; #[repr(C)] -struct ShapeRequestHeader { - text_offset: u32, - text_length: u32, - runs_offset: u32, - run_count: u32, - features_offset: u32, - feature_count: u32, - languages_offset: u32, - languages_length: u32, +struct PolicyRequestHeader { + byte_length: u32, + capability_sets_offset: u32, + capability_set_count: u32, + programs_offset: u32, + program_count: u32, + buffers_offset: u32, + buffer_count: u32, + operations_offset: u32, + operation_count: u32, + inputs_offset: u32, + input_count: u32, } #[repr(C)] -struct ReshapeRequestHeader { - shape: ShapeRequestHeader, - ranges_offset: u32, - range_count: u32, +struct PolicyCapabilitySetRecord { + id: u32, + flags: u32, + max_buffer_bytes: u32, + update_alignment: u32, + coalesce_gap_bytes: u32, + range_call_penalty_bytes: u32, + max_buffers_per_draw: u16, + max_resources_per_draw: u16, + max_indirect_draws: u16, + fragmentation_budget: u16, + whole_buffer_threshold_basis_points: u16, + reserved: [u16; 3], } #[repr(C)] -struct BidiRequestHeader { - text_offset: u32, - text_length: u32, - direction: u8, - reserved: [u8; 3], +struct PolicyProgramRecord { + technique_id: u32, + program_id: u32, + capability_set_id: u32, + resource_kind_mask: u32, + semantic_view_mask: u32, + storage_key_mask: u32, + paint_capabilities: u32, + compositing_capabilities: u32, + buffer_start: u32, + operation_start: u32, + variant: u16, + buffer_count: u16, + operation_count: u16, + allocation_strategy: u16, + f32_input_count: u8, + u32_input_count: u8, + reserved0: u16, + draw_key_mask: u32, + input_start: u32, + input_count: u16, + reserved1: u16, } #[repr(C)] -struct FeatureRecord { - tag: u32, - value: u32, - start: u32, - end: u32, +struct PolicyInputRecord { + scope: u8, + field: u8, + reserved: u16, +} + +#[repr(C)] +struct FontBindingRequestHeader { + abi_version: u32, + byte_length: u32, + technique_id: u32, + program_variant: u16, + reserved0: u16, + glyph_count: u32, + strike_count: u32, + resource_count: u32, + glyph_f32_field_count: u8, + glyph_u32_field_count: u8, + strike_f32_field_count: u8, + strike_u32_field_count: u8, + resource_f32_field_count: u8, + resource_u32_field_count: u8, + reserved1: u16, + strikes_offset: u32, + resources_offset: u32, + resource_indices_offset: u32, + glyph_f32_offset: u32, + glyph_u32_offset: u32, + strike_f32_offset: u32, + strike_u32_offset: u32, + resource_f32_offset: u32, + resource_u32_offset: u32, + reserved2: u32, +} + +#[repr(C)] +struct FontBindingStrikeRecord { + ppem: u32, + reserved: u32, +} + +#[repr(C)] +struct FontBindingResourceRecord { + id: u32, + generation: u32, + kind: u16, + reserved: u16, + reference: u32, +} + +#[repr(C)] +struct PolicyBufferRecord { + id: u16, + scalar: u8, + vector_width: u8, + alignment: u16, + stride: u16, + usage: u32, + capacity_class: u16, + reserved0: u16, +} + +#[repr(C)] +struct PolicyOperationRecord { + opcode: u8, + target: u8, + operand0: u8, + operand1: u8, + immediate0: u32, + immediate1: u32, + immediate2: u32, +} + +#[repr(C)] +struct EngineUpdateRequestHeader { + abi_version: u32, + byte_length: u32, + session_id: u32, + expected_engine_revision: u32, + consumed_plan_revision: u32, + acknowledged_publication_generation: u32, + policy_handle: u32, + capability_set: u32, + flags: u32, + semantic_view_mask: u32, + max_clusters: u32, + max_lines: u32, + max_regions: u32, + max_exclusions: u32, + max_inline_objects: u32, + max_slots_per_band: u32, + max_output_bytes: u32, + text_mutations_offset: u32, + text_mutation_count: u32, + style_mutations_offset: u32, + style_mutation_count: u32, + constraints_offset: u32, + constraint_count: u32, + regions_offset: u32, + region_count: u32, + exclusions_offset: u32, + exclusion_count: u32, + inline_objects_offset: u32, + inline_object_count: u32, + policy_parameters_offset: u32, + policy_parameters_length: u32, + max_paragraphs: u32, + paragraph_mutations_offset: u32, + paragraph_mutation_count: u32, +} + +#[repr(C)] +struct EngineParagraphMutationRecord { + opcode: u8, + flags: u8, + reserved0: u16, + paragraph_id: u32, + order: u32, +} + +#[repr(C)] +struct EngineTextMutationRecord { + opcode: u8, + encoding: u8, + reserved0: u16, + text_start: u32, + delete_count: u32, + insert_offset: u32, + insert_count: u32, + paragraph_id: u32, } #[repr(C)] -struct RunRecord { - font_handle: u32, +struct EngineStyleMutationRecord { + opcode: u8, + direction: u8, + decoration_style: u8, + flags: u8, + style_id: u32, + cascade_order: u32, + field_mask: u32, text_start: u32, text_end: u32, - script: u32, + font_stack_handle: u32, + material_id: u32, language_offset: u32, - feature_start: u32, + language_length: u16, feature_count: u16, - direction: u8, - cluster_level: u8, - flags: u32, + features_offset: u32, + font_size: f32, + line_height: f32, + letter_spacing: f32, + word_spacing: f32, + baseline_shift: f32, + raster_pixel_ratio: f32, + foreground_rgba: u32, + decoration_rgba: u32, + decoration_flags: u32, + decoration_thickness: f32, + decoration_offset: f32, + paragraph_id: u32, } #[repr(C)] -struct ReshapeRangeRecord { - run: u32, - item_start: u32, - item_end: u32, - context_start: u32, - context_end: u32, - flags: u32, +struct EngineConstraintRecord { + flow_thread_id: u32, + geometry_revision: u32, + width: f32, + height: f32, + viewport_block_start: f32, + viewport_block_end: f32, + resume_block_offset: f32, + max_lines: u32, + region_start: u32, + resume_cluster: u32, + region_count: u16, + resume_region: u16, + width_mode: u8, + height_mode: u8, + wrap: u8, + align: u8, + overflow: u8, + block_align: u8, + flags: u16, + paragraph_id: u32, } #[repr(C)] -struct ResultHeader { - byte_length: u32, - font_handles_offset: u32, - font_handle_count: u32, - run_font_slots_offset: u32, - run_glyph_starts_offset: u32, - run_glyph_counts_offset: u32, - run_count: u32, - glyph_ids_offset: u32, - clusters_offset: u32, - x_advances_offset: u32, - y_advances_offset: u32, - x_offsets_offset: u32, - y_offsets_offset: u32, - glyph_flags_offset: u32, - glyph_count: u32, +struct EngineFlowVertexRecord { + inline: f32, + block: f32, } #[repr(C)] -struct BidiResultHeader { +struct EngineRegionRecord { + id: u32, + geometry_revision: u32, + transform_index: u32, + vertices_offset: u32, + vertex_count: u16, + exclusion_start: u16, + exclusion_count: u16, + flags: u16, + shape: u8, + writing_mode: u8, + text_orientation: u8, + reserved0: u8, + inline_start: f32, + block_start: f32, + inline_end: f32, + block_end: f32, + clip_inline_start: f32, + clip_block_start: f32, + clip_inline_end: f32, + clip_block_end: f32, +} + +#[repr(C)] +struct EngineExclusionRecord { + id: u32, + region_id: u32, + geometry_revision: u32, + vertices_offset: u32, + vertex_count: u16, + flags: u16, + shape: u8, + wrap_side: u8, + reserved0: u16, + inline_start: f32, + block_start: f32, + inline_end: f32, + block_end: f32, + margin_inline: f32, + margin_block: f32, +} + +#[repr(C)] +struct EngineInlineObjectRecord { + id: u32, + content_revision: u32, + text_offset: u32, + material_id: u32, + resource_id: u32, + resource_generation: u32, + inline_extent: f32, + block_extent: f32, + baseline_offset: f32, + margin_inline_start: f32, + margin_inline_end: f32, + margin_block_start: f32, + margin_block_end: f32, + baseline_alignment: u8, + flags: u8, + reserved0: u16, + paragraph_id: u32, +} + +#[repr(C, align(16))] +struct EngineResultHeader { + abi_version: u32, byte_length: u32, - levels_offset: u32, - classes_offset: u32, - text_length: u32, - paragraph_starts_offset: u32, - paragraph_ends_offset: u32, - paragraph_levels_offset: u32, - paragraph_count: u32, + status: u32, + flags: u32, + session_id: u32, + engine_revision: u32, + plan_revision: u32, + required_base_revision: u32, + publication_generation: u32, + output_slot: u32, + request_capacity: u32, + required_request_capacity: u32, + result_capacity: u32, + required_result_capacity: u32, + policy_handle: u32, + capability_set: u32, + policy_fingerprint_low: u32, + policy_fingerprint_high: u32, + semantic_views_offset: u32, + semantic_view_count: u32, + resources_offset: u32, + resource_count: u32, + buffers_offset: u32, + buffer_count: u32, + patches_offset: u32, + patch_count: u32, + primitives_offset: u32, + primitive_count: u32, + draws_offset: u32, + draw_count: u32, + retirements_offset: u32, + retirement_count: u32, + diagnostics_offset: u32, + diagnostic_count: u32, +} + +#[repr(C)] +struct FeatureRecord { + tag: u32, + value: u32, + start: u32, + end: u32, } macro_rules! layout { @@ -106,33 +426,129 @@ macro_rules! layout { } layout!( - SHAPE_REQUEST_HEADER_SIZE, - SHAPE_REQUEST_HEADER_ALIGNMENT, - ShapeRequestHeader + POLICY_REQUEST_HEADER_SIZE, + POLICY_REQUEST_HEADER_ALIGNMENT, + PolicyRequestHeader ); layout!( - RESHAPE_REQUEST_HEADER_SIZE, - RESHAPE_REQUEST_HEADER_ALIGNMENT, - ReshapeRequestHeader + POLICY_CAPABILITY_SET_RECORD_SIZE, + POLICY_CAPABILITY_SET_RECORD_ALIGNMENT, + PolicyCapabilitySetRecord ); layout!( - BIDI_REQUEST_HEADER_SIZE, - BIDI_REQUEST_HEADER_ALIGNMENT, - BidiRequestHeader + POLICY_PROGRAM_RECORD_SIZE, + POLICY_PROGRAM_RECORD_ALIGNMENT, + PolicyProgramRecord ); -layout!(FEATURE_RECORD_SIZE, FEATURE_RECORD_ALIGNMENT, FeatureRecord); -layout!(RUN_RECORD_SIZE, RUN_RECORD_ALIGNMENT, RunRecord); layout!( - RESHAPE_RANGE_RECORD_SIZE, - RESHAPE_RANGE_RECORD_ALIGNMENT, - ReshapeRangeRecord + POLICY_BUFFER_RECORD_SIZE, + POLICY_BUFFER_RECORD_ALIGNMENT, + PolicyBufferRecord +); +layout!( + POLICY_OPERATION_RECORD_SIZE, + POLICY_OPERATION_RECORD_ALIGNMENT, + PolicyOperationRecord +); +layout!( + POLICY_INPUT_RECORD_SIZE, + POLICY_INPUT_RECORD_ALIGNMENT, + PolicyInputRecord +); +layout!( + FONT_BINDING_REQUEST_HEADER_SIZE, + FONT_BINDING_REQUEST_HEADER_ALIGNMENT, + FontBindingRequestHeader +); +layout!( + FONT_BINDING_STRIKE_RECORD_SIZE, + FONT_BINDING_STRIKE_RECORD_ALIGNMENT, + FontBindingStrikeRecord +); +layout!( + FONT_BINDING_RESOURCE_RECORD_SIZE, + FONT_BINDING_RESOURCE_RECORD_ALIGNMENT, + FontBindingResourceRecord +); +layout!( + ENGINE_UPDATE_REQUEST_HEADER_SIZE, + ENGINE_UPDATE_REQUEST_HEADER_ALIGNMENT, + EngineUpdateRequestHeader +); +layout!( + ENGINE_PARAGRAPH_MUTATION_RECORD_SIZE, + ENGINE_PARAGRAPH_MUTATION_RECORD_ALIGNMENT, + EngineParagraphMutationRecord +); +layout!( + ENGINE_TEXT_MUTATION_RECORD_SIZE, + ENGINE_TEXT_MUTATION_RECORD_ALIGNMENT, + EngineTextMutationRecord +); +layout!( + ENGINE_STYLE_MUTATION_RECORD_SIZE, + ENGINE_STYLE_MUTATION_RECORD_ALIGNMENT, + EngineStyleMutationRecord +); +layout!( + ENGINE_CONSTRAINT_RECORD_SIZE, + ENGINE_CONSTRAINT_RECORD_ALIGNMENT, + EngineConstraintRecord +); +layout!( + ENGINE_FLOW_VERTEX_RECORD_SIZE, + ENGINE_FLOW_VERTEX_RECORD_ALIGNMENT, + EngineFlowVertexRecord +); +layout!( + ENGINE_REGION_RECORD_SIZE, + ENGINE_REGION_RECORD_ALIGNMENT, + EngineRegionRecord +); +layout!( + ENGINE_EXCLUSION_RECORD_SIZE, + ENGINE_EXCLUSION_RECORD_ALIGNMENT, + EngineExclusionRecord +); +layout!( + ENGINE_INLINE_OBJECT_RECORD_SIZE, + ENGINE_INLINE_OBJECT_RECORD_ALIGNMENT, + EngineInlineObjectRecord +); +layout!( + ENGINE_RESULT_HEADER_SIZE, + ENGINE_RESULT_HEADER_ALIGNMENT, + EngineResultHeader +); +layout!( + SEMANTIC_RECORD_SIZE, + SEMANTIC_RECORD_ALIGNMENT, + SemanticRecord +); +layout!( + RESOURCE_RECORD_SIZE, + RESOURCE_RECORD_ALIGNMENT, + ResourceRecord +); +layout!(BUFFER_RECORD_SIZE, BUFFER_RECORD_ALIGNMENT, BufferRecord); +layout!(PATCH_RECORD_SIZE, PATCH_RECORD_ALIGNMENT, PatchRecord); +layout!( + PRIMITIVE_RECORD_SIZE, + PRIMITIVE_RECORD_ALIGNMENT, + PrimitiveRecord +); +layout!(DRAW_RECORD_SIZE, DRAW_RECORD_ALIGNMENT, DrawRecord); +layout!( + RETIREMENT_RECORD_SIZE, + RETIREMENT_RECORD_ALIGNMENT, + RetirementRecord ); -layout!(RESULT_HEADER_SIZE, RESULT_HEADER_ALIGNMENT, ResultHeader); layout!( - BIDI_RESULT_HEADER_SIZE, - BIDI_RESULT_HEADER_ALIGNMENT, - BidiResultHeader + DIAGNOSTIC_RECORD_SIZE, + DIAGNOSTIC_RECORD_ALIGNMENT, + DiagnosticRecord ); +layout!(FEATURE_RECORD_SIZE, FEATURE_RECORD_ALIGNMENT, FeatureRecord); macro_rules! field_offset { ($name:ident, $type:ty, $field:ident) => { @@ -140,221 +556,1974 @@ macro_rules! field_offset { }; } -field_offset!(SHAPE_TEXT_OFFSET, ShapeRequestHeader, text_offset); -field_offset!(SHAPE_TEXT_LENGTH, ShapeRequestHeader, text_length); -field_offset!(SHAPE_RUNS_OFFSET, ShapeRequestHeader, runs_offset); -field_offset!(SHAPE_RUN_COUNT, ShapeRequestHeader, run_count); -field_offset!(SHAPE_FEATURES_OFFSET, ShapeRequestHeader, features_offset); -field_offset!(SHAPE_FEATURE_COUNT, ShapeRequestHeader, feature_count); -field_offset!(SHAPE_LANGUAGES_OFFSET, ShapeRequestHeader, languages_offset); -field_offset!(SHAPE_LANGUAGES_LENGTH, ShapeRequestHeader, languages_length); -field_offset!(RESHAPE_RANGES_OFFSET, ReshapeRequestHeader, ranges_offset); -field_offset!(RESHAPE_RANGE_COUNT, ReshapeRequestHeader, range_count); -field_offset!(BIDI_TEXT_OFFSET, BidiRequestHeader, text_offset); -field_offset!(BIDI_TEXT_LENGTH, BidiRequestHeader, text_length); -field_offset!(BIDI_DIRECTION, BidiRequestHeader, direction); -field_offset!(FEATURE_TAG, FeatureRecord, tag); -field_offset!(FEATURE_VALUE, FeatureRecord, value); -field_offset!(FEATURE_START, FeatureRecord, start); -field_offset!(FEATURE_END, FeatureRecord, end); -field_offset!(RUN_FONT_HANDLE, RunRecord, font_handle); -field_offset!(RUN_TEXT_START, RunRecord, text_start); -field_offset!(RUN_TEXT_END, RunRecord, text_end); -field_offset!(RUN_SCRIPT, RunRecord, script); -field_offset!(RUN_LANGUAGE_OFFSET, RunRecord, language_offset); -field_offset!(RUN_FEATURE_START, RunRecord, feature_start); -field_offset!(RUN_FEATURE_COUNT, RunRecord, feature_count); -field_offset!(RUN_DIRECTION, RunRecord, direction); -field_offset!(RUN_CLUSTER_LEVEL, RunRecord, cluster_level); -field_offset!(RUN_FLAGS, RunRecord, flags); -field_offset!(RANGE_RUN, ReshapeRangeRecord, run); -field_offset!(RANGE_ITEM_START, ReshapeRangeRecord, item_start); -field_offset!(RANGE_ITEM_END, ReshapeRangeRecord, item_end); -field_offset!(RANGE_CONTEXT_START, ReshapeRangeRecord, context_start); -field_offset!(RANGE_CONTEXT_END, ReshapeRangeRecord, context_end); -field_offset!(RANGE_FLAGS, ReshapeRangeRecord, flags); -field_offset!(RESULT_BYTE_LENGTH, ResultHeader, byte_length); -field_offset!( - RESULT_FONT_HANDLES_OFFSET, - ResultHeader, - font_handles_offset -); -field_offset!(RESULT_FONT_HANDLE_COUNT, ResultHeader, font_handle_count); -field_offset!( - RESULT_RUN_FONT_SLOTS_OFFSET, - ResultHeader, - run_font_slots_offset -); -field_offset!( - RESULT_RUN_GLYPH_STARTS_OFFSET, - ResultHeader, - run_glyph_starts_offset -); -field_offset!( - RESULT_RUN_GLYPH_COUNTS_OFFSET, - ResultHeader, - run_glyph_counts_offset -); -field_offset!(RESULT_RUN_COUNT, ResultHeader, run_count); -field_offset!(RESULT_GLYPH_IDS_OFFSET, ResultHeader, glyph_ids_offset); -field_offset!(RESULT_CLUSTERS_OFFSET, ResultHeader, clusters_offset); -field_offset!(RESULT_X_ADVANCES_OFFSET, ResultHeader, x_advances_offset); -field_offset!(RESULT_Y_ADVANCES_OFFSET, ResultHeader, y_advances_offset); -field_offset!(RESULT_X_OFFSETS_OFFSET, ResultHeader, x_offsets_offset); -field_offset!(RESULT_Y_OFFSETS_OFFSET, ResultHeader, y_offsets_offset); -field_offset!(RESULT_GLYPH_FLAGS_OFFSET, ResultHeader, glyph_flags_offset); -field_offset!(RESULT_GLYPH_COUNT, ResultHeader, glyph_count); -field_offset!(BIDI_RESULT_BYTE_LENGTH, BidiResultHeader, byte_length); -field_offset!(BIDI_RESULT_LEVELS_OFFSET, BidiResultHeader, levels_offset); -field_offset!(BIDI_RESULT_CLASSES_OFFSET, BidiResultHeader, classes_offset); -field_offset!(BIDI_RESULT_TEXT_LENGTH, BidiResultHeader, text_length); -field_offset!( - BIDI_RESULT_PARAGRAPH_STARTS_OFFSET, - BidiResultHeader, - paragraph_starts_offset -); -field_offset!( - BIDI_RESULT_PARAGRAPH_ENDS_OFFSET, - BidiResultHeader, - paragraph_ends_offset -); -field_offset!( - BIDI_RESULT_PARAGRAPH_LEVELS_OFFSET, - BidiResultHeader, - paragraph_levels_offset -); -field_offset!( - BIDI_RESULT_PARAGRAPH_COUNT, - BidiResultHeader, - paragraph_count +field_offset!(POLICY_BYTE_LENGTH, PolicyRequestHeader, byte_length); +field_offset!( + POLICY_CAPABILITY_SETS_OFFSET, + PolicyRequestHeader, + capability_sets_offset ); - -pub fn json() -> String { - json!({ - "name": "pmndrs-text-shaper", - "version": ABI_VERSION, - "endianness": "little", - "pointerWidth": 32, - "memory": "memory", - "versions": { - "shaper": SHAPER_VERSION, - "harfrust": HARFRUST_VERSION, - "harfrustCommit": HARFRUST_COMMIT, - "unicode": UNICODE_VERSION, - "fontFormat": 0 - }, - "functions": { - "allocate": "pmndrs_text_shaper_alloc", - "deallocate": "pmndrs_text_shaper_dealloc", - "registerFont": "pmndrs_text_shaper_register_font", - "disposeFont": "pmndrs_text_shaper_dispose_font", - "fontCount": "pmndrs_text_shaper_font_count", - "retainedFontBytes": "pmndrs_text_shaper_retained_font_bytes", - "planCount": "pmndrs_text_shaper_plan_count", - "shapeBatch": "pmndrs_text_shaper_shape_batch", - "reshapeRanges": "pmndrs_text_shaper_reshape_ranges", - "analyzeBidi": "pmndrs_text_shaper_analyze_bidi", - "resultPointer": "pmndrs_text_shaper_result_ptr", - "resultLength": "pmndrs_text_shaper_result_len" - }, - "layouts": { - "shapeRequest": { - "size": SHAPE_REQUEST_HEADER_SIZE, - "alignment": SHAPE_REQUEST_HEADER_ALIGNMENT, - "textOffset": SHAPE_TEXT_OFFSET, - "textLength": SHAPE_TEXT_LENGTH, - "runsOffset": SHAPE_RUNS_OFFSET, - "runCount": SHAPE_RUN_COUNT, - "featuresOffset": SHAPE_FEATURES_OFFSET, - "featureCount": SHAPE_FEATURE_COUNT, - "languagesOffset": SHAPE_LANGUAGES_OFFSET, - "languagesLength": SHAPE_LANGUAGES_LENGTH - }, - "reshapeRequest": { - "size": RESHAPE_REQUEST_HEADER_SIZE, - "alignment": RESHAPE_REQUEST_HEADER_ALIGNMENT, - "rangesOffset": RESHAPE_RANGES_OFFSET, - "rangeCount": RESHAPE_RANGE_COUNT - }, - "bidiRequest": { - "size": BIDI_REQUEST_HEADER_SIZE, - "alignment": BIDI_REQUEST_HEADER_ALIGNMENT, - "textOffset": BIDI_TEXT_OFFSET, - "textLength": BIDI_TEXT_LENGTH, - "direction": BIDI_DIRECTION - }, - "feature": { - "size": FEATURE_RECORD_SIZE, - "alignment": FEATURE_RECORD_ALIGNMENT, - "tag": FEATURE_TAG, - "value": FEATURE_VALUE, - "start": FEATURE_START, - "end": FEATURE_END - }, - "run": { - "size": RUN_RECORD_SIZE, - "alignment": RUN_RECORD_ALIGNMENT, - "fontHandle": RUN_FONT_HANDLE, - "textStart": RUN_TEXT_START, - "textEnd": RUN_TEXT_END, - "script": RUN_SCRIPT, - "languageOffset": RUN_LANGUAGE_OFFSET, - "featureStart": RUN_FEATURE_START, - "featureCount": RUN_FEATURE_COUNT, - "direction": RUN_DIRECTION, - "clusterLevel": RUN_CLUSTER_LEVEL, - "flags": RUN_FLAGS - }, - "reshapeRange": { - "size": RESHAPE_RANGE_RECORD_SIZE, - "alignment": RESHAPE_RANGE_RECORD_ALIGNMENT, - "run": RANGE_RUN, - "itemStart": RANGE_ITEM_START, - "itemEnd": RANGE_ITEM_END, - "contextStart": RANGE_CONTEXT_START, - "contextEnd": RANGE_CONTEXT_END, - "flags": RANGE_FLAGS - }, - "result": { - "size": RESULT_HEADER_SIZE, - "alignment": RESULT_HEADER_ALIGNMENT, - "byteLength": RESULT_BYTE_LENGTH, - "fontHandlesOffset": RESULT_FONT_HANDLES_OFFSET, - "fontHandleCount": RESULT_FONT_HANDLE_COUNT, - "runFontSlotsOffset": RESULT_RUN_FONT_SLOTS_OFFSET, - "runGlyphStartsOffset": RESULT_RUN_GLYPH_STARTS_OFFSET, - "runGlyphCountsOffset": RESULT_RUN_GLYPH_COUNTS_OFFSET, - "runCount": RESULT_RUN_COUNT, - "glyphIdsOffset": RESULT_GLYPH_IDS_OFFSET, - "clustersOffset": RESULT_CLUSTERS_OFFSET, - "xAdvancesOffset": RESULT_X_ADVANCES_OFFSET, - "yAdvancesOffset": RESULT_Y_ADVANCES_OFFSET, - "xOffsetsOffset": RESULT_X_OFFSETS_OFFSET, - "yOffsetsOffset": RESULT_Y_OFFSETS_OFFSET, - "glyphFlagsOffset": RESULT_GLYPH_FLAGS_OFFSET, - "glyphCount": RESULT_GLYPH_COUNT - }, - "bidiResult": { - "size": BIDI_RESULT_HEADER_SIZE, - "alignment": BIDI_RESULT_HEADER_ALIGNMENT, - "byteLength": BIDI_RESULT_BYTE_LENGTH, - "levelsOffset": BIDI_RESULT_LEVELS_OFFSET, - "classesOffset": BIDI_RESULT_CLASSES_OFFSET, - "textLength": BIDI_RESULT_TEXT_LENGTH, - "paragraphStartsOffset": BIDI_RESULT_PARAGRAPH_STARTS_OFFSET, - "paragraphEndsOffset": BIDI_RESULT_PARAGRAPH_ENDS_OFFSET, - "paragraphLevelsOffset": BIDI_RESULT_PARAGRAPH_LEVELS_OFFSET, - "paragraphCount": BIDI_RESULT_PARAGRAPH_COUNT - } - }, - "bidi": { - "directions": { "auto": 0, "ltr": 1, "rtl": 2 }, - "classes": { - "L": 0, "R": 1, "AL": 2, "EN": 3, "ES": 4, "ET": 5, - "AN": 6, "CS": 7, "NSM": 8, "BN": 9, "B": 10, "S": 11, - "WS": 12, "ON": 13, "LRE": 14, "LRO": 15, "RLE": 16, - "RLO": 17, "PDF": 18, "LRI": 19, "RLI": 20, "FSI": 21, - "PDI": 22 +field_offset!( + POLICY_CAPABILITY_SET_COUNT, + PolicyRequestHeader, + capability_set_count +); +field_offset!(POLICY_PROGRAMS_OFFSET, PolicyRequestHeader, programs_offset); +field_offset!(POLICY_PROGRAM_COUNT, PolicyRequestHeader, program_count); +field_offset!(POLICY_BUFFERS_OFFSET, PolicyRequestHeader, buffers_offset); +field_offset!(POLICY_BUFFER_COUNT, PolicyRequestHeader, buffer_count); +field_offset!( + POLICY_OPERATIONS_OFFSET, + PolicyRequestHeader, + operations_offset +); +field_offset!(POLICY_OPERATION_COUNT, PolicyRequestHeader, operation_count); +field_offset!(POLICY_INPUTS_OFFSET, PolicyRequestHeader, inputs_offset); +field_offset!(POLICY_INPUT_COUNT, PolicyRequestHeader, input_count); +field_offset!(POLICY_CAPABILITY_SET_ID, PolicyCapabilitySetRecord, id); +field_offset!( + POLICY_CAPABILITY_SET_FLAGS, + PolicyCapabilitySetRecord, + flags +); +field_offset!( + POLICY_CAPABILITY_SET_MAX_BUFFER_BYTES, + PolicyCapabilitySetRecord, + max_buffer_bytes +); +field_offset!( + POLICY_CAPABILITY_SET_UPDATE_ALIGNMENT, + PolicyCapabilitySetRecord, + update_alignment +); +field_offset!( + POLICY_CAPABILITY_SET_COALESCE_GAP_BYTES, + PolicyCapabilitySetRecord, + coalesce_gap_bytes +); +field_offset!( + POLICY_CAPABILITY_SET_RANGE_CALL_PENALTY_BYTES, + PolicyCapabilitySetRecord, + range_call_penalty_bytes +); +field_offset!( + POLICY_CAPABILITY_SET_MAX_BUFFERS_PER_DRAW, + PolicyCapabilitySetRecord, + max_buffers_per_draw +); +field_offset!( + POLICY_CAPABILITY_SET_MAX_RESOURCES_PER_DRAW, + PolicyCapabilitySetRecord, + max_resources_per_draw +); +field_offset!( + POLICY_CAPABILITY_SET_MAX_INDIRECT_DRAWS, + PolicyCapabilitySetRecord, + max_indirect_draws +); +field_offset!( + POLICY_CAPABILITY_SET_FRAGMENTATION_BUDGET, + PolicyCapabilitySetRecord, + fragmentation_budget +); +field_offset!( + POLICY_CAPABILITY_SET_WHOLE_BUFFER_THRESHOLD_BASIS_POINTS, + PolicyCapabilitySetRecord, + whole_buffer_threshold_basis_points +); +field_offset!( + POLICY_CAPABILITY_SET_RESERVED, + PolicyCapabilitySetRecord, + reserved +); +field_offset!( + POLICY_PROGRAM_TECHNIQUE_ID, + PolicyProgramRecord, + technique_id +); +field_offset!(POLICY_PROGRAM_ID, PolicyProgramRecord, program_id); +field_offset!( + POLICY_PROGRAM_CAPABILITY_SET_ID, + PolicyProgramRecord, + capability_set_id +); +field_offset!( + POLICY_PROGRAM_RESOURCE_KIND_MASK, + PolicyProgramRecord, + resource_kind_mask +); +field_offset!( + POLICY_PROGRAM_SEMANTIC_VIEW_MASK, + PolicyProgramRecord, + semantic_view_mask +); +field_offset!( + POLICY_PROGRAM_STORAGE_KEY_MASK, + PolicyProgramRecord, + storage_key_mask +); +field_offset!(POLICY_PROGRAM_VARIANT, PolicyProgramRecord, variant); +field_offset!( + POLICY_PROGRAM_F32_INPUT_COUNT, + PolicyProgramRecord, + f32_input_count +); +field_offset!( + POLICY_PROGRAM_U32_INPUT_COUNT, + PolicyProgramRecord, + u32_input_count +); +field_offset!( + POLICY_PROGRAM_PAINT_CAPABILITIES, + PolicyProgramRecord, + paint_capabilities +); +field_offset!( + POLICY_PROGRAM_COMPOSITING_CAPABILITIES, + PolicyProgramRecord, + compositing_capabilities +); +field_offset!( + POLICY_PROGRAM_BUFFER_START, + PolicyProgramRecord, + buffer_start +); +field_offset!( + POLICY_PROGRAM_BUFFER_COUNT, + PolicyProgramRecord, + buffer_count +); +field_offset!(POLICY_PROGRAM_RESERVED0, PolicyProgramRecord, reserved0); +field_offset!( + POLICY_PROGRAM_OPERATION_START, + PolicyProgramRecord, + operation_start +); +field_offset!( + POLICY_PROGRAM_OPERATION_COUNT, + PolicyProgramRecord, + operation_count +); +field_offset!( + POLICY_PROGRAM_ALLOCATION_STRATEGY, + PolicyProgramRecord, + allocation_strategy +); +field_offset!( + POLICY_PROGRAM_DRAW_KEY_MASK, + PolicyProgramRecord, + draw_key_mask +); +field_offset!(POLICY_PROGRAM_INPUT_START, PolicyProgramRecord, input_start); +field_offset!(POLICY_PROGRAM_INPUT_COUNT, PolicyProgramRecord, input_count); +field_offset!(POLICY_PROGRAM_RESERVED1, PolicyProgramRecord, reserved1); +field_offset!(POLICY_BUFFER_ID, PolicyBufferRecord, id); +field_offset!(POLICY_BUFFER_SCALAR, PolicyBufferRecord, scalar); +field_offset!(POLICY_BUFFER_VECTOR_WIDTH, PolicyBufferRecord, vector_width); +field_offset!(POLICY_BUFFER_ALIGNMENT, PolicyBufferRecord, alignment); +field_offset!(POLICY_BUFFER_STRIDE, PolicyBufferRecord, stride); +field_offset!(POLICY_BUFFER_USAGE, PolicyBufferRecord, usage); +field_offset!( + POLICY_BUFFER_CAPACITY_CLASS, + PolicyBufferRecord, + capacity_class +); +field_offset!(POLICY_BUFFER_RESERVED0, PolicyBufferRecord, reserved0); +field_offset!(POLICY_OPERATION_OPCODE, PolicyOperationRecord, opcode); +field_offset!(POLICY_OPERATION_TARGET, PolicyOperationRecord, target); +field_offset!(POLICY_INPUT_SCOPE, PolicyInputRecord, scope); +field_offset!(POLICY_INPUT_FIELD, PolicyInputRecord, field); +field_offset!(POLICY_INPUT_RESERVED, PolicyInputRecord, reserved); +field_offset!( + FONT_BINDING_ABI_VERSION, + FontBindingRequestHeader, + abi_version +); +field_offset!( + FONT_BINDING_BYTE_LENGTH, + FontBindingRequestHeader, + byte_length +); +field_offset!( + FONT_BINDING_TECHNIQUE_ID, + FontBindingRequestHeader, + technique_id +); +field_offset!( + FONT_BINDING_PROGRAM_VARIANT, + FontBindingRequestHeader, + program_variant +); +field_offset!(FONT_BINDING_RESERVED0, FontBindingRequestHeader, reserved0); +field_offset!( + FONT_BINDING_GLYPH_COUNT, + FontBindingRequestHeader, + glyph_count +); +field_offset!( + FONT_BINDING_STRIKE_COUNT, + FontBindingRequestHeader, + strike_count +); +field_offset!( + FONT_BINDING_RESOURCE_COUNT, + FontBindingRequestHeader, + resource_count +); +field_offset!( + FONT_BINDING_GLYPH_F32_FIELD_COUNT, + FontBindingRequestHeader, + glyph_f32_field_count +); +field_offset!( + FONT_BINDING_GLYPH_U32_FIELD_COUNT, + FontBindingRequestHeader, + glyph_u32_field_count +); +field_offset!( + FONT_BINDING_STRIKE_F32_FIELD_COUNT, + FontBindingRequestHeader, + strike_f32_field_count +); +field_offset!( + FONT_BINDING_STRIKE_U32_FIELD_COUNT, + FontBindingRequestHeader, + strike_u32_field_count +); +field_offset!( + FONT_BINDING_RESOURCE_F32_FIELD_COUNT, + FontBindingRequestHeader, + resource_f32_field_count +); +field_offset!( + FONT_BINDING_RESOURCE_U32_FIELD_COUNT, + FontBindingRequestHeader, + resource_u32_field_count +); +field_offset!(FONT_BINDING_RESERVED1, FontBindingRequestHeader, reserved1); +field_offset!( + FONT_BINDING_STRIKES_OFFSET, + FontBindingRequestHeader, + strikes_offset +); +field_offset!( + FONT_BINDING_RESOURCES_OFFSET, + FontBindingRequestHeader, + resources_offset +); +field_offset!( + FONT_BINDING_RESOURCE_INDICES_OFFSET, + FontBindingRequestHeader, + resource_indices_offset +); +field_offset!( + FONT_BINDING_GLYPH_F32_OFFSET, + FontBindingRequestHeader, + glyph_f32_offset +); +field_offset!( + FONT_BINDING_GLYPH_U32_OFFSET, + FontBindingRequestHeader, + glyph_u32_offset +); +field_offset!( + FONT_BINDING_STRIKE_F32_OFFSET, + FontBindingRequestHeader, + strike_f32_offset +); +field_offset!( + FONT_BINDING_STRIKE_U32_OFFSET, + FontBindingRequestHeader, + strike_u32_offset +); +field_offset!( + FONT_BINDING_RESOURCE_F32_OFFSET, + FontBindingRequestHeader, + resource_f32_offset +); +field_offset!( + FONT_BINDING_RESOURCE_U32_OFFSET, + FontBindingRequestHeader, + resource_u32_offset +); +field_offset!(FONT_BINDING_RESERVED2, FontBindingRequestHeader, reserved2); +field_offset!(FONT_BINDING_STRIKE_PPEM, FontBindingStrikeRecord, ppem); +field_offset!( + FONT_BINDING_STRIKE_RESERVED, + FontBindingStrikeRecord, + reserved +); +field_offset!(FONT_BINDING_RESOURCE_ID, FontBindingResourceRecord, id); +field_offset!( + FONT_BINDING_RESOURCE_GENERATION, + FontBindingResourceRecord, + generation +); +field_offset!(FONT_BINDING_RESOURCE_KIND, FontBindingResourceRecord, kind); +field_offset!( + FONT_BINDING_RESOURCE_RESERVED, + FontBindingResourceRecord, + reserved +); +field_offset!( + FONT_BINDING_RESOURCE_REFERENCE, + FontBindingResourceRecord, + reference +); +field_offset!(POLICY_OPERATION_OPERAND0, PolicyOperationRecord, operand0); +field_offset!(POLICY_OPERATION_OPERAND1, PolicyOperationRecord, operand1); +field_offset!( + POLICY_OPERATION_IMMEDIATE0, + PolicyOperationRecord, + immediate0 +); +field_offset!( + POLICY_OPERATION_IMMEDIATE1, + PolicyOperationRecord, + immediate1 +); +field_offset!( + POLICY_OPERATION_IMMEDIATE2, + PolicyOperationRecord, + immediate2 +); +field_offset!( + ENGINE_UPDATE_ABI_VERSION, + EngineUpdateRequestHeader, + abi_version +); +field_offset!( + ENGINE_UPDATE_BYTE_LENGTH, + EngineUpdateRequestHeader, + byte_length +); +field_offset!( + ENGINE_UPDATE_SESSION_ID, + EngineUpdateRequestHeader, + session_id +); +field_offset!( + ENGINE_UPDATE_EXPECTED_ENGINE_REVISION, + EngineUpdateRequestHeader, + expected_engine_revision +); +field_offset!( + ENGINE_UPDATE_CONSUMED_PLAN_REVISION, + EngineUpdateRequestHeader, + consumed_plan_revision +); +field_offset!( + ENGINE_UPDATE_ACKNOWLEDGED_PUBLICATION_GENERATION, + EngineUpdateRequestHeader, + acknowledged_publication_generation +); +field_offset!( + ENGINE_UPDATE_POLICY_HANDLE, + EngineUpdateRequestHeader, + policy_handle +); +field_offset!( + ENGINE_UPDATE_CAPABILITY_SET, + EngineUpdateRequestHeader, + capability_set +); +field_offset!(ENGINE_UPDATE_FLAGS, EngineUpdateRequestHeader, flags); +field_offset!( + ENGINE_UPDATE_SEMANTIC_VIEW_MASK, + EngineUpdateRequestHeader, + semantic_view_mask +); +field_offset!( + ENGINE_UPDATE_MAX_CLUSTERS, + EngineUpdateRequestHeader, + max_clusters +); +field_offset!( + ENGINE_UPDATE_MAX_LINES, + EngineUpdateRequestHeader, + max_lines +); +field_offset!( + ENGINE_UPDATE_MAX_REGIONS, + EngineUpdateRequestHeader, + max_regions +); +field_offset!( + ENGINE_UPDATE_MAX_EXCLUSIONS, + EngineUpdateRequestHeader, + max_exclusions +); +field_offset!( + ENGINE_UPDATE_MAX_INLINE_OBJECTS, + EngineUpdateRequestHeader, + max_inline_objects +); +field_offset!( + ENGINE_UPDATE_MAX_SLOTS_PER_BAND, + EngineUpdateRequestHeader, + max_slots_per_band +); +field_offset!( + ENGINE_UPDATE_MAX_OUTPUT_BYTES, + EngineUpdateRequestHeader, + max_output_bytes +); +field_offset!( + ENGINE_UPDATE_TEXT_MUTATIONS_OFFSET, + EngineUpdateRequestHeader, + text_mutations_offset +); +field_offset!( + ENGINE_UPDATE_TEXT_MUTATION_COUNT, + EngineUpdateRequestHeader, + text_mutation_count +); +field_offset!( + ENGINE_UPDATE_STYLE_MUTATIONS_OFFSET, + EngineUpdateRequestHeader, + style_mutations_offset +); +field_offset!( + ENGINE_UPDATE_STYLE_MUTATION_COUNT, + EngineUpdateRequestHeader, + style_mutation_count +); +field_offset!( + ENGINE_UPDATE_CONSTRAINTS_OFFSET, + EngineUpdateRequestHeader, + constraints_offset +); +field_offset!( + ENGINE_UPDATE_CONSTRAINT_COUNT, + EngineUpdateRequestHeader, + constraint_count +); +field_offset!( + ENGINE_UPDATE_REGIONS_OFFSET, + EngineUpdateRequestHeader, + regions_offset +); +field_offset!( + ENGINE_UPDATE_REGION_COUNT, + EngineUpdateRequestHeader, + region_count +); +field_offset!( + ENGINE_UPDATE_EXCLUSIONS_OFFSET, + EngineUpdateRequestHeader, + exclusions_offset +); +field_offset!( + ENGINE_UPDATE_EXCLUSION_COUNT, + EngineUpdateRequestHeader, + exclusion_count +); +field_offset!( + ENGINE_UPDATE_INLINE_OBJECTS_OFFSET, + EngineUpdateRequestHeader, + inline_objects_offset +); +field_offset!( + ENGINE_UPDATE_INLINE_OBJECT_COUNT, + EngineUpdateRequestHeader, + inline_object_count +); +field_offset!( + ENGINE_UPDATE_POLICY_PARAMETERS_OFFSET, + EngineUpdateRequestHeader, + policy_parameters_offset +); +field_offset!( + ENGINE_UPDATE_POLICY_PARAMETERS_LENGTH, + EngineUpdateRequestHeader, + policy_parameters_length +); +field_offset!( + ENGINE_UPDATE_MAX_PARAGRAPHS, + EngineUpdateRequestHeader, + max_paragraphs +); +field_offset!( + ENGINE_UPDATE_PARAGRAPH_MUTATIONS_OFFSET, + EngineUpdateRequestHeader, + paragraph_mutations_offset +); +field_offset!( + ENGINE_UPDATE_PARAGRAPH_MUTATION_COUNT, + EngineUpdateRequestHeader, + paragraph_mutation_count +); +field_offset!( + ENGINE_PARAGRAPH_MUTATION_OPCODE, + EngineParagraphMutationRecord, + opcode +); +field_offset!( + ENGINE_PARAGRAPH_MUTATION_FLAGS, + EngineParagraphMutationRecord, + flags +); +field_offset!( + ENGINE_PARAGRAPH_MUTATION_RESERVED0, + EngineParagraphMutationRecord, + reserved0 +); +field_offset!( + ENGINE_PARAGRAPH_MUTATION_PARAGRAPH_ID, + EngineParagraphMutationRecord, + paragraph_id +); +field_offset!( + ENGINE_PARAGRAPH_MUTATION_ORDER, + EngineParagraphMutationRecord, + order +); +field_offset!( + ENGINE_TEXT_MUTATION_OPCODE, + EngineTextMutationRecord, + opcode +); +field_offset!( + ENGINE_TEXT_MUTATION_ENCODING, + EngineTextMutationRecord, + encoding +); +field_offset!( + ENGINE_TEXT_MUTATION_RESERVED0, + EngineTextMutationRecord, + reserved0 +); +field_offset!( + ENGINE_TEXT_MUTATION_TEXT_START, + EngineTextMutationRecord, + text_start +); +field_offset!( + ENGINE_TEXT_MUTATION_DELETE_COUNT, + EngineTextMutationRecord, + delete_count +); +field_offset!( + ENGINE_TEXT_MUTATION_INSERT_OFFSET, + EngineTextMutationRecord, + insert_offset +); +field_offset!( + ENGINE_TEXT_MUTATION_INSERT_COUNT, + EngineTextMutationRecord, + insert_count +); +field_offset!( + ENGINE_TEXT_MUTATION_PARAGRAPH_ID, + EngineTextMutationRecord, + paragraph_id +); +field_offset!( + ENGINE_STYLE_MUTATION_OPCODE, + EngineStyleMutationRecord, + opcode +); +field_offset!( + ENGINE_STYLE_MUTATION_DIRECTION, + EngineStyleMutationRecord, + direction +); +field_offset!( + ENGINE_STYLE_MUTATION_DECORATION_STYLE, + EngineStyleMutationRecord, + decoration_style +); +field_offset!( + ENGINE_STYLE_MUTATION_FLAGS, + EngineStyleMutationRecord, + flags +); +field_offset!( + ENGINE_STYLE_MUTATION_STYLE_ID, + EngineStyleMutationRecord, + style_id +); +field_offset!( + ENGINE_STYLE_MUTATION_CASCADE_ORDER, + EngineStyleMutationRecord, + cascade_order +); +field_offset!( + ENGINE_STYLE_MUTATION_FIELD_MASK, + EngineStyleMutationRecord, + field_mask +); +field_offset!( + ENGINE_STYLE_MUTATION_TEXT_START, + EngineStyleMutationRecord, + text_start +); +field_offset!( + ENGINE_STYLE_MUTATION_TEXT_END, + EngineStyleMutationRecord, + text_end +); +field_offset!( + ENGINE_STYLE_MUTATION_FONT_STACK_HANDLE, + EngineStyleMutationRecord, + font_stack_handle +); +field_offset!( + ENGINE_STYLE_MUTATION_MATERIAL_ID, + EngineStyleMutationRecord, + material_id +); +field_offset!( + ENGINE_STYLE_MUTATION_LANGUAGE_OFFSET, + EngineStyleMutationRecord, + language_offset +); +field_offset!( + ENGINE_STYLE_MUTATION_LANGUAGE_LENGTH, + EngineStyleMutationRecord, + language_length +); +field_offset!( + ENGINE_STYLE_MUTATION_FEATURE_COUNT, + EngineStyleMutationRecord, + feature_count +); +field_offset!( + ENGINE_STYLE_MUTATION_FEATURES_OFFSET, + EngineStyleMutationRecord, + features_offset +); +field_offset!( + ENGINE_STYLE_MUTATION_FONT_SIZE, + EngineStyleMutationRecord, + font_size +); +field_offset!( + ENGINE_STYLE_MUTATION_LINE_HEIGHT, + EngineStyleMutationRecord, + line_height +); +field_offset!( + ENGINE_STYLE_MUTATION_LETTER_SPACING, + EngineStyleMutationRecord, + letter_spacing +); +field_offset!( + ENGINE_STYLE_MUTATION_WORD_SPACING, + EngineStyleMutationRecord, + word_spacing +); +field_offset!( + ENGINE_STYLE_MUTATION_BASELINE_SHIFT, + EngineStyleMutationRecord, + baseline_shift +); +field_offset!( + ENGINE_STYLE_MUTATION_RASTER_PIXEL_RATIO, + EngineStyleMutationRecord, + raster_pixel_ratio +); +field_offset!( + ENGINE_STYLE_MUTATION_FOREGROUND_RGBA, + EngineStyleMutationRecord, + foreground_rgba +); +field_offset!( + ENGINE_STYLE_MUTATION_DECORATION_RGBA, + EngineStyleMutationRecord, + decoration_rgba +); +field_offset!( + ENGINE_STYLE_MUTATION_DECORATION_FLAGS, + EngineStyleMutationRecord, + decoration_flags +); +field_offset!( + ENGINE_STYLE_MUTATION_DECORATION_THICKNESS, + EngineStyleMutationRecord, + decoration_thickness +); +field_offset!( + ENGINE_STYLE_MUTATION_DECORATION_OFFSET, + EngineStyleMutationRecord, + decoration_offset +); +field_offset!( + ENGINE_STYLE_MUTATION_PARAGRAPH_ID, + EngineStyleMutationRecord, + paragraph_id +); +field_offset!( + ENGINE_CONSTRAINT_FLOW_THREAD_ID, + EngineConstraintRecord, + flow_thread_id +); +field_offset!( + ENGINE_CONSTRAINT_GEOMETRY_REVISION, + EngineConstraintRecord, + geometry_revision +); +field_offset!(ENGINE_CONSTRAINT_WIDTH, EngineConstraintRecord, width); +field_offset!(ENGINE_CONSTRAINT_HEIGHT, EngineConstraintRecord, height); +field_offset!( + ENGINE_CONSTRAINT_VIEWPORT_BLOCK_START, + EngineConstraintRecord, + viewport_block_start +); +field_offset!( + ENGINE_CONSTRAINT_VIEWPORT_BLOCK_END, + EngineConstraintRecord, + viewport_block_end +); +field_offset!( + ENGINE_CONSTRAINT_RESUME_BLOCK_OFFSET, + EngineConstraintRecord, + resume_block_offset +); +field_offset!( + ENGINE_CONSTRAINT_MAX_LINES, + EngineConstraintRecord, + max_lines +); +field_offset!( + ENGINE_CONSTRAINT_REGION_START, + EngineConstraintRecord, + region_start +); +field_offset!( + ENGINE_CONSTRAINT_RESUME_CLUSTER, + EngineConstraintRecord, + resume_cluster +); +field_offset!( + ENGINE_CONSTRAINT_REGION_COUNT, + EngineConstraintRecord, + region_count +); +field_offset!( + ENGINE_CONSTRAINT_RESUME_REGION, + EngineConstraintRecord, + resume_region +); +field_offset!( + ENGINE_CONSTRAINT_WIDTH_MODE, + EngineConstraintRecord, + width_mode +); +field_offset!( + ENGINE_CONSTRAINT_HEIGHT_MODE, + EngineConstraintRecord, + height_mode +); +field_offset!(ENGINE_CONSTRAINT_WRAP, EngineConstraintRecord, wrap); +field_offset!(ENGINE_CONSTRAINT_ALIGN, EngineConstraintRecord, align); +field_offset!(ENGINE_CONSTRAINT_OVERFLOW, EngineConstraintRecord, overflow); +field_offset!( + ENGINE_CONSTRAINT_BLOCK_ALIGN, + EngineConstraintRecord, + block_align +); +field_offset!(ENGINE_CONSTRAINT_FLAGS, EngineConstraintRecord, flags); +field_offset!( + ENGINE_CONSTRAINT_PARAGRAPH_ID, + EngineConstraintRecord, + paragraph_id +); +field_offset!(ENGINE_FLOW_VERTEX_INLINE, EngineFlowVertexRecord, inline); +field_offset!(ENGINE_FLOW_VERTEX_BLOCK, EngineFlowVertexRecord, block); +field_offset!(ENGINE_REGION_ID, EngineRegionRecord, id); +field_offset!( + ENGINE_REGION_GEOMETRY_REVISION, + EngineRegionRecord, + geometry_revision +); +field_offset!( + ENGINE_REGION_TRANSFORM_INDEX, + EngineRegionRecord, + transform_index +); +field_offset!( + ENGINE_REGION_VERTICES_OFFSET, + EngineRegionRecord, + vertices_offset +); +field_offset!(ENGINE_REGION_VERTEX_COUNT, EngineRegionRecord, vertex_count); +field_offset!( + ENGINE_REGION_EXCLUSION_START, + EngineRegionRecord, + exclusion_start +); +field_offset!( + ENGINE_REGION_EXCLUSION_COUNT, + EngineRegionRecord, + exclusion_count +); +field_offset!(ENGINE_REGION_FLAGS, EngineRegionRecord, flags); +field_offset!(ENGINE_REGION_SHAPE, EngineRegionRecord, shape); +field_offset!(ENGINE_REGION_WRITING_MODE, EngineRegionRecord, writing_mode); +field_offset!( + ENGINE_REGION_TEXT_ORIENTATION, + EngineRegionRecord, + text_orientation +); +field_offset!(ENGINE_REGION_RESERVED0, EngineRegionRecord, reserved0); +field_offset!(ENGINE_REGION_INLINE_START, EngineRegionRecord, inline_start); +field_offset!(ENGINE_REGION_BLOCK_START, EngineRegionRecord, block_start); +field_offset!(ENGINE_REGION_INLINE_END, EngineRegionRecord, inline_end); +field_offset!(ENGINE_REGION_BLOCK_END, EngineRegionRecord, block_end); +field_offset!( + ENGINE_REGION_CLIP_INLINE_START, + EngineRegionRecord, + clip_inline_start +); +field_offset!( + ENGINE_REGION_CLIP_BLOCK_START, + EngineRegionRecord, + clip_block_start +); +field_offset!( + ENGINE_REGION_CLIP_INLINE_END, + EngineRegionRecord, + clip_inline_end +); +field_offset!( + ENGINE_REGION_CLIP_BLOCK_END, + EngineRegionRecord, + clip_block_end +); +field_offset!(ENGINE_EXCLUSION_ID, EngineExclusionRecord, id); +field_offset!(ENGINE_EXCLUSION_REGION_ID, EngineExclusionRecord, region_id); +field_offset!( + ENGINE_EXCLUSION_GEOMETRY_REVISION, + EngineExclusionRecord, + geometry_revision +); +field_offset!( + ENGINE_EXCLUSION_VERTICES_OFFSET, + EngineExclusionRecord, + vertices_offset +); +field_offset!( + ENGINE_EXCLUSION_VERTEX_COUNT, + EngineExclusionRecord, + vertex_count +); +field_offset!(ENGINE_EXCLUSION_FLAGS, EngineExclusionRecord, flags); +field_offset!(ENGINE_EXCLUSION_SHAPE, EngineExclusionRecord, shape); +field_offset!(ENGINE_EXCLUSION_WRAP_SIDE, EngineExclusionRecord, wrap_side); +field_offset!(ENGINE_EXCLUSION_RESERVED0, EngineExclusionRecord, reserved0); +field_offset!( + ENGINE_EXCLUSION_INLINE_START, + EngineExclusionRecord, + inline_start +); +field_offset!( + ENGINE_EXCLUSION_BLOCK_START, + EngineExclusionRecord, + block_start +); +field_offset!( + ENGINE_EXCLUSION_INLINE_END, + EngineExclusionRecord, + inline_end +); +field_offset!(ENGINE_EXCLUSION_BLOCK_END, EngineExclusionRecord, block_end); +field_offset!( + ENGINE_EXCLUSION_MARGIN_INLINE, + EngineExclusionRecord, + margin_inline +); +field_offset!( + ENGINE_EXCLUSION_MARGIN_BLOCK, + EngineExclusionRecord, + margin_block +); +field_offset!(ENGINE_INLINE_OBJECT_ID, EngineInlineObjectRecord, id); +field_offset!( + ENGINE_INLINE_OBJECT_CONTENT_REVISION, + EngineInlineObjectRecord, + content_revision +); +field_offset!( + ENGINE_INLINE_OBJECT_TEXT_OFFSET, + EngineInlineObjectRecord, + text_offset +); +field_offset!( + ENGINE_INLINE_OBJECT_MATERIAL_ID, + EngineInlineObjectRecord, + material_id +); +field_offset!( + ENGINE_INLINE_OBJECT_RESOURCE_ID, + EngineInlineObjectRecord, + resource_id +); +field_offset!( + ENGINE_INLINE_OBJECT_RESOURCE_GENERATION, + EngineInlineObjectRecord, + resource_generation +); +field_offset!( + ENGINE_INLINE_OBJECT_INLINE_EXTENT, + EngineInlineObjectRecord, + inline_extent +); +field_offset!( + ENGINE_INLINE_OBJECT_BLOCK_EXTENT, + EngineInlineObjectRecord, + block_extent +); +field_offset!( + ENGINE_INLINE_OBJECT_BASELINE_OFFSET, + EngineInlineObjectRecord, + baseline_offset +); +field_offset!( + ENGINE_INLINE_OBJECT_MARGIN_INLINE_START, + EngineInlineObjectRecord, + margin_inline_start +); +field_offset!( + ENGINE_INLINE_OBJECT_MARGIN_INLINE_END, + EngineInlineObjectRecord, + margin_inline_end +); +field_offset!( + ENGINE_INLINE_OBJECT_MARGIN_BLOCK_START, + EngineInlineObjectRecord, + margin_block_start +); +field_offset!( + ENGINE_INLINE_OBJECT_MARGIN_BLOCK_END, + EngineInlineObjectRecord, + margin_block_end +); +field_offset!( + ENGINE_INLINE_OBJECT_BASELINE_ALIGNMENT, + EngineInlineObjectRecord, + baseline_alignment +); +field_offset!(ENGINE_INLINE_OBJECT_FLAGS, EngineInlineObjectRecord, flags); +field_offset!( + ENGINE_INLINE_OBJECT_RESERVED0, + EngineInlineObjectRecord, + reserved0 +); +field_offset!( + ENGINE_INLINE_OBJECT_PARAGRAPH_ID, + EngineInlineObjectRecord, + paragraph_id +); +field_offset!(ENGINE_RESULT_ABI_VERSION, EngineResultHeader, abi_version); +field_offset!(ENGINE_RESULT_BYTE_LENGTH, EngineResultHeader, byte_length); +field_offset!(ENGINE_RESULT_STATUS, EngineResultHeader, status); +field_offset!(ENGINE_RESULT_FLAGS, EngineResultHeader, flags); +field_offset!(ENGINE_RESULT_SESSION_ID, EngineResultHeader, session_id); +field_offset!( + ENGINE_RESULT_ENGINE_REVISION, + EngineResultHeader, + engine_revision +); +field_offset!( + ENGINE_RESULT_PLAN_REVISION, + EngineResultHeader, + plan_revision +); +field_offset!( + ENGINE_RESULT_REQUIRED_BASE_REVISION, + EngineResultHeader, + required_base_revision +); +field_offset!( + ENGINE_RESULT_PUBLICATION_GENERATION, + EngineResultHeader, + publication_generation +); +field_offset!(ENGINE_RESULT_OUTPUT_SLOT, EngineResultHeader, output_slot); +field_offset!( + ENGINE_RESULT_REQUEST_CAPACITY, + EngineResultHeader, + request_capacity +); +field_offset!( + ENGINE_RESULT_REQUIRED_REQUEST_CAPACITY, + EngineResultHeader, + required_request_capacity +); +field_offset!( + ENGINE_RESULT_RESULT_CAPACITY, + EngineResultHeader, + result_capacity +); +field_offset!( + ENGINE_RESULT_REQUIRED_RESULT_CAPACITY, + EngineResultHeader, + required_result_capacity +); +field_offset!( + ENGINE_RESULT_POLICY_HANDLE, + EngineResultHeader, + policy_handle +); +field_offset!( + ENGINE_RESULT_CAPABILITY_SET, + EngineResultHeader, + capability_set +); +field_offset!( + ENGINE_RESULT_POLICY_FINGERPRINT_LOW, + EngineResultHeader, + policy_fingerprint_low +); +field_offset!( + ENGINE_RESULT_POLICY_FINGERPRINT_HIGH, + EngineResultHeader, + policy_fingerprint_high +); +field_offset!( + ENGINE_RESULT_SEMANTICS_OFFSET, + EngineResultHeader, + semantic_views_offset +); +field_offset!( + ENGINE_RESULT_SEMANTICS_COUNT, + EngineResultHeader, + semantic_view_count +); +field_offset!( + ENGINE_RESULT_RESOURCES_OFFSET, + EngineResultHeader, + resources_offset +); +field_offset!( + ENGINE_RESULT_RESOURCE_COUNT, + EngineResultHeader, + resource_count +); +field_offset!( + ENGINE_RESULT_BUFFERS_OFFSET, + EngineResultHeader, + buffers_offset +); +field_offset!(ENGINE_RESULT_BUFFER_COUNT, EngineResultHeader, buffer_count); +field_offset!( + ENGINE_RESULT_PATCHES_OFFSET, + EngineResultHeader, + patches_offset +); +field_offset!(ENGINE_RESULT_PATCH_COUNT, EngineResultHeader, patch_count); +field_offset!( + ENGINE_RESULT_PRIMITIVES_OFFSET, + EngineResultHeader, + primitives_offset +); +field_offset!( + ENGINE_RESULT_PRIMITIVE_COUNT, + EngineResultHeader, + primitive_count +); +field_offset!(ENGINE_RESULT_DRAWS_OFFSET, EngineResultHeader, draws_offset); +field_offset!(ENGINE_RESULT_DRAW_COUNT, EngineResultHeader, draw_count); +field_offset!( + ENGINE_RESULT_RETIREMENTS_OFFSET, + EngineResultHeader, + retirements_offset +); +field_offset!( + ENGINE_RESULT_RETIREMENT_COUNT, + EngineResultHeader, + retirement_count +); +field_offset!( + ENGINE_RESULT_DIAGNOSTICS_OFFSET, + EngineResultHeader, + diagnostics_offset +); +field_offset!( + ENGINE_RESULT_DIAGNOSTIC_COUNT, + EngineResultHeader, + diagnostic_count +); +field_offset!(SEMANTIC_ID, SemanticRecord, id); +field_offset!(SEMANTIC_KIND, SemanticRecord, kind); +field_offset!(SEMANTIC_FLAGS, SemanticRecord, flags); +field_offset!(SEMANTIC_PARENT_ID, SemanticRecord, parent_id); +field_offset!(SEMANTIC_TEXT_START, SemanticRecord, text_start); +field_offset!(SEMANTIC_TEXT_END, SemanticRecord, text_end); +field_offset!(SEMANTIC_ITEM_START, SemanticRecord, item_start); +field_offset!(SEMANTIC_ITEM_COUNT, SemanticRecord, item_count); +field_offset!(SEMANTIC_INLINE_START, SemanticRecord, inline_start); +field_offset!(SEMANTIC_BLOCK_START, SemanticRecord, block_start); +field_offset!(SEMANTIC_INLINE_EXTENT, SemanticRecord, inline_extent); +field_offset!(SEMANTIC_BLOCK_EXTENT, SemanticRecord, block_extent); +field_offset!(RESOURCE_ID, ResourceRecord, id); +field_offset!(RESOURCE_GENERATION, ResourceRecord, generation); +field_offset!(RESOURCE_TECHNIQUE_ID, ResourceRecord, technique_id); +field_offset!(RESOURCE_KIND, ResourceRecord, resource_kind); +field_offset!(RESOURCE_ACTION, ResourceRecord, action); +field_offset!(RESOURCE_FLAGS, ResourceRecord, flags); +field_offset!(RESOURCE_REFERENCE_ID, ResourceRecord, reference_id); +field_offset!(RESOURCE_LOWER_BOUND, ResourceRecord, lower_bound); +field_offset!(RESOURCE_UPPER_BOUND, ResourceRecord, upper_bound); +field_offset!(RESOURCE_AUXILIARY0, ResourceRecord, auxiliary0); +field_offset!(RESOURCE_AUXILIARY1, ResourceRecord, auxiliary1); +field_offset!(BUFFER_ID, BufferRecord, id); +field_offset!(BUFFER_GENERATION, BufferRecord, generation); +field_offset!(BUFFER_PROGRAM_ID, BufferRecord, program_id); +field_offset!(BUFFER_POLICY_BUFFER_ID, BufferRecord, policy_buffer_id); +field_offset!(BUFFER_SCALAR_TYPE, BufferRecord, scalar_type); +field_offset!(BUFFER_VECTOR_WIDTH, BufferRecord, vector_width); +field_offset!(BUFFER_STRATEGY, BufferRecord, strategy); +field_offset!(BUFFER_FLAGS, BufferRecord, flags); +field_offset!(BUFFER_LIVE_RECORDS, BufferRecord, live_records); +field_offset!(BUFFER_CAPACITY_RECORDS, BufferRecord, capacity_records); +field_offset!(BUFFER_BYTE_LENGTH, BufferRecord, byte_length); +field_offset!(BUFFER_ORDER_BUFFER_ID, BufferRecord, order_buffer_id); +field_offset!(PATCH_OPCODE, PatchRecord, opcode); +field_offset!(PATCH_FLAGS, PatchRecord, flags); +field_offset!(PATCH_BUFFER_ID, PatchRecord, buffer_id); +field_offset!(PATCH_BUFFER_GENERATION, PatchRecord, buffer_generation); +field_offset!(PATCH_DESTINATION_OFFSET, PatchRecord, destination_offset); +field_offset!(PATCH_BYTE_LENGTH, PatchRecord, byte_length); +field_offset!(PATCH_PAYLOAD_OFFSET, PatchRecord, payload_start); +field_offset!(PATCH_SOURCE_BUFFER_ID, PatchRecord, source_buffer_id); +field_offset!(PATCH_SOURCE_OFFSET, PatchRecord, source_offset); +field_offset!(PATCH_FILL_VALUE, PatchRecord, fill_value); +field_offset!(PRIMITIVE_ID, PrimitiveRecord, id); +field_offset!(PRIMITIVE_KIND, PrimitiveRecord, kind); +field_offset!(PRIMITIVE_FLAGS, PrimitiveRecord, flags); +field_offset!(PRIMITIVE_TECHNIQUE_ID, PrimitiveRecord, technique_id); +field_offset!(PRIMITIVE_RESOURCE_ID, PrimitiveRecord, resource_id); +field_offset!( + PRIMITIVE_RESOURCE_GENERATION, + PrimitiveRecord, + resource_generation +); +field_offset!(PRIMITIVE_PROGRAM_ID, PrimitiveRecord, program_id); +field_offset!(PRIMITIVE_PROGRAM_VARIANT, PrimitiveRecord, program_variant); +field_offset!(PRIMITIVE_RECORD_COUNT, PrimitiveRecord, record_count); +field_offset!(PRIMITIVE_BUFFER_ID, PrimitiveRecord, buffer_id); +field_offset!(PRIMITIVE_RECORD_INDEX, PrimitiveRecord, record_index); +field_offset!(PRIMITIVE_LOGICAL_ORDER, PrimitiveRecord, logical_order); +field_offset!(PRIMITIVE_CLIP_ID, PrimitiveRecord, clip_id); +field_offset!(PRIMITIVE_SEMANTIC_ID, PrimitiveRecord, semantic_id); +field_offset!(PRIMITIVE_INLINE_START, PrimitiveRecord, inline_start); +field_offset!(PRIMITIVE_BLOCK_START, PrimitiveRecord, block_start); +field_offset!(PRIMITIVE_INLINE_EXTENT, PrimitiveRecord, inline_extent); +field_offset!(PRIMITIVE_BLOCK_EXTENT, PrimitiveRecord, block_extent); +field_offset!(DRAW_ID, DrawRecord, id); +field_offset!(DRAW_PROGRAM_ID, DrawRecord, program_id); +field_offset!(DRAW_PROGRAM_VARIANT, DrawRecord, program_variant); +field_offset!(DRAW_FLAGS, DrawRecord, flags); +field_offset!(DRAW_MATERIAL_ID, DrawRecord, material_id); +field_offset!(DRAW_CLIP_ID, DrawRecord, clip_id); +field_offset!(DRAW_DEPTH_KEY, DrawRecord, depth_key); +field_offset!(DRAW_TRANSFORM_ID, DrawRecord, transform_id); +field_offset!(DRAW_PRIMITIVE_START, DrawRecord, primitive_start); +field_offset!(DRAW_PRIMITIVE_COUNT, DrawRecord, primitive_count); +field_offset!(DRAW_BUFFER_START, DrawRecord, buffer_start); +field_offset!(DRAW_BUFFER_COUNT, DrawRecord, buffer_count); +field_offset!(DRAW_RESOURCE_START, DrawRecord, resource_start); +field_offset!(DRAW_RESOURCE_COUNT, DrawRecord, resource_count); +field_offset!(DRAW_ORDER_TOKEN, DrawRecord, order_token); +field_offset!(DRAW_INDIRECT_BUFFER_ID, DrawRecord, indirect_buffer_id); +field_offset!(DRAW_INDIRECT_OFFSET, DrawRecord, indirect_offset); +field_offset!(RETIREMENT_KIND, RetirementRecord, kind); +field_offset!(RETIREMENT_FLAGS, RetirementRecord, flags); +field_offset!(RETIREMENT_ID, RetirementRecord, id); +field_offset!(RETIREMENT_GENERATION, RetirementRecord, generation); +field_offset!( + RETIREMENT_AFTER_PUBLICATION_GENERATION, + RetirementRecord, + after_publication_generation +); +field_offset!(RETIREMENT_BYTE_OFFSET, RetirementRecord, byte_offset); +field_offset!(RETIREMENT_BYTE_LENGTH, RetirementRecord, byte_length); +field_offset!(DIAGNOSTIC_CODE, DiagnosticRecord, code); +field_offset!(DIAGNOSTIC_SEVERITY, DiagnosticRecord, severity); +field_offset!(DIAGNOSTIC_PHASE, DiagnosticRecord, phase); +field_offset!(DIAGNOSTIC_SUBJECT_ID, DiagnosticRecord, subject_id); +field_offset!(DIAGNOSTIC_VALUE0, DiagnosticRecord, value0); +field_offset!(DIAGNOSTIC_VALUE1, DiagnosticRecord, value1); +field_offset!( + DIAGNOSTIC_DURATION_NANOS_LOW, + DiagnosticRecord, + duration_nanos_low +); +field_offset!( + DIAGNOSTIC_DURATION_NANOS_HIGH, + DiagnosticRecord, + duration_nanos_high +); +field_offset!(FEATURE_TAG, FeatureRecord, tag); +field_offset!(FEATURE_VALUE, FeatureRecord, value); +field_offset!(FEATURE_START, FeatureRecord, start); +field_offset!(FEATURE_END, FeatureRecord, end); + +pub fn json() -> String { + json!({ + "name": "pmndrs-text-shaper", + "version": ABI_VERSION, + "endianness": "little", + "pointerWidth": 32, + "memory": "memory", + "versions": { + "shaper": SHAPER_VERSION, + "harfrust": HARFRUST_VERSION, + "harfrustCommit": HARFRUST_COMMIT, + "unicode": UNICODE_VERSION, + "fontFormat": 0 + }, + "functions": { + "initialize": "pmndrs_text_shaper_initialize", + "allocate": "pmndrs_text_shaper_alloc", + "deallocate": "pmndrs_text_shaper_dealloc", + "registerFont": "pmndrs_text_shaper_register_font", + "disposeFont": "pmndrs_text_shaper_dispose_font", + "fontCount": "pmndrs_text_shaper_font_count", + "retainedFontBytes": "pmndrs_text_shaper_retained_font_bytes", + "planCount": "pmndrs_text_shaper_plan_count", + "registerFontStack": "pmndrs_text_engine_register_font_stack", + "disposeFontStack": "pmndrs_text_engine_dispose_font_stack", + "fontStackCount": "pmndrs_text_engine_font_stack_count", + "registerFontBinding": "pmndrs_text_engine_register_font_binding", + "fontBindingCount": "pmndrs_text_engine_font_binding_count", + "registerPolicy": "pmndrs_text_engine_register_policy", + "disposePolicy": "pmndrs_text_engine_dispose_policy", + "policyCount": "pmndrs_text_engine_policy_count", + "createSession": "pmndrs_text_engine_create_session", + "reserveSession": "pmndrs_text_engine_reserve_session", + "disposeSession": "pmndrs_text_engine_dispose_session", + "sessionCount": "pmndrs_text_engine_session_count", + "requestPointer": "pmndrs_text_engine_request_ptr", + "requestCapacity": "pmndrs_text_engine_request_capacity", + "textUpdate": "pmndrs_text_engine_update" + }, + "layouts": { + "policyRequest": { + "size": POLICY_REQUEST_HEADER_SIZE, + "alignment": POLICY_REQUEST_HEADER_ALIGNMENT, + "byteLength": POLICY_BYTE_LENGTH, + "capabilitySetsOffset": POLICY_CAPABILITY_SETS_OFFSET, + "capabilitySetCount": POLICY_CAPABILITY_SET_COUNT, + "programsOffset": POLICY_PROGRAMS_OFFSET, + "programCount": POLICY_PROGRAM_COUNT, + "buffersOffset": POLICY_BUFFERS_OFFSET, + "bufferCount": POLICY_BUFFER_COUNT, + "operationsOffset": POLICY_OPERATIONS_OFFSET, + "operationCount": POLICY_OPERATION_COUNT, + "inputsOffset": POLICY_INPUTS_OFFSET, + "inputCount": POLICY_INPUT_COUNT + }, + "policyCapabilitySet": { + "size": POLICY_CAPABILITY_SET_RECORD_SIZE, + "alignment": POLICY_CAPABILITY_SET_RECORD_ALIGNMENT, + "id": POLICY_CAPABILITY_SET_ID, + "flags": POLICY_CAPABILITY_SET_FLAGS, + "maxBufferBytes": POLICY_CAPABILITY_SET_MAX_BUFFER_BYTES, + "updateAlignment": POLICY_CAPABILITY_SET_UPDATE_ALIGNMENT, + "coalesceGapBytes": POLICY_CAPABILITY_SET_COALESCE_GAP_BYTES, + "rangeCallPenaltyBytes": POLICY_CAPABILITY_SET_RANGE_CALL_PENALTY_BYTES, + "maxBuffersPerDraw": POLICY_CAPABILITY_SET_MAX_BUFFERS_PER_DRAW, + "maxResourcesPerDraw": POLICY_CAPABILITY_SET_MAX_RESOURCES_PER_DRAW, + "maxIndirectDraws": POLICY_CAPABILITY_SET_MAX_INDIRECT_DRAWS, + "fragmentationBudget": POLICY_CAPABILITY_SET_FRAGMENTATION_BUDGET, + "wholeBufferThresholdBasisPoints": POLICY_CAPABILITY_SET_WHOLE_BUFFER_THRESHOLD_BASIS_POINTS, + "reserved": POLICY_CAPABILITY_SET_RESERVED + }, + "policyProgram": { + "size": POLICY_PROGRAM_RECORD_SIZE, + "alignment": POLICY_PROGRAM_RECORD_ALIGNMENT, + "techniqueId": POLICY_PROGRAM_TECHNIQUE_ID, + "programId": POLICY_PROGRAM_ID, + "capabilitySetId": POLICY_PROGRAM_CAPABILITY_SET_ID, + "resourceKindMask": POLICY_PROGRAM_RESOURCE_KIND_MASK, + "semanticViewMask": POLICY_PROGRAM_SEMANTIC_VIEW_MASK, + "storageKeyMask": POLICY_PROGRAM_STORAGE_KEY_MASK, + "drawKeyMask": POLICY_PROGRAM_DRAW_KEY_MASK, + "variant": POLICY_PROGRAM_VARIANT, + "f32InputCount": POLICY_PROGRAM_F32_INPUT_COUNT, + "u32InputCount": POLICY_PROGRAM_U32_INPUT_COUNT, + "paintCapabilities": POLICY_PROGRAM_PAINT_CAPABILITIES, + "compositingCapabilities": POLICY_PROGRAM_COMPOSITING_CAPABILITIES, + "bufferStart": POLICY_PROGRAM_BUFFER_START, + "bufferCount": POLICY_PROGRAM_BUFFER_COUNT, + "reserved0": POLICY_PROGRAM_RESERVED0, + "operationStart": POLICY_PROGRAM_OPERATION_START, + "operationCount": POLICY_PROGRAM_OPERATION_COUNT, + "allocationStrategy": POLICY_PROGRAM_ALLOCATION_STRATEGY, + "inputStart": POLICY_PROGRAM_INPUT_START, + "inputCount": POLICY_PROGRAM_INPUT_COUNT, + "reserved1": POLICY_PROGRAM_RESERVED1 + }, + "policyBuffer": { + "size": POLICY_BUFFER_RECORD_SIZE, + "alignment": POLICY_BUFFER_RECORD_ALIGNMENT, + "id": POLICY_BUFFER_ID, + "scalar": POLICY_BUFFER_SCALAR, + "vectorWidth": POLICY_BUFFER_VECTOR_WIDTH, + "alignment": POLICY_BUFFER_ALIGNMENT, + "stride": POLICY_BUFFER_STRIDE, + "usage": POLICY_BUFFER_USAGE, + "capacityClass": POLICY_BUFFER_CAPACITY_CLASS, + "reserved0": POLICY_BUFFER_RESERVED0 + }, + "policyOperation": { + "size": POLICY_OPERATION_RECORD_SIZE, + "alignment": POLICY_OPERATION_RECORD_ALIGNMENT, + "opcode": POLICY_OPERATION_OPCODE, + "target": POLICY_OPERATION_TARGET, + "operand0": POLICY_OPERATION_OPERAND0, + "operand1": POLICY_OPERATION_OPERAND1, + "immediate0": POLICY_OPERATION_IMMEDIATE0, + "immediate1": POLICY_OPERATION_IMMEDIATE1, + "immediate2": POLICY_OPERATION_IMMEDIATE2 + }, + "policyInput": { + "size": POLICY_INPUT_RECORD_SIZE, + "alignment": POLICY_INPUT_RECORD_ALIGNMENT, + "scope": POLICY_INPUT_SCOPE, + "field": POLICY_INPUT_FIELD, + "reserved": POLICY_INPUT_RESERVED + }, + "fontBindingRequest": { + "size": FONT_BINDING_REQUEST_HEADER_SIZE, + "alignment": FONT_BINDING_REQUEST_HEADER_ALIGNMENT, + "abiVersion": FONT_BINDING_ABI_VERSION, + "byteLength": FONT_BINDING_BYTE_LENGTH, + "techniqueId": FONT_BINDING_TECHNIQUE_ID, + "programVariant": FONT_BINDING_PROGRAM_VARIANT, + "reserved0": FONT_BINDING_RESERVED0, + "glyphCount": FONT_BINDING_GLYPH_COUNT, + "strikeCount": FONT_BINDING_STRIKE_COUNT, + "resourceCount": FONT_BINDING_RESOURCE_COUNT, + "glyphF32FieldCount": FONT_BINDING_GLYPH_F32_FIELD_COUNT, + "glyphU32FieldCount": FONT_BINDING_GLYPH_U32_FIELD_COUNT, + "strikeF32FieldCount": FONT_BINDING_STRIKE_F32_FIELD_COUNT, + "strikeU32FieldCount": FONT_BINDING_STRIKE_U32_FIELD_COUNT, + "resourceF32FieldCount": FONT_BINDING_RESOURCE_F32_FIELD_COUNT, + "resourceU32FieldCount": FONT_BINDING_RESOURCE_U32_FIELD_COUNT, + "reserved1": FONT_BINDING_RESERVED1, + "strikesOffset": FONT_BINDING_STRIKES_OFFSET, + "resourcesOffset": FONT_BINDING_RESOURCES_OFFSET, + "resourceIndicesOffset": FONT_BINDING_RESOURCE_INDICES_OFFSET, + "glyphF32Offset": FONT_BINDING_GLYPH_F32_OFFSET, + "glyphU32Offset": FONT_BINDING_GLYPH_U32_OFFSET, + "strikeF32Offset": FONT_BINDING_STRIKE_F32_OFFSET, + "strikeU32Offset": FONT_BINDING_STRIKE_U32_OFFSET, + "resourceF32Offset": FONT_BINDING_RESOURCE_F32_OFFSET, + "resourceU32Offset": FONT_BINDING_RESOURCE_U32_OFFSET, + "reserved2": FONT_BINDING_RESERVED2 + }, + "fontBindingStrike": { + "size": FONT_BINDING_STRIKE_RECORD_SIZE, + "alignment": FONT_BINDING_STRIKE_RECORD_ALIGNMENT, + "ppem": FONT_BINDING_STRIKE_PPEM, + "reserved": FONT_BINDING_STRIKE_RESERVED + }, + "fontBindingResource": { + "size": FONT_BINDING_RESOURCE_RECORD_SIZE, + "alignment": FONT_BINDING_RESOURCE_RECORD_ALIGNMENT, + "id": FONT_BINDING_RESOURCE_ID, + "generation": FONT_BINDING_RESOURCE_GENERATION, + "kind": FONT_BINDING_RESOURCE_KIND, + "reserved": FONT_BINDING_RESOURCE_RESERVED, + "reference": FONT_BINDING_RESOURCE_REFERENCE + }, + "engineUpdateRequest": { + "size": ENGINE_UPDATE_REQUEST_HEADER_SIZE, + "alignment": ENGINE_UPDATE_REQUEST_HEADER_ALIGNMENT, + "abiVersion": ENGINE_UPDATE_ABI_VERSION, + "byteLength": ENGINE_UPDATE_BYTE_LENGTH, + "sessionId": ENGINE_UPDATE_SESSION_ID, + "expectedEngineRevision": ENGINE_UPDATE_EXPECTED_ENGINE_REVISION, + "consumedPlanRevision": ENGINE_UPDATE_CONSUMED_PLAN_REVISION, + "acknowledgedPublicationGeneration": ENGINE_UPDATE_ACKNOWLEDGED_PUBLICATION_GENERATION, + "policyHandle": ENGINE_UPDATE_POLICY_HANDLE, + "capabilitySet": ENGINE_UPDATE_CAPABILITY_SET, + "flags": ENGINE_UPDATE_FLAGS, + "semanticViewMask": ENGINE_UPDATE_SEMANTIC_VIEW_MASK, + "maxClusters": ENGINE_UPDATE_MAX_CLUSTERS, + "maxLines": ENGINE_UPDATE_MAX_LINES, + "maxRegions": ENGINE_UPDATE_MAX_REGIONS, + "maxExclusions": ENGINE_UPDATE_MAX_EXCLUSIONS, + "maxInlineObjects": ENGINE_UPDATE_MAX_INLINE_OBJECTS, + "maxSlotsPerBand": ENGINE_UPDATE_MAX_SLOTS_PER_BAND, + "maxOutputBytes": ENGINE_UPDATE_MAX_OUTPUT_BYTES, + "textMutationsOffset": ENGINE_UPDATE_TEXT_MUTATIONS_OFFSET, + "textMutationCount": ENGINE_UPDATE_TEXT_MUTATION_COUNT, + "styleMutationsOffset": ENGINE_UPDATE_STYLE_MUTATIONS_OFFSET, + "styleMutationCount": ENGINE_UPDATE_STYLE_MUTATION_COUNT, + "constraintsOffset": ENGINE_UPDATE_CONSTRAINTS_OFFSET, + "constraintCount": ENGINE_UPDATE_CONSTRAINT_COUNT, + "regionsOffset": ENGINE_UPDATE_REGIONS_OFFSET, + "regionCount": ENGINE_UPDATE_REGION_COUNT, + "exclusionsOffset": ENGINE_UPDATE_EXCLUSIONS_OFFSET, + "exclusionCount": ENGINE_UPDATE_EXCLUSION_COUNT, + "inlineObjectsOffset": ENGINE_UPDATE_INLINE_OBJECTS_OFFSET, + "inlineObjectCount": ENGINE_UPDATE_INLINE_OBJECT_COUNT, + "policyParametersOffset": ENGINE_UPDATE_POLICY_PARAMETERS_OFFSET, + "policyParametersLength": ENGINE_UPDATE_POLICY_PARAMETERS_LENGTH, + "maxParagraphs": ENGINE_UPDATE_MAX_PARAGRAPHS, + "paragraphMutationsOffset": ENGINE_UPDATE_PARAGRAPH_MUTATIONS_OFFSET, + "paragraphMutationCount": ENGINE_UPDATE_PARAGRAPH_MUTATION_COUNT + }, + "engineParagraphMutation": { + "size": ENGINE_PARAGRAPH_MUTATION_RECORD_SIZE, + "alignment": ENGINE_PARAGRAPH_MUTATION_RECORD_ALIGNMENT, + "opcode": ENGINE_PARAGRAPH_MUTATION_OPCODE, + "flags": ENGINE_PARAGRAPH_MUTATION_FLAGS, + "reserved0": ENGINE_PARAGRAPH_MUTATION_RESERVED0, + "paragraphId": ENGINE_PARAGRAPH_MUTATION_PARAGRAPH_ID, + "order": ENGINE_PARAGRAPH_MUTATION_ORDER + }, + "engineTextMutation": { + "size": ENGINE_TEXT_MUTATION_RECORD_SIZE, + "alignment": ENGINE_TEXT_MUTATION_RECORD_ALIGNMENT, + "opcode": ENGINE_TEXT_MUTATION_OPCODE, + "encoding": ENGINE_TEXT_MUTATION_ENCODING, + "reserved0": ENGINE_TEXT_MUTATION_RESERVED0, + "textStart": ENGINE_TEXT_MUTATION_TEXT_START, + "deleteCount": ENGINE_TEXT_MUTATION_DELETE_COUNT, + "insertOffset": ENGINE_TEXT_MUTATION_INSERT_OFFSET, + "insertCount": ENGINE_TEXT_MUTATION_INSERT_COUNT, + "paragraphId": ENGINE_TEXT_MUTATION_PARAGRAPH_ID + }, + "engineStyleMutation": { + "size": ENGINE_STYLE_MUTATION_RECORD_SIZE, + "alignment": ENGINE_STYLE_MUTATION_RECORD_ALIGNMENT, + "opcode": ENGINE_STYLE_MUTATION_OPCODE, + "direction": ENGINE_STYLE_MUTATION_DIRECTION, + "decorationStyle": ENGINE_STYLE_MUTATION_DECORATION_STYLE, + "flags": ENGINE_STYLE_MUTATION_FLAGS, + "styleId": ENGINE_STYLE_MUTATION_STYLE_ID, + "cascadeOrder": ENGINE_STYLE_MUTATION_CASCADE_ORDER, + "fieldMask": ENGINE_STYLE_MUTATION_FIELD_MASK, + "textStart": ENGINE_STYLE_MUTATION_TEXT_START, + "textEnd": ENGINE_STYLE_MUTATION_TEXT_END, + "fontStackHandle": ENGINE_STYLE_MUTATION_FONT_STACK_HANDLE, + "materialId": ENGINE_STYLE_MUTATION_MATERIAL_ID, + "languageOffset": ENGINE_STYLE_MUTATION_LANGUAGE_OFFSET, + "languageLength": ENGINE_STYLE_MUTATION_LANGUAGE_LENGTH, + "featureCount": ENGINE_STYLE_MUTATION_FEATURE_COUNT, + "featuresOffset": ENGINE_STYLE_MUTATION_FEATURES_OFFSET, + "fontSize": ENGINE_STYLE_MUTATION_FONT_SIZE, + "lineHeight": ENGINE_STYLE_MUTATION_LINE_HEIGHT, + "letterSpacing": ENGINE_STYLE_MUTATION_LETTER_SPACING, + "wordSpacing": ENGINE_STYLE_MUTATION_WORD_SPACING, + "baselineShift": ENGINE_STYLE_MUTATION_BASELINE_SHIFT, + "rasterPixelRatio": ENGINE_STYLE_MUTATION_RASTER_PIXEL_RATIO, + "foregroundRgba": ENGINE_STYLE_MUTATION_FOREGROUND_RGBA, + "decorationRgba": ENGINE_STYLE_MUTATION_DECORATION_RGBA, + "decorationFlags": ENGINE_STYLE_MUTATION_DECORATION_FLAGS, + "decorationThickness": ENGINE_STYLE_MUTATION_DECORATION_THICKNESS, + "decorationOffset": ENGINE_STYLE_MUTATION_DECORATION_OFFSET, + "paragraphId": ENGINE_STYLE_MUTATION_PARAGRAPH_ID + }, + "engineConstraint": { + "size": ENGINE_CONSTRAINT_RECORD_SIZE, + "alignment": ENGINE_CONSTRAINT_RECORD_ALIGNMENT, + "flowThreadId": ENGINE_CONSTRAINT_FLOW_THREAD_ID, + "geometryRevision": ENGINE_CONSTRAINT_GEOMETRY_REVISION, + "width": ENGINE_CONSTRAINT_WIDTH, + "height": ENGINE_CONSTRAINT_HEIGHT, + "viewportBlockStart": ENGINE_CONSTRAINT_VIEWPORT_BLOCK_START, + "viewportBlockEnd": ENGINE_CONSTRAINT_VIEWPORT_BLOCK_END, + "resumeBlockOffset": ENGINE_CONSTRAINT_RESUME_BLOCK_OFFSET, + "maxLines": ENGINE_CONSTRAINT_MAX_LINES, + "regionStart": ENGINE_CONSTRAINT_REGION_START, + "resumeCluster": ENGINE_CONSTRAINT_RESUME_CLUSTER, + "regionCount": ENGINE_CONSTRAINT_REGION_COUNT, + "resumeRegion": ENGINE_CONSTRAINT_RESUME_REGION, + "widthMode": ENGINE_CONSTRAINT_WIDTH_MODE, + "heightMode": ENGINE_CONSTRAINT_HEIGHT_MODE, + "wrap": ENGINE_CONSTRAINT_WRAP, + "align": ENGINE_CONSTRAINT_ALIGN, + "overflow": ENGINE_CONSTRAINT_OVERFLOW, + "blockAlign": ENGINE_CONSTRAINT_BLOCK_ALIGN, + "flags": ENGINE_CONSTRAINT_FLAGS, + "paragraphId": ENGINE_CONSTRAINT_PARAGRAPH_ID + }, + "engineFlowVertex": { + "size": ENGINE_FLOW_VERTEX_RECORD_SIZE, + "alignment": ENGINE_FLOW_VERTEX_RECORD_ALIGNMENT, + "inline": ENGINE_FLOW_VERTEX_INLINE, + "block": ENGINE_FLOW_VERTEX_BLOCK + }, + "engineRegion": { + "size": ENGINE_REGION_RECORD_SIZE, + "alignment": ENGINE_REGION_RECORD_ALIGNMENT, + "id": ENGINE_REGION_ID, + "geometryRevision": ENGINE_REGION_GEOMETRY_REVISION, + "transformIndex": ENGINE_REGION_TRANSFORM_INDEX, + "verticesOffset": ENGINE_REGION_VERTICES_OFFSET, + "vertexCount": ENGINE_REGION_VERTEX_COUNT, + "exclusionStart": ENGINE_REGION_EXCLUSION_START, + "exclusionCount": ENGINE_REGION_EXCLUSION_COUNT, + "flags": ENGINE_REGION_FLAGS, + "shape": ENGINE_REGION_SHAPE, + "writingMode": ENGINE_REGION_WRITING_MODE, + "textOrientation": ENGINE_REGION_TEXT_ORIENTATION, + "reserved0": ENGINE_REGION_RESERVED0, + "inlineStart": ENGINE_REGION_INLINE_START, + "blockStart": ENGINE_REGION_BLOCK_START, + "inlineEnd": ENGINE_REGION_INLINE_END, + "blockEnd": ENGINE_REGION_BLOCK_END, + "clipInlineStart": ENGINE_REGION_CLIP_INLINE_START, + "clipBlockStart": ENGINE_REGION_CLIP_BLOCK_START, + "clipInlineEnd": ENGINE_REGION_CLIP_INLINE_END, + "clipBlockEnd": ENGINE_REGION_CLIP_BLOCK_END + }, + "engineExclusion": { + "size": ENGINE_EXCLUSION_RECORD_SIZE, + "alignment": ENGINE_EXCLUSION_RECORD_ALIGNMENT, + "id": ENGINE_EXCLUSION_ID, + "regionId": ENGINE_EXCLUSION_REGION_ID, + "geometryRevision": ENGINE_EXCLUSION_GEOMETRY_REVISION, + "verticesOffset": ENGINE_EXCLUSION_VERTICES_OFFSET, + "vertexCount": ENGINE_EXCLUSION_VERTEX_COUNT, + "flags": ENGINE_EXCLUSION_FLAGS, + "shape": ENGINE_EXCLUSION_SHAPE, + "wrapSide": ENGINE_EXCLUSION_WRAP_SIDE, + "reserved0": ENGINE_EXCLUSION_RESERVED0, + "inlineStart": ENGINE_EXCLUSION_INLINE_START, + "blockStart": ENGINE_EXCLUSION_BLOCK_START, + "inlineEnd": ENGINE_EXCLUSION_INLINE_END, + "blockEnd": ENGINE_EXCLUSION_BLOCK_END, + "marginInline": ENGINE_EXCLUSION_MARGIN_INLINE, + "marginBlock": ENGINE_EXCLUSION_MARGIN_BLOCK + }, + "engineInlineObject": { + "size": ENGINE_INLINE_OBJECT_RECORD_SIZE, + "alignment": ENGINE_INLINE_OBJECT_RECORD_ALIGNMENT, + "id": ENGINE_INLINE_OBJECT_ID, + "contentRevision": ENGINE_INLINE_OBJECT_CONTENT_REVISION, + "textOffset": ENGINE_INLINE_OBJECT_TEXT_OFFSET, + "materialId": ENGINE_INLINE_OBJECT_MATERIAL_ID, + "resourceId": ENGINE_INLINE_OBJECT_RESOURCE_ID, + "resourceGeneration": ENGINE_INLINE_OBJECT_RESOURCE_GENERATION, + "inlineExtent": ENGINE_INLINE_OBJECT_INLINE_EXTENT, + "blockExtent": ENGINE_INLINE_OBJECT_BLOCK_EXTENT, + "baselineOffset": ENGINE_INLINE_OBJECT_BASELINE_OFFSET, + "marginInlineStart": ENGINE_INLINE_OBJECT_MARGIN_INLINE_START, + "marginInlineEnd": ENGINE_INLINE_OBJECT_MARGIN_INLINE_END, + "marginBlockStart": ENGINE_INLINE_OBJECT_MARGIN_BLOCK_START, + "marginBlockEnd": ENGINE_INLINE_OBJECT_MARGIN_BLOCK_END, + "baselineAlignment": ENGINE_INLINE_OBJECT_BASELINE_ALIGNMENT, + "flags": ENGINE_INLINE_OBJECT_FLAGS, + "reserved0": ENGINE_INLINE_OBJECT_RESERVED0, + "paragraphId": ENGINE_INLINE_OBJECT_PARAGRAPH_ID + }, + "engineResult": { + "size": ENGINE_RESULT_HEADER_SIZE, + "alignment": ENGINE_RESULT_HEADER_ALIGNMENT, + "abiVersion": ENGINE_RESULT_ABI_VERSION, + "byteLength": ENGINE_RESULT_BYTE_LENGTH, + "status": ENGINE_RESULT_STATUS, + "flags": ENGINE_RESULT_FLAGS, + "sessionId": ENGINE_RESULT_SESSION_ID, + "engineRevision": ENGINE_RESULT_ENGINE_REVISION, + "planRevision": ENGINE_RESULT_PLAN_REVISION, + "requiredBaseRevision": ENGINE_RESULT_REQUIRED_BASE_REVISION, + "publicationGeneration": ENGINE_RESULT_PUBLICATION_GENERATION, + "outputSlot": ENGINE_RESULT_OUTPUT_SLOT, + "requestCapacity": ENGINE_RESULT_REQUEST_CAPACITY, + "requiredRequestCapacity": ENGINE_RESULT_REQUIRED_REQUEST_CAPACITY, + "resultCapacity": ENGINE_RESULT_RESULT_CAPACITY, + "requiredResultCapacity": ENGINE_RESULT_REQUIRED_RESULT_CAPACITY, + "policyHandle": ENGINE_RESULT_POLICY_HANDLE, + "capabilitySet": ENGINE_RESULT_CAPABILITY_SET, + "policyFingerprintLow": ENGINE_RESULT_POLICY_FINGERPRINT_LOW, + "policyFingerprintHigh": ENGINE_RESULT_POLICY_FINGERPRINT_HIGH, + "semanticViewsOffset": ENGINE_RESULT_SEMANTICS_OFFSET, + "semanticViewCount": ENGINE_RESULT_SEMANTICS_COUNT, + "resourcesOffset": ENGINE_RESULT_RESOURCES_OFFSET, + "resourceCount": ENGINE_RESULT_RESOURCE_COUNT, + "buffersOffset": ENGINE_RESULT_BUFFERS_OFFSET, + "bufferCount": ENGINE_RESULT_BUFFER_COUNT, + "patchesOffset": ENGINE_RESULT_PATCHES_OFFSET, + "patchCount": ENGINE_RESULT_PATCH_COUNT, + "primitivesOffset": ENGINE_RESULT_PRIMITIVES_OFFSET, + "primitiveCount": ENGINE_RESULT_PRIMITIVE_COUNT, + "drawsOffset": ENGINE_RESULT_DRAWS_OFFSET, + "drawCount": ENGINE_RESULT_DRAW_COUNT, + "retirementsOffset": ENGINE_RESULT_RETIREMENTS_OFFSET, + "retirementCount": ENGINE_RESULT_RETIREMENT_COUNT, + "diagnosticsOffset": ENGINE_RESULT_DIAGNOSTICS_OFFSET, + "diagnosticCount": ENGINE_RESULT_DIAGNOSTIC_COUNT + }, + "engineSemanticView": { + "size": SEMANTIC_RECORD_SIZE, + "alignment": SEMANTIC_RECORD_ALIGNMENT, + "id": SEMANTIC_ID, + "kind": SEMANTIC_KIND, + "flags": SEMANTIC_FLAGS, + "parentId": SEMANTIC_PARENT_ID, + "textStart": SEMANTIC_TEXT_START, + "textEnd": SEMANTIC_TEXT_END, + "itemStart": SEMANTIC_ITEM_START, + "itemCount": SEMANTIC_ITEM_COUNT, + "inlineStart": SEMANTIC_INLINE_START, + "blockStart": SEMANTIC_BLOCK_START, + "inlineExtent": SEMANTIC_INLINE_EXTENT, + "blockExtent": SEMANTIC_BLOCK_EXTENT + }, + "engineResource": { + "size": RESOURCE_RECORD_SIZE, + "alignment": RESOURCE_RECORD_ALIGNMENT, + "id": RESOURCE_ID, + "generation": RESOURCE_GENERATION, + "techniqueId": RESOURCE_TECHNIQUE_ID, + "resourceKind": RESOURCE_KIND, + "action": RESOURCE_ACTION, + "flags": RESOURCE_FLAGS, + "referenceId": RESOURCE_REFERENCE_ID, + "lowerBound": RESOURCE_LOWER_BOUND, + "upperBound": RESOURCE_UPPER_BOUND, + "auxiliary0": RESOURCE_AUXILIARY0, + "auxiliary1": RESOURCE_AUXILIARY1 + }, + "engineBuffer": { + "size": BUFFER_RECORD_SIZE, + "alignment": BUFFER_RECORD_ALIGNMENT, + "id": BUFFER_ID, + "generation": BUFFER_GENERATION, + "programId": BUFFER_PROGRAM_ID, + "policyBufferId": BUFFER_POLICY_BUFFER_ID, + "scalarType": BUFFER_SCALAR_TYPE, + "vectorWidth": BUFFER_VECTOR_WIDTH, + "strategy": BUFFER_STRATEGY, + "flags": BUFFER_FLAGS, + "liveRecords": BUFFER_LIVE_RECORDS, + "capacityRecords": BUFFER_CAPACITY_RECORDS, + "byteLength": BUFFER_BYTE_LENGTH, + "orderBufferId": BUFFER_ORDER_BUFFER_ID + }, + "enginePatch": { + "size": PATCH_RECORD_SIZE, + "alignment": PATCH_RECORD_ALIGNMENT, + "opcode": PATCH_OPCODE, + "flags": PATCH_FLAGS, + "bufferId": PATCH_BUFFER_ID, + "bufferGeneration": PATCH_BUFFER_GENERATION, + "destinationOffset": PATCH_DESTINATION_OFFSET, + "byteLength": PATCH_BYTE_LENGTH, + "payloadOffset": PATCH_PAYLOAD_OFFSET, + "sourceBufferId": PATCH_SOURCE_BUFFER_ID, + "sourceOffset": PATCH_SOURCE_OFFSET, + "fillValue": PATCH_FILL_VALUE + }, + "enginePrimitive": { + "size": PRIMITIVE_RECORD_SIZE, + "alignment": PRIMITIVE_RECORD_ALIGNMENT, + "id": PRIMITIVE_ID, + "kind": PRIMITIVE_KIND, + "flags": PRIMITIVE_FLAGS, + "techniqueId": PRIMITIVE_TECHNIQUE_ID, + "resourceId": PRIMITIVE_RESOURCE_ID, + "resourceGeneration": PRIMITIVE_RESOURCE_GENERATION, + "programId": PRIMITIVE_PROGRAM_ID, + "programVariant": PRIMITIVE_PROGRAM_VARIANT, + "recordCount": PRIMITIVE_RECORD_COUNT, + "bufferId": PRIMITIVE_BUFFER_ID, + "recordIndex": PRIMITIVE_RECORD_INDEX, + "logicalOrder": PRIMITIVE_LOGICAL_ORDER, + "clipId": PRIMITIVE_CLIP_ID, + "semanticId": PRIMITIVE_SEMANTIC_ID, + "inlineStart": PRIMITIVE_INLINE_START, + "blockStart": PRIMITIVE_BLOCK_START, + "inlineExtent": PRIMITIVE_INLINE_EXTENT, + "blockExtent": PRIMITIVE_BLOCK_EXTENT + }, + "engineDraw": { + "size": DRAW_RECORD_SIZE, + "alignment": DRAW_RECORD_ALIGNMENT, + "id": DRAW_ID, + "programId": DRAW_PROGRAM_ID, + "programVariant": DRAW_PROGRAM_VARIANT, + "flags": DRAW_FLAGS, + "materialId": DRAW_MATERIAL_ID, + "clipId": DRAW_CLIP_ID, + "depthKey": DRAW_DEPTH_KEY, + "transformId": DRAW_TRANSFORM_ID, + "primitiveStart": DRAW_PRIMITIVE_START, + "primitiveCount": DRAW_PRIMITIVE_COUNT, + "bufferStart": DRAW_BUFFER_START, + "bufferCount": DRAW_BUFFER_COUNT, + "resourceStart": DRAW_RESOURCE_START, + "resourceCount": DRAW_RESOURCE_COUNT, + "orderToken": DRAW_ORDER_TOKEN, + "indirectBufferId": DRAW_INDIRECT_BUFFER_ID, + "indirectOffset": DRAW_INDIRECT_OFFSET + }, + "engineRetirement": { + "size": RETIREMENT_RECORD_SIZE, + "alignment": RETIREMENT_RECORD_ALIGNMENT, + "kind": RETIREMENT_KIND, + "flags": RETIREMENT_FLAGS, + "id": RETIREMENT_ID, + "generation": RETIREMENT_GENERATION, + "afterPublicationGeneration": RETIREMENT_AFTER_PUBLICATION_GENERATION, + "byteOffset": RETIREMENT_BYTE_OFFSET, + "byteLength": RETIREMENT_BYTE_LENGTH + }, + "engineDiagnostic": { + "size": DIAGNOSTIC_RECORD_SIZE, + "alignment": DIAGNOSTIC_RECORD_ALIGNMENT, + "code": DIAGNOSTIC_CODE, + "severity": DIAGNOSTIC_SEVERITY, + "phase": DIAGNOSTIC_PHASE, + "subjectId": DIAGNOSTIC_SUBJECT_ID, + "value0": DIAGNOSTIC_VALUE0, + "value1": DIAGNOSTIC_VALUE1, + "durationNanosLow": DIAGNOSTIC_DURATION_NANOS_LOW, + "durationNanosHigh": DIAGNOSTIC_DURATION_NANOS_HIGH + }, + "feature": { + "size": FEATURE_RECORD_SIZE, + "alignment": FEATURE_RECORD_ALIGNMENT, + "tag": FEATURE_TAG, + "value": FEATURE_VALUE, + "start": FEATURE_START, + "end": FEATURE_END + }, + }, + "policy": { + "capabilityFlags": { + "storageBuffers": CAP_STORAGE_BUFFERS, + "indirectDraws": CAP_INDIRECT_DRAWS, + "aliasVec2": CAP_ALIAS_VEC2, + "aliasVec4": CAP_ALIAS_VEC4, + "orderedDirect": CAP_ORDERED_DIRECT, + "stableIndirect": CAP_STABLE_INDIRECT + }, + "batchFields": { + "technique": BATCH_TECHNIQUE, + "resource": BATCH_RESOURCE, + "program": BATCH_PROGRAM, + "material": BATCH_MATERIAL, + "clip": BATCH_CLIP, + "depth": BATCH_DEPTH, + "order": BATCH_ORDER, + "transform": BATCH_TRANSFORM + }, + "bufferUsage": { + "vertex": BUFFER_USAGE_VERTEX, + "storage": BUFFER_USAGE_STORAGE, + "copyDst": BUFFER_USAGE_COPY_DST + }, + "allocationStrategies": { + "orderedDirect": ALLOCATION_ORDERED_DIRECT, + "stableIndirect": ALLOCATION_STABLE_INDIRECT + }, + "scalarTypes": { + "f32": ScalarType::F32 as u8, + "u32": ScalarType::U32 as u8, + "u16": ScalarType::U16 as u8 + }, + "inputScopes": { + "semantic": INPUT_SEMANTIC, + "glyph": INPUT_GLYPH, + "resource": INPUT_RESOURCE, + "strike": INPUT_STRIKE + }, + "opcodes": { + "loadF32": OP_LOAD_F32, + "loadU32": OP_LOAD_U32, + "constantF32": OP_CONSTANT_F32, + "constantU32": OP_CONSTANT_U32, + "addF32": OP_ADD_F32, + "subtractF32": OP_SUBTRACT_F32, + "multiplyF32": OP_MULTIPLY_F32, + "lessThanF32": OP_LESS_THAN_F32, + "selectF32": OP_SELECT_F32, + "convertU32ToF32": OP_CONVERT_U32_TO_F32, + "storeF32": OP_STORE_F32, + "storeU32": OP_STORE_U32, + "storeU16": OP_STORE_U16 + } + }, + "engine": { + "defaultSessionTextCapacity": DEFAULT_SESSION_TEXT_CAPACITY, + "frameFlags": { + "compositingIndependent": crate::engine::frame::FRAME_FLAG_COMPOSITING_INDEPENDENT + }, + "semanticF32Fields": { + "inlineStart": SEMANTIC_F32_INLINE_START, + "blockStart": SEMANTIC_F32_BLOCK_START, + "inlineExtent": SEMANTIC_F32_INLINE_EXTENT, + "blockExtent": SEMANTIC_F32_BLOCK_EXTENT, + "fontSize": SEMANTIC_F32_FONT_SIZE, + "rasterPixelRatio": SEMANTIC_F32_RASTER_PIXEL_RATIO, + "inlineOrigin": SEMANTIC_F32_INLINE_ORIGIN, + "blockOrigin": SEMANTIC_F32_BLOCK_ORIGIN, + "foregroundRed": SEMANTIC_F32_FOREGROUND_RED, + "foregroundGreen": SEMANTIC_F32_FOREGROUND_GREEN, + "foregroundBlue": SEMANTIC_F32_FOREGROUND_BLUE, + "foregroundAlpha": SEMANTIC_F32_FOREGROUND_ALPHA, + "inverseFontSize": SEMANTIC_F32_INVERSE_FONT_SIZE + }, + "semanticU32Fields": { + "foregroundRgba": SEMANTIC_U32_FOREGROUND_RGBA, + "clusterId": SEMANTIC_U32_CLUSTER_ID, + "regionId": SEMANTIC_U32_REGION_ID, + "flowThreadId": SEMANTIC_U32_FLOW_THREAD_ID, + "transformIndex": SEMANTIC_U32_TRANSFORM_INDEX, + "stableGlyphId": SEMANTIC_U32_STABLE_GLYPH_ID + }, + "paragraphMutationOpcodes": { + "upsert": PARAGRAPH_MUTATION_UPSERT, + "remove": PARAGRAPH_MUTATION_REMOVE + }, + "textMutationOpcodes": { + "replaceUtf16": TEXT_MUTATION_REPLACE_UTF16 + }, + "textEncodings": { + "utf16Le": TEXT_ENCODING_UTF16_LE + }, + "styleMutationOpcodes": { + "upsert": STYLE_MUTATION_UPSERT, + "remove": STYLE_MUTATION_REMOVE + }, + "styleFlags": { + "root": STYLE_FLAG_ROOT + }, + "styleFields": { + "fontStack": STYLE_FIELD_FONT_STACK, + "material": STYLE_FIELD_MATERIAL, + "language": STYLE_FIELD_LANGUAGE, + "features": STYLE_FIELD_FEATURES, + "fontSize": STYLE_FIELD_FONT_SIZE, + "lineHeight": STYLE_FIELD_LINE_HEIGHT, + "letterSpacing": STYLE_FIELD_LETTER_SPACING, + "wordSpacing": STYLE_FIELD_WORD_SPACING, + "baselineShift": STYLE_FIELD_BASELINE_SHIFT, + "rasterPixelRatio": STYLE_FIELD_RASTER_PIXEL_RATIO, + "direction": STYLE_FIELD_DIRECTION, + "foreground": STYLE_FIELD_FOREGROUND, + "decoration": STYLE_FIELD_DECORATION, + "all": STYLE_FIELD_MASK + }, + "decorationStyles": { + "none": DECORATION_NONE, + "solid": DECORATION_SOLID, + "double": DECORATION_DOUBLE, + "dotted": DECORATION_DOTTED, + "dashed": DECORATION_DASHED, + "wavy": DECORATION_WAVY + }, + "decorationFlags": { + "underline": DECORATION_UNDERLINE, + "overline": DECORATION_OVERLINE, + "lineThrough": DECORATION_LINE_THROUGH, + "skipInk": DECORATION_SKIP_INK, + "all": DECORATION_FLAGS_MASK + }, + "flowShapeKinds": { + "rectangle": SHAPE_RECTANGLE, + "polygon": SHAPE_POLYGON + }, + "writingModes": { + "horizontalTb": WRITING_HORIZONTAL_TB, + "verticalRl": WRITING_VERTICAL_RL, + "verticalLr": WRITING_VERTICAL_LR + }, + "textOrientations": { + "mixed": ORIENTATION_MIXED, + "upright": ORIENTATION_UPRIGHT, + "sideways": ORIENTATION_SIDEWAYS + }, + "axisModes": { + "unconstrained": AXIS_UNCONSTRAINED, + "atMost": AXIS_AT_MOST, + "exact": AXIS_EXACT + }, + "wrapModes": { + "none": WRAP_NONE, + "word": WRAP_WORD, + "character": WRAP_CHARACTER + }, + "inlineAlignments": { + "start": ALIGN_START, + "center": ALIGN_CENTER, + "end": ALIGN_END, + "justify": ALIGN_JUSTIFY + }, + "overflowModes": { + "visible": OVERFLOW_VISIBLE, + "clip": OVERFLOW_CLIP, + "ellipsis": OVERFLOW_ELLIPSIS + }, + "blockAlignments": { + "start": BLOCK_ALIGN_START, + "center": BLOCK_ALIGN_CENTER, + "end": BLOCK_ALIGN_END + }, + "exclusionWrapSides": { + "both": EXCLUSION_WRAP_BOTH, + "inlineStart": EXCLUSION_WRAP_INLINE_START, + "inlineEnd": EXCLUSION_WRAP_INLINE_END, + "largest": EXCLUSION_WRAP_LARGEST + }, + "inlineObjectBaselines": { + "alphabetic": BASELINE_ALPHABETIC, + "textTop": BASELINE_TEXT_TOP, + "middle": BASELINE_MIDDLE, + "textBottom": BASELINE_TEXT_BOTTOM + }, + "resultFlags": { + "checkpoint": RESULT_FLAG_CHECKPOINT + }, + "semanticViewMasks": { + "all": SEMANTIC_VIEW_MASK, + "measurement": SEMANTIC_VIEW_MEASUREMENT, + "layoutInspection": SEMANTIC_VIEW_LAYOUT_INSPECTION + }, + "measurementFlags": { + "overflowed": crate::engine::layout_query::MEASUREMENT_FLAG_OVERFLOWED + }, + "semanticKinds": { + "line": SEMANTIC_LINE, + "fragment": SEMANTIC_FRAGMENT, + "run": SEMANTIC_RUN, + "cluster": SEMANTIC_CLUSTER, + "caret": SEMANTIC_CARET, + "selection": SEMANTIC_SELECTION, + "insertedGlyph": SEMANTIC_INSERTED_GLYPH, + "paragraphMeasurement": SEMANTIC_PARAGRAPH_MEASUREMENT, + "glyph": SEMANTIC_GLYPH + }, + "resourceActions": { + "create": RESOURCE_ACTION_CREATE, + "update": RESOURCE_ACTION_UPDATE, + "retain": RESOURCE_ACTION_RETAIN + }, + "bufferStrategies": { + "orderedDirect": BUFFER_ORDERED_DIRECT, + "stableIndirect": BUFFER_STABLE_INDIRECT + }, + "internalBufferBindings": { + "order": POLICY_BUFFER_ORDER + }, + "patchOpcodes": { + "allocateOrResize": PATCH_ALLOCATE_OR_RESIZE, + "write": PATCH_WRITE, + "fill": PATCH_FILL, + "copy": PATCH_COPY, + "retire": PATCH_RETIRE + }, + "primitiveKinds": { + "glyph": PRIMITIVE_GLYPH, + "decoration": PRIMITIVE_DECORATION, + "inlineObject": PRIMITIVE_INLINE_OBJECT, + "clip": PRIMITIVE_CLIP, + "policy": PRIMITIVE_POLICY + }, + "retirementKinds": { + "resource": RETIRE_RESOURCE, + "buffer": RETIRE_BUFFER, + "slotRange": RETIRE_SLOT_RANGE, + "outputBytes": RETIRE_OUTPUT_BYTES } }, "status": { @@ -365,7 +2534,14 @@ pub fn json() -> String { "handleConflict": 4, "fontMissing": 5, "invalidRequest": 6, - "resultTooLarge": 7 + "resultTooLarge": 7, + "policyConflict": 8, + "policyMissing": 9, + "sessionConflict": 10, + "sessionMissing": 11, + "revisionConflict": 12, + "fontStackMissing": 13, + "fontInUse": 14 } }) .to_string() diff --git a/packages/text/rust/shaper/src/bidi.rs b/packages/text/rust/shaper/src/bidi.rs index eacc0120..6f9b0610 100644 --- a/packages/text/rust/shaper/src/bidi.rs +++ b/packages/text/rust/shaper/src/bidi.rs @@ -20,15 +20,34 @@ pub enum BidiError { ResultTooLarge, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BidiRun { + pub text_start: u32, + pub text_end: u32, + pub level: u8, +} + +#[derive(Default)] pub struct BidiAnalysis { pub levels: Vec, pub classes: Vec, pub paragraph_starts: Vec, pub paragraph_ends: Vec, pub paragraph_levels: Vec, + pub runs: Vec, } pub fn analyze(text: &[u16], direction: u8) -> Result { + let mut output = BidiAnalysis::default(); + analyze_into(text, direction, &mut output)?; + Ok(output) +} + +pub fn analyze_into( + text: &[u16], + direction: u8, + output: &mut BidiAnalysis, +) -> Result<(), BidiError> { let default_level = match direction { DIRECTION_AUTO => None, DIRECTION_LTR => Some(LTR_LEVEL), @@ -36,34 +55,83 @@ pub fn analyze(text: &[u16], direction: u8) -> Result { _ => return Err(BidiError::InvalidDirection), }; let info = BidiInfo::new_with_data_source(&Unicode17BidiData, text, default_level); - let mut paragraph_starts = Vec::with_capacity(info.paragraphs.len().max(1)); - let mut paragraph_ends = Vec::with_capacity(info.paragraphs.len().max(1)); - let mut paragraph_levels = Vec::with_capacity(info.paragraphs.len().max(1)); + output.clear(); + output.reserve_for_analysis(text.len(), info.paragraphs.len().max(1))?; if info.paragraphs.is_empty() { - paragraph_starts.push(0); - paragraph_ends.push(0); - paragraph_levels.push(default_level.unwrap_or(LTR_LEVEL).number()); + output.paragraph_starts.push(0); + output.paragraph_ends.push(0); + output + .paragraph_levels + .push(default_level.unwrap_or(LTR_LEVEL).number()); } else { for paragraph in &info.paragraphs { - paragraph_starts + output + .paragraph_starts .push(u32::try_from(paragraph.range.start).map_err(|_| BidiError::ResultTooLarge)?); - paragraph_ends + output + .paragraph_ends .push(u32::try_from(paragraph.range.end).map_err(|_| BidiError::ResultTooLarge)?); - paragraph_levels.push(paragraph.level.number()); + output.paragraph_levels.push(paragraph.level.number()); } } - Ok(BidiAnalysis { - levels: info.levels.iter().map(|level| level.number()).collect(), - classes: info - .original_classes - .iter() - .copied() - .map(class_code) - .collect(), - paragraph_starts, - paragraph_ends, - paragraph_levels, - }) + output + .levels + .extend(info.levels.iter().map(|level| level.number())); + output + .classes + .extend(info.original_classes.iter().copied().map(class_code)); + let mut start = 0usize; + while start < output.levels.len() { + let level = output.levels[start]; + let mut end = start + 1; + while end < output.levels.len() && output.levels[end] == level { + end += 1; + } + output.runs.push(BidiRun { + text_start: u32::try_from(start).map_err(|_| BidiError::ResultTooLarge)?, + text_end: u32::try_from(end).map_err(|_| BidiError::ResultTooLarge)?, + level, + }); + start = end; + } + Ok(()) +} + +impl BidiAnalysis { + pub fn reserve(&mut self, text_capacity: usize) -> Result<(), BidiError> { + self.reserve_for_analysis(text_capacity, 1) + } + + fn reserve_for_analysis( + &mut self, + text_capacity: usize, + paragraph_capacity: usize, + ) -> Result<(), BidiError> { + reserve(&mut self.levels, text_capacity)?; + reserve(&mut self.classes, text_capacity)?; + reserve(&mut self.runs, text_capacity)?; + reserve(&mut self.paragraph_starts, paragraph_capacity)?; + reserve(&mut self.paragraph_ends, paragraph_capacity)?; + reserve(&mut self.paragraph_levels, paragraph_capacity) + } + + pub(crate) fn clear(&mut self) { + self.levels.clear(); + self.classes.clear(); + self.paragraph_starts.clear(); + self.paragraph_ends.clear(); + self.paragraph_levels.clear(); + self.runs.clear(); + } +} + +fn reserve(values: &mut Vec, capacity: usize) -> Result<(), BidiError> { + if values.capacity() < capacity { + values + .try_reserve_exact(capacity.saturating_sub(values.len())) + .map_err(|_| BidiError::ResultTooLarge)?; + } + Ok(()) } pub struct Unicode17BidiData; @@ -160,4 +228,32 @@ mod tests { assert_eq!(result.levels[1], result.levels[2]); assert_eq!(result.classes[1], result.classes[2]); } + + #[test] + fn analysis_reuses_output_and_builds_equal_level_runs() { + let text: Vec = "abc אבג".encode_utf16().collect(); + let mut result = BidiAnalysis::default(); + result.reserve(32).unwrap(); + analyze_into(&text, DIRECTION_AUTO, &mut result).unwrap(); + let capacities = [ + result.levels.capacity(), + result.classes.capacity(), + result.runs.capacity(), + ]; + assert_eq!(result.runs.first().map(|run| run.text_start), Some(0)); + assert_eq!( + result.runs.last().map(|run| run.text_end), + Some(text.len() as u32) + ); + assert!(result.runs.iter().any(|run| run.level & 1 == 1)); + analyze_into(&text, DIRECTION_AUTO, &mut result).unwrap(); + assert_eq!( + [ + result.levels.capacity(), + result.classes.capacity(), + result.runs.capacity(), + ], + capacities + ); + } } diff --git a/packages/text/rust/shaper/src/engine/cluster_state.rs b/packages/text/rust/shaper/src/engine/cluster_state.rs new file mode 100644 index 00000000..94e75554 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/cluster_state.rs @@ -0,0 +1,1021 @@ +use alloc::vec::Vec; + +use crate::{FontMetrics, unicode::UnicodeAnalysis}; + +use super::{ + EngineError, + identity_index::{IdentityIndex, IdentityIndexError}, + shaping_state::{ShapeArena, ShapingRun}, + style_state::StyleSegment, +}; + +pub(crate) const CLUSTER_SAFE_BEFORE: u8 = 1 << 0; +pub(crate) const CLUSTER_REQUIRED_BREAK: u8 = 1 << 1; +pub(crate) const CLUSTER_HARD_BREAK: u8 = 1 << 2; +pub(crate) const CLUSTER_ALLOWED_BREAK: u8 = 1 << 3; + +const GLYPH_UNSAFE_TO_BREAK: u16 = 1; +const NO_SOURCE_RUN: u32 = u32::MAX; + +#[derive(Default)] +pub(crate) struct ClusterArena { + pub starts: Vec, + pub ends: Vec, + pub advances: Vec, + pub flags: Vec, + pub style_indexes: Vec, + pub source_runs: Vec, + pub binding_handles: Vec, + pub font_handles: Vec, + pub stable_ids: Vec, + pub glyph_starts: Vec, + pub glyph_counts: Vec, + pub glyph_indices: Vec, + pub glyph_stable_ids: Vec, + pub index_at: Vec, + pub(super) shaped: Vec, + pub(super) unsafe_before: Vec, +} + +pub(crate) struct ClusterBuildInput<'a> { + pub text: &'a [u16], + pub text_unit_ids: &'a [u32], + pub unicode: &'a UnicodeAnalysis, + pub styles: &'a [StyleSegment], + pub runs: &'a [ShapingRun], + pub shape: &'a ShapeArena, +} + +impl ClusterArena { + pub(crate) fn reserve(&mut self, capacity: usize) -> Result<(), EngineError> { + reserve(&mut self.starts, capacity)?; + reserve(&mut self.ends, capacity)?; + reserve(&mut self.advances, capacity)?; + reserve(&mut self.flags, capacity)?; + reserve(&mut self.style_indexes, capacity)?; + reserve(&mut self.source_runs, capacity)?; + reserve(&mut self.binding_handles, capacity)?; + reserve(&mut self.font_handles, capacity)?; + reserve(&mut self.stable_ids, capacity)?; + reserve(&mut self.glyph_starts, capacity)?; + reserve(&mut self.glyph_counts, capacity)?; + reserve(&mut self.glyph_indices, capacity.saturating_mul(2))?; + reserve(&mut self.glyph_stable_ids, capacity.saturating_mul(2))?; + reserve(&mut self.index_at, capacity.saturating_add(1))?; + reserve(&mut self.shaped, capacity)?; + reserve(&mut self.unsafe_before, capacity) + } + + pub(crate) fn build( + &mut self, + input: ClusterBuildInput<'_>, + metrics_for: impl Fn(u32) -> Option, + ) -> Result<(), EngineError> { + let ClusterBuildInput { + text, + text_unit_ids, + unicode, + styles, + runs, + shape, + } = input; + self.clear(); + if text.len() != text_unit_ids.len() || text_unit_ids.contains(&0) { + return Err(EngineError::InvalidRequest); + } + let boundaries = unicode.grapheme_boundaries(); + let count = boundaries.len().saturating_sub(1); + self.reserve(text.len().max(count))?; + let mut style_index = 0usize; + for boundaries in boundaries.windows(2) { + let start = boundaries[0]; + let end = boundaries[1]; + while styles + .get(style_index) + .is_some_and(|style| style.text_end <= start) + { + style_index += 1; + } + let style = styles.get(style_index).ok_or(EngineError::InvalidRequest)?; + if style.text_start > start || style.text_end < end { + return Err(EngineError::InvalidRequest); + } + let hard_break = is_hard_break(text, start)?; + let word_spacing = if text.get(start as usize) == Some(&0x20) { + style.style.word_spacing + } else { + 0.0 + }; + self.starts.push(start); + self.ends.push(end); + self.advances.push(if hard_break { + 0.0 + } else { + f64::from(style.style.letter_spacing + word_spacing) + }); + self.flags + .push(if hard_break { CLUSTER_HARD_BREAK } else { 0 }); + self.style_indexes + .push(u32::try_from(style_index).map_err(|_| EngineError::ResultTooLarge)?); + self.source_runs.push(NO_SOURCE_RUN); + self.binding_handles.push(0); + self.font_handles.push(0); + self.stable_ids.push( + *text_unit_ids + .get(usize::try_from(start).map_err(|_| EngineError::InvalidRequest)?) + .ok_or(EngineError::InvalidRequest)?, + ); + self.glyph_starts.push(0); + self.glyph_counts.push(0); + self.shaped.push(0); + self.unsafe_before.push(0); + } + self.build_index(text.len())?; + self.aggregate_shape(runs, shape, metrics_for)?; + self.apply_break_flags(unicode)?; + Ok(()) + } + + pub(crate) fn rebuild_source_run_if_topology_is_stable( + &mut self, + previous: &Self, + input: ClusterBuildInput<'_>, + source_run: u32, + metrics_for: impl Fn(u32) -> Option, + ) -> Result, EngineError> { + let ClusterBuildInput { + text, + text_unit_ids, + unicode, + styles, + runs, + shape, + } = input; + let boundaries = unicode.grapheme_boundaries(); + if text.len() != text_unit_ids.len() + || text_unit_ids.contains(&0) + || previous.index_at.len() != text.len().saturating_add(1) + || boundaries.len().saturating_sub(1) != previous.starts.len() + || boundaries + .windows(2) + .zip(&previous.starts) + .any(|(pair, start)| pair[0] != *start) + || boundaries + .windows(2) + .zip(&previous.ends) + .any(|(pair, end)| pair[1] != *end) + || shape.glyph_ids.len() != previous.glyph_indices.len() + || [ + shape.clusters.len(), + shape.x_advances.len(), + shape.y_advances.len(), + shape.x_offsets.len(), + shape.y_offsets.len(), + shape.glyph_flags.len(), + ] + .iter() + .any(|length| *length != shape.glyph_ids.len()) + { + return Ok(None); + } + let source_index = usize::try_from(source_run).map_err(|_| EngineError::InvalidRequest)?; + let source = *runs.get(source_index).ok_or(EngineError::InvalidRequest)?; + let cluster_start = previous + .starts + .binary_search(&source.text_start) + .map_err(|_| EngineError::InvalidRequest)?; + let cluster_end = previous + .ends + .binary_search(&source.text_end) + .map(|index| index + 1) + .map_err(|_| EngineError::InvalidRequest)?; + if cluster_start >= cluster_end + || shape.runs.iter().any(|run| { + let start = usize::try_from(run.glyph_start).ok(); + let end = start.and_then(|start| { + usize::try_from(run.glyph_count) + .ok() + .and_then(|count| start.checked_add(count)) + }); + start.is_none() + || end.is_none() + || end.is_some_and(|end| end > shape.glyph_ids.len()) + }) + { + return Ok(None); + } + self.copy_from(previous)?; + for cluster in cluster_start..cluster_end { + let start = self.starts[cluster]; + let end = self.ends[cluster]; + let style_index = usize::try_from(self.style_indexes[cluster]) + .map_err(|_| EngineError::InvalidRequest)?; + let style = styles.get(style_index).ok_or(EngineError::InvalidRequest)?; + if style.text_start > start || style.text_end < end { + self.clear(); + return Ok(None); + } + let hard_break = is_hard_break(text, start)?; + let word_spacing = if text.get(start as usize) == Some(&0x20) { + style.style.word_spacing + } else { + 0.0 + }; + self.advances[cluster] = if hard_break { + 0.0 + } else { + f64::from(style.style.letter_spacing + word_spacing) + }; + self.flags[cluster] = if hard_break { CLUSTER_HARD_BREAK } else { 0 }; + self.source_runs[cluster] = NO_SOURCE_RUN; + self.binding_handles[cluster] = 0; + self.font_handles[cluster] = 0; + self.stable_ids[cluster] = *text_unit_ids + .get(usize::try_from(start).map_err(|_| EngineError::InvalidRequest)?) + .ok_or(EngineError::InvalidRequest)?; + self.glyph_counts[cluster] = 0; + self.shaped[cluster] = 0; + self.unsafe_before[cluster] = 0; + } + for shaped_run in shape.runs.iter().filter(|run| run.source_run == source_run) { + let metrics = metrics_for(shaped_run.font_handle).ok_or(EngineError::InvalidRequest)?; + if metrics.units_per_em == 0 { + return Err(EngineError::InvalidRequest); + } + let scale = f64::from(source.style.font_size) / f64::from(metrics.units_per_em); + let glyph_start = + usize::try_from(shaped_run.glyph_start).map_err(|_| EngineError::InvalidRequest)?; + let glyph_end = glyph_start + .checked_add( + usize::try_from(shaped_run.glyph_count) + .map_err(|_| EngineError::InvalidRequest)?, + ) + .ok_or(EngineError::InvalidRequest)?; + for glyph in glyph_start..glyph_end { + let cluster = self.cluster_at(shape.clusters[glyph])?; + if cluster < cluster_start || cluster >= cluster_end { + return Err(EngineError::InvalidRequest); + } + if self.source_runs[cluster] == NO_SOURCE_RUN { + self.source_runs[cluster] = source_run; + self.binding_handles[cluster] = shaped_run.binding_handle; + self.font_handles[cluster] = shaped_run.font_handle; + } else if self.source_runs[cluster] != source_run + || self.binding_handles[cluster] != shaped_run.binding_handle + || self.font_handles[cluster] != shaped_run.font_handle + { + return Err(EngineError::InvalidRequest); + } + self.shaped[cluster] = 1; + self.glyph_counts[cluster] = self.glyph_counts[cluster] + .checked_add(1) + .ok_or(EngineError::ResultTooLarge)?; + self.unsafe_before[cluster] |= + u8::from(shape.glyph_flags[glyph] & GLYPH_UNSAFE_TO_BREAK != 0); + self.advances[cluster] += f64::from(shape.x_advances[glyph].unsigned_abs()) * scale; + } + } + let adjacency_start = usize::try_from(previous.glyph_starts[cluster_start]) + .map_err(|_| EngineError::InvalidRequest)?; + let adjacency_end = usize::try_from(previous.glyph_starts[cluster_end - 1]) + .ok() + .and_then(|start| { + usize::try_from(previous.glyph_counts[cluster_end - 1]) + .ok() + .and_then(|count| start.checked_add(count)) + }) + .ok_or(EngineError::InvalidRequest)?; + let mut cursor = adjacency_start; + for cluster in cluster_start..cluster_end { + self.glyph_starts[cluster] = + u32::try_from(cursor).map_err(|_| EngineError::ResultTooLarge)?; + cursor = cursor + .checked_add( + usize::try_from(self.glyph_counts[cluster]) + .map_err(|_| EngineError::InvalidRequest)?, + ) + .ok_or(EngineError::ResultTooLarge)?; + self.glyph_counts[cluster] = 0; + } + if cursor != adjacency_end { + self.clear(); + return Ok(None); + } + for shaped_run in shape.runs.iter().filter(|run| run.source_run == source_run) { + let glyph_start = + usize::try_from(shaped_run.glyph_start).map_err(|_| EngineError::InvalidRequest)?; + let glyph_end = glyph_start + .checked_add( + usize::try_from(shaped_run.glyph_count) + .map_err(|_| EngineError::InvalidRequest)?, + ) + .ok_or(EngineError::InvalidRequest)?; + for glyph in glyph_start..glyph_end { + let cluster = self.cluster_at(shape.clusters[glyph])?; + let ordinal = usize::try_from(self.glyph_counts[cluster]) + .map_err(|_| EngineError::InvalidRequest)?; + let destination = usize::try_from(self.glyph_starts[cluster]) + .ok() + .and_then(|start| start.checked_add(ordinal)) + .ok_or(EngineError::ResultTooLarge)?; + self.glyph_indices[destination] = + u32::try_from(glyph).map_err(|_| EngineError::ResultTooLarge)?; + self.glyph_counts[cluster] = self.glyph_counts[cluster] + .checked_add(1) + .ok_or(EngineError::ResultTooLarge)?; + } + } + for cluster in cluster_start..cluster_end { + if self.shaped[cluster] != 0 && self.unsafe_before[cluster] == 0 { + self.flags[cluster] |= CLUSTER_SAFE_BEFORE; + } + } + if cluster_start > 0 { + self.flags[cluster_start - 1] &= !CLUSTER_ALLOWED_BREAK; + } + for line_break in unicode.line_breaks() { + let Ok(preceding) = self.ends.binary_search(&line_break.position) else { + continue; + }; + if preceding < cluster_start.saturating_sub(1) || preceding >= cluster_end { + continue; + } + if line_break.required { + self.flags[preceding] |= CLUSTER_REQUIRED_BREAK; + } else { + let safe = line_break.position == self.ends.last().copied().unwrap_or(0) + || self + .starts + .binary_search(&line_break.position) + .ok() + .is_some_and(|next| self.flags[next] & CLUSTER_SAFE_BEFORE != 0); + if safe { + self.flags[preceding] |= CLUSTER_ALLOWED_BREAK; + } + } + } + Ok(Some((cluster_start, cluster_end))) + } + + fn copy_from(&mut self, source: &Self) -> Result<(), EngineError> { + self.clear(); + self.reserve(source.starts.len())?; + reserve(&mut self.glyph_indices, source.glyph_indices.len())?; + reserve(&mut self.glyph_stable_ids, source.glyph_stable_ids.len())?; + reserve(&mut self.index_at, source.index_at.len())?; + macro_rules! copy_lane { + ($field:ident) => { + self.$field.extend_from_slice(&source.$field); + }; + } + copy_lane!(starts); + copy_lane!(ends); + copy_lane!(advances); + copy_lane!(flags); + copy_lane!(style_indexes); + copy_lane!(source_runs); + copy_lane!(binding_handles); + copy_lane!(font_handles); + copy_lane!(stable_ids); + copy_lane!(glyph_starts); + copy_lane!(glyph_counts); + copy_lane!(glyph_indices); + copy_lane!(glyph_stable_ids); + copy_lane!(index_at); + copy_lane!(shaped); + copy_lane!(unsafe_before); + Ok(()) + } + + pub(crate) fn assign_stable_glyph_ids_in_range( + &mut self, + previous: &Self, + cluster_start: usize, + cluster_end: usize, + index: &mut IdentityIndex, + next_id: &mut u32, + ) -> Result<(), EngineError> { + index + .prepare(cluster_end.saturating_sub(cluster_start)) + .map_err(identity_index_error)?; + for cluster in cluster_start..cluster_end { + index + .insert( + previous.stable_ids[cluster], + u32::try_from(cluster).map_err(|_| EngineError::ResultTooLarge)?, + ) + .map_err(identity_index_error)?; + } + *next_id = (*next_id).max(1); + for cluster in cluster_start..cluster_end { + let new_start = usize::try_from(self.glyph_starts[cluster]) + .map_err(|_| EngineError::InvalidRequest)?; + let new_count = usize::try_from(self.glyph_counts[cluster]) + .map_err(|_| EngineError::InvalidRequest)?; + let previous_cluster = index + .get(self.stable_ids[cluster]) + .and_then(|value| usize::try_from(value).ok()); + let previous_start = previous_cluster + .and_then(|cluster| previous.glyph_starts.get(cluster)) + .copied() + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or(0); + let previous_count = previous_cluster + .and_then(|cluster| previous.glyph_counts.get(cluster)) + .copied() + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or(0); + for ordinal in 0..new_count { + self.glyph_stable_ids[new_start + ordinal] = if ordinal < previous_count { + previous.glyph_stable_ids[previous_start + ordinal] + } else { + let allocated = *next_id; + *next_id = next_id.checked_add(1).ok_or(EngineError::ResultTooLarge)?; + allocated + }; + } + } + Ok(()) + } + + pub(crate) fn assign_stable_glyph_ids( + &mut self, + previous: &Self, + index: &mut IdentityIndex, + next_id: &mut u32, + ) -> Result<(), EngineError> { + index + .prepare(previous.stable_ids.len()) + .map_err(identity_index_error)?; + for (cluster, &stable_id) in previous.stable_ids.iter().enumerate() { + index + .insert( + stable_id, + u32::try_from(cluster).map_err(|_| EngineError::ResultTooLarge)?, + ) + .map_err(identity_index_error)?; + } + reserve(&mut self.glyph_stable_ids, self.glyph_indices.len())?; + self.glyph_stable_ids.resize(self.glyph_indices.len(), 0); + *next_id = (*next_id).max(1); + for cluster in 0..self.stable_ids.len() { + let new_start = usize::try_from(self.glyph_starts[cluster]) + .map_err(|_| EngineError::InvalidRequest)?; + let new_count = usize::try_from(self.glyph_counts[cluster]) + .map_err(|_| EngineError::InvalidRequest)?; + let previous_cluster = index + .get(self.stable_ids[cluster]) + .and_then(|value| usize::try_from(value).ok()); + let previous_start = previous_cluster + .and_then(|cluster| previous.glyph_starts.get(cluster)) + .copied() + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or(0); + let previous_count = previous_cluster + .and_then(|cluster| previous.glyph_counts.get(cluster)) + .copied() + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or(0); + for ordinal in 0..new_count { + let stable_id = if ordinal < previous_count { + *previous + .glyph_stable_ids + .get(previous_start + ordinal) + .filter(|id| **id != 0) + .ok_or(EngineError::InvalidRequest)? + } else { + let allocated = *next_id; + *next_id = next_id.checked_add(1).ok_or(EngineError::ResultTooLarge)?; + allocated + }; + *self + .glyph_stable_ids + .get_mut(new_start + ordinal) + .ok_or(EngineError::InvalidRequest)? = stable_id; + } + } + Ok(()) + } + + pub(crate) fn clear(&mut self) { + self.starts.clear(); + self.ends.clear(); + self.advances.clear(); + self.flags.clear(); + self.style_indexes.clear(); + self.source_runs.clear(); + self.binding_handles.clear(); + self.font_handles.clear(); + self.stable_ids.clear(); + self.glyph_starts.clear(); + self.glyph_counts.clear(); + self.glyph_indices.clear(); + self.glyph_stable_ids.clear(); + self.index_at.clear(); + self.shaped.clear(); + self.unsafe_before.clear(); + } + + fn build_index(&mut self, text_length: usize) -> Result<(), EngineError> { + let mut cluster = 0usize; + for offset in 0..=text_length { + while self + .starts + .get(cluster) + .is_some_and(|start| *start < offset as u32) + { + cluster += 1; + } + self.index_at + .push(u32::try_from(cluster).map_err(|_| EngineError::ResultTooLarge)?); + } + Ok(()) + } + + fn aggregate_shape( + &mut self, + runs: &[ShapingRun], + shape: &ShapeArena, + metrics_for: impl Fn(u32) -> Option, + ) -> Result<(), EngineError> { + if shape.glyph_ids.len() != shape.clusters.len() { + return Err(EngineError::InvalidRequest); + } + reserve(&mut self.glyph_indices, shape.glyph_ids.len())?; + self.glyph_indices.resize(shape.glyph_ids.len(), 0); + for shaped_run in &shape.runs { + let source_index = + usize::try_from(shaped_run.source_run).map_err(|_| EngineError::InvalidRequest)?; + let source = runs.get(source_index).ok_or(EngineError::InvalidRequest)?; + let metrics = metrics_for(shaped_run.font_handle).ok_or(EngineError::InvalidRequest)?; + if metrics.units_per_em == 0 { + return Err(EngineError::InvalidRequest); + } + let scale = f64::from(source.style.font_size) / f64::from(metrics.units_per_em); + let start = + usize::try_from(shaped_run.glyph_start).map_err(|_| EngineError::InvalidRequest)?; + let end = start + .checked_add( + usize::try_from(shaped_run.glyph_count) + .map_err(|_| EngineError::InvalidRequest)?, + ) + .ok_or(EngineError::InvalidRequest)?; + for glyph in start..end { + let cluster = *shape + .clusters + .get(glyph) + .ok_or(EngineError::InvalidRequest)?; + let cluster_index = self.cluster_at(cluster)?; + let source_slot = &mut self.source_runs[cluster_index]; + let binding_slot = &mut self.binding_handles[cluster_index]; + let font_slot = &mut self.font_handles[cluster_index]; + if *source_slot == NO_SOURCE_RUN { + *source_slot = shaped_run.source_run; + *binding_slot = shaped_run.binding_handle; + *font_slot = shaped_run.font_handle; + } else if *source_slot != shaped_run.source_run + || *binding_slot != shaped_run.binding_handle + || *font_slot != shaped_run.font_handle + { + return Err(EngineError::InvalidRequest); + } + self.shaped[cluster_index] = 1; + self.glyph_counts[cluster_index] = self.glyph_counts[cluster_index] + .checked_add(1) + .ok_or(EngineError::ResultTooLarge)?; + self.unsafe_before[cluster_index] |= u8::from( + shape + .glyph_flags + .get(glyph) + .is_some_and(|flags| flags & GLYPH_UNSAFE_TO_BREAK != 0), + ); + self.advances[cluster_index] += f64::from( + shape + .x_advances + .get(glyph) + .copied() + .ok_or(EngineError::InvalidRequest)? + .unsigned_abs(), + ) * scale; + } + } + let mut glyph_start = 0_u32; + for index in 0..self.glyph_starts.len() { + self.glyph_starts[index] = glyph_start; + glyph_start = glyph_start + .checked_add(self.glyph_counts[index]) + .ok_or(EngineError::ResultTooLarge)?; + self.glyph_counts[index] = 0; + } + if usize::try_from(glyph_start).ok() != Some(shape.glyph_ids.len()) { + return Err(EngineError::InvalidRequest); + } + for shaped_run in &shape.runs { + let start = + usize::try_from(shaped_run.glyph_start).map_err(|_| EngineError::InvalidRequest)?; + let end = start + .checked_add( + usize::try_from(shaped_run.glyph_count) + .map_err(|_| EngineError::InvalidRequest)?, + ) + .ok_or(EngineError::InvalidRequest)?; + for glyph in start..end { + let cluster = *shape + .clusters + .get(glyph) + .ok_or(EngineError::InvalidRequest)?; + let cluster_index = self.cluster_at(cluster)?; + let ordinal = self.glyph_counts[cluster_index]; + let destination = self.glyph_starts[cluster_index] + .checked_add(ordinal) + .and_then(|value| usize::try_from(value).ok()) + .ok_or(EngineError::ResultTooLarge)?; + *self + .glyph_indices + .get_mut(destination) + .ok_or(EngineError::InvalidRequest)? = + u32::try_from(glyph).map_err(|_| EngineError::ResultTooLarge)?; + self.glyph_counts[cluster_index] = + ordinal.checked_add(1).ok_or(EngineError::ResultTooLarge)?; + } + } + for index in 0..self.starts.len() { + if self.shaped[index] != 0 && self.unsafe_before[index] == 0 { + self.flags[index] |= CLUSTER_SAFE_BEFORE; + } + } + Ok(()) + } + + fn apply_break_flags(&mut self, unicode: &UnicodeAnalysis) -> Result<(), EngineError> { + for line_break in unicode.line_breaks() { + let end = line_break.position; + if self.ends.is_empty() && end == 0 { + continue; + } + let preceding = self + .ends + .binary_search(&end) + .map_err(|_| EngineError::InvalidRequest)?; + if line_break.required { + self.flags[preceding] |= CLUSTER_REQUIRED_BREAK; + continue; + } + let safe = end == self.ends.last().copied().unwrap_or(0) + || self + .starts + .binary_search(&end) + .ok() + .is_some_and(|next| self.flags[next] & CLUSTER_SAFE_BEFORE != 0); + if safe { + self.flags[preceding] |= CLUSTER_ALLOWED_BREAK; + } + } + Ok(()) + } + + fn cluster_at(&self, offset: u32) -> Result { + let index = *self + .index_at + .get(usize::try_from(offset).map_err(|_| EngineError::InvalidRequest)?) + .ok_or(EngineError::InvalidRequest)?; + let index = usize::try_from(index).map_err(|_| EngineError::InvalidRequest)?; + if self.starts.get(index) != Some(&offset) { + return Err(EngineError::InvalidRequest); + } + Ok(index) + } +} + +fn is_hard_break(text: &[u16], start: u32) -> Result { + let unit = *text + .get(usize::try_from(start).map_err(|_| EngineError::InvalidRequest)?) + .ok_or(EngineError::InvalidRequest)?; + Ok(matches!( + unit, + 0x0a | 0x0b | 0x0c | 0x0d | 0x85 | 0x2028 | 0x2029 + )) +} + +fn reserve(values: &mut Vec, capacity: usize) -> Result<(), EngineError> { + if values.capacity() < capacity { + values + .try_reserve_exact(capacity.saturating_sub(values.len())) + .map_err(|_| EngineError::ResultTooLarge)?; + } + Ok(()) +} + +fn identity_index_error(error: IdentityIndexError) -> EngineError { + match error { + IdentityIndexError::AllocationFailed | IdentityIndexError::ArithmeticOverflow => { + EngineError::ResultTooLarge + } + IdentityIndexError::DuplicateIdentity => EngineError::InvalidRequest, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::{ + shaping_state::{ShapedRun, ShapingRun}, + style_state::{ResolvedStyle, StyleSegment}, + }; + use alloc::vec; + + #[test] + fn aggregates_scaled_advances_spacing_and_legal_breaks() { + let text: Vec = "a b\n".encode_utf16().collect(); + let mut unicode = UnicodeAnalysis::default(); + unicode.analyze(&text).unwrap(); + let style = ResolvedStyle::test_typography(16.0, 1.0, 2.0); + let styles = [StyleSegment { + text_start: 0, + text_end: 4, + style, + }]; + let runs = [ShapingRun { + text_start: 0, + text_end: 3, + script: u32::from_be_bytes(*b"Latn"), + direction: 4, + bidi_level: 0, + style, + }]; + let mut shape = ShapeArena { + runs: vec![ShapedRun { + source_run: 0, + binding_handle: 19, + font_handle: 9, + text_start: 0, + text_end: 3, + glyph_start: 0, + glyph_count: 3, + }], + glyph_ids: vec![3, 2, 1], + clusters: vec![2, 1, 0], + x_advances: vec![500, 250, 500], + y_advances: vec![0; 3], + x_offsets: vec![0; 3], + y_offsets: vec![0; 3], + glyph_flags: vec![0; 3], + }; + let metrics = |_| { + Some(FontMetrics { + units_per_em: 1_000, + ascender: 800, + descender: -200, + line_gap: 0, + }) + }; + let mut clusters = ClusterArena::default(); + clusters + .build( + ClusterBuildInput { + text: &text, + text_unit_ids: &[1, 2, 3, 4], + unicode: &unicode, + styles: &styles, + runs: &runs, + shape: &shape, + }, + metrics, + ) + .unwrap(); + assert_eq!(clusters.starts, [0, 1, 2, 3]); + assert_eq!(clusters.ends, [1, 2, 3, 4]); + assert_eq!(clusters.advances, [9.0, 7.0, 9.0, 0.0]); + assert_eq!(clusters.style_indexes, [0; 4]); + assert_eq!(clusters.source_runs, [0, 0, 0, NO_SOURCE_RUN]); + assert_eq!(clusters.font_handles, [9, 9, 9, 0]); + assert_eq!(clusters.stable_ids, [1, 2, 3, 4]); + assert_eq!(clusters.glyph_starts, [0, 1, 2, 3]); + assert_eq!(clusters.glyph_counts, [1, 1, 1, 0]); + assert_eq!(clusters.glyph_indices, [2, 1, 0]); + assert_eq!(clusters.index_at, [0, 1, 2, 3, 4]); + assert_eq!(clusters.flags[0], CLUSTER_SAFE_BEFORE); + assert_eq!( + clusters.flags[1], + CLUSTER_SAFE_BEFORE | CLUSTER_ALLOWED_BREAK + ); + assert_eq!(clusters.flags[2], CLUSTER_SAFE_BEFORE); + assert_eq!( + clusters.flags[3], + CLUSTER_HARD_BREAK | CLUSTER_REQUIRED_BREAK + ); + + let capacities = ( + clusters.starts.capacity(), + clusters.advances.capacity(), + clusters.flags.capacity(), + clusters.glyph_starts.capacity(), + clusters.glyph_counts.capacity(), + clusters.glyph_indices.capacity(), + clusters.index_at.capacity(), + ); + shape.glyph_flags[0] = GLYPH_UNSAFE_TO_BREAK; + clusters + .build( + ClusterBuildInput { + text: &text, + text_unit_ids: &[1, 2, 3, 4], + unicode: &unicode, + styles: &styles, + runs: &runs, + shape: &shape, + }, + metrics, + ) + .unwrap(); + assert_eq!( + capacities, + ( + clusters.starts.capacity(), + clusters.advances.capacity(), + clusters.flags.capacity(), + clusters.glyph_starts.capacity(), + clusters.glyph_counts.capacity(), + clusters.glyph_indices.capacity(), + clusters.index_at.capacity(), + ) + ); + assert_eq!(clusters.flags[1], CLUSTER_SAFE_BEFORE); + assert_eq!(clusters.flags[2], 0); + } + + #[test] + fn stable_glyph_ids_follow_clusters_and_reuse_ordinals_transactionally() { + let previous = ClusterArena { + stable_ids: vec![10, 20], + glyph_starts: vec![0, 2], + glyph_counts: vec![2, 1], + glyph_indices: vec![0, 1, 2], + glyph_stable_ids: vec![1, 2, 3], + ..ClusterArena::default() + }; + let mut pending = ClusterArena { + stable_ids: vec![30, 10, 20], + glyph_starts: vec![0, 1, 2], + glyph_counts: vec![1, 1, 2], + glyph_indices: vec![0, 1, 2, 3], + ..ClusterArena::default() + }; + let mut index = IdentityIndex::default(); + let mut next_id = 4; + pending + .assign_stable_glyph_ids(&previous, &mut index, &mut next_id) + .unwrap(); + assert_eq!(pending.glyph_stable_ids, [4, 1, 3, 5]); + assert_eq!(next_id, 6); + + let capacities = index.capacities(); + pending.glyph_stable_ids.clear(); + next_id = 4; + pending + .assign_stable_glyph_ids(&previous, &mut index, &mut next_id) + .unwrap(); + assert_eq!(pending.glyph_stable_ids, [4, 1, 3, 5]); + assert_eq!(next_id, 6); + assert_eq!(index.capacities(), capacities); + } + + #[test] + fn retained_source_run_rebuild_matches_the_cold_cluster_oracle() { + let old_text: Vec = "ab".encode_utf16().collect(); + let new_text: Vec = "ac".encode_utf16().collect(); + let mut old_unicode = UnicodeAnalysis::default(); + old_unicode.analyze(&old_text).unwrap(); + let mut new_unicode = UnicodeAnalysis::default(); + new_unicode.analyze(&new_text).unwrap(); + let style = ResolvedStyle::test_typography(16.0, 1.0, 0.0); + let styles = [StyleSegment { + text_start: 0, + text_end: 2, + style, + }]; + let runs = [ShapingRun { + text_start: 0, + text_end: 2, + script: u32::from_be_bytes(*b"Latn"), + direction: 4, + bidi_level: 0, + style, + }]; + let make_shape = |second_glyph, second_advance| ShapeArena { + runs: vec![ShapedRun { + source_run: 0, + binding_handle: 19, + font_handle: 9, + text_start: 0, + text_end: 2, + glyph_start: 0, + glyph_count: 2, + }], + glyph_ids: vec![1, second_glyph], + clusters: vec![0, 1], + x_advances: vec![500, second_advance], + y_advances: vec![0; 2], + x_offsets: vec![0; 2], + y_offsets: vec![0; 2], + glyph_flags: vec![0; 2], + }; + let old_shape = make_shape(2, 500); + let new_shape = make_shape(3, 600); + let metrics = |_| { + Some(FontMetrics { + units_per_em: 1_000, + ascender: 800, + descender: -200, + line_gap: 0, + }) + }; + let mut previous = ClusterArena::default(); + previous + .build( + ClusterBuildInput { + text: &old_text, + text_unit_ids: &[10, 20], + unicode: &old_unicode, + styles: &styles, + runs: &runs, + shape: &old_shape, + }, + metrics, + ) + .unwrap(); + let mut next_id = 1; + previous + .assign_stable_glyph_ids( + &ClusterArena::default(), + &mut IdentityIndex::default(), + &mut next_id, + ) + .unwrap(); + let mut cold = ClusterArena::default(); + cold.build( + ClusterBuildInput { + text: &new_text, + text_unit_ids: &[10, 30], + unicode: &new_unicode, + styles: &styles, + runs: &runs, + shape: &new_shape, + }, + metrics, + ) + .unwrap(); + let mut cold_next_id = next_id; + cold.assign_stable_glyph_ids(&previous, &mut IdentityIndex::default(), &mut cold_next_id) + .unwrap(); + let mut retained = ClusterArena::default(); + let (cluster_start, cluster_end) = retained + .rebuild_source_run_if_topology_is_stable( + &previous, + ClusterBuildInput { + text: &new_text, + text_unit_ids: &[10, 30], + unicode: &new_unicode, + styles: &styles, + runs: &runs, + shape: &new_shape, + }, + 0, + metrics, + ) + .unwrap() + .unwrap(); + let mut retained_next_id = next_id; + retained + .assign_stable_glyph_ids_in_range( + &previous, + cluster_start, + cluster_end, + &mut IdentityIndex::default(), + &mut retained_next_id, + ) + .unwrap(); + macro_rules! assert_lane { + ($field:ident) => { + assert_eq!(retained.$field, cold.$field, stringify!($field)); + }; + } + assert_lane!(starts); + assert_lane!(ends); + assert_lane!(advances); + assert_lane!(flags); + assert_lane!(style_indexes); + assert_lane!(source_runs); + assert_lane!(binding_handles); + assert_lane!(font_handles); + assert_lane!(stable_ids); + assert_lane!(glyph_starts); + assert_lane!(glyph_counts); + assert_lane!(glyph_indices); + assert_lane!(glyph_stable_ids); + assert_lane!(index_at); + assert_lane!(shaped); + assert_lane!(unsafe_before); + assert_eq!(retained_next_id, cold_next_id); + } +} diff --git a/packages/text/rust/shaper/src/engine/flow_composition.rs b/packages/text/rust/shaper/src/engine/flow_composition.rs new file mode 100644 index 00000000..59043d91 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/flow_composition.rs @@ -0,0 +1,1315 @@ +use alloc::vec::Vec; + +use crate::FontMetrics; + +use super::{ + EngineError, + cluster_state::{CLUSTER_HARD_BREAK, ClusterArena}, + flow_geometry::{FlowGeometryArena, InlineSlotArena}, + frame::{OVERFLOW_ELLIPSIS, OVERFLOW_VISIBLE, WRITING_HORIZONTAL_TB}, + line_composition::{ComposedLine, LineCursor, layout_next_line}, + style_state::StyleSegment, +}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct FlowLine { + pub flow_thread_id: u32, + pub region_id: u32, + pub transform_index: u32, + pub clip_id: u32, + pub fragment_start: u32, + pub fragment_count: u16, + pub align: u8, + pub block_start: f64, + pub baseline: f64, + pub height: f64, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct FlowFragment { + pub line: ComposedLine, + pub slot_start: f64, + pub slot_end: f64, + pub boundary_index: u32, +} + +pub(crate) const NO_BOUNDARY: u32 = u32::MAX; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct EllipsisTarget { + pub fragment_index: u32, + pub line_cluster_start: u32, + pub boundary_cluster_start: u32, + pub cluster_end: u32, + pub text_end: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct EllipsisReplacement { + pub cluster_start: usize, + pub advance_adjustment: f64, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct LineExtents { + above: f64, + below: f64, +} + +impl LineExtents { + fn height(self) -> f64 { + self.above + self.below + } + + fn include(&mut self, other: Self) { + self.above = self.above.max(other.above); + self.below = self.below.max(other.below); + } +} + +#[derive(Default)] +pub(crate) struct FlowLayoutArena { + pub lines: Vec, + pub fragments: Vec, + pub(crate) ellipsis_threads: Vec, + pub(crate) recomposed_lines: Option<(usize, usize)>, +} + +impl FlowLayoutArena { + pub(crate) fn reserve( + &mut self, + line_capacity: usize, + max_slots_per_band: usize, + ) -> Result<(), EngineError> { + reserve(&mut self.lines, line_capacity)?; + reserve( + &mut self.fragments, + line_capacity.saturating_mul(max_slots_per_band), + )?; + reserve(&mut self.ellipsis_threads, line_capacity) + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn build( + &mut self, + geometry: &FlowGeometryArena, + clusters: &ClusterArena, + styles: &[StyleSegment], + slots: &mut InlineSlotArena, + max_lines: usize, + max_slots_per_band: usize, + metrics_for: impl Fn(u32) -> Option + Copy, + first_font_for_stack: impl Fn(u32) -> Option + Copy, + ) -> Result<(), EngineError> { + self.clear(); + if clusters.starts.is_empty() || geometry.constraints.is_empty() { + return Ok(()); + } + self.reserve(max_lines, max_slots_per_band)?; + for constraint in geometry.constraints.iter().copied() { + let resume = cluster_for_offset(clusters, constraint.resume_cluster)?; + let mut cursor = LineCursor::at_cluster(resume); + let region_start = usize::try_from(constraint.region_start) + .map_err(|_| EngineError::InvalidRequest)?; + let first_region = region_start + .checked_add(usize::from(constraint.resume_region)) + .ok_or(EngineError::InvalidRequest)?; + let region_end = region_start + .checked_add(usize::from(constraint.region_count)) + .ok_or(EngineError::InvalidRequest)?; + let constraint_line_limit = if constraint.max_lines == 0 { + max_lines + } else { + usize::try_from(constraint.max_lines) + .map_err(|_| EngineError::ResultTooLarge)? + .min(max_lines) + }; + let thread_line_start = self.lines.len(); + for region_index in first_region..region_end { + if cursor.is_complete(clusters.starts.len()) + || self.lines.len().saturating_sub(thread_line_start) >= constraint_line_limit + { + break; + } + let region = geometry + .regions + .get(region_index) + .ok_or(EngineError::InvalidRequest)?; + if region.record.writing_mode != WRITING_HORIZONTAL_TB { + return Err(EngineError::InvalidRequest); + } + let mut block = f64::from(region.record.block_start); + if region_index == first_region { + block += f64::from(constraint.resume_block_offset); + } + let block_end = f64::from(region.record.block_end); + while !cursor.is_complete(clusters.starts.len()) + && self.lines.len().saturating_sub(thread_line_start) < constraint_line_limit + && self.lines.len() < max_lines + && block < block_end + { + let estimate = extents_for_cluster( + clusters, + styles, + cursor + .cluster() + .min(clusters.starts.len().saturating_sub(1)), + metrics_for, + first_font_for_stack, + )?; + let estimate = positive_extents(estimate, styles, clusters, cursor.cluster())?; + match self.compose_band( + geometry, + region_index, + constraint.flow_thread_id, + region.record.id, + region.record.transform_index, + if constraint.overflow == OVERFLOW_VISIBLE { + 0 + } else { + region.record.id + }, + clusters, + styles, + slots, + &mut cursor, + block, + block_end, + estimate, + constraint.wrap, + constraint.align, + max_slots_per_band, + metrics_for, + first_font_for_stack, + )? { + Some(height) => block += height, + None => block += estimate.height(), + } + } + } + if constraint.overflow == OVERFLOW_ELLIPSIS { + let final_fragment_overflows = self.lines.last().is_some_and(|line| { + line.flow_thread_id == constraint.flow_thread_id + && usize::try_from(line.fragment_start) + .ok() + .and_then(|start| start.checked_add(usize::from(line.fragment_count))) + .and_then(|end| end.checked_sub(1)) + .and_then(|index| self.fragments.get(index)) + .is_some_and(|fragment| { + fragment.line.advance > fragment.slot_end - fragment.slot_start + }) + }); + if (!cursor.is_complete(clusters.starts.len()) || final_fragment_overflows) + && self.lines.len() > thread_line_start + { + self.ellipsis_threads.push(constraint.flow_thread_id); + } + } + } + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn rebuild_until_state_converges( + &mut self, + previous: &Self, + geometry: &FlowGeometryArena, + previous_clusters: &ClusterArena, + clusters: &ClusterArena, + styles: &[StyleSegment], + slots: &mut InlineSlotArena, + edit_offset: u32, + max_lines: usize, + max_slots_per_band: usize, + metrics_for: impl Fn(u32) -> Option + Copy, + first_font_for_stack: impl Fn(u32) -> Option + Copy, + ) -> Result { + self.clear(); + if !previous.ellipsis_threads.is_empty() + || previous.lines.is_empty() + || previous.lines.len() > max_lines + || previous.lines.iter().any(|line| { + usize::try_from(line.fragment_count) + .map_or(true, |count| count > max_slots_per_band) + }) + || previous_clusters.starts.len() != clusters.starts.len() + { + return Ok(false); + } + let Some(line_index) = previous.lines.iter().position(|line| { + line_fragments(previous, *line).is_ok_and(|fragments| { + fragments.iter().any(|fragment| { + fragment.line.text_start <= edit_offset + && edit_offset + < fragment + .line + .text_end + .max(fragment.line.text_start.saturating_add(1)) + }) + }) + }) else { + return Ok(false); + }; + let first_line = previous.lines[line_index]; + let old_fragments = line_fragments(previous, first_line)?; + let Some(first_fragment) = old_fragments.first() else { + return Ok(false); + }; + let cluster_start = usize::try_from(first_fragment.line.cluster_start) + .map_err(|_| EngineError::InvalidRequest)?; + if previous_clusters.stable_ids.get(cluster_start) != clusters.stable_ids.get(cluster_start) + { + return Ok(false); + } + for prefix in 0..line_index { + self.append_retained_line(previous, prefix)?; + } + let mut cursor = LineCursor::at_cluster(cluster_start); + for candidate in line_index..previous.lines.len() { + let old_line = previous.lines[candidate]; + let old_fragments = line_fragments(previous, old_line)?; + let old_cluster_end = old_fragments + .last() + .and_then(|fragment| usize::try_from(fragment.line.cluster_end).ok()) + .ok_or(EngineError::InvalidRequest)?; + let Some(region_index) = geometry + .regions + .iter() + .position(|region| region.record.id == old_line.region_id) + else { + self.clear(); + return Ok(false); + }; + let region = geometry + .regions + .get(region_index) + .ok_or(EngineError::InvalidRequest)?; + let Some(height) = self.compose_band( + geometry, + region_index, + old_line.flow_thread_id, + old_line.region_id, + old_line.transform_index, + old_line.clip_id, + clusters, + styles, + slots, + &mut cursor, + old_line.block_start, + f64::from(region.record.block_end), + LineExtents { + above: old_line.baseline, + below: old_line.height - old_line.baseline, + }, + wrapping_for_flow_thread(geometry, old_line.flow_thread_id)?, + old_line.align, + max_slots_per_band, + metrics_for, + first_font_for_stack, + )? + else { + self.clear(); + return Ok(false); + }; + let new_line = *self.lines.last().ok_or(EngineError::InvalidRequest)?; + let metrics_stable = + height == old_line.height && new_line.baseline == old_line.baseline; + if metrics_stable && cursor.cluster() == old_cluster_end { + for suffix in candidate + 1..previous.lines.len() { + self.append_retained_line(previous, suffix)?; + } + self.recomposed_lines = Some((line_index, candidate + 1)); + return Ok(true); + } + if !metrics_stable { + self.clear(); + return Ok(false); + } + } + self.clear(); + Ok(false) + } + + fn append_retained_line( + &mut self, + source: &Self, + line_index: usize, + ) -> Result<(), EngineError> { + let mut line = *source + .lines + .get(line_index) + .ok_or(EngineError::InvalidRequest)?; + let fragments = line_fragments(source, line)?; + line.fragment_start = + u32::try_from(self.fragments.len()).map_err(|_| EngineError::ResultTooLarge)?; + self.fragments.extend_from_slice(fragments); + self.lines.push(line); + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + fn compose_band( + &mut self, + geometry: &FlowGeometryArena, + region_index: usize, + flow_thread_id: u32, + region_id: u32, + transform_index: u32, + clip_id: u32, + clusters: &ClusterArena, + styles: &[StyleSegment], + slot_arena: &mut InlineSlotArena, + cursor: &mut LineCursor, + block_start: f64, + region_block_end: f64, + initial_extents: LineExtents, + wrap: u8, + align: u8, + max_slots: usize, + metrics_for: impl Fn(u32) -> Option + Copy, + first_font_for_stack: impl Fn(u32) -> Option + Copy, + ) -> Result, EngineError> { + let saved_cursor = *cursor; + let fragment_start = self.fragments.len(); + let mut extents = initial_extents; + for attempt in 0..2 { + let height = extents.height(); + if block_start + height > region_block_end { + self.fragments.truncate(fragment_start); + *cursor = saved_cursor; + return Ok(None); + } + let available = slot_arena.resolve_band( + geometry, + region_index, + block_start, + block_start + height, + max_slots, + )?; + if available.is_empty() { + self.fragments.truncate(fragment_start); + *cursor = saved_cursor; + return Ok(None); + } + let mut measured = LineExtents::default(); + let mut composed = false; + for slot in available.iter().copied() { + let Some(line) = layout_next_line(clusters, cursor, slot.end - slot.start, wrap)? + else { + break; + }; + include_range_extents( + &mut measured, + clusters, + styles, + line, + metrics_for, + first_font_for_stack, + )?; + self.fragments.push(FlowFragment { + line, + slot_start: slot.start, + slot_end: slot.end, + boundary_index: NO_BOUNDARY, + }); + composed = true; + if line.hard_break || cursor.is_complete(clusters.starts.len()) { + break; + } + } + if !composed { + self.fragments.truncate(fragment_start); + *cursor = saved_cursor; + return Ok(None); + } + measured.include(initial_extents); + if attempt == 0 && measured.height() > height { + self.fragments.truncate(fragment_start); + *cursor = saved_cursor; + extents = measured; + continue; + } + let fragment_count = self.fragments.len() - fragment_start; + self.lines.push(FlowLine { + flow_thread_id, + region_id, + transform_index, + clip_id, + fragment_start: u32::try_from(fragment_start) + .map_err(|_| EngineError::ResultTooLarge)?, + fragment_count: u16::try_from(fragment_count) + .map_err(|_| EngineError::ResultTooLarge)?, + align, + block_start, + baseline: extents.above, + height: extents.height(), + }); + return Ok(Some(extents.height())); + } + Err(EngineError::InvalidRequest) + } + + pub(crate) fn clear(&mut self) { + self.lines.clear(); + self.fragments.clear(); + self.ellipsis_threads.clear(); + self.recomposed_lines = None; + } + + pub(crate) fn recomposed_line_range(&self) -> Option<(usize, usize)> { + self.recomposed_lines + } + + pub(crate) fn ellipsis_threads(&self) -> &[u32] { + &self.ellipsis_threads + } + + pub(crate) fn truncate_for_ellipsis( + &mut self, + flow_thread_id: u32, + clusters: &ClusterArena, + mut replacement_at: impl FnMut(usize, u32) -> Result, + ) -> Result, EngineError> { + let Some(line) = self + .lines + .iter() + .rev() + .find(|line| line.flow_thread_id == flow_thread_id) + .copied() + else { + return Ok(None); + }; + let fragment_start = + usize::try_from(line.fragment_start).map_err(|_| EngineError::InvalidRequest)?; + let fragment_end = fragment_start + .checked_add(usize::from(line.fragment_count)) + .ok_or(EngineError::InvalidRequest)?; + let fragment_index = fragment_end + .checked_sub(1) + .ok_or(EngineError::InvalidRequest)?; + let fragment = self + .fragments + .get_mut(fragment_index) + .ok_or(EngineError::InvalidRequest)?; + let cluster_count = clusters.starts.len(); + let consumed = + usize::try_from(fragment.line.cluster_end).map_err(|_| EngineError::InvalidRequest)?; + let available = fragment.slot_end - fragment.slot_start; + if consumed >= cluster_count && fragment.line.advance <= available { + return Ok(None); + } + let cluster_start = usize::try_from(fragment.line.cluster_start) + .map_err(|_| EngineError::InvalidRequest)?; + let mut cluster_end = consumed.min(cluster_count); + let mut source_advance = fragment.line.advance; + while cluster_end > cluster_start + && clusters.flags[cluster_end - 1] & CLUSTER_HARD_BREAK != 0 + { + cluster_end -= 1; + source_advance -= clusters.advances[cluster_end]; + } + let mut text_end = cluster_text_end(clusters, cluster_end); + let mut replacement = replacement_at(cluster_end, text_end)?; + if replacement.cluster_start < cluster_start + || replacement.cluster_start > cluster_end + || !replacement.advance_adjustment.is_finite() + { + return Err(EngineError::InvalidRequest); + } + while cluster_end > cluster_start + && source_advance + replacement.advance_adjustment > available + { + cluster_end -= 1; + source_advance -= clusters.advances[cluster_end]; + text_end = cluster_text_end(clusters, cluster_end); + replacement = replacement_at(cluster_end, text_end)?; + if replacement.cluster_start < cluster_start + || replacement.cluster_start > cluster_end + || !replacement.advance_adjustment.is_finite() + { + return Err(EngineError::InvalidRequest); + } + } + fragment.line.cluster_end = + u32::try_from(cluster_end).map_err(|_| EngineError::ResultTooLarge)?; + fragment.line.text_end = text_end; + fragment.line.advance = (source_advance + replacement.advance_adjustment).max(0.0); + fragment.line.hard_break = false; + Ok(Some(EllipsisTarget { + fragment_index: u32::try_from(fragment_index) + .map_err(|_| EngineError::ResultTooLarge)?, + line_cluster_start: u32::try_from(cluster_start) + .map_err(|_| EngineError::ResultTooLarge)?, + boundary_cluster_start: u32::try_from(replacement.cluster_start) + .map_err(|_| EngineError::ResultTooLarge)?, + cluster_end: u32::try_from(cluster_end).map_err(|_| EngineError::ResultTooLarge)?, + text_end, + })) + } +} + +fn wrapping_for_flow_thread( + geometry: &FlowGeometryArena, + flow_thread_id: u32, +) -> Result { + geometry + .constraints + .iter() + .find(|constraint| constraint.flow_thread_id == flow_thread_id) + .map(|constraint| constraint.wrap) + .ok_or(EngineError::InvalidRequest) +} + +fn line_fragments(flow: &FlowLayoutArena, line: FlowLine) -> Result<&[FlowFragment], EngineError> { + let start = usize::try_from(line.fragment_start).map_err(|_| EngineError::InvalidRequest)?; + let end = start + .checked_add(usize::from(line.fragment_count)) + .ok_or(EngineError::InvalidRequest)?; + flow.fragments + .get(start..end) + .ok_or(EngineError::InvalidRequest) +} + +fn cluster_text_end(clusters: &ClusterArena, cluster_end: usize) -> u32 { + clusters + .starts + .get(cluster_end) + .copied() + .or_else(|| clusters.ends.last().copied()) + .unwrap_or(0) +} + +fn cluster_for_offset(clusters: &ClusterArena, offset: u32) -> Result { + let offset = usize::try_from(offset).map_err(|_| EngineError::InvalidRequest)?; + let index = usize::try_from( + *clusters + .index_at + .get(offset) + .ok_or(EngineError::InvalidRequest)?, + ) + .map_err(|_| EngineError::InvalidRequest)?; + if offset != 0 + && index < clusters.starts.len() + && clusters.starts[index] + != u32::try_from(offset).map_err(|_| EngineError::InvalidRequest)? + { + return Err(EngineError::InvalidRequest); + } + Ok(index) +} + +fn include_range_extents( + target: &mut LineExtents, + clusters: &ClusterArena, + styles: &[StyleSegment], + line: ComposedLine, + metrics_for: impl Fn(u32) -> Option + Copy, + first_font_for_stack: impl Fn(u32) -> Option + Copy, +) -> Result<(), EngineError> { + let start = usize::try_from(line.cluster_start).map_err(|_| EngineError::InvalidRequest)?; + let end = usize::try_from(line.cluster_end).map_err(|_| EngineError::InvalidRequest)?; + if start == end { + let fallback = start + .saturating_sub(1) + .min(clusters.starts.len().saturating_sub(1)); + target.include(extents_for_cluster( + clusters, + styles, + fallback, + metrics_for, + first_font_for_stack, + )?); + return Ok(()); + } + for index in start..end { + if clusters.flags[index] & CLUSTER_HARD_BREAK == 0 { + target.include(extents_for_cluster( + clusters, + styles, + index, + metrics_for, + first_font_for_stack, + )?); + } + } + Ok(()) +} + +fn extents_for_cluster( + clusters: &ClusterArena, + styles: &[StyleSegment], + index: usize, + metrics_for: impl Fn(u32) -> Option, + first_font_for_stack: impl Fn(u32) -> Option, +) -> Result { + let style_index = usize::try_from( + *clusters + .style_indexes + .get(index) + .ok_or(EngineError::InvalidRequest)?, + ) + .map_err(|_| EngineError::InvalidRequest)?; + let style = styles + .get(style_index) + .ok_or(EngineError::InvalidRequest)? + .style; + let selected = clusters.font_handles.get(index).copied().unwrap_or(0); + let font_handle = if selected == 0 { + first_font_for_stack(style.font_stack_handle).ok_or(EngineError::FontStackMissing)? + } else { + selected + }; + let metrics = metrics_for(font_handle).ok_or(EngineError::InvalidRequest)?; + if metrics.units_per_em == 0 { + return Err(EngineError::InvalidRequest); + } + let scale = f64::from(style.font_size) / f64::from(metrics.units_per_em); + let ascent = (f64::from(metrics.ascender) * scale).max(0.0); + let descent = (-f64::from(metrics.descender) * scale).max(0.0); + let natural = (f64::from(metrics.ascender) - f64::from(metrics.descender) + + f64::from(metrics.line_gap)) + * scale; + let requested = if style.has_line_height { + f64::from(style.font_size * style.line_height) + } else { + natural + }; + let leading = (requested - ascent - descent).max(0.0); + let shift = f64::from(style.baseline_shift); + Ok(LineExtents { + above: (ascent + leading * 0.5 + shift).max(0.0), + below: (descent + leading * 0.5 - shift).max(0.0), + }) +} + +fn positive_extents( + extents: LineExtents, + styles: &[StyleSegment], + clusters: &ClusterArena, + index: usize, +) -> Result { + if extents.height().is_finite() && extents.height() > 0.0 { + return Ok(extents); + } + let style_index = usize::try_from( + *clusters + .style_indexes + .get(index.min(clusters.starts.len().saturating_sub(1))) + .ok_or(EngineError::InvalidRequest)?, + ) + .map_err(|_| EngineError::InvalidRequest)?; + let fallback = f64::from( + styles + .get(style_index) + .ok_or(EngineError::InvalidRequest)? + .style + .font_size, + ); + if !fallback.is_finite() || fallback <= 0.0 { + return Err(EngineError::InvalidRequest); + } + Ok(LineExtents { + above: fallback, + below: 0.0, + }) +} + +fn reserve(values: &mut Vec, additional: usize) -> Result<(), EngineError> { + if values.capacity().saturating_sub(values.len()) < additional { + values + .try_reserve_exact(additional) + .map_err(|_| EngineError::ResultTooLarge)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::{ + cluster_state::CLUSTER_SAFE_BEFORE, + flow_geometry::{RetainedExclusion, RetainedRegion}, + frame::{ + ALIGN_START, AXIS_EXACT, BLOCK_ALIGN_START, EXCLUSION_WRAP_BOTH, ORIENTATION_MIXED, + OVERFLOW_ELLIPSIS, OVERFLOW_VISIBLE, SHAPE_RECTANGLE, WRAP_CHARACTER, WRAP_NONE, + }, + semantic_wire::{FlowConstraint, FlowExclusion, FlowRegion}, + style_state::ResolvedStyle, + }; + use alloc::vec; + + #[test] + fn one_update_flows_fragments_around_a_hole_and_retries_for_tall_text() { + let clusters = ClusterArena { + starts: vec![0, 1, 2, 3], + ends: vec![1, 2, 3, 4], + advances: vec![2.0; 4], + flags: vec![CLUSTER_SAFE_BEFORE; 4], + style_indexes: vec![0, 0, 1, 1], + source_runs: vec![0; 4], + font_handles: vec![1; 4], + index_at: vec![0, 1, 2, 3, 4], + ..ClusterArena::default() + }; + let styles = [ + StyleSegment { + text_start: 0, + text_end: 2, + style: ResolvedStyle::test_typography(10.0, 0.0, 0.0), + }, + StyleSegment { + text_start: 2, + text_end: 4, + style: ResolvedStyle::test_typography(20.0, 0.0, 0.0), + }, + ]; + let geometry = FlowGeometryArena { + constraints: vec![constraint()], + regions: vec![RetainedRegion { + record: region(), + vertex_start: 0, + }], + exclusions: vec![RetainedExclusion { + record: exclusion(), + vertex_start: 0, + }], + vertices: vec![], + }; + let mut layout = FlowLayoutArena::default(); + let mut slots = InlineSlotArena::default(); + layout + .build( + &geometry, + &clusters, + &styles, + &mut slots, + 8, + 4, + |_| { + Some(FontMetrics { + units_per_em: 1_000, + ascender: 800, + descender: -200, + line_gap: 0, + }) + }, + |_| Some(1), + ) + .unwrap(); + assert_eq!(layout.lines.len(), 1); + assert_eq!(layout.fragments.len(), 2); + assert_eq!(layout.lines[0].height, 20.0); + assert_eq!(layout.lines[0].baseline, 16.0); + assert_eq!(layout.lines[0].fragment_count, 2); + assert_eq!(layout.fragments[0].slot_start, 0.0); + assert_eq!(layout.fragments[0].slot_end, 4.0); + assert_eq!(layout.fragments[0].line.cluster_end, 2); + assert_eq!(layout.fragments[1].slot_start, 6.0); + assert_eq!(layout.fragments[1].line.cluster_end, 4); + } + + #[test] + fn overflowing_text_continues_through_ordered_regions_without_balancing() { + let clusters = ClusterArena { + starts: vec![0, 1, 2, 3], + ends: vec![1, 2, 3, 4], + advances: vec![3.0; 4], + flags: vec![CLUSTER_SAFE_BEFORE; 4], + style_indexes: vec![0; 4], + source_runs: vec![0; 4], + font_handles: vec![1; 4], + index_at: vec![0, 1, 2, 3, 4], + ..ClusterArena::default() + }; + let styles = [StyleSegment { + text_start: 0, + text_end: 4, + style: ResolvedStyle::test_typography(10.0, 0.0, 0.0), + }]; + let mut first = region(); + first.id = 1; + first.inline_end = 4.0; + first.clip_inline_end = 4.0; + first.block_end = 10.0; + first.clip_block_end = 10.0; + first.exclusion_count = 0; + let mut second = first; + second.id = 2; + second.block_end = 30.0; + second.clip_block_end = 30.0; + let mut flow = constraint(); + flow.region_count = 2; + let geometry = FlowGeometryArena { + constraints: vec![flow], + regions: vec![ + RetainedRegion { + record: first, + vertex_start: 0, + }, + RetainedRegion { + record: second, + vertex_start: 0, + }, + ], + exclusions: vec![], + vertices: vec![], + }; + let mut layout = FlowLayoutArena::default(); + layout + .build( + &geometry, + &clusters, + &styles, + &mut InlineSlotArena::default(), + 8, + 2, + |_| { + Some(FontMetrics { + units_per_em: 1_000, + ascender: 800, + descender: -200, + line_gap: 0, + }) + }, + |_| Some(1), + ) + .unwrap(); + assert_eq!( + layout + .lines + .iter() + .map(|line| line.region_id) + .collect::>(), + [1, 2, 2, 2] + ); + assert_eq!(layout.fragments.last().unwrap().line.cluster_end, 4); + } + + #[test] + fn localized_edit_recomposes_one_line_and_reuses_converged_prefix_and_suffix() { + let make_clusters = |advances: Vec| ClusterArena { + starts: vec![0, 1, 2, 3, 4, 5], + ends: vec![1, 2, 3, 4, 5, 6], + advances, + flags: vec![CLUSTER_SAFE_BEFORE; 6], + style_indexes: vec![0; 6], + source_runs: vec![0; 6], + font_handles: vec![1; 6], + stable_ids: vec![1, 2, 3, 4, 5, 6], + index_at: vec![0, 1, 2, 3, 4, 5, 6], + ..ClusterArena::default() + }; + let previous_clusters = make_clusters(vec![2.0; 6]); + let changed_clusters = make_clusters(vec![2.0, 2.0, 1.0, 2.0, 2.0, 2.0]); + let styles = [StyleSegment { + text_start: 0, + text_end: 6, + style: ResolvedStyle::test_typography(10.0, 0.0, 0.0), + }]; + let mut narrow_region = region(); + narrow_region.inline_end = 4.0; + narrow_region.clip_inline_end = 4.0; + narrow_region.exclusion_count = 0; + let geometry = FlowGeometryArena { + constraints: vec![constraint()], + regions: vec![RetainedRegion { + record: narrow_region, + vertex_start: 0, + }], + ..FlowGeometryArena::default() + }; + let metrics = |_| { + Some(FontMetrics { + units_per_em: 1_000, + ascender: 800, + descender: -200, + line_gap: 0, + }) + }; + let mut previous = FlowLayoutArena::default(); + previous + .build( + &geometry, + &previous_clusters, + &styles, + &mut InlineSlotArena::default(), + 8, + 1, + metrics, + |_| Some(1), + ) + .unwrap(); + let retained_prefix = previous.fragments[0]; + let retained_suffix = previous.fragments[2]; + let mut changed = FlowLayoutArena::default(); + assert!( + changed + .rebuild_until_state_converges( + &previous, + &geometry, + &previous_clusters, + &changed_clusters, + &styles, + &mut InlineSlotArena::default(), + 2, + 8, + 1, + metrics, + |_| Some(1), + ) + .unwrap() + ); + assert_eq!(changed.lines.len(), 3); + assert_eq!(changed.fragments[0], retained_prefix); + assert_eq!(changed.fragments[1].line.advance, 3.0); + assert_eq!(changed.fragments[2], retained_suffix); + } + + #[test] + fn localized_edit_recomposes_multiple_lines_until_cursor_state_converges() { + let make_clusters = |advances: Vec| ClusterArena { + starts: (0..9).collect(), + ends: (1..10).collect(), + advances, + flags: vec![CLUSTER_SAFE_BEFORE; 9], + style_indexes: vec![0; 9], + source_runs: vec![0; 9], + font_handles: vec![1; 9], + stable_ids: (1..10).collect(), + index_at: (0..10).collect(), + ..ClusterArena::default() + }; + let previous_clusters = make_clusters(vec![2.0; 9]); + let changed_clusters = make_clusters(vec![2.0, 2.0, 2.0, 3.0, 3.0, 1.0, 1.0, 2.0, 2.0]); + let styles = [StyleSegment { + text_start: 0, + text_end: 9, + style: ResolvedStyle::test_typography(10.0, 0.0, 0.0), + }]; + let mut narrow_region = region(); + narrow_region.inline_end = 6.0; + narrow_region.clip_inline_end = 6.0; + narrow_region.exclusion_count = 0; + let geometry = FlowGeometryArena { + constraints: vec![constraint()], + regions: vec![RetainedRegion { + record: narrow_region, + vertex_start: 0, + }], + ..FlowGeometryArena::default() + }; + let metrics = |_| { + Some(FontMetrics { + units_per_em: 1_000, + ascender: 800, + descender: -200, + line_gap: 0, + }) + }; + let mut previous = FlowLayoutArena::default(); + previous + .build( + &geometry, + &previous_clusters, + &styles, + &mut InlineSlotArena::default(), + 8, + 1, + metrics, + |_| Some(1), + ) + .unwrap(); + assert_eq!( + previous + .fragments + .iter() + .map(|fragment| fragment.line.cluster_end) + .collect::>(), + [3, 6, 9] + ); + + let retained_prefix = previous.fragments[0]; + let mut changed = FlowLayoutArena::default(); + assert!( + changed + .rebuild_until_state_converges( + &previous, + &geometry, + &previous_clusters, + &changed_clusters, + &styles, + &mut InlineSlotArena::default(), + 3, + 8, + 1, + metrics, + |_| Some(1), + ) + .unwrap() + ); + assert_eq!(changed.fragments[0], retained_prefix); + assert_eq!( + changed + .fragments + .iter() + .map(|fragment| (fragment.line.cluster_start, fragment.line.cluster_end)) + .collect::>(), + [(0, 3), (3, 5), (5, 9)] + ); + assert_eq!(changed.recomposed_line_range(), Some((1, 3))); + } + + #[test] + fn localized_edit_clears_partial_layout_when_line_state_does_not_converge() { + let make_clusters = |advances: Vec| ClusterArena { + starts: vec![0, 1, 2, 3, 4, 5], + ends: vec![1, 2, 3, 4, 5, 6], + advances, + flags: vec![CLUSTER_SAFE_BEFORE; 6], + style_indexes: vec![0; 6], + source_runs: vec![0; 6], + font_handles: vec![1; 6], + stable_ids: vec![1, 2, 3, 4, 5, 6], + index_at: vec![0, 1, 2, 3, 4, 5, 6], + ..ClusterArena::default() + }; + let previous_clusters = make_clusters(vec![2.0; 6]); + let changed_clusters = make_clusters(vec![2.0, 2.0, 3.0, 2.0, 2.0, 2.0]); + let styles = [StyleSegment { + text_start: 0, + text_end: 6, + style: ResolvedStyle::test_typography(10.0, 0.0, 0.0), + }]; + let mut narrow_region = region(); + narrow_region.inline_end = 4.0; + narrow_region.clip_inline_end = 4.0; + narrow_region.exclusion_count = 0; + let geometry = FlowGeometryArena { + constraints: vec![constraint()], + regions: vec![RetainedRegion { + record: narrow_region, + vertex_start: 0, + }], + ..FlowGeometryArena::default() + }; + let metrics = |_| { + Some(FontMetrics { + units_per_em: 1_000, + ascender: 800, + descender: -200, + line_gap: 0, + }) + }; + let mut previous = FlowLayoutArena::default(); + previous + .build( + &geometry, + &previous_clusters, + &styles, + &mut InlineSlotArena::default(), + 8, + 1, + metrics, + |_| Some(1), + ) + .unwrap(); + let mut changed = FlowLayoutArena::default(); + assert!( + !changed + .rebuild_until_state_converges( + &previous, + &geometry, + &previous_clusters, + &changed_clusters, + &styles, + &mut InlineSlotArena::default(), + 2, + 8, + 1, + metrics, + |_| Some(1), + ) + .unwrap() + ); + assert!(changed.lines.is_empty()); + assert!(changed.fragments.is_empty()); + assert_eq!(changed.recomposed_line_range(), None); + } + + #[test] + fn ellipsis_truncation_reuses_the_final_slot_and_removes_only_required_clusters() { + let clusters = ClusterArena { + starts: vec![0, 1, 2, 3], + ends: vec![1, 2, 3, 4], + advances: vec![3.0; 4], + flags: vec![CLUSTER_SAFE_BEFORE; 4], + ..ClusterArena::default() + }; + let mut layout = FlowLayoutArena { + lines: vec![FlowLine { + flow_thread_id: 7, + region_id: 1, + transform_index: 0, + clip_id: 1, + fragment_start: 0, + fragment_count: 1, + align: ALIGN_START, + block_start: 0.0, + baseline: 8.0, + height: 10.0, + }], + fragments: vec![FlowFragment { + line: ComposedLine { + cluster_start: 0, + cluster_end: 2, + text_start: 0, + text_end: 2, + advance: 6.0, + hard_break: false, + }, + slot_start: 0.0, + slot_end: 10.0, + boundary_index: NO_BOUNDARY, + }], + ..FlowLayoutArena::default() + }; + + let target = layout + .truncate_for_ellipsis(7, &clusters, |cluster_end, _| { + Ok(EllipsisReplacement { + cluster_start: cluster_end, + advance_adjustment: 5.0, + }) + }) + .unwrap() + .unwrap(); + assert_eq!(target.line_cluster_start, 0); + assert_eq!(target.boundary_cluster_start, 1); + assert_eq!(target.cluster_end, 1); + assert_eq!(target.text_end, 1); + assert_eq!(layout.fragments[0].line.cluster_end, 1); + assert_eq!(layout.fragments[0].line.text_end, 1); + assert_eq!(layout.fragments[0].line.advance, 8.0); + } + + #[test] + fn complete_no_wrap_line_still_requests_ellipsis_when_its_slot_overflows() { + let clusters = ClusterArena { + starts: vec![0, 1, 2], + ends: vec![1, 2, 3], + advances: vec![3.0; 3], + flags: vec![CLUSTER_SAFE_BEFORE; 3], + style_indexes: vec![0; 3], + source_runs: vec![0; 3], + font_handles: vec![1; 3], + index_at: vec![0, 1, 2, 3], + ..ClusterArena::default() + }; + let styles = [StyleSegment { + text_start: 0, + text_end: 3, + style: ResolvedStyle::test_typography(10.0, 0.0, 0.0), + }]; + let mut constraint = constraint(); + constraint.overflow = OVERFLOW_ELLIPSIS; + constraint.wrap = WRAP_NONE; + let mut constrained_region = region(); + constrained_region.inline_end = 4.0; + constrained_region.clip_inline_end = 4.0; + constrained_region.exclusion_count = 0; + let geometry = FlowGeometryArena { + constraints: vec![constraint], + regions: vec![RetainedRegion { + record: constrained_region, + vertex_start: 0, + }], + ..FlowGeometryArena::default() + }; + let mut layout = FlowLayoutArena::default(); + layout + .build( + &geometry, + &clusters, + &styles, + &mut InlineSlotArena::default(), + 4, + 1, + |_| { + Some(FontMetrics { + units_per_em: 1_000, + ascender: 800, + descender: -200, + line_gap: 0, + }) + }, + |_| Some(1), + ) + .unwrap(); + assert_eq!(layout.fragments[0].line.cluster_end, 3); + assert_eq!(layout.ellipsis_threads(), [constraint.flow_thread_id]); + } + + fn constraint() -> FlowConstraint { + FlowConstraint { + paragraph_id: 1, + flow_thread_id: 1, + width: 10.0, + height: 100.0, + viewport_block_start: 0.0, + viewport_block_end: 100.0, + resume_block_offset: 0.0, + max_lines: 8, + region_start: 0, + resume_cluster: 0, + region_count: 1, + resume_region: 0, + width_mode: AXIS_EXACT, + height_mode: AXIS_EXACT, + wrap: WRAP_CHARACTER, + align: ALIGN_START, + overflow: OVERFLOW_VISIBLE, + block_align: BLOCK_ALIGN_START, + } + } + + fn region() -> FlowRegion { + FlowRegion { + id: 7, + geometry_revision: 1, + transform_index: 7, + vertices_offset: 0, + vertex_count: 0, + exclusion_start: 0, + exclusion_count: 1, + shape: SHAPE_RECTANGLE, + writing_mode: WRITING_HORIZONTAL_TB, + text_orientation: ORIENTATION_MIXED, + inline_start: 0.0, + block_start: 0.0, + inline_end: 10.0, + block_end: 100.0, + clip_inline_start: 0.0, + clip_block_start: 0.0, + clip_inline_end: 10.0, + clip_block_end: 100.0, + } + } + + fn exclusion() -> FlowExclusion { + FlowExclusion { + id: 9, + region_id: 7, + geometry_revision: 1, + vertices_offset: 0, + vertex_count: 0, + shape: SHAPE_RECTANGLE, + wrap_side: EXCLUSION_WRAP_BOTH, + inline_start: 4.0, + block_start: 0.0, + inline_end: 6.0, + block_end: 40.0, + margin_inline: 0.0, + margin_block: 0.0, + } + } +} diff --git a/packages/text/rust/shaper/src/engine/flow_geometry.rs b/packages/text/rust/shaper/src/engine/flow_geometry.rs new file mode 100644 index 00000000..75914b35 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/flow_geometry.rs @@ -0,0 +1,643 @@ +use alloc::vec::Vec; + +use super::{ + EngineError, + frame::{ + EXCLUSION_WRAP_BOTH, EXCLUSION_WRAP_INLINE_END, EXCLUSION_WRAP_INLINE_START, + EXCLUSION_WRAP_LARGEST, SHAPE_POLYGON, SHAPE_RECTANGLE, + }, + semantic_wire::{FlowConstraint, FlowExclusion, FlowRegion, FlowVertex, GeometryBatch}, +}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct InlineSlot { + pub start: f64, + pub end: f64, +} + +#[derive(Default)] +pub(crate) struct InlineSlotArena { + slots: Vec, + scratch: Vec, + section: Vec, + crossings: Vec, + critical_blocks: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct RetainedRegion { + pub record: FlowRegion, + pub vertex_start: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct RetainedExclusion { + pub record: FlowExclusion, + pub vertex_start: u32, +} + +#[derive(Clone, Default, PartialEq)] +pub(crate) struct FlowGeometryArena { + pub constraints: Vec, + pub regions: Vec, + pub exclusions: Vec, + pub vertices: Vec, +} + +impl FlowGeometryArena { + pub(crate) fn build(&mut self, geometry: GeometryBatch<'_>) -> Result<(), EngineError> { + self.clear(); + reserve(&mut self.constraints, geometry.constraint_count())?; + reserve(&mut self.regions, geometry.region_count())?; + reserve(&mut self.exclusions, geometry.exclusion_count())?; + for index in 0..geometry.constraint_count() { + let mut constraint = geometry + .constraint(index) + .ok_or(EngineError::InvalidRequest)?; + let source_region_start = usize::try_from(constraint.region_start) + .map_err(|_| EngineError::InvalidRequest)?; + constraint.region_start = + u32::try_from(self.regions.len()).map_err(|_| EngineError::InvalidRequest)?; + for region_index in source_region_start + ..source_region_start + .checked_add(usize::from(constraint.region_count)) + .ok_or(EngineError::InvalidRequest)? + { + let mut region = geometry + .region(region_index) + .ok_or(EngineError::InvalidRequest)?; + let source_exclusion_start = usize::from(region.exclusion_start); + region.exclusion_start = u16::try_from(self.exclusions.len()) + .map_err(|_| EngineError::InvalidRequest)?; + let vertex_start = append_vertices( + &mut self.vertices, + geometry, + region.vertices_offset, + region.vertex_count, + )?; + for exclusion_index in source_exclusion_start + ..source_exclusion_start + .checked_add(usize::from(region.exclusion_count)) + .ok_or(EngineError::InvalidRequest)? + { + let exclusion = geometry + .exclusion(exclusion_index) + .ok_or(EngineError::InvalidRequest)?; + let vertex_start = append_vertices( + &mut self.vertices, + geometry, + exclusion.vertices_offset, + exclusion.vertex_count, + )?; + self.exclusions.push(RetainedExclusion { + record: exclusion, + vertex_start, + }); + } + self.regions.push(RetainedRegion { + record: region, + vertex_start, + }); + } + self.constraints.push(constraint); + } + Ok(()) + } + + pub(crate) fn clear(&mut self) { + self.constraints.clear(); + self.regions.clear(); + self.exclusions.clear(); + self.vertices.clear(); + } +} + +impl InlineSlotArena { + pub(crate) fn resolve_band<'a>( + &'a mut self, + geometry: &FlowGeometryArena, + region_index: usize, + block_start: f64, + block_end: f64, + max_slots: usize, + ) -> Result<&'a [InlineSlot], EngineError> { + if !block_start.is_finite() || !block_end.is_finite() || block_start >= block_end { + return Err(EngineError::InvalidRequest); + } + let region = geometry + .regions + .get(region_index) + .ok_or(EngineError::InvalidRequest)?; + if max_slots == 0 { + return Err(EngineError::InvalidRequest); + } + self.slots.clear(); + self.scratch.clear(); + reserve(&mut self.slots, max_slots)?; + reserve(&mut self.scratch, max_slots)?; + match region.record.shape { + SHAPE_RECTANGLE => self.slots.push(InlineSlot { + start: f64::from(region.record.inline_start), + end: f64::from(region.record.inline_end), + }), + SHAPE_POLYGON => { + self.slots.push(InlineSlot { + start: f64::from(region.record.inline_start), + end: f64::from(region.record.inline_end), + }); + self.resolve_polygon_region(geometry, region, block_start, block_end, max_slots)? + } + _ => return Err(EngineError::InvalidRequest), + } + let exclusion_start = usize::from(region.record.exclusion_start); + let exclusion_end = exclusion_start + .checked_add(usize::from(region.record.exclusion_count)) + .ok_or(EngineError::InvalidRequest)?; + for exclusion in geometry + .exclusions + .get(exclusion_start..exclusion_end) + .ok_or(EngineError::InvalidRequest)? + { + let record = exclusion.record; + let margin_block = f64::from(record.margin_block); + let margin_inline = f64::from(record.margin_inline); + let cut = match record.shape { + SHAPE_RECTANGLE => { + if f64::from(record.block_start) - margin_block >= block_end + || f64::from(record.block_end) + margin_block <= block_start + { + continue; + } + Some(InlineSlot { + start: f64::from(record.inline_start) - margin_inline, + end: f64::from(record.inline_end) + margin_inline, + }) + } + SHAPE_POLYGON => polygon_projection( + polygon_vertices(geometry, exclusion.vertex_start, record.vertex_count)?, + block_start - margin_block, + block_end + margin_block, + )? + .map(|slot| InlineSlot { + start: slot.start - margin_inline, + end: slot.end + margin_inline, + }), + _ => return Err(EngineError::InvalidRequest), + }; + let Some(cut) = cut else { continue }; + self.scratch.clear(); + for slot in self.slots.iter().copied() { + subtract_slot( + &mut self.scratch, + slot, + cut.start, + cut.end, + record.wrap_side, + max_slots, + )?; + } + core::mem::swap(&mut self.slots, &mut self.scratch); + } + Ok(&self.slots) + } + + pub(crate) fn resolve_rectangle_band<'a>( + &'a mut self, + geometry: &FlowGeometryArena, + region_index: usize, + block_start: f64, + block_end: f64, + max_slots: usize, + ) -> Result<&'a [InlineSlot], EngineError> { + self.resolve_band(geometry, region_index, block_start, block_end, max_slots) + } + + fn resolve_polygon_region( + &mut self, + geometry: &FlowGeometryArena, + region: &RetainedRegion, + block_start: f64, + block_end: f64, + max_slots: usize, + ) -> Result<(), EngineError> { + let vertices = polygon_vertices(geometry, region.vertex_start, region.record.vertex_count)?; + reserve(&mut self.critical_blocks, vertices.len().saturating_add(2))?; + reserve(&mut self.crossings, vertices.len())?; + reserve(&mut self.section, vertices.len())?; + self.critical_blocks.clear(); + self.critical_blocks.push(block_start); + for vertex in vertices { + let block = f64::from(vertex.block); + if block_start < block && block < block_end { + self.critical_blocks.push(block); + } + } + self.critical_blocks.push(block_end); + self.critical_blocks.sort_by(f64::total_cmp); + self.critical_blocks.dedup(); + let mut sample_index = 0usize; + while sample_index < self.critical_blocks.len() { + let block = self.critical_blocks[sample_index]; + self.intersect_polygon_section(vertices, block, max_slots)?; + if let Some(next) = self.critical_blocks.get(sample_index + 1).copied() + && block < next + { + self.intersect_polygon_section(vertices, block + (next - block) * 0.5, max_slots)?; + } + if self.slots.is_empty() { + break; + } + sample_index += 1; + } + Ok(()) + } + + fn intersect_polygon_section( + &mut self, + vertices: &[FlowVertex], + block: f64, + max_slots: usize, + ) -> Result<(), EngineError> { + polygon_section( + vertices, + block, + &mut self.crossings, + &mut self.section, + max_slots, + )?; + self.scratch.clear(); + intersect_sorted(&self.slots, &self.section, &mut self.scratch, max_slots)?; + core::mem::swap(&mut self.slots, &mut self.scratch); + Ok(()) + } +} + +fn polygon_vertices( + geometry: &FlowGeometryArena, + start: u32, + count: u16, +) -> Result<&[FlowVertex], EngineError> { + let start = usize::try_from(start).map_err(|_| EngineError::InvalidRequest)?; + let end = start + .checked_add(usize::from(count)) + .ok_or(EngineError::InvalidRequest)?; + geometry + .vertices + .get(start..end) + .ok_or(EngineError::InvalidRequest) +} + +fn polygon_section( + vertices: &[FlowVertex], + block: f64, + crossings: &mut Vec, + output: &mut Vec, + max_slots: usize, +) -> Result<(), EngineError> { + crossings.clear(); + output.clear(); + if vertices.len() < 3 { + return Err(EngineError::InvalidRequest); + } + reserve(crossings, vertices.len())?; + reserve(output, vertices.len().saturating_mul(2))?; + for index in 0..vertices.len() { + let first = vertices[index]; + let second = vertices[(index + 1) % vertices.len()]; + let first_block = f64::from(first.block); + let second_block = f64::from(second.block); + if first_block == second_block { + if block == first_block { + push_raw_nonempty( + output, + InlineSlot { + start: f64::from(first.inline.min(second.inline)), + end: f64::from(first.inline.max(second.inline)), + }, + ); + } + continue; + } + if (first_block <= block && block < second_block) + || (second_block <= block && block < first_block) + { + let ratio = (block - first_block) / (second_block - first_block); + crossings.push( + f64::from(first.inline) + + (f64::from(second.inline) - f64::from(first.inline)) * ratio, + ); + } + } + crossings.sort_by(f64::total_cmp); + for pair in crossings.chunks_exact(2) { + push_raw_nonempty( + output, + InlineSlot { + start: pair[0], + end: pair[1], + }, + ); + } + normalize_slots(output); + if output.len() > max_slots { + return Err(EngineError::ResultTooLarge); + } + Ok(()) +} + +fn polygon_projection( + vertices: &[FlowVertex], + block_start: f64, + block_end: f64, +) -> Result, EngineError> { + if vertices.len() < 3 || block_start >= block_end { + return Err(EngineError::InvalidRequest); + } + let mut minimum = f64::INFINITY; + let mut maximum = f64::NEG_INFINITY; + for index in 0..vertices.len() { + let first = vertices[index]; + let second = vertices[(index + 1) % vertices.len()]; + let first_block = f64::from(first.block); + let second_block = f64::from(second.block); + if block_start <= first_block && first_block <= block_end { + minimum = minimum.min(f64::from(first.inline)); + maximum = maximum.max(f64::from(first.inline)); + } + for boundary in [block_start, block_end] { + if first_block != second_block + && ((first_block <= boundary && boundary <= second_block) + || (second_block <= boundary && boundary <= first_block)) + { + let ratio = (boundary - first_block) / (second_block - first_block); + let inline = f64::from(first.inline) + + (f64::from(second.inline) - f64::from(first.inline)) * ratio; + minimum = minimum.min(inline); + maximum = maximum.max(inline); + } + } + } + Ok((minimum < maximum).then_some(InlineSlot { + start: minimum, + end: maximum, + })) +} + +fn intersect_sorted( + first: &[InlineSlot], + second: &[InlineSlot], + output: &mut Vec, + max_slots: usize, +) -> Result<(), EngineError> { + let mut first_index = 0usize; + let mut second_index = 0usize; + while first_index < first.len() && second_index < second.len() { + let left = first[first_index]; + let right = second[second_index]; + push_nonempty( + output, + InlineSlot { + start: left.start.max(right.start), + end: left.end.min(right.end), + }, + max_slots, + )?; + if left.end <= right.end { + first_index += 1; + } else { + second_index += 1; + } + } + Ok(()) +} + +fn normalize_slots(slots: &mut Vec) { + slots.sort_by(|first, second| first.start.total_cmp(&second.start)); + let mut write = 0usize; + for read in 0..slots.len() { + let slot = slots[read]; + if write > 0 && slot.start <= slots[write - 1].end { + slots[write - 1].end = slots[write - 1].end.max(slot.end); + } else { + slots[write] = slot; + write += 1; + } + } + slots.truncate(write); +} + +fn subtract_slot( + destination: &mut Vec, + slot: InlineSlot, + cut_start: f64, + cut_end: f64, + wrap_side: u8, + max_slots: usize, +) -> Result<(), EngineError> { + if cut_end <= slot.start || cut_start >= slot.end { + return push_slot(destination, slot, max_slots); + } + let before = InlineSlot { + start: slot.start, + end: cut_start.min(slot.end), + }; + let after = InlineSlot { + start: cut_end.max(slot.start), + end: slot.end, + }; + match wrap_side { + EXCLUSION_WRAP_BOTH => { + push_nonempty(destination, before, max_slots)?; + push_nonempty(destination, after, max_slots) + } + EXCLUSION_WRAP_INLINE_START => push_nonempty(destination, before, max_slots), + EXCLUSION_WRAP_INLINE_END => push_nonempty(destination, after, max_slots), + EXCLUSION_WRAP_LARGEST => { + let selected = if before.end - before.start >= after.end - after.start { + before + } else { + after + }; + push_nonempty(destination, selected, max_slots) + } + _ => Err(EngineError::InvalidRequest), + } +} + +fn push_nonempty( + destination: &mut Vec, + slot: InlineSlot, + max_slots: usize, +) -> Result<(), EngineError> { + if slot.start < slot.end { + push_slot(destination, slot, max_slots)?; + } + Ok(()) +} + +fn push_raw_nonempty(destination: &mut Vec, slot: InlineSlot) { + if slot.start < slot.end { + destination.push(slot); + } +} + +fn push_slot( + destination: &mut Vec, + slot: InlineSlot, + max_slots: usize, +) -> Result<(), EngineError> { + if destination.len() >= max_slots { + return Err(EngineError::ResultTooLarge); + } + destination.push(slot); + Ok(()) +} + +fn append_vertices( + destination: &mut Vec, + geometry: GeometryBatch<'_>, + offset: u32, + count: u16, +) -> Result { + let start = u32::try_from(destination.len()).map_err(|_| EngineError::ResultTooLarge)?; + reserve(destination, usize::from(count))?; + for index in 0..usize::from(count) { + destination.push( + geometry + .vertex(offset, index) + .ok_or(EngineError::InvalidRequest)?, + ); + } + Ok(start) +} + +fn reserve(values: &mut Vec, additional: usize) -> Result<(), EngineError> { + if values.capacity().saturating_sub(values.len()) < additional { + values + .try_reserve_exact(additional) + .map_err(|_| EngineError::ResultTooLarge)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::frame::{ORIENTATION_MIXED, WRITING_HORIZONTAL_TB}; + use alloc::vec; + + #[test] + fn concave_region_and_polygon_exclusion_resolve_conservatively() { + let mut concave = FlowGeometryArena { + vertices: vec![ + vertex(0.0, 0.0), + vertex(100.0, 0.0), + vertex(100.0, 100.0), + vertex(60.0, 100.0), + vertex(60.0, 40.0), + vertex(40.0, 40.0), + vertex(40.0, 100.0), + vertex(0.0, 100.0), + ], + ..FlowGeometryArena::default() + }; + concave.regions.push(RetainedRegion { + record: region(SHAPE_POLYGON, 8, 0), + vertex_start: 0, + }); + let mut slots = InlineSlotArena::default(); + assert_eq!( + slots.resolve_band(&concave, 0, 20.0, 60.0, 4).unwrap(), + [ + InlineSlot { + start: 0.0, + end: 40.0, + }, + InlineSlot { + start: 60.0, + end: 100.0, + }, + ] + ); + assert_eq!( + slots.resolve_band(&concave, 0, 20.0, 60.0, 1), + Err(EngineError::ResultTooLarge) + ); + + let mut excluded = FlowGeometryArena { + vertices: vec![ + vertex(40.0, 20.0), + vertex(60.0, 40.0), + vertex(40.0, 60.0), + vertex(20.0, 40.0), + ], + ..FlowGeometryArena::default() + }; + excluded.regions.push(RetainedRegion { + record: region(SHAPE_RECTANGLE, 0, 1), + vertex_start: 0, + }); + excluded.exclusions.push(RetainedExclusion { + record: exclusion(SHAPE_POLYGON, 4), + vertex_start: 0, + }); + assert_eq!( + slots.resolve_band(&excluded, 0, 30.0, 50.0, 4).unwrap(), + [ + InlineSlot { + start: 0.0, + end: 20.0, + }, + InlineSlot { + start: 60.0, + end: 100.0, + }, + ] + ); + } + + fn vertex(inline: f32, block: f32) -> FlowVertex { + FlowVertex { inline, block } + } + + fn region(shape: u8, vertex_count: u16, exclusion_count: u16) -> FlowRegion { + FlowRegion { + id: 1, + geometry_revision: 1, + transform_index: 1, + vertices_offset: 0, + vertex_count, + exclusion_start: 0, + exclusion_count, + shape, + writing_mode: WRITING_HORIZONTAL_TB, + text_orientation: ORIENTATION_MIXED, + inline_start: 0.0, + block_start: 0.0, + inline_end: 100.0, + block_end: 100.0, + clip_inline_start: 0.0, + clip_block_start: 0.0, + clip_inline_end: 100.0, + clip_block_end: 100.0, + } + } + + fn exclusion(shape: u8, vertex_count: u16) -> FlowExclusion { + FlowExclusion { + id: 2, + region_id: 1, + geometry_revision: 1, + vertices_offset: 0, + vertex_count, + shape, + wrap_side: EXCLUSION_WRAP_BOTH, + inline_start: 20.0, + block_start: 20.0, + inline_end: 60.0, + block_end: 60.0, + margin_inline: 0.0, + margin_block: 0.0, + } + } +} diff --git a/packages/text/rust/shaper/src/engine/font_binding.rs b/packages/text/rust/shaper/src/engine/font_binding.rs new file mode 100644 index 00000000..47b45b09 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/font_binding.rs @@ -0,0 +1,397 @@ +//! Normalized renderer data owned by one shaping font. + +use alloc::vec::Vec; + +use super::policy::TechniqueId; + +pub const MAX_BINDING_FIELDS: u8 = 32; +pub const MISSING_RESOURCE_INDEX: u32 = u32::MAX; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FontResource { + pub id: u32, + pub generation: u32, + pub kind: u16, + pub reference: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FontStrike { + /// Pixels per em. Zero identifies the sole scalable strike used by MSDF and Slug. + pub ppem: u32, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct FieldTable { + row_count: u32, + field_count: u8, + /// Field-major values: every field is one contiguous `row_count` lane. + values: Vec, +} + +impl FieldTable { + pub fn new(row_count: u32, field_count: u8, values: Vec) -> Result { + let expected = usize::try_from(row_count) + .ok() + .and_then(|rows| rows.checked_mul(usize::from(field_count))) + .ok_or(FontBindingError::ArithmeticOverflow)?; + if field_count > MAX_BINDING_FIELDS || values.len() != expected { + return Err(FontBindingError::InvalidFieldTable); + } + Ok(Self { + row_count, + field_count, + values, + }) + } + + pub fn row_count(&self) -> u32 { + self.row_count + } + + pub fn field_count(&self) -> u8 { + self.field_count + } + + pub fn field(&self, field: u8) -> Option<&[T]> { + if field >= self.field_count { + return None; + } + let rows = usize::try_from(self.row_count).ok()?; + let start = usize::from(field).checked_mul(rows)?; + self.values.get(start..start.checked_add(rows)?) + } + + pub fn values(&self) -> &[T] { + &self.values + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct FontRenderBinding { + technique: TechniqueId, + program_variant: u16, + glyph_count: u32, + strikes: Vec, + resources: Vec, + resource_indices: Vec, + glyph_f32: FieldTable, + glyph_u32: FieldTable, + strike_f32: FieldTable, + strike_u32: FieldTable, + resource_f32: FieldTable, + resource_u32: FieldTable, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SelectedGlyphBinding { + pub strike: u32, + pub strike_row: u32, + pub resource: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FontBindingError { + InvalidTechnique, + InvalidGlyphCount, + InvalidStrikes, + InvalidResources, + InvalidResourceIndex, + InvalidFieldTable, + ArithmeticOverflow, +} + +#[allow(clippy::too_many_arguments)] +impl FontRenderBinding { + pub fn new( + technique: TechniqueId, + program_variant: u16, + glyph_count: u32, + strikes: Vec, + resources: Vec, + resource_indices: Vec, + glyph_f32: FieldTable, + glyph_u32: FieldTable, + strike_f32: FieldTable, + strike_u32: FieldTable, + resource_f32: FieldTable, + resource_u32: FieldTable, + ) -> Result { + if technique.0 == 0 { + return Err(FontBindingError::InvalidTechnique); + } + if glyph_count == 0 { + return Err(FontBindingError::InvalidGlyphCount); + } + validate_strikes(&strikes)?; + validate_resources(&resources)?; + let strike_rows = glyph_count + .checked_mul( + u32::try_from(strikes.len()).map_err(|_| FontBindingError::ArithmeticOverflow)?, + ) + .ok_or(FontBindingError::ArithmeticOverflow)?; + if resource_indices.len() != usize::try_from(strike_rows).unwrap_or(usize::MAX) + || resource_indices.iter().any(|&resource| { + resource != MISSING_RESOURCE_INDEX + && usize::try_from(resource).map_or(true, |index| index >= resources.len()) + }) + { + return Err(FontBindingError::InvalidResourceIndex); + } + let resource_count = + u32::try_from(resources.len()).map_err(|_| FontBindingError::ArithmeticOverflow)?; + if glyph_f32.row_count() != glyph_count + || glyph_u32.row_count() != glyph_count + || strike_f32.row_count() != strike_rows + || strike_u32.row_count() != strike_rows + || resource_f32.row_count() != resource_count + || resource_u32.row_count() != resource_count + { + return Err(FontBindingError::InvalidFieldTable); + } + Ok(Self { + technique, + program_variant, + glyph_count, + strikes, + resources, + resource_indices, + glyph_f32, + glyph_u32, + strike_f32, + strike_u32, + resource_f32, + resource_u32, + }) + } + + pub fn technique(&self) -> TechniqueId { + self.technique + } + + pub fn program_variant(&self) -> u16 { + self.program_variant + } + + pub fn glyph_count(&self) -> u32 { + self.glyph_count + } + + pub fn strikes(&self) -> &[FontStrike] { + &self.strikes + } + + pub fn resources(&self) -> &[FontResource] { + &self.resources + } + + pub fn glyph_f32(&self) -> &FieldTable { + &self.glyph_f32 + } + + pub fn glyph_u32(&self) -> &FieldTable { + &self.glyph_u32 + } + + pub fn strike_f32(&self) -> &FieldTable { + &self.strike_f32 + } + + pub fn strike_u32(&self) -> &FieldTable { + &self.strike_u32 + } + + pub fn resource_f32(&self) -> &FieldTable { + &self.resource_f32 + } + + pub fn resource_u32(&self) -> &FieldTable { + &self.resource_u32 + } + + pub fn select( + &self, + glyph: u32, + font_size: f32, + raster_pixel_ratio: f32, + ) -> Option { + if glyph >= self.glyph_count + || !font_size.is_finite() + || font_size <= 0.0 + || !raster_pixel_ratio.is_finite() + || raster_pixel_ratio <= 0.0 + { + return None; + } + let target = font_size * raster_pixel_ratio; + if !target.is_finite() { + return None; + } + let strike = if self.strikes.len() == 1 && self.strikes[0].ppem == 0 { + 0 + } else { + nearest_strike(&self.strikes, target) + }; + let strike = u32::try_from(strike).ok()?; + let strike_row = strike.checked_mul(self.glyph_count)?.checked_add(glyph)?; + let resource = *self + .resource_indices + .get(usize::try_from(strike_row).ok()?)?; + (resource != MISSING_RESOURCE_INDEX).then_some(SelectedGlyphBinding { + strike, + strike_row, + resource, + }) + } +} + +fn validate_strikes(strikes: &[FontStrike]) -> Result<(), FontBindingError> { + if strikes.is_empty() || strikes.len() > usize::from(u16::MAX) { + return Err(FontBindingError::InvalidStrikes); + } + if strikes[0].ppem == 0 { + return if strikes.len() == 1 { + Ok(()) + } else { + Err(FontBindingError::InvalidStrikes) + }; + } + if strikes.windows(2).any(|pair| pair[0].ppem >= pair[1].ppem) { + return Err(FontBindingError::InvalidStrikes); + } + Ok(()) +} + +fn validate_resources(resources: &[FontResource]) -> Result<(), FontBindingError> { + if resources.is_empty() + || resources.len() > usize::from(u16::MAX) + || resources.iter().any(|resource| { + resource.id == 0 || resource.generation == 0 || !(1..=32).contains(&resource.kind) + }) + || resources.windows(2).any(|pair| pair[0].id >= pair[1].id) + { + return Err(FontBindingError::InvalidResources); + } + Ok(()) +} + +fn nearest_strike(strikes: &[FontStrike], target: f32) -> usize { + let mut selected = 0; + let mut distance = ((strikes[0].ppem as f64) - f64::from(target)).abs(); + for (index, strike) in strikes.iter().enumerate().skip(1) { + let candidate = ((strike.ppem as f64) - f64::from(target)).abs(); + if candidate < distance { + selected = index; + distance = candidate; + } + } + selected +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + + fn table(rows: u32, fields: u8, values: Vec) -> FieldTable { + FieldTable::new(rows, fields, values).unwrap() + } + + fn binding(strikes: Vec, indices: Vec) -> FontRenderBinding { + let glyph_count = 3; + let strike_rows = glyph_count * strikes.len() as u32; + FontRenderBinding::new( + TechniqueId(7), + 2, + glyph_count, + strikes, + vec![ + FontResource { + id: 11, + generation: 1, + kind: 2, + reference: 91, + }, + FontResource { + id: 12, + generation: 1, + kind: 2, + reference: 92, + }, + ], + indices, + table(glyph_count, 1, vec![1.0, 2.0, 3.0]), + table(glyph_count, 0, vec![]), + table(strike_rows, 0, vec![]), + table(strike_rows, 1, (0..strike_rows).collect()), + table(2, 0, vec![]), + table(2, 1, vec![100, 200]), + ) + .unwrap() + } + + #[test] + fn scalable_and_bitmap_selection_share_one_dense_address_model() { + let scalable = binding(vec![FontStrike { ppem: 0 }], vec![0, 0, 1]); + assert_eq!( + scalable.select(2, 300.0, 2.0), + Some(SelectedGlyphBinding { + strike: 0, + strike_row: 2, + resource: 1, + }) + ); + + let bitmap = binding( + vec![FontStrike { ppem: 16 }, FontStrike { ppem: 32 }], + vec![0, 0, MISSING_RESOURCE_INDEX, 1, 1, 0], + ); + assert_eq!(bitmap.select(1, 12.0, 2.0).unwrap().strike, 0); + assert_eq!(bitmap.select(1, 12.1, 2.0).unwrap().strike, 1); + assert_eq!(bitmap.select(2, 8.0, 2.0), None); + assert_eq!(bitmap.select(2, 16.0, 2.0).unwrap().resource, 0); + } + + #[test] + fn rejects_noncanonical_strikes_resources_and_field_shapes() { + let glyph = table(1, 0, Vec::::new()); + let empty_u32 = table(1, 0, Vec::::new()); + assert_eq!( + FontRenderBinding::new( + TechniqueId(1), + 0, + 1, + vec![FontStrike { ppem: 0 }, FontStrike { ppem: 16 }], + vec![FontResource { + id: 1, + generation: 1, + kind: 1, + reference: 0, + }], + vec![0, 0], + glyph.clone(), + empty_u32.clone(), + table(2, 0, vec![]), + table(2, 0, vec![]), + table(1, 0, vec![]), + table(1, 0, vec![]), + ), + Err(FontBindingError::InvalidStrikes) + ); + assert_eq!( + FieldTable::new(2, 1, vec![0_u32]), + Err(FontBindingError::InvalidFieldTable) + ); + assert_eq!( + FieldTable::new(1, MAX_BINDING_FIELDS + 1, vec![0_u32; 33]), + Err(FontBindingError::InvalidFieldTable) + ); + + let mut unsorted = binding(vec![FontStrike { ppem: 0 }], vec![0, 0, 1]); + unsorted.resources.swap(0, 1); + assert_eq!( + validate_resources(&unsorted.resources), + Err(FontBindingError::InvalidResources) + ); + } +} diff --git a/packages/text/rust/shaper/src/engine/font_binding_wire.rs b/packages/text/rust/shaper/src/engine/font_binding_wire.rs new file mode 100644 index 00000000..c6d044d7 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/font_binding_wire.rs @@ -0,0 +1,374 @@ +//! Compiler-mapped cold decoder for normalized per-font renderer data. + +use alloc::vec::Vec; + +use crate::{ + STATUS_INVALID_REQUEST, STATUS_RESULT_TOO_LARGE, + abi_contract::{ + ABI_VERSION, FONT_BINDING_ABI_VERSION, FONT_BINDING_BYTE_LENGTH, FONT_BINDING_GLYPH_COUNT, + FONT_BINDING_GLYPH_F32_FIELD_COUNT, FONT_BINDING_GLYPH_F32_OFFSET, + FONT_BINDING_GLYPH_U32_FIELD_COUNT, FONT_BINDING_GLYPH_U32_OFFSET, + FONT_BINDING_PROGRAM_VARIANT, FONT_BINDING_REQUEST_HEADER_SIZE, FONT_BINDING_RESERVED0, + FONT_BINDING_RESERVED1, FONT_BINDING_RESERVED2, FONT_BINDING_RESOURCE_COUNT, + FONT_BINDING_RESOURCE_F32_FIELD_COUNT, FONT_BINDING_RESOURCE_F32_OFFSET, + FONT_BINDING_RESOURCE_GENERATION, FONT_BINDING_RESOURCE_ID, + FONT_BINDING_RESOURCE_INDICES_OFFSET, FONT_BINDING_RESOURCE_KIND, + FONT_BINDING_RESOURCE_RECORD_ALIGNMENT, FONT_BINDING_RESOURCE_RECORD_SIZE, + FONT_BINDING_RESOURCE_REFERENCE, FONT_BINDING_RESOURCE_RESERVED, + FONT_BINDING_RESOURCE_U32_FIELD_COUNT, FONT_BINDING_RESOURCE_U32_OFFSET, + FONT_BINDING_RESOURCES_OFFSET, FONT_BINDING_STRIKE_COUNT, + FONT_BINDING_STRIKE_F32_FIELD_COUNT, FONT_BINDING_STRIKE_F32_OFFSET, + FONT_BINDING_STRIKE_PPEM, FONT_BINDING_STRIKE_RECORD_ALIGNMENT, + FONT_BINDING_STRIKE_RECORD_SIZE, FONT_BINDING_STRIKE_RESERVED, + FONT_BINDING_STRIKE_U32_FIELD_COUNT, FONT_BINDING_STRIKE_U32_OFFSET, + FONT_BINDING_STRIKES_OFFSET, FONT_BINDING_TECHNIQUE_ID, + }, + engine::{ + font_binding::{ + FieldTable, FontRenderBinding, FontResource, FontStrike, MAX_BINDING_FIELDS, + }, + policy::TechniqueId, + }, + wire::{array, read_u16, read_u32}, +}; + +const MAX_GLYPHS: u32 = u16::MAX as u32; +const MAX_STRIKES: u32 = u16::MAX as u32; +const MAX_RESOURCES: u32 = u16::MAX as u32; + +pub(crate) fn parse_font_binding(bytes: &[u8]) -> Result { + if bytes.len() < FONT_BINDING_REQUEST_HEADER_SIZE as usize + || read_u32(bytes, FONT_BINDING_ABI_VERSION)? != ABI_VERSION + || read_u32(bytes, FONT_BINDING_BYTE_LENGTH)? + != u32::try_from(bytes.len()).map_err(|_| STATUS_INVALID_REQUEST)? + || read_u16(bytes, FONT_BINDING_RESERVED0)? != 0 + || read_u16(bytes, FONT_BINDING_RESERVED1)? != 0 + || read_u32(bytes, FONT_BINDING_RESERVED2)? != 0 + { + return Err(STATUS_INVALID_REQUEST); + } + let glyph_count = bounded_positive(bytes, FONT_BINDING_GLYPH_COUNT, MAX_GLYPHS)?; + let strike_count = bounded_positive(bytes, FONT_BINDING_STRIKE_COUNT, MAX_STRIKES)?; + let resource_count = bounded_positive(bytes, FONT_BINDING_RESOURCE_COUNT, MAX_RESOURCES)?; + let strike_rows = glyph_count + .checked_mul(strike_count) + .ok_or(STATUS_INVALID_REQUEST)?; + let field_counts = [ + byte(bytes, FONT_BINDING_GLYPH_F32_FIELD_COUNT)?, + byte(bytes, FONT_BINDING_GLYPH_U32_FIELD_COUNT)?, + byte(bytes, FONT_BINDING_STRIKE_F32_FIELD_COUNT)?, + byte(bytes, FONT_BINDING_STRIKE_U32_FIELD_COUNT)?, + byte(bytes, FONT_BINDING_RESOURCE_F32_FIELD_COUNT)?, + byte(bytes, FONT_BINDING_RESOURCE_U32_FIELD_COUNT)?, + ]; + if field_counts.iter().any(|&count| count > MAX_BINDING_FIELDS) { + return Err(STATUS_INVALID_REQUEST); + } + + let strikes = table( + bytes, + read_u32(bytes, FONT_BINDING_STRIKES_OFFSET)?, + strike_count, + FONT_BINDING_STRIKE_RECORD_SIZE, + FONT_BINDING_STRIKE_RECORD_ALIGNMENT, + )?; + let resources = table( + bytes, + read_u32(bytes, FONT_BINDING_RESOURCES_OFFSET)?, + resource_count, + FONT_BINDING_RESOURCE_RECORD_SIZE, + FONT_BINDING_RESOURCE_RECORD_ALIGNMENT, + )?; + let resource_indices = scalar_table(bytes, FONT_BINDING_RESOURCE_INDICES_OFFSET, strike_rows)?; + let glyph_f32 = scalar_table( + bytes, + FONT_BINDING_GLYPH_F32_OFFSET, + field_rows(glyph_count, field_counts[0])?, + )?; + let glyph_u32 = scalar_table( + bytes, + FONT_BINDING_GLYPH_U32_OFFSET, + field_rows(glyph_count, field_counts[1])?, + )?; + let strike_f32 = scalar_table( + bytes, + FONT_BINDING_STRIKE_F32_OFFSET, + field_rows(strike_rows, field_counts[2])?, + )?; + let strike_u32 = scalar_table( + bytes, + FONT_BINDING_STRIKE_U32_OFFSET, + field_rows(strike_rows, field_counts[3])?, + )?; + let resource_f32 = scalar_table( + bytes, + FONT_BINDING_RESOURCE_F32_OFFSET, + field_rows(resource_count, field_counts[4])?, + )?; + let resource_u32 = scalar_table( + bytes, + FONT_BINDING_RESOURCE_U32_OFFSET, + field_rows(resource_count, field_counts[5])?, + )?; + reject_overlaps( + bytes, + &[ + strikes, + resources, + resource_indices, + glyph_f32, + glyph_u32, + strike_f32, + strike_u32, + resource_f32, + resource_u32, + ], + )?; + + FontRenderBinding::new( + TechniqueId(read_u32(bytes, FONT_BINDING_TECHNIQUE_ID)?), + read_u16(bytes, FONT_BINDING_PROGRAM_VARIANT)?, + glyph_count, + decode_strikes(strikes)?, + decode_resources(resources)?, + decode_u32(resource_indices)?, + FieldTable::new(glyph_count, field_counts[0], decode_f32(glyph_f32)?) + .map_err(|_| STATUS_INVALID_REQUEST)?, + FieldTable::new(glyph_count, field_counts[1], decode_u32(glyph_u32)?) + .map_err(|_| STATUS_INVALID_REQUEST)?, + FieldTable::new(strike_rows, field_counts[2], decode_f32(strike_f32)?) + .map_err(|_| STATUS_INVALID_REQUEST)?, + FieldTable::new(strike_rows, field_counts[3], decode_u32(strike_u32)?) + .map_err(|_| STATUS_INVALID_REQUEST)?, + FieldTable::new(resource_count, field_counts[4], decode_f32(resource_f32)?) + .map_err(|_| STATUS_INVALID_REQUEST)?, + FieldTable::new(resource_count, field_counts[5], decode_u32(resource_u32)?) + .map_err(|_| STATUS_INVALID_REQUEST)?, + ) + .map_err(|_| STATUS_INVALID_REQUEST) +} + +fn bounded_positive(bytes: &[u8], offset: usize, maximum: u32) -> Result { + let value = read_u32(bytes, offset)?; + if value == 0 || value > maximum { + return Err(STATUS_INVALID_REQUEST); + } + Ok(value) +} + +fn field_rows(rows: u32, fields: u8) -> Result { + rows.checked_mul(u32::from(fields)) + .ok_or(STATUS_INVALID_REQUEST) +} + +fn scalar_table(bytes: &[u8], offset_field: usize, count: u32) -> Result<&[u8], u32> { + table(bytes, read_u32(bytes, offset_field)?, count, 4, 4) +} + +fn table(bytes: &[u8], offset: u32, count: u32, stride: u32, alignment: u32) -> Result<&[u8], u32> { + if count == 0 { + return if offset == 0 { + Ok(&bytes[..0]) + } else { + Err(STATUS_INVALID_REQUEST) + }; + } + if offset < FONT_BINDING_REQUEST_HEADER_SIZE { + return Err(STATUS_INVALID_REQUEST); + } + array(bytes, offset, count, stride, alignment) +} + +fn reject_overlaps(bytes: &[u8], tables: &[&[u8]]) -> Result<(), u32> { + for (index, table) in tables.iter().enumerate() { + if table.is_empty() { + continue; + } + let current = relative_range(bytes, table)?; + for previous in &tables[..index] { + if previous.is_empty() { + continue; + } + let previous = relative_range(bytes, previous)?; + if current.0 < previous.1 && previous.0 < current.1 { + return Err(STATUS_INVALID_REQUEST); + } + } + } + Ok(()) +} + +fn relative_range(container: &[u8], selected: &[u8]) -> Result<(usize, usize), u32> { + let start = (selected.as_ptr() as usize) + .checked_sub(container.as_ptr() as usize) + .ok_or(STATUS_INVALID_REQUEST)?; + Ok(( + start, + start + .checked_add(selected.len()) + .ok_or(STATUS_INVALID_REQUEST)?, + )) +} + +fn decode_strikes(records: &[u8]) -> Result, u32> { + let mut decoded = reserved_vec(records.len() / FONT_BINDING_STRIKE_RECORD_SIZE as usize)?; + for record in records.chunks_exact(FONT_BINDING_STRIKE_RECORD_SIZE as usize) { + if read_u32(record, FONT_BINDING_STRIKE_RESERVED)? != 0 { + return Err(STATUS_INVALID_REQUEST); + } + decoded.push(FontStrike { + ppem: read_u32(record, FONT_BINDING_STRIKE_PPEM)?, + }); + } + Ok(decoded) +} + +fn decode_resources(records: &[u8]) -> Result, u32> { + let mut decoded = reserved_vec(records.len() / FONT_BINDING_RESOURCE_RECORD_SIZE as usize)?; + for record in records.chunks_exact(FONT_BINDING_RESOURCE_RECORD_SIZE as usize) { + if read_u16(record, FONT_BINDING_RESOURCE_RESERVED)? != 0 { + return Err(STATUS_INVALID_REQUEST); + } + decoded.push(FontResource { + id: read_u32(record, FONT_BINDING_RESOURCE_ID)?, + generation: read_u32(record, FONT_BINDING_RESOURCE_GENERATION)?, + kind: read_u16(record, FONT_BINDING_RESOURCE_KIND)?, + reference: read_u32(record, FONT_BINDING_RESOURCE_REFERENCE)?, + }); + } + Ok(decoded) +} + +fn decode_u32(bytes: &[u8]) -> Result, u32> { + let mut decoded = reserved_vec(bytes.len() / 4)?; + for value in bytes.chunks_exact(4) { + decoded.push(u32::from_le_bytes([value[0], value[1], value[2], value[3]])); + } + Ok(decoded) +} + +fn decode_f32(bytes: &[u8]) -> Result, u32> { + let mut decoded = reserved_vec(bytes.len() / 4)?; + for value in bytes.chunks_exact(4) { + let value = f32::from_bits(u32::from_le_bytes([value[0], value[1], value[2], value[3]])); + if !value.is_finite() { + return Err(STATUS_INVALID_REQUEST); + } + decoded.push(value); + } + Ok(decoded) +} + +fn reserved_vec(capacity: usize) -> Result, u32> { + let mut values = Vec::new(); + values + .try_reserve_exact(capacity) + .map_err(|_| STATUS_RESULT_TOO_LARGE)?; + Ok(values) +} + +fn byte(bytes: &[u8], offset: usize) -> Result { + bytes.get(offset).copied().ok_or(STATUS_INVALID_REQUEST) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{STATUS_INVALID_REQUEST, wire::write_u32}; + + #[test] + fn decodes_exact_field_major_tables_and_dense_selection() { + let bytes = valid_bytes(); + let binding = parse_font_binding(&bytes).unwrap(); + assert_eq!(binding.technique(), TechniqueId(7)); + assert_eq!(binding.program_variant(), 2); + assert_eq!(binding.glyph_f32().field(0), Some(&[1.0, 2.0][..])); + assert_eq!(binding.glyph_u32().field(0), Some(&[10, 20][..])); + assert_eq!(binding.strike_f32().field(0), Some(&[3.0, 4.0][..])); + assert_eq!(binding.strike_u32().field(0), Some(&[30, 40][..])); + assert_eq!(binding.resource_f32().field(0), Some(&[5.0][..])); + assert_eq!(binding.resource_u32().field(0), Some(&[50][..])); + assert_eq!(binding.select(1, 12.0, 2.0).unwrap().resource, 0); + } + + #[test] + fn rejects_reserved_overlap_nonfinite_and_out_of_range_data() { + let mut reserved = valid_bytes(); + write_u32(&mut reserved, FONT_BINDING_RESERVED2, 1); + assert_eq!(parse_font_binding(&reserved), Err(STATUS_INVALID_REQUEST)); + + let mut overlap = valid_bytes(); + let indices = read_u32(&overlap, FONT_BINDING_RESOURCE_INDICES_OFFSET).unwrap(); + write_u32(&mut overlap, FONT_BINDING_GLYPH_F32_OFFSET, indices); + assert_eq!(parse_font_binding(&overlap), Err(STATUS_INVALID_REQUEST)); + + let mut nonfinite = valid_bytes(); + let offset = read_u32(&nonfinite, FONT_BINDING_GLYPH_F32_OFFSET).unwrap() as usize; + write_u32(&mut nonfinite, offset, f32::NAN.to_bits()); + assert_eq!(parse_font_binding(&nonfinite), Err(STATUS_INVALID_REQUEST)); + + let mut bad_index = valid_bytes(); + let offset = read_u32(&bad_index, FONT_BINDING_RESOURCE_INDICES_OFFSET).unwrap() as usize; + write_u32(&mut bad_index, offset, 1); + assert_eq!(parse_font_binding(&bad_index), Err(STATUS_INVALID_REQUEST)); + + let mut fields = valid_bytes(); + fields[FONT_BINDING_GLYPH_F32_FIELD_COUNT] = MAX_BINDING_FIELDS + 1; + assert_eq!(parse_font_binding(&fields), Err(STATUS_INVALID_REQUEST)); + } + + fn valid_bytes() -> Vec { + let mut bytes = vec![0; FONT_BINDING_REQUEST_HEADER_SIZE as usize]; + write_u32(&mut bytes, FONT_BINDING_ABI_VERSION, ABI_VERSION); + write_u32(&mut bytes, FONT_BINDING_TECHNIQUE_ID, 7); + bytes[FONT_BINDING_PROGRAM_VARIANT..FONT_BINDING_PROGRAM_VARIANT + 2] + .copy_from_slice(&2_u16.to_le_bytes()); + write_u32(&mut bytes, FONT_BINDING_GLYPH_COUNT, 2); + write_u32(&mut bytes, FONT_BINDING_STRIKE_COUNT, 1); + write_u32(&mut bytes, FONT_BINDING_RESOURCE_COUNT, 1); + for offset in [ + FONT_BINDING_GLYPH_F32_FIELD_COUNT, + FONT_BINDING_GLYPH_U32_FIELD_COUNT, + FONT_BINDING_STRIKE_F32_FIELD_COUNT, + FONT_BINDING_STRIKE_U32_FIELD_COUNT, + FONT_BINDING_RESOURCE_F32_FIELD_COUNT, + FONT_BINDING_RESOURCE_U32_FIELD_COUNT, + ] { + bytes[offset] = 1; + } + + let strikes = append(&mut bytes, &[0_u32, 0]); + let resources = append(&mut bytes, &[11, 1, 2, 91]); + let indices = append(&mut bytes, &[0, 0]); + let glyph_f32 = append(&mut bytes, &[1.0_f32.to_bits(), 2.0_f32.to_bits()]); + let glyph_u32 = append(&mut bytes, &[10, 20]); + let strike_f32 = append(&mut bytes, &[3.0_f32.to_bits(), 4.0_f32.to_bits()]); + let strike_u32 = append(&mut bytes, &[30, 40]); + let resource_f32 = append(&mut bytes, &[5.0_f32.to_bits()]); + let resource_u32 = append(&mut bytes, &[50]); + for (field, value) in [ + (FONT_BINDING_STRIKES_OFFSET, strikes), + (FONT_BINDING_RESOURCES_OFFSET, resources), + (FONT_BINDING_RESOURCE_INDICES_OFFSET, indices), + (FONT_BINDING_GLYPH_F32_OFFSET, glyph_f32), + (FONT_BINDING_GLYPH_U32_OFFSET, glyph_u32), + (FONT_BINDING_STRIKE_F32_OFFSET, strike_f32), + (FONT_BINDING_STRIKE_U32_OFFSET, strike_u32), + (FONT_BINDING_RESOURCE_F32_OFFSET, resource_f32), + (FONT_BINDING_RESOURCE_U32_OFFSET, resource_u32), + ] { + write_u32(&mut bytes, field, value); + } + let length = bytes.len() as u32; + write_u32(&mut bytes, FONT_BINDING_BYTE_LENGTH, length); + bytes + } + + fn append(bytes: &mut Vec, values: &[u32]) -> u32 { + let offset = bytes.len() as u32; + for value in values { + bytes.extend_from_slice(&value.to_le_bytes()); + } + offset + } +} diff --git a/packages/text/rust/shaper/src/engine/frame.rs b/packages/text/rust/shaper/src/engine/frame.rs new file mode 100644 index 00000000..9962317a --- /dev/null +++ b/packages/text/rust/shaper/src/engine/frame.rs @@ -0,0 +1,166 @@ +pub(crate) const RESULT_FLAG_CHECKPOINT: u32 = 1; +pub(crate) const FRAME_FLAG_COMPOSITING_INDEPENDENT: u32 = 1; +pub(crate) const FRAME_FLAGS: u32 = FRAME_FLAG_COMPOSITING_INDEPENDENT; +pub(crate) const SEMANTIC_VIEW_MEASUREMENT: u32 = 1 << 0; +pub(crate) const SEMANTIC_VIEW_LAYOUT_INSPECTION: u32 = 1 << 1; +pub(crate) const SEMANTIC_VIEW_MASK: u32 = + SEMANTIC_VIEW_MEASUREMENT | SEMANTIC_VIEW_LAYOUT_INSPECTION; + +pub(crate) const TEXT_MUTATION_REPLACE_UTF16: u8 = 1; +pub(crate) const TEXT_ENCODING_UTF16_LE: u8 = 1; +pub(crate) const STYLE_MUTATION_UPSERT: u8 = 1; +pub(crate) const STYLE_MUTATION_REMOVE: u8 = 2; +pub(crate) const STYLE_FLAG_ROOT: u8 = 1; +pub(crate) const STYLE_FIELD_FONT_STACK: u32 = 1 << 0; +pub(crate) const STYLE_FIELD_MATERIAL: u32 = 1 << 1; +pub(crate) const STYLE_FIELD_LANGUAGE: u32 = 1 << 2; +pub(crate) const STYLE_FIELD_FEATURES: u32 = 1 << 3; +pub(crate) const STYLE_FIELD_FONT_SIZE: u32 = 1 << 4; +pub(crate) const STYLE_FIELD_LINE_HEIGHT: u32 = 1 << 5; +pub(crate) const STYLE_FIELD_LETTER_SPACING: u32 = 1 << 6; +pub(crate) const STYLE_FIELD_WORD_SPACING: u32 = 1 << 7; +pub(crate) const STYLE_FIELD_BASELINE_SHIFT: u32 = 1 << 8; +pub(crate) const STYLE_FIELD_RASTER_PIXEL_RATIO: u32 = 1 << 9; +pub(crate) const STYLE_FIELD_DIRECTION: u32 = 1 << 10; +pub(crate) const STYLE_FIELD_FOREGROUND: u32 = 1 << 11; +pub(crate) const STYLE_FIELD_DECORATION: u32 = 1 << 12; +pub(crate) const STYLE_FIELD_MASK: u32 = (1 << 13) - 1; +pub(crate) const DECORATION_NONE: u8 = 0; +pub(crate) const DECORATION_SOLID: u8 = 1; +pub(crate) const DECORATION_DOUBLE: u8 = 2; +pub(crate) const DECORATION_DOTTED: u8 = 3; +pub(crate) const DECORATION_DASHED: u8 = 4; +pub(crate) const DECORATION_WAVY: u8 = 5; +pub(crate) const DECORATION_UNDERLINE: u32 = 1 << 0; +pub(crate) const DECORATION_OVERLINE: u32 = 1 << 1; +pub(crate) const DECORATION_LINE_THROUGH: u32 = 1 << 2; +pub(crate) const DECORATION_SKIP_INK: u32 = 1 << 3; +pub(crate) const DECORATION_FLAGS_MASK: u32 = (1 << 4) - 1; +pub(crate) const SHAPE_RECTANGLE: u8 = 1; +pub(crate) const SHAPE_POLYGON: u8 = 2; +pub(crate) const WRITING_HORIZONTAL_TB: u8 = 1; +pub(crate) const WRITING_VERTICAL_RL: u8 = 2; +pub(crate) const WRITING_VERTICAL_LR: u8 = 3; +pub(crate) const ORIENTATION_MIXED: u8 = 1; +pub(crate) const ORIENTATION_UPRIGHT: u8 = 2; +pub(crate) const ORIENTATION_SIDEWAYS: u8 = 3; +pub(crate) const AXIS_UNCONSTRAINED: u8 = 1; +pub(crate) const AXIS_AT_MOST: u8 = 2; +pub(crate) const AXIS_EXACT: u8 = 3; +pub(crate) const WRAP_NONE: u8 = 1; +pub(crate) const WRAP_WORD: u8 = 2; +pub(crate) const WRAP_CHARACTER: u8 = 3; +pub(crate) const ALIGN_START: u8 = 1; +pub(crate) const ALIGN_CENTER: u8 = 2; +pub(crate) const ALIGN_END: u8 = 3; +pub(crate) const ALIGN_JUSTIFY: u8 = 4; +pub(crate) const OVERFLOW_VISIBLE: u8 = 1; +pub(crate) const OVERFLOW_CLIP: u8 = 2; +pub(crate) const OVERFLOW_ELLIPSIS: u8 = 3; +pub(crate) const BLOCK_ALIGN_START: u8 = 1; +pub(crate) const BLOCK_ALIGN_CENTER: u8 = 2; +pub(crate) const BLOCK_ALIGN_END: u8 = 3; +pub(crate) const EXCLUSION_WRAP_BOTH: u8 = 1; +pub(crate) const EXCLUSION_WRAP_INLINE_START: u8 = 2; +pub(crate) const EXCLUSION_WRAP_INLINE_END: u8 = 3; +pub(crate) const EXCLUSION_WRAP_LARGEST: u8 = 4; +pub(crate) const BASELINE_ALPHABETIC: u8 = 1; +pub(crate) const BASELINE_TEXT_TOP: u8 = 2; +pub(crate) const BASELINE_MIDDLE: u8 = 3; +pub(crate) const BASELINE_TEXT_BOTTOM: u8 = 4; +pub(crate) const DEFAULT_SESSION_TEXT_CAPACITY: u32 = 1024; +pub(crate) const SEMANTIC_F32_INLINE_START: u8 = 0; +pub(crate) const SEMANTIC_F32_BLOCK_START: u8 = 1; +pub(crate) const SEMANTIC_F32_INLINE_EXTENT: u8 = 2; +pub(crate) const SEMANTIC_F32_BLOCK_EXTENT: u8 = 3; +pub(crate) const SEMANTIC_F32_FONT_SIZE: u8 = 4; +pub(crate) const SEMANTIC_F32_RASTER_PIXEL_RATIO: u8 = 5; +pub(crate) const SEMANTIC_F32_INLINE_ORIGIN: u8 = 6; +pub(crate) const SEMANTIC_F32_BLOCK_ORIGIN: u8 = 7; +pub(crate) const SEMANTIC_F32_FOREGROUND_RED: u8 = 8; +pub(crate) const SEMANTIC_F32_FOREGROUND_GREEN: u8 = 9; +pub(crate) const SEMANTIC_F32_FOREGROUND_BLUE: u8 = 10; +pub(crate) const SEMANTIC_F32_FOREGROUND_ALPHA: u8 = 11; +pub(crate) const SEMANTIC_F32_INVERSE_FONT_SIZE: u8 = 12; +pub(crate) const SEMANTIC_U32_FOREGROUND_RGBA: u8 = 0; +pub(crate) const SEMANTIC_U32_CLUSTER_ID: u8 = 1; +pub(crate) const SEMANTIC_U32_REGION_ID: u8 = 2; +pub(crate) const SEMANTIC_U32_FLOW_THREAD_ID: u8 = 3; +pub(crate) const SEMANTIC_U32_TRANSFORM_INDEX: u8 = 4; +pub(crate) const SEMANTIC_U32_STABLE_GLYPH_ID: u8 = 5; +pub(crate) const PARAGRAPH_MUTATION_UPSERT: u8 = 1; +pub(crate) const PARAGRAPH_MUTATION_REMOVE: u8 = 2; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct UpdateRequest<'a> { + pub session_id: u32, + pub expected_engine_revision: u32, + pub consumed_plan_revision: u32, + pub acknowledged_publication_generation: u32, + pub policy_handle: u32, + pub capability_set: u32, + pub semantic_view_mask: u32, + pub compositing_independent: bool, + pub limits: UpdateLimits, + pub paragraph_mutations: super::semantic_wire::ParagraphMutationBatch<'a>, + pub text_mutations: super::semantic_wire::TextMutationBatch<'a>, + pub style_mutations: super::semantic_wire::StyleMutationBatch<'a>, + pub geometry: super::semantic_wire::GeometryBatch<'a>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct UpdateLimits { + pub max_paragraphs: u32, + pub max_clusters: u32, + pub max_lines: u32, + pub max_regions: u32, + pub max_exclusions: u32, + pub max_inline_objects: u32, + pub max_slots_per_band: u32, + pub max_output_bytes: u32, +} + +impl UpdateLimits { + pub fn all_nonzero(self) -> bool { + self.max_paragraphs != 0 + && self.max_clusters != 0 + && self.max_lines != 0 + && self.max_regions != 0 + && self.max_exclusions != 0 + && self.max_inline_objects != 0 + && self.max_slots_per_band != 0 + && self.max_output_bytes != 0 + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct SessionRevision { + pub engine: u32, + pub plan: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct PreparedUpdate { + pub(super) session_id: u32, + pub(super) previous: SessionRevision, + pub(super) next: SessionRevision, + pub(super) required_base_revision: u32, + pub(super) checkpoint: bool, + pub(super) policy_handle: u32, + pub(super) capability_set: u32, + pub(super) policy_fingerprint: u64, +} + +impl PreparedUpdate { + pub(crate) fn session_id(self) -> u32 { + self.session_id + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct CommittedUpdate { + pub session_id: u32, + pub revision: SessionRevision, + pub required_base_revision: u32, + pub checkpoint: bool, +} diff --git a/packages/text/rust/shaper/src/engine/frame_wire.rs b/packages/text/rust/shaper/src/engine/frame_wire.rs new file mode 100644 index 00000000..27754af7 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/frame_wire.rs @@ -0,0 +1,266 @@ +//! Direct-memory decoding for the retained engine update transaction. +//! +//! The compiler-derived offsets in `abi_contract` remain the sole layout authority. Stage 1 +//! admits only semantic sections whose Rust consumer has landed; no request can silently fall +//! back to TypeScript logic. + +use crate::{ + STATUS_INVALID_REQUEST, + abi_contract::{ + ABI_VERSION, ENGINE_RESULT_HEADER_SIZE, ENGINE_UPDATE_ABI_VERSION, + ENGINE_UPDATE_ACKNOWLEDGED_PUBLICATION_GENERATION, ENGINE_UPDATE_BYTE_LENGTH, + ENGINE_UPDATE_CAPABILITY_SET, ENGINE_UPDATE_CONSTRAINT_COUNT, + ENGINE_UPDATE_CONSTRAINTS_OFFSET, ENGINE_UPDATE_CONSUMED_PLAN_REVISION, + ENGINE_UPDATE_EXCLUSION_COUNT, ENGINE_UPDATE_EXCLUSIONS_OFFSET, + ENGINE_UPDATE_EXPECTED_ENGINE_REVISION, ENGINE_UPDATE_FLAGS, + ENGINE_UPDATE_INLINE_OBJECT_COUNT, ENGINE_UPDATE_INLINE_OBJECTS_OFFSET, + ENGINE_UPDATE_MAX_CLUSTERS, ENGINE_UPDATE_MAX_EXCLUSIONS, ENGINE_UPDATE_MAX_INLINE_OBJECTS, + ENGINE_UPDATE_MAX_LINES, ENGINE_UPDATE_MAX_OUTPUT_BYTES, ENGINE_UPDATE_MAX_PARAGRAPHS, + ENGINE_UPDATE_MAX_REGIONS, ENGINE_UPDATE_MAX_SLOTS_PER_BAND, + ENGINE_UPDATE_PARAGRAPH_MUTATION_COUNT, ENGINE_UPDATE_PARAGRAPH_MUTATIONS_OFFSET, + ENGINE_UPDATE_POLICY_HANDLE, ENGINE_UPDATE_POLICY_PARAMETERS_LENGTH, + ENGINE_UPDATE_POLICY_PARAMETERS_OFFSET, ENGINE_UPDATE_REGION_COUNT, + ENGINE_UPDATE_REGIONS_OFFSET, ENGINE_UPDATE_REQUEST_HEADER_SIZE, + ENGINE_UPDATE_SEMANTIC_VIEW_MASK, ENGINE_UPDATE_SESSION_ID, + ENGINE_UPDATE_STYLE_MUTATION_COUNT, ENGINE_UPDATE_STYLE_MUTATIONS_OFFSET, + ENGINE_UPDATE_TEXT_MUTATION_COUNT, ENGINE_UPDATE_TEXT_MUTATIONS_OFFSET, + }, + engine::{ + frame::{UpdateLimits, UpdateRequest}, + semantic_wire::{parse_geometry, parse_paragraph_mutations, parse_text_mutations}, + }, + wire::read_u32, +}; + +const MAX_DECLARED_OUTPUT_BYTES: u32 = 64 * 1024 * 1024; + +pub(crate) fn parse_update_request( + bytes: &[u8], + session_id: u32, +) -> Result, u32> { + if bytes.len() < ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize + || read_u32(bytes, ENGINE_UPDATE_ABI_VERSION)? != ABI_VERSION + || read_u32(bytes, ENGINE_UPDATE_SESSION_ID)? != session_id + || read_u32(bytes, ENGINE_UPDATE_BYTE_LENGTH)? + != u32::try_from(bytes.len()).map_err(|_| STATUS_INVALID_REQUEST)? + { + return Err(STATUS_INVALID_REQUEST); + } + let flags = read_u32(bytes, ENGINE_UPDATE_FLAGS)?; + if flags & !super::frame::FRAME_FLAGS != 0 { + return Err(STATUS_INVALID_REQUEST); + } + let semantic_view_mask = read_u32(bytes, ENGINE_UPDATE_SEMANTIC_VIEW_MASK)?; + if semantic_view_mask & !super::frame::SEMANTIC_VIEW_MASK != 0 { + return Err(STATUS_INVALID_REQUEST); + } + + for (offset, count) in [( + ENGINE_UPDATE_POLICY_PARAMETERS_OFFSET, + ENGINE_UPDATE_POLICY_PARAMETERS_LENGTH, + )] { + if read_u32(bytes, offset)? != 0 || read_u32(bytes, count)? != 0 { + return Err(STATUS_INVALID_REQUEST); + } + } + let limits = UpdateLimits { + max_paragraphs: positive(bytes, ENGINE_UPDATE_MAX_PARAGRAPHS)?, + max_clusters: positive(bytes, ENGINE_UPDATE_MAX_CLUSTERS)?, + max_lines: positive(bytes, ENGINE_UPDATE_MAX_LINES)?, + max_regions: positive(bytes, ENGINE_UPDATE_MAX_REGIONS)?, + max_exclusions: positive(bytes, ENGINE_UPDATE_MAX_EXCLUSIONS)?, + max_inline_objects: positive(bytes, ENGINE_UPDATE_MAX_INLINE_OBJECTS)?, + max_slots_per_band: positive(bytes, ENGINE_UPDATE_MAX_SLOTS_PER_BAND)?, + max_output_bytes: read_u32(bytes, ENGINE_UPDATE_MAX_OUTPUT_BYTES)?, + }; + if !limits.all_nonzero() + || limits.max_output_bytes < ENGINE_RESULT_HEADER_SIZE + || limits.max_output_bytes > MAX_DECLARED_OUTPUT_BYTES + { + return Err(STATUS_INVALID_REQUEST); + } + let paragraph_mutation_count = read_u32(bytes, ENGINE_UPDATE_PARAGRAPH_MUTATION_COUNT)?; + if paragraph_mutation_count > limits.max_paragraphs { + return Err(STATUS_INVALID_REQUEST); + } + let paragraph_mutations = parse_paragraph_mutations( + bytes, + read_u32(bytes, ENGINE_UPDATE_PARAGRAPH_MUTATIONS_OFFSET)?, + paragraph_mutation_count, + )?; + let text_mutation_count = read_u32(bytes, ENGINE_UPDATE_TEXT_MUTATION_COUNT)?; + if text_mutation_count > limits.max_clusters { + return Err(STATUS_INVALID_REQUEST); + } + let text_mutations = parse_text_mutations( + bytes, + read_u32(bytes, ENGINE_UPDATE_TEXT_MUTATIONS_OFFSET)?, + text_mutation_count, + )?; + let style_mutation_count = read_u32(bytes, ENGINE_UPDATE_STYLE_MUTATION_COUNT)?; + if style_mutation_count > limits.max_clusters { + return Err(STATUS_INVALID_REQUEST); + } + let style_mutations = super::semantic_wire::parse_style_mutations( + bytes, + read_u32(bytes, ENGINE_UPDATE_STYLE_MUTATIONS_OFFSET)?, + style_mutation_count, + )?; + let constraint_count = read_u32(bytes, ENGINE_UPDATE_CONSTRAINT_COUNT)?; + let region_count = read_u32(bytes, ENGINE_UPDATE_REGION_COUNT)?; + let exclusion_count = read_u32(bytes, ENGINE_UPDATE_EXCLUSION_COUNT)?; + let inline_object_count = read_u32(bytes, ENGINE_UPDATE_INLINE_OBJECT_COUNT)?; + let geometry = parse_geometry( + bytes, + read_u32(bytes, ENGINE_UPDATE_CONSTRAINTS_OFFSET)?, + constraint_count, + read_u32(bytes, ENGINE_UPDATE_REGIONS_OFFSET)?, + region_count, + read_u32(bytes, ENGINE_UPDATE_EXCLUSIONS_OFFSET)?, + exclusion_count, + read_u32(bytes, ENGINE_UPDATE_INLINE_OBJECTS_OFFSET)?, + inline_object_count, + limits, + )?; + text_mutations.validate_disjoint_geometry(geometry)?; + style_mutations.validate_disjoint_semantics(text_mutations, geometry)?; + paragraph_mutations.validate_disjoint_semantics(text_mutations, style_mutations, geometry)?; + if paragraph_mutation_count == 0 + && text_mutation_count == 0 + && style_mutation_count == 0 + && constraint_count == 0 + && region_count == 0 + && exclusion_count == 0 + && inline_object_count == 0 + && bytes.len() != ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize + { + return Err(STATUS_INVALID_REQUEST); + } + Ok(UpdateRequest { + session_id, + expected_engine_revision: read_u32(bytes, ENGINE_UPDATE_EXPECTED_ENGINE_REVISION)?, + consumed_plan_revision: read_u32(bytes, ENGINE_UPDATE_CONSUMED_PLAN_REVISION)?, + acknowledged_publication_generation: read_u32( + bytes, + ENGINE_UPDATE_ACKNOWLEDGED_PUBLICATION_GENERATION, + )?, + policy_handle: read_u32(bytes, ENGINE_UPDATE_POLICY_HANDLE)?, + capability_set: positive(bytes, ENGINE_UPDATE_CAPABILITY_SET)?, + semantic_view_mask, + compositing_independent: flags & super::frame::FRAME_FLAG_COMPOSITING_INDEPENDENT != 0, + limits, + paragraph_mutations, + text_mutations, + style_mutations, + geometry, + }) +} + +fn positive(bytes: &[u8], offset: usize) -> Result { + let value = read_u32(bytes, offset)?; + if value == 0 { + Err(STATUS_INVALID_REQUEST) + } else { + Ok(value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wire::write_u32; + use alloc::vec; + + #[test] + fn accepts_only_the_canonical_empty_stage_one_transaction() { + let bytes = request(); + let parsed = parse_update_request(&bytes, 4).unwrap(); + assert_eq!(parsed.session_id, 4); + assert_eq!(parsed.policy_handle, 9); + + let mut independent = bytes.clone(); + write_u32( + &mut independent, + ENGINE_UPDATE_FLAGS, + super::super::frame::FRAME_FLAG_COMPOSITING_INDEPENDENT, + ); + assert!( + parse_update_request(&independent, 4) + .unwrap() + .compositing_independent + ); + + let mut unknown_flag = bytes.clone(); + write_u32(&mut unknown_flag, ENGINE_UPDATE_FLAGS, 1 << 31); + assert_eq!( + parse_update_request(&unknown_flag, 4), + Err(STATUS_INVALID_REQUEST) + ); + + let mut measurement = bytes.clone(); + write_u32( + &mut measurement, + ENGINE_UPDATE_SEMANTIC_VIEW_MASK, + super::super::frame::SEMANTIC_VIEW_MEASUREMENT, + ); + assert_eq!( + parse_update_request(&measurement, 4) + .unwrap() + .semantic_view_mask, + super::super::frame::SEMANTIC_VIEW_MEASUREMENT + ); + + let mut unknown_view = bytes.clone(); + write_u32(&mut unknown_view, ENGINE_UPDATE_SEMANTIC_VIEW_MASK, 1 << 31); + assert_eq!( + parse_update_request(&unknown_view, 4), + Err(STATUS_INVALID_REQUEST) + ); + + let mut trailing = bytes.clone(); + trailing.push(0); + let trailing_length = u32::try_from(trailing.len()).unwrap(); + write_u32(&mut trailing, ENGINE_UPDATE_BYTE_LENGTH, trailing_length); + assert_eq!( + parse_update_request(&trailing, 4), + Err(STATUS_INVALID_REQUEST) + ); + + let mut nonempty = bytes; + write_u32(&mut nonempty, ENGINE_UPDATE_REGION_COUNT, 1); + assert_eq!( + parse_update_request(&nonempty, 4), + Err(STATUS_INVALID_REQUEST) + ); + } + + pub(super) fn request() -> Vec { + let mut bytes = vec![0; ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize]; + write_u32(&mut bytes, ENGINE_UPDATE_ABI_VERSION, ABI_VERSION); + write_u32( + &mut bytes, + ENGINE_UPDATE_BYTE_LENGTH, + ENGINE_UPDATE_REQUEST_HEADER_SIZE, + ); + write_u32(&mut bytes, ENGINE_UPDATE_SESSION_ID, 4); + write_u32(&mut bytes, ENGINE_UPDATE_POLICY_HANDLE, 9); + write_u32(&mut bytes, ENGINE_UPDATE_CAPABILITY_SET, 1); + for offset in [ + ENGINE_UPDATE_MAX_PARAGRAPHS, + ENGINE_UPDATE_MAX_CLUSTERS, + ENGINE_UPDATE_MAX_LINES, + ENGINE_UPDATE_MAX_REGIONS, + ENGINE_UPDATE_MAX_EXCLUSIONS, + ENGINE_UPDATE_MAX_INLINE_OBJECTS, + ENGINE_UPDATE_MAX_SLOTS_PER_BAND, + ] { + write_u32(&mut bytes, offset, 1); + } + write_u32( + &mut bytes, + ENGINE_UPDATE_MAX_OUTPUT_BYTES, + ENGINE_RESULT_HEADER_SIZE, + ); + bytes + } +} diff --git a/packages/text/rust/shaper/src/engine/identity_index.rs b/packages/text/rust/shaper/src/engine/identity_index.rs new file mode 100644 index 00000000..d10705ce --- /dev/null +++ b/packages/text/rust/shaper/src/engine/identity_index.rs @@ -0,0 +1,216 @@ +//! Reusable exact-identity lookup scratch with epoch-based clearing. + +use alloc::vec::Vec; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum IdentityIndexError { + AllocationFailed, + ArithmeticOverflow, + DuplicateIdentity, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum IdentitySetError { + AllocationFailed, + ArithmeticOverflow, +} + +/// Reusable exact-identity membership scratch. +/// +/// Unlike [`IdentityIndex`], this stores no associated value. Plan compilers use it to reject +/// duplicate semantic identities without allocating or clearing a table on warm updates. +#[derive(Default)] +pub(crate) struct IdentitySet { + keys: Vec, + epochs: Vec, + epoch: u32, +} + +impl IdentitySet { + pub(crate) fn prepare(&mut self, entry_count: usize) -> Result<(), IdentitySetError> { + let required = entry_count + .checked_mul(2) + .and_then(usize::checked_next_power_of_two) + .ok_or(IdentitySetError::ArithmeticOverflow)? + .max(8); + if self.keys.len() < required { + let additional_keys = required - self.keys.len(); + let additional_epochs = required - self.epochs.len(); + reserve(&mut self.keys, additional_keys).map_err(identity_set_error)?; + reserve(&mut self.epochs, additional_epochs).map_err(identity_set_error)?; + self.keys.resize(required, 0); + self.epochs.resize(required, 0); + } + self.epoch = next_epoch(&mut self.epochs, self.epoch); + Ok(()) + } + + pub(crate) fn insert(&mut self, identity: u32) -> bool { + let mask = self.keys.len() - 1; + let mut index = hash(identity) & mask; + loop { + if self.epochs[index] != self.epoch { + self.epochs[index] = self.epoch; + self.keys[index] = identity; + return true; + } + if self.keys[index] == identity { + return false; + } + index = (index + 1) & mask; + } + } + + #[cfg(test)] + pub(crate) fn capacities(&self) -> [usize; 2] { + [self.keys.capacity(), self.epochs.capacity()] + } +} + +#[derive(Default)] +pub(crate) struct IdentityIndex { + keys: Vec, + values: Vec, + epochs: Vec, + epoch: u32, +} + +impl IdentityIndex { + pub(crate) fn prepare(&mut self, entry_count: usize) -> Result<(), IdentityIndexError> { + let required = entry_count + .checked_mul(2) + .and_then(usize::checked_next_power_of_two) + .ok_or(IdentityIndexError::ArithmeticOverflow)? + .max(8); + if self.keys.len() < required { + let additional_keys = required - self.keys.len(); + let additional_values = required - self.values.len(); + let additional_epochs = required - self.epochs.len(); + reserve(&mut self.keys, additional_keys)?; + reserve(&mut self.values, additional_values)?; + reserve(&mut self.epochs, additional_epochs)?; + self.keys.resize(required, 0); + self.values.resize(required, 0); + self.epochs.resize(required, 0); + } + self.epoch = next_epoch(&mut self.epochs, self.epoch); + Ok(()) + } + + pub(crate) fn insert(&mut self, identity: u32, value: u32) -> Result<(), IdentityIndexError> { + let index = self.insert_position(identity)?; + if self.epochs[index] == self.epoch { + return Err(IdentityIndexError::DuplicateIdentity); + } + self.epochs[index] = self.epoch; + self.keys[index] = identity; + self.values[index] = value; + Ok(()) + } + + pub(crate) fn get(&self, identity: u32) -> Option { + let mask = self.keys.len().checked_sub(1)?; + let mut index = hash(identity) & mask; + loop { + if self.epochs[index] != self.epoch { + return None; + } + if self.keys[index] == identity { + return Some(self.values[index]); + } + index = (index + 1) & mask; + } + } + + #[cfg(test)] + pub(crate) fn capacities(&self) -> [usize; 3] { + [ + self.keys.capacity(), + self.values.capacity(), + self.epochs.capacity(), + ] + } + + fn insert_position(&self, identity: u32) -> Result { + let mask = self + .keys + .len() + .checked_sub(1) + .ok_or(IdentityIndexError::ArithmeticOverflow)?; + let mut index = hash(identity) & mask; + loop { + if self.epochs[index] != self.epoch || self.keys[index] == identity { + return Ok(index); + } + index = (index + 1) & mask; + } + } +} + +fn next_epoch(values: &mut [u32], current: u32) -> u32 { + match current.checked_add(1) { + Some(epoch) => epoch, + None => { + values.fill(0); + 1 + } + } +} + +fn hash(identity: u32) -> usize { + identity.wrapping_mul(0x9e37_79b1) as usize +} + +fn reserve(values: &mut Vec, additional: usize) -> Result<(), IdentityIndexError> { + values + .try_reserve(additional) + .map_err(|_| IdentityIndexError::AllocationFailed) +} + +fn identity_set_error(error: IdentityIndexError) -> IdentitySetError { + match error { + IdentityIndexError::AllocationFailed => IdentitySetError::AllocationFailed, + IdentityIndexError::ArithmeticOverflow | IdentityIndexError::DuplicateIdentity => { + IdentitySetError::ArithmeticOverflow + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn collisions_remain_exact_and_prepare_reuses_storage() { + let mut index = IdentityIndex::default(); + index.prepare(3).unwrap(); + let capacities = index.capacities(); + index.insert(1, 10).unwrap(); + index.insert(9, 90).unwrap(); + assert_eq!(index.get(1), Some(10)); + assert_eq!(index.get(9), Some(90)); + assert_eq!(index.get(17), None); + assert_eq!( + index.insert(1, 11), + Err(IdentityIndexError::DuplicateIdentity) + ); + + index.prepare(3).unwrap(); + assert_eq!(index.capacities(), capacities); + assert_eq!(index.get(1), None); + } + + #[test] + fn identity_set_rejects_duplicates_and_reuses_storage() { + let mut set = IdentitySet::default(); + set.prepare(3).unwrap(); + let capacities = set.capacities(); + assert!(set.insert(1)); + assert!(set.insert(9)); + assert!(!set.insert(1)); + + set.prepare(3).unwrap(); + assert_eq!(set.capacities(), capacities); + assert!(set.insert(1)); + } +} diff --git a/packages/text/rust/shaper/src/engine/kernel_lab.rs b/packages/text/rust/shaper/src/engine/kernel_lab.rs new file mode 100644 index 00000000..ba2806ab --- /dev/null +++ b/packages/text/rust/shaper/src/engine/kernel_lab.rs @@ -0,0 +1,621 @@ +//! Test-only kernels used to choose the retained engine's data layout before semantic storage lands. + +#[cfg(target_arch = "wasm32")] +use core::{mem, slice}; + +#[cfg(target_arch = "wasm32")] +use crate::STATUS_INVALID_REQUEST; +#[cfg(target_arch = "wasm32")] +use crate::engine::policy::{ + BUFFER_USAGE_COPY_DST, BUFFER_USAGE_STORAGE, BufferId, BufferSchema, CapabilitySetId, + PhysicalBufferMut, ScalarType, SemanticInputBatch, TechniqueId, ValidatedPolicy, +}; + +const MAX_RECORDS: usize = 1_000_000; +const VALID_CHUNK_SIZES: [usize; 3] = [32, 64, 128]; + +#[cfg(all(target_arch = "wasm32", feature = "simd128"))] +pub(crate) const BACKEND: u32 = 1; +#[cfg(not(all(target_arch = "wasm32", feature = "simd128")))] +pub(crate) const BACKEND: u32 = 0; + +#[allow(clippy::too_many_arguments)] +pub(crate) fn pack_origins_and_sizes( + x: &[f32], + y: &[f32], + font_size: &[f32], + plane_left: &[f32], + plane_bottom: &[f32], + plane_right: &[f32], + plane_top: &[f32], + inverse_units_per_em: f32, + origins: &mut [f32], + sizes: &mut [f32], +) { + debug_assert_eq!(x.len(), y.len()); + debug_assert_eq!(x.len(), font_size.len()); + debug_assert_eq!(x.len(), plane_left.len()); + debug_assert_eq!(x.len(), plane_bottom.len()); + debug_assert_eq!(x.len(), plane_right.len()); + debug_assert_eq!(x.len(), plane_top.len()); + debug_assert_eq!(origins.len(), x.len() * 2); + debug_assert_eq!(sizes.len(), x.len() * 2); + + // This straight-line loop is deliberately shared by both builds. With `+simd128`, LLVM + // vectorizes it more efficiently than the rejected hand-written shuffle/store candidate. + pack_scalar( + 0, + x, + y, + font_size, + plane_left, + plane_bottom, + plane_right, + plane_top, + inverse_units_per_em, + origins, + sizes, + ); +} + +#[allow(clippy::too_many_arguments)] +fn pack_scalar( + start: usize, + x: &[f32], + y: &[f32], + font_size: &[f32], + plane_left: &[f32], + plane_bottom: &[f32], + plane_right: &[f32], + plane_top: &[f32], + inverse_units_per_em: f32, + origins: &mut [f32], + sizes: &mut [f32], +) { + for index in start..x.len() { + let scale = font_size[index] * inverse_units_per_em; + let output = index * 2; + origins[output] = x[index] + plane_left[index] * scale; + origins[output + 1] = y[index] - plane_top[index] * scale; + sizes[output] = (plane_right[index] - plane_left[index]) * scale; + sizes[output + 1] = (plane_top[index] - plane_bottom[index]) * scale; + } +} + +pub(crate) fn break_masks(flags: &[u8], output: &mut [u16]) { + #[cfg(all(target_arch = "wasm32", feature = "simd128"))] + let completed = unsafe { break_masks_simd(flags, output) }; + #[cfg(not(all(target_arch = "wasm32", feature = "simd128")))] + let completed = 0; + + for (block, output_mask) in output.iter_mut().enumerate().skip(completed / 16) { + let start = block * 16; + let end = flags.len().min(start + 16); + let mut mask = 0_u16; + for (lane, flag) in flags[start..end].iter().enumerate() { + if flag & 1 != 0 { + mask |= 1 << lane; + } + } + *output_mask = mask; + } +} + +#[cfg(all(target_arch = "wasm32", feature = "simd128"))] +unsafe fn break_masks_simd(flags: &[u8], output: &mut [u16]) -> usize { + use core::arch::wasm32::{i8x16_bitmask, i8x16_ne, u8x16_splat, v128, v128_and, v128_load}; + + let completed = flags.len() & !15; + for start in (0..completed).step_by(16) { + // SAFETY: `completed` includes only full sixteen-byte blocks. + let values = unsafe { v128_load(flags.as_ptr().add(start).cast::()) }; + let allowed = i8x16_ne(v128_and(values, u8x16_splat(1)), u8x16_splat(0)); + output[start / 16] = i8x16_bitmask(allowed); + } + completed +} + +pub(crate) fn bidi_transition_masks(levels: &[u8], output: &mut [u16]) { + let mut previous = 0_u8; + #[cfg(all(target_arch = "wasm32", feature = "simd128"))] + let completed = unsafe { bidi_masks_simd(levels, output, &mut previous) }; + #[cfg(not(all(target_arch = "wasm32", feature = "simd128")))] + let completed = 0; + + for (block, output_mask) in output.iter_mut().enumerate().skip(completed / 16) { + let start = block * 16; + let end = levels.len().min(start + 16); + let mut mask = 0_u16; + for (lane, level) in levels[start..end].iter().copied().enumerate() { + if level != previous { + mask |= 1 << lane; + } + previous = level; + } + *output_mask = mask; + } +} + +#[cfg(all(target_arch = "wasm32", feature = "simd128"))] +unsafe fn bidi_masks_simd(levels: &[u8], output: &mut [u16], previous: &mut u8) -> usize { + use core::arch::wasm32::{ + i8x16_bitmask, i8x16_ne, i8x16_shuffle, u8x16_splat, v128, v128_load, + }; + + let completed = levels.len() & !15; + for start in (0..completed).step_by(16) { + // SAFETY: `completed` includes only full sixteen-byte blocks. + let values = unsafe { v128_load(levels.as_ptr().add(start).cast::()) }; + let prior = u8x16_splat(*previous); + let shifted = i8x16_shuffle::<0, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30>( + prior, values, + ); + output[start / 16] = i8x16_bitmask(i8x16_ne(values, shifted)); + *previous = levels[start + 15]; + } + completed +} + +pub(crate) fn chunk_summaries( + advances: &[i32], + flags: &[u8], + chunk_size: usize, + advance_sums: &mut [i64], + break_counts: &mut [u32], +) { + debug_assert!(VALID_CHUNK_SIZES.contains(&chunk_size)); + for chunk in 0..advance_sums.len() { + let start = chunk * chunk_size; + let end = advances.len().min(start + chunk_size); + #[cfg(all(target_arch = "wasm32", feature = "simd128"))] + let (completed, mut advance_sum, mut break_count) = + unsafe { chunk_summary_simd(&advances[start..end], &flags[start..end]) }; + #[cfg(not(all(target_arch = "wasm32", feature = "simd128")))] + let (completed, mut advance_sum, mut break_count) = (0, 0_i64, 0_u32); + + for index in completed..end - start { + advance_sum += i64::from(advances[start + index]); + break_count += u32::from(flags[start + index] & 1 != 0); + } + advance_sums[chunk] = advance_sum; + break_counts[chunk] = break_count; + } +} + +#[cfg(all(target_arch = "wasm32", feature = "simd128"))] +unsafe fn chunk_summary_simd(advances: &[i32], flags: &[u8]) -> (usize, i64, u32) { + use core::arch::wasm32::{ + i8x16_bitmask, i8x16_ne, i64x2_add, i64x2_extend_high_i32x4, i64x2_extend_low_i32x4, + i64x2_extract_lane, i64x2_splat, u8x16_splat, v128, v128_and, v128_load, + }; + + let completed = advances.len() & !15; + let mut sums = i64x2_splat(0); + let mut break_count = 0_u32; + for start in (0..completed).step_by(16) { + for lane_group in 0..4 { + // SAFETY: each group contains four in-bounds i32 values. + let values = + unsafe { v128_load(advances.as_ptr().add(start + lane_group * 4).cast::()) }; + sums = i64x2_add(sums, i64x2_extend_low_i32x4(values)); + sums = i64x2_add(sums, i64x2_extend_high_i32x4(values)); + } + // SAFETY: `completed` includes only full sixteen-byte blocks. + let values = unsafe { v128_load(flags.as_ptr().add(start).cast::()) }; + let allowed = i8x16_ne(v128_and(values, u8x16_splat(1)), u8x16_splat(0)); + break_count += i8x16_bitmask(allowed).count_ones(); + } + ( + completed, + i64x2_extract_lane::<0>(sums) + i64x2_extract_lane::<1>(sums), + break_count, + ) +} + +#[cfg(target_arch = "wasm32")] +#[allow(clippy::too_many_arguments)] +pub(crate) unsafe fn exported_pack( + count: u32, + x_pointer: u32, + y_pointer: u32, + font_size_pointer: u32, + plane_left_pointer: u32, + plane_bottom_pointer: u32, + plane_right_pointer: u32, + plane_top_pointer: u32, + inverse_units_per_em: f32, + origins_pointer: u32, + sizes_pointer: u32, +) -> u32 { + let Some(count) = bounded_count(count) else { + return STATUS_INVALID_REQUEST; + }; + let Some(pair_count) = count.checked_mul(2) else { + return STATUS_INVALID_REQUEST; + }; + let regions = [ + region::(x_pointer, count), + region::(y_pointer, count), + region::(font_size_pointer, count), + region::(plane_left_pointer, count), + region::(plane_bottom_pointer, count), + region::(plane_right_pointer, count), + region::(plane_top_pointer, count), + region::(origins_pointer, pair_count), + region::(sizes_pointer, pair_count), + ]; + if !valid_disjoint_regions(®ions) { + return STATUS_INVALID_REQUEST; + } + // SAFETY: all typed regions are aligned, in linear memory, and pairwise disjoint. + unsafe { + pack_origins_and_sizes( + typed_slice(x_pointer, count), + typed_slice(y_pointer, count), + typed_slice(font_size_pointer, count), + typed_slice(plane_left_pointer, count), + typed_slice(plane_bottom_pointer, count), + typed_slice(plane_right_pointer, count), + typed_slice(plane_top_pointer, count), + inverse_units_per_em, + typed_slice_mut(origins_pointer, pair_count), + typed_slice_mut(sizes_pointer, pair_count), + ); + } + 0 +} + +#[cfg(target_arch = "wasm32")] +pub(crate) unsafe fn exported_break_masks( + count: u32, + flags_pointer: u32, + output_pointer: u32, +) -> u32 { + let Some(count) = bounded_count(count) else { + return STATUS_INVALID_REQUEST; + }; + let output_count = count.div_ceil(16); + let regions = [ + region::(flags_pointer, count), + region::(output_pointer, output_count), + ]; + if !valid_disjoint_regions(®ions) { + return STATUS_INVALID_REQUEST; + } + // SAFETY: both typed regions are aligned, in linear memory, and disjoint. + unsafe { + break_masks( + typed_slice(flags_pointer, count), + typed_slice_mut(output_pointer, output_count), + ) + }; + 0 +} + +#[cfg(target_arch = "wasm32")] +pub(crate) unsafe fn exported_bidi_masks( + count: u32, + levels_pointer: u32, + output_pointer: u32, +) -> u32 { + let Some(count) = bounded_count(count) else { + return STATUS_INVALID_REQUEST; + }; + let output_count = count.div_ceil(16); + let regions = [ + region::(levels_pointer, count), + region::(output_pointer, output_count), + ]; + if !valid_disjoint_regions(®ions) { + return STATUS_INVALID_REQUEST; + } + // SAFETY: both typed regions are aligned, in linear memory, and disjoint. + unsafe { + bidi_transition_masks( + typed_slice(levels_pointer, count), + typed_slice_mut(output_pointer, output_count), + ) + }; + 0 +} + +#[cfg(target_arch = "wasm32")] +pub(crate) unsafe fn exported_chunk_summaries( + count: u32, + chunk_size: u32, + advances_pointer: u32, + flags_pointer: u32, + advance_sums_pointer: u32, + break_counts_pointer: u32, +) -> u32 { + let Some(count) = bounded_count(count) else { + return STATUS_INVALID_REQUEST; + }; + let Ok(chunk_size) = usize::try_from(chunk_size) else { + return STATUS_INVALID_REQUEST; + }; + if !VALID_CHUNK_SIZES.contains(&chunk_size) { + return STATUS_INVALID_REQUEST; + } + let summary_count = count.div_ceil(chunk_size); + let regions = [ + region::(advances_pointer, count), + region::(flags_pointer, count), + region::(advance_sums_pointer, summary_count), + region::(break_counts_pointer, summary_count), + ]; + if !valid_disjoint_regions(®ions) { + return STATUS_INVALID_REQUEST; + } + // SAFETY: all typed regions are aligned, in linear memory, and pairwise disjoint. + unsafe { + chunk_summaries( + typed_slice(advances_pointer, count), + typed_slice(flags_pointer, count), + chunk_size, + typed_slice_mut(advance_sums_pointer, summary_count), + typed_slice_mut(break_counts_pointer, summary_count), + ); + } + 0 +} + +#[cfg(target_arch = "wasm32")] +#[allow(clippy::too_many_arguments)] +pub(crate) unsafe fn exported_policy( + policy: &ValidatedPolicy, + technique: u32, + variant: u32, + count: u32, + f32_input0_pointer: u32, + f32_input1_pointer: u32, + f32_input2_pointer: u32, + f32_input3_pointer: u32, + u32_input0_pointer: u32, + f32_output_pointer: u32, + u32_output_pointer: u32, + u16_output_pointer: u32, +) -> u32 { + let Some(count) = bounded_count(count) else { + return STATUS_INVALID_REQUEST; + }; + let Ok(variant) = u16::try_from(variant) else { + return STATUS_INVALID_REQUEST; + }; + let Some(f32_output_count) = count.checked_mul(4) else { + return STATUS_INVALID_REQUEST; + }; + let regions = [ + region::(f32_input0_pointer, count), + region::(f32_input1_pointer, count), + region::(f32_input2_pointer, count), + region::(f32_input3_pointer, count), + region::(u32_input0_pointer, count), + region::(f32_output_pointer, f32_output_count), + region::(u32_output_pointer, count), + region::(u16_output_pointer, count), + ]; + if !valid_disjoint_regions(®ions) { + return STATUS_INVALID_REQUEST; + } + // SAFETY: all input and output regions are aligned, in linear memory, and pairwise disjoint. + unsafe { + let f32_inputs = [ + typed_slice(f32_input0_pointer, count), + typed_slice(f32_input1_pointer, count), + typed_slice(f32_input2_pointer, count), + typed_slice(f32_input3_pointer, count), + ]; + let u32_inputs = [typed_slice(u32_input0_pointer, count)]; + let mut outputs = [ + PhysicalBufferMut { + schema: BufferSchema::packed( + BufferId(1), + ScalarType::F32, + 4, + BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST, + 1, + ), + bytes: byte_slice_mut(f32_output_pointer, f32_output_count * mem::size_of::()), + }, + PhysicalBufferMut { + schema: BufferSchema::packed( + BufferId(2), + ScalarType::U32, + 1, + BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST, + 1, + ), + bytes: byte_slice_mut(u32_output_pointer, count * mem::size_of::()), + }, + PhysicalBufferMut { + schema: BufferSchema::packed( + BufferId(3), + ScalarType::U16, + 1, + BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST, + 1, + ), + bytes: byte_slice_mut(u16_output_pointer, count * mem::size_of::()), + }, + ]; + policy + .execute( + CapabilitySetId(1), + TechniqueId(technique), + variant, + SemanticInputBatch { + f32_fields: &f32_inputs, + u32_fields: &u32_inputs, + record_count: count, + }, + 0, + &mut outputs, + ) + .map_or(STATUS_INVALID_REQUEST, |()| 0) + } +} + +#[cfg(target_arch = "wasm32")] +#[derive(Clone, Copy)] +struct MemoryRegion { + start: usize, + end: usize, + alignment: usize, +} + +#[cfg(target_arch = "wasm32")] +fn region(pointer: u32, count: usize) -> Option { + let start = pointer as usize; + let bytes = count.checked_mul(mem::size_of::())?; + Some(MemoryRegion { + start, + end: start.checked_add(bytes)?, + alignment: mem::align_of::(), + }) +} + +#[cfg(target_arch = "wasm32")] +fn valid_disjoint_regions(regions: &[Option]) -> bool { + let memory_bytes = core::arch::wasm32::memory_size::<0>().saturating_mul(65_536); + for (index, region) in regions.iter().enumerate() { + let Some(region) = region else { + return false; + }; + if region.start == 0 || region.start % region.alignment != 0 || region.end > memory_bytes { + return false; + } + for previous in regions[..index].iter().flatten() { + if region.start < previous.end && previous.start < region.end { + return false; + } + } + } + true +} + +#[cfg(target_arch = "wasm32")] +unsafe fn typed_slice(pointer: u32, count: usize) -> &'static [Value] { + // SAFETY: exported entry points validate alignment, memory bounds, and non-overlap first. + unsafe { slice::from_raw_parts(pointer as *const Value, count) } +} + +#[cfg(target_arch = "wasm32")] +unsafe fn typed_slice_mut(pointer: u32, count: usize) -> &'static mut [Value] { + // SAFETY: exported entry points validate alignment, memory bounds, and non-overlap first. + unsafe { slice::from_raw_parts_mut(pointer as *mut Value, count) } +} + +#[cfg(target_arch = "wasm32")] +unsafe fn byte_slice_mut(pointer: u32, count: usize) -> &'static mut [u8] { + // SAFETY: exported entry points validate the corresponding typed region and non-overlap first. + unsafe { slice::from_raw_parts_mut(pointer as *mut u8, count) } +} + +fn bounded_count(count: u32) -> Option { + let count = usize::try_from(count).ok()?; + (count != 0 && count <= MAX_RECORDS).then_some(count) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + + #[test] + fn scalar_packing_is_bit_exact_for_partial_groups() { + let x = [10.0, -2.0, 3.5, 8.0, 1.0]; + let y = [20.0, 7.0, -4.0, 2.0, 9.0]; + let font_sizes = [16.0, 24.0, 12.0, 32.0, 18.0]; + let left = [-2.0, 0.0, 1.0, -1.0, 2.0]; + let bottom = [-3.0, -2.0, 0.0, 1.0, -1.0]; + let right = [8.0, 9.0, 7.0, 11.0, 10.0]; + let top = [12.0, 14.0, 9.0, 17.0, 13.0]; + let mut origins = [0.0; 10]; + let mut packed_sizes = [0.0; 10]; + pack_origins_and_sizes( + &x, + &y, + &font_sizes, + &left, + &bottom, + &right, + &top, + 1.0 / 16.0, + &mut origins, + &mut packed_sizes, + ); + let mut expected_origins = [0.0; 10]; + let mut expected_sizes = [0.0; 10]; + pack_scalar( + 0, + &x, + &y, + &font_sizes, + &left, + &bottom, + &right, + &top, + 1.0 / 16.0, + &mut expected_origins, + &mut expected_sizes, + ); + assert_eq!( + origins.map(f32::to_bits), + expected_origins.map(f32::to_bits) + ); + assert_eq!( + packed_sizes.map(f32::to_bits), + expected_sizes.map(f32::to_bits) + ); + } + + #[test] + fn masks_preserve_partial_blocks_and_cross_block_bidi_state() { + let flags = (0..35) + .map(|index| u8::from(index % 3 == 0)) + .collect::>(); + let mut masks = [0_u16; 3]; + break_masks(&flags, &mut masks); + assert_eq!(masks, [0x9249, 0x4924, 0x0002]); + + let levels = [0, 0, 1, 1, 1, 2, 2, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 1, 0]; + let mut transitions = [0_u16; 2]; + bidi_transition_masks(&levels, &mut transitions); + assert_eq!(transitions, [0x94a4, 0x0004]); + } + + #[test] + fn every_candidate_chunk_size_has_exact_integer_summaries() { + let advances = (0..259) + .map(|index| index % 17 - 8) + .collect::>(); + let flags = (0..259) + .map(|index| u8::from(index % 7 == 0)) + .collect::>(); + for chunk_size in VALID_CHUNK_SIZES { + let count = advances.len().div_ceil(chunk_size); + let mut sums = vec![0_i64; count]; + let mut breaks = vec![0_u32; count]; + chunk_summaries(&advances, &flags, chunk_size, &mut sums, &mut breaks); + for chunk in 0..count { + let start = chunk * chunk_size; + let end = advances.len().min(start + chunk_size); + assert_eq!( + sums[chunk], + advances[start..end] + .iter() + .map(|value| i64::from(*value)) + .sum::() + ); + assert_eq!( + breaks[chunk], + flags[start..end] + .iter() + .map(|value| u32::from(value & 1)) + .sum::() + ); + } + } + } +} diff --git a/packages/text/rust/shaper/src/engine/layout_query.rs b/packages/text/rust/shaper/src/engine/layout_query.rs new file mode 100644 index 00000000..d55973ef --- /dev/null +++ b/packages/text/rust/shaper/src/engine/layout_query.rs @@ -0,0 +1,569 @@ +//! Demand-shaped semantic layout views. +//! +//! These records share the immutable frame publication for lifetime and transport only. They are +//! not render-plan commands and render executors must not consume them. + +use alloc::vec::Vec; + +use super::{ + EngineError, + cluster_state::ClusterArena, + flow_composition::{FlowFragment, FlowLayoutArena, FlowLine}, + flow_geometry::FlowGeometryArena, + frame::{AXIS_AT_MOST, AXIS_EXACT, AXIS_UNCONSTRAINED}, + positioning::{SemanticGlyph, positioned_fragment_advance}, + semantic_view::{ + SEMANTIC_GLYPH, SEMANTIC_LINE, SEMANTIC_PARAGRAPH_MEASUREMENT, SemanticRecord, + }, +}; + +pub(crate) const MEASUREMENT_FLAG_OVERFLOWED: u16 = 1; + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct LayoutExtents { + pub width: f64, + pub height: f64, + pub consumed_clusters: usize, +} + +pub(crate) fn append_measurement( + target: &mut Vec, + paragraph_id: u32, + text_length: usize, + cluster_count: usize, + geometry: &FlowGeometryArena, + flow: &FlowLayoutArena, + positioned_glyphs: &[SemanticGlyph], + semantic_line_glyph_starts: &[u32], + semantic_line_glyph_counts: &[u32], + semantic_line_inline_extents: Option<&[f64]>, + text: &[u16], + clusters: &ClusterArena, + intrinsic_extents: Option, + include_glyphs: bool, +) -> Result<(), EngineError> { + let constraint = geometry + .constraints + .first() + .ok_or(EngineError::InvalidRequest)?; + if constraint.paragraph_id != paragraph_id { + return Err(EngineError::InvalidRequest); + } + if include_glyphs + && (semantic_line_glyph_starts.len() != flow.lines.len() + || semantic_line_glyph_counts.len() != flow.lines.len()) + || semantic_line_inline_extents.is_some_and(|extents| extents.len() != flow.lines.len()) + { + return Err(EngineError::InvalidRequest); + } + let mut line_count = 0_usize; + for line in flow.lines.iter().copied() { + if line.flow_thread_id == constraint.flow_thread_id + && !line_fragments(flow, line)?.is_empty() + { + line_count = line_count + .checked_add(1) + .ok_or(EngineError::ResultTooLarge)?; + } + } + let reserve = line_count + .saturating_add(1) + .saturating_add(if include_glyphs { + positioned_glyphs.len() + } else { + 0 + }); + target + .try_reserve(reserve) + .map_err(|_| EngineError::ResultTooLarge)?; + + let summary_index = target.len(); + let line_start = + u32::try_from(summary_index.saturating_add(1)).map_err(|_| EngineError::ResultTooLarge)?; + let glyph_record_start = summary_index + .checked_add(1) + .and_then(|value| value.checked_add(line_count)) + .ok_or(EngineError::ResultTooLarge)?; + target.push(SemanticRecord::default()); + let mut content_width = 0.0_f64; + let mut content_height = 0.0_f64; + let mut consumed_clusters = + usize::try_from(constraint.resume_cluster).map_err(|_| EngineError::InvalidRequest)?; + + for (index, line) in flow.lines.iter().copied().enumerate() { + if line.flow_thread_id != constraint.flow_thread_id { + continue; + } + let fragments = line_fragments(flow, line)?; + let Some(first) = fragments.first() else { + continue; + }; + let Some(last) = fragments.last() else { + continue; + }; + let advance = if let Some(extents) = semantic_line_inline_extents { + extents[index] + } else { + line_inline_extent(flow, line, index, text, clusters)? + }; + content_width = content_width.max(advance); + content_height = content_height.max(line.block_start + line.height); + consumed_clusters = consumed_clusters + .max(usize::try_from(last.line.cluster_end).map_err(|_| EngineError::InvalidRequest)?); + target.push(SemanticRecord { + id: u32::try_from(index.saturating_add(1)).map_err(|_| EngineError::ResultTooLarge)?, + kind: SEMANTIC_LINE, + parent_id: paragraph_id, + text_start: first.line.text_start, + text_end: last.line.text_end, + item_start: if include_glyphs { + u32::try_from(glyph_record_start) + .map_err(|_| EngineError::ResultTooLarge)? + .checked_add(semantic_line_glyph_starts[index]) + .ok_or(EngineError::ResultTooLarge)? + } else { + 0 + }, + item_count: if include_glyphs { + semantic_line_glyph_counts[index] + } else { + 0 + }, + block_start: finite_f32(line.block_start + line.baseline)?, + inline_extent: finite_nonnegative_f32(advance)?, + block_extent: finite_nonnegative_f32(line.height)?, + ..SemanticRecord::default() + }); + } + + if include_glyphs { + if target.len() != glyph_record_start { + return Err(EngineError::InvalidRequest); + } + for glyph in positioned_glyphs { + target.push(SemanticRecord { + id: glyph.stable_id, + kind: SEMANTIC_GLYPH, + flags: glyph.flags, + parent_id: paragraph_id, + text_start: glyph.cluster, + text_end: glyph.font_handle, + item_start: u32::from(glyph.glyph_id), + inline_start: glyph.inline_origin, + block_start: glyph.block_origin, + inline_extent: glyph.font_size, + ..SemanticRecord::default() + }); + } + } + + let intrinsic = intrinsic_extents.unwrap_or(LayoutExtents { + width: content_width, + height: content_height, + consumed_clusters, + }); + let full_content_width = content_width.max(intrinsic.width); + let full_content_height = content_height.max(intrinsic.height); + let width = resolve_axis(constraint.width_mode, constraint.width, content_width)?; + let height = resolve_axis(constraint.height_mode, constraint.height, content_height)?; + let overflowed = consumed_clusters < cluster_count + || intrinsic.consumed_clusters < cluster_count + || axis_overflowed(constraint.width_mode, constraint.width, full_content_width)? + || axis_overflowed( + constraint.height_mode, + constraint.height, + full_content_height, + )?; + let missing_glyph_count = positioned_glyphs + .iter() + .filter(|glyph| glyph.glyph_id == 0) + .count(); + target[summary_index] = SemanticRecord { + id: paragraph_id, + kind: SEMANTIC_PARAGRAPH_MEASUREMENT, + flags: if overflowed { + MEASUREMENT_FLAG_OVERFLOWED + } else { + 0 + }, + parent_id: u32::try_from(positioned_glyphs.len()) + .map_err(|_| EngineError::ResultTooLarge)?, + text_start: u32::try_from(missing_glyph_count).map_err(|_| EngineError::ResultTooLarge)?, + text_end: u32::try_from(text_length).map_err(|_| EngineError::ResultTooLarge)?, + item_start: line_start, + item_count: u32::try_from(line_count).map_err(|_| EngineError::ResultTooLarge)?, + inline_start: width, + block_start: height, + inline_extent: finite_nonnegative_f32(full_content_width)?, + block_extent: finite_nonnegative_f32(full_content_height)?, + ..SemanticRecord::default() + }; + Ok(()) +} + +pub(crate) fn flow_extents( + flow_thread_id: u32, + flow: &FlowLayoutArena, + text: &[u16], + clusters: &ClusterArena, +) -> Result { + let mut extents = LayoutExtents::default(); + for (index, line) in flow.lines.iter().copied().enumerate() { + if line.flow_thread_id != flow_thread_id { + continue; + } + let fragments = line_fragments(flow, line)?; + if fragments.is_empty() { + continue; + } + let Some(last) = fragments.last() else { + continue; + }; + extents.width = extents + .width + .max(line_inline_extent(flow, line, index, text, clusters)?); + extents.height = extents.height.max(line.block_start + line.height); + extents.consumed_clusters = extents + .consumed_clusters + .max(usize::try_from(last.line.cluster_end).map_err(|_| EngineError::InvalidRequest)?); + } + Ok(extents) +} + +fn line_inline_extent( + flow: &FlowLayoutArena, + line: FlowLine, + index: usize, + text: &[u16], + clusters: &ClusterArena, +) -> Result { + let fragments = line_fragments(flow, line)?; + if fragments.is_empty() { + return Ok(0.0); + } + let final_line = flow + .lines + .get(index + 1) + .is_none_or(|next| next.flow_thread_id != line.flow_thread_id); + let inline_start = fragments + .iter() + .map(|fragment| fragment.slot_start) + .fold(f64::INFINITY, f64::min); + let mut inline_end = f64::NEG_INFINITY; + for fragment in fragments.iter().copied() { + inline_end = inline_end.max( + fragment.slot_start + + positioned_fragment_advance(line, fragment, final_line, text, clusters)?, + ); + } + Ok((inline_end - inline_start).max(0.0)) +} + +fn line_fragments(flow: &FlowLayoutArena, line: FlowLine) -> Result<&[FlowFragment], EngineError> { + let start = usize::try_from(line.fragment_start).map_err(|_| EngineError::InvalidRequest)?; + let end = start + .checked_add(usize::from(line.fragment_count)) + .ok_or(EngineError::InvalidRequest)?; + flow.fragments + .get(start..end) + .ok_or(EngineError::InvalidRequest) +} + +fn resolve_axis(mode: u8, requested: f32, content: f64) -> Result { + match mode { + AXIS_UNCONSTRAINED => finite_nonnegative_f32(content), + AXIS_AT_MOST => finite_nonnegative_f32(content.min(f64::from(requested))), + AXIS_EXACT => Ok(requested), + _ => Err(EngineError::InvalidRequest), + } +} + +fn axis_overflowed(mode: u8, requested: f32, content: f64) -> Result { + match mode { + AXIS_UNCONSTRAINED => Ok(false), + AXIS_AT_MOST | AXIS_EXACT => Ok(content > f64::from(requested)), + _ => Err(EngineError::InvalidRequest), + } +} + +fn finite_f32(value: f64) -> Result { + let narrowed = value as f32; + if narrowed.is_finite() { + Ok(narrowed) + } else { + Err(EngineError::ResultTooLarge) + } +} + +fn finite_nonnegative_f32(value: f64) -> Result { + if !value.is_finite() || value < 0.0 { + return Err(EngineError::InvalidRequest); + } + finite_f32(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::{ + flow_composition::{FlowFragment, FlowLine, NO_BOUNDARY}, + line_composition::ComposedLine, + semantic_wire::FlowConstraint, + }; + + #[test] + fn measurement_is_demand_shaped_from_retained_lines_without_glyph_arrays() { + let geometry = FlowGeometryArena { + constraints: vec![constraint(AXIS_EXACT, 20.0, AXIS_EXACT, 10.0)], + ..FlowGeometryArena::default() + }; + let flow = FlowLayoutArena { + lines: vec![FlowLine { + flow_thread_id: 11, + region_id: 3, + transform_index: 1, + clip_id: 0, + fragment_start: 0, + fragment_count: 1, + align: 1, + block_start: 0.0, + baseline: 4.0, + height: 5.0, + }], + fragments: vec![FlowFragment { + line: ComposedLine { + cluster_start: 0, + cluster_end: 2, + text_start: 0, + text_end: 2, + advance: 7.0, + hard_break: false, + }, + slot_start: 0.0, + slot_end: 20.0, + boundary_index: NO_BOUNDARY, + }], + ..FlowLayoutArena::default() + }; + let mut records = vec![]; + let positioned = [layout_glyph(3), layout_glyph(0)]; + append_measurement( + &mut records, + 7, + 2, + 2, + &geometry, + &flow, + &positioned, + &[0], + &[2], + Some(&[7.0]), + &[], + &ClusterArena::default(), + None, + false, + ) + .unwrap(); + + assert_eq!(records.len(), 2); + assert_eq!(records[0].kind, SEMANTIC_PARAGRAPH_MEASUREMENT); + assert_eq!(records[0].id, 7); + assert_eq!(records[0].flags, 0); + assert_eq!(records[0].parent_id, 2); + assert_eq!(records[0].text_start, 1); + assert_eq!(records[0].item_start, 1); + assert_eq!(records[0].item_count, 1); + assert_eq!(records[0].inline_start, 20.0); + assert_eq!(records[0].block_start, 10.0); + assert_eq!(records[0].inline_extent, 7.0); + assert_eq!(records[0].block_extent, 5.0); + assert_eq!(records[1].kind, SEMANTIC_LINE); + assert_eq!(records[1].parent_id, 7); + assert_eq!(records[1].block_start, 4.0); + assert_eq!(records[1].inline_extent, 7.0); + + let mut inspected = vec![]; + append_measurement( + &mut inspected, + 7, + 2, + 2, + &geometry, + &flow, + &positioned, + &[0], + &[2], + Some(&[7.0]), + &[], + &ClusterArena::default(), + None, + true, + ) + .unwrap(); + assert_eq!(inspected.len(), 4); + assert_eq!(inspected[1].item_start, 2); + assert_eq!(inspected[1].item_count, 2); + assert_eq!(inspected[2].kind, SEMANTIC_GLYPH); + assert_eq!(inspected[2].item_start, 3); + assert_eq!(inspected[3].kind, SEMANTIC_GLYPH); + assert_eq!(inspected[3].item_start, 0); + } + + #[test] + fn constrained_measurement_preserves_demanded_intrinsic_content_extents() { + let geometry = FlowGeometryArena { + constraints: vec![constraint(AXIS_AT_MOST, 6.0, AXIS_AT_MOST, 4.0)], + ..FlowGeometryArena::default() + }; + let flow = FlowLayoutArena { + lines: vec![FlowLine { + flow_thread_id: 11, + region_id: 3, + transform_index: 1, + clip_id: 0, + fragment_start: 0, + fragment_count: 1, + align: 1, + block_start: 0.0, + baseline: 4.0, + height: 5.0, + }], + fragments: vec![FlowFragment { + line: ComposedLine { + cluster_start: 0, + cluster_end: 1, + text_start: 0, + text_end: 1, + advance: 7.0, + hard_break: false, + }, + slot_start: 0.0, + slot_end: 6.0, + boundary_index: NO_BOUNDARY, + }], + ..FlowLayoutArena::default() + }; + let mut records = vec![]; + append_measurement( + &mut records, + 7, + 2, + 2, + &geometry, + &flow, + &[], + &[0], + &[0], + Some(&[7.0]), + &[], + &ClusterArena::default(), + Some(LayoutExtents { + width: 9.0, + height: 8.0, + consumed_clusters: 2, + }), + false, + ) + .unwrap(); + + assert_eq!(records[0].flags, MEASUREMENT_FLAG_OVERFLOWED); + assert_eq!(records[0].inline_start, 6.0); + assert_eq!(records[0].block_start, 4.0); + assert_eq!(records[0].inline_extent, 9.0); + assert_eq!(records[0].block_extent, 8.0); + } + + #[test] + fn unconstrained_content_does_not_overflow_after_single_precision_publication() { + let geometry = FlowGeometryArena { + constraints: vec![constraint(AXIS_UNCONSTRAINED, 0.0, AXIS_UNCONSTRAINED, 0.0)], + ..FlowGeometryArena::default() + }; + let flow = FlowLayoutArena { + lines: vec![FlowLine { + flow_thread_id: 11, + region_id: 3, + transform_index: 1, + clip_id: 0, + fragment_start: 0, + fragment_count: 1, + align: 1, + block_start: 0.0, + baseline: 100.0, + height: 140.64, + }], + fragments: vec![FlowFragment { + line: ComposedLine { + cluster_start: 0, + cluster_end: 1, + text_start: 0, + text_end: 1, + advance: 140.64, + hard_break: false, + }, + slot_start: 0.0, + slot_end: 140.64, + boundary_index: NO_BOUNDARY, + }], + ..FlowLayoutArena::default() + }; + let mut records = vec![]; + append_measurement( + &mut records, + 7, + 1, + 1, + &geometry, + &flow, + &[], + &[0], + &[0], + Some(&[140.64]), + &[], + &ClusterArena::default(), + None, + false, + ) + .unwrap(); + + assert_eq!(records[0].flags, 0); + assert_eq!(records[0].inline_start, 140.64_f32); + assert_eq!(records[0].block_start, 140.64_f32); + } + + fn constraint(width_mode: u8, width: f32, height_mode: u8, height: f32) -> FlowConstraint { + FlowConstraint { + paragraph_id: 7, + flow_thread_id: 11, + width, + height, + viewport_block_start: 0.0, + viewport_block_end: height, + resume_block_offset: 0.0, + max_lines: 8, + region_start: 0, + resume_cluster: 0, + region_count: 1, + resume_region: 0, + width_mode, + height_mode, + wrap: 2, + align: 1, + overflow: 1, + block_align: 1, + } + } + + fn layout_glyph(glyph_id: u16) -> SemanticGlyph { + SemanticGlyph { + stable_id: u32::from(glyph_id) + 1, + font_handle: 1, + glyph_id, + cluster: 0, + flags: 0, + font_size: 16.0, + inline_origin: 0.0, + block_origin: 0.0, + } + } +} diff --git a/packages/text/rust/shaper/src/engine/line_composition.rs b/packages/text/rust/shaper/src/engine/line_composition.rs new file mode 100644 index 00000000..a841d2cd --- /dev/null +++ b/packages/text/rust/shaper/src/engine/line_composition.rs @@ -0,0 +1,281 @@ +use super::{ + EngineError, + cluster_state::{ + CLUSTER_ALLOWED_BREAK, CLUSTER_HARD_BREAK, CLUSTER_REQUIRED_BREAK, CLUSTER_SAFE_BEFORE, + ClusterArena, + }, + frame::{WRAP_CHARACTER, WRAP_NONE, WRAP_WORD}, +}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct LineCursor { + cluster: usize, + trailing_empty: bool, +} + +impl LineCursor { + pub(crate) const fn at_cluster(cluster: usize) -> Self { + Self { + cluster, + trailing_empty: false, + } + } + + pub(crate) const fn cluster(self) -> usize { + self.cluster + } + + pub(crate) const fn is_complete(self, cluster_count: usize) -> bool { + self.cluster == cluster_count && !self.trailing_empty + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct ComposedLine { + pub cluster_start: u32, + pub cluster_end: u32, + pub text_start: u32, + pub text_end: u32, + pub advance: f64, + pub hard_break: bool, +} + +pub(crate) fn layout_next_line( + clusters: &ClusterArena, + cursor: &mut LineCursor, + max_width: f64, + wrap: u8, +) -> Result, EngineError> { + if max_width.is_nan() + || max_width < 0.0 + || !matches!(wrap, WRAP_NONE | WRAP_WORD | WRAP_CHARACTER) + { + return Err(EngineError::InvalidRequest); + } + let count = clusters.starts.len(); + if cursor.cluster > count { + return Err(EngineError::InvalidRequest); + } + if cursor.trailing_empty { + cursor.trailing_empty = false; + cursor.cluster = count; + let text_end = clusters.ends.last().copied().unwrap_or(0); + let count = u32::try_from(count).map_err(|_| EngineError::ResultTooLarge)?; + return Ok(Some(ComposedLine { + cluster_start: count, + cluster_end: count, + text_start: text_end, + text_end, + advance: 0.0, + hard_break: false, + })); + } + if cursor.cluster == count { + return Ok(None); + } + + let line_start = cursor.cluster; + let mut advance = 0.0; + let mut last_allowed = None; + let mut last_allowed_advance = 0.0; + let mut last_safe = None; + let mut last_safe_advance = 0.0; + let mut selected_end = count; + let mut selected_advance = 0.0; + + for index in line_start..count { + let flags = clusters.flags[index]; + if index > line_start && flags & CLUSTER_SAFE_BEFORE != 0 { + last_safe = Some(index); + last_safe_advance = advance; + } + let required_break = flags & CLUSTER_REQUIRED_BREAK != 0; + let next_advance = advance + clusters.advances[index]; + if wrap != WRAP_NONE + && max_width.is_finite() + && next_advance > max_width + && index > line_start + { + if let Some(end) = last_allowed.filter(|end| *end > line_start) { + selected_end = end; + selected_advance = last_allowed_advance; + } else if let Some(end) = last_safe.filter(|end| *end > line_start) { + selected_end = end; + selected_advance = last_safe_advance; + } else { + advance = next_advance; + if required_break || index + 1 == count { + selected_end = index + 1; + selected_advance = advance; + break; + } + continue; + } + break; + } + advance = next_advance; + if required_break { + selected_end = index + 1; + selected_advance = advance; + break; + } + let allowed = match wrap { + WRAP_WORD => flags & CLUSTER_ALLOWED_BREAK != 0, + WRAP_CHARACTER => { + index + 1 == count || clusters.flags[index + 1] & CLUSTER_SAFE_BEFORE != 0 + } + WRAP_NONE => false, + _ => unreachable!(), + }; + if allowed { + last_allowed = Some(index + 1); + last_allowed_advance = advance; + } + if index + 1 == count { + selected_advance = advance; + } + } + + if selected_end <= line_start { + selected_end = line_start + 1; + selected_advance = clusters.advances[line_start]; + } + let last = selected_end - 1; + let hard_break = clusters.flags[last] & CLUSTER_HARD_BREAK != 0; + let text_start = clusters.starts[line_start]; + let text_end = if hard_break { + clusters.starts[last] + } else { + clusters.ends[last] + }; + cursor.cluster = selected_end; + cursor.trailing_empty = selected_end == count && hard_break; + Ok(Some(ComposedLine { + cluster_start: u32::try_from(line_start).map_err(|_| EngineError::ResultTooLarge)?, + cluster_end: u32::try_from(selected_end).map_err(|_| EngineError::ResultTooLarge)?, + text_start, + text_end, + advance: selected_advance, + hard_break, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + + fn make_clusters(advances: &[f64], flags: &[u8]) -> ClusterArena { + let count = advances.len(); + ClusterArena { + starts: (0..count as u32).collect(), + ends: (1..=count as u32).collect(), + advances: advances.to_vec(), + flags: flags.to_vec(), + style_indexes: vec![0; count], + source_runs: vec![0; count], + font_handles: vec![1; count], + index_at: (0..=count as u32).collect(), + ..ClusterArena::default() + } + } + + #[test] + fn composes_word_character_and_unwrapped_lines_without_allocating() { + let clusters = make_clusters( + &[4.0, 4.0, 4.0, 4.0], + &[ + CLUSTER_SAFE_BEFORE, + CLUSTER_SAFE_BEFORE | CLUSTER_ALLOWED_BREAK, + CLUSTER_SAFE_BEFORE, + CLUSTER_SAFE_BEFORE, + ], + ); + let mut cursor = LineCursor::default(); + assert_eq!( + layout_next_line(&clusters, &mut cursor, 10.0, WRAP_WORD).unwrap(), + Some(ComposedLine { + cluster_start: 0, + cluster_end: 2, + text_start: 0, + text_end: 2, + advance: 8.0, + hard_break: false, + }) + ); + assert_eq!( + layout_next_line(&clusters, &mut cursor, 10.0, WRAP_WORD) + .unwrap() + .unwrap() + .cluster_end, + 4 + ); + assert_eq!( + layout_next_line(&clusters, &mut cursor, 10.0, WRAP_WORD).unwrap(), + None + ); + + let mut character = LineCursor::default(); + assert_eq!( + layout_next_line(&clusters, &mut character, 5.0, WRAP_CHARACTER) + .unwrap() + .unwrap() + .cluster_end, + 1 + ); + let mut unwrapped = LineCursor::default(); + assert_eq!( + layout_next_line(&clusters, &mut unwrapped, 1.0, WRAP_NONE) + .unwrap() + .unwrap() + .advance, + 16.0 + ); + + let unsafe_boundary = make_clusters( + &[4.0, 4.0, 4.0], + &[CLUSTER_SAFE_BEFORE, 0, CLUSTER_SAFE_BEFORE], + ); + let mut unsafe_cursor = LineCursor::default(); + let line = layout_next_line(&unsafe_boundary, &mut unsafe_cursor, 5.0, WRAP_WORD) + .unwrap() + .unwrap(); + assert_eq!((line.cluster_end, line.advance), (2, 8.0)); + + let oversized = make_clusters(&[7.0, 3.0], &[CLUSTER_SAFE_BEFORE, CLUSTER_SAFE_BEFORE]); + let mut oversized_cursor = LineCursor::default(); + let line = layout_next_line(&oversized, &mut oversized_cursor, 5.0, WRAP_WORD) + .unwrap() + .unwrap(); + assert_eq!((line.cluster_end, line.advance), (1, 7.0)); + } + + #[test] + fn required_break_and_trailing_empty_line_match_paragraph_semantics() { + let clusters = make_clusters( + &[3.0, 0.0], + &[ + CLUSTER_SAFE_BEFORE, + CLUSTER_SAFE_BEFORE | CLUSTER_REQUIRED_BREAK | CLUSTER_HARD_BREAK, + ], + ); + let mut cursor = LineCursor::default(); + let first = layout_next_line(&clusters, &mut cursor, f64::INFINITY, WRAP_WORD) + .unwrap() + .unwrap(); + assert_eq!( + (first.text_start, first.text_end, first.advance), + (0, 1, 3.0) + ); + assert!(first.hard_break); + let trailing = layout_next_line(&clusters, &mut cursor, 0.0, WRAP_WORD) + .unwrap() + .unwrap(); + assert_eq!((trailing.cluster_start, trailing.cluster_end), (2, 2)); + assert_eq!((trailing.text_start, trailing.text_end), (2, 2)); + assert_eq!( + layout_next_line(&clusters, &mut cursor, 0.0, WRAP_WORD).unwrap(), + None + ); + } +} diff --git a/packages/text/rust/shaper/src/engine/mod.rs b/packages/text/rust/shaper/src/engine/mod.rs new file mode 100644 index 00000000..55699565 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/mod.rs @@ -0,0 +1,53 @@ +//! Renderer-neutral retained text-engine state. +//! +//! The public types in this module are available to native consumers. Wasm memory ownership and +//! pointer validation stay in the target-gated transport module. + +mod cluster_state; +#[cfg_attr(not(test), allow(dead_code))] +mod flow_composition; +#[cfg_attr(not(test), allow(dead_code))] +mod flow_geometry; +pub mod font_binding; +#[cfg_attr(not(any(target_arch = "wasm32", test)), allow(dead_code))] +pub(crate) mod font_binding_wire; +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +pub(crate) mod frame; +#[cfg_attr(not(any(target_arch = "wasm32", test)), allow(dead_code))] +pub(crate) mod frame_wire; +mod identity_index; +#[cfg(feature = "kernel-lab")] +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +pub(crate) mod kernel_lab; +pub(crate) mod layout_query; +#[cfg_attr(not(test), allow(dead_code))] +mod line_composition; +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +mod state; +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +pub(crate) mod transport; + +pub mod ordered_plan; +mod plan_draw; +mod plan_error; +pub mod plan_input; +mod plan_packing; +pub mod policy; +pub mod policy_gather; +mod positioning; +pub mod render_plan; +pub mod render_plan_compiler; +pub(crate) mod render_plan_wire; +pub(crate) mod semantic_view; +mod semantic_wire; +mod shaping_state; +#[cfg_attr(not(test), allow(dead_code))] +mod stable_order; +pub mod stable_plan; +#[cfg_attr(not(test), allow(dead_code))] +mod stable_pool; +mod style_state; +#[cfg_attr(not(any(target_arch = "wasm32", test)), allow(dead_code))] +pub(crate) mod wire; + +pub use state::{EngineError, TextEngine}; diff --git a/packages/text/rust/shaper/src/engine/ordered_plan.rs b/packages/text/rust/shaper/src/engine/ordered_plan.rs new file mode 100644 index 00000000..80c02a8a --- /dev/null +++ b/packages/text/rust/shaper/src/engine/ordered_plan.rs @@ -0,0 +1,1998 @@ +//! Retained ordered-direct physical storage and invalidation-directed patch planning. +//! +//! The planner compares stable semantic identities and caller-owned content revisions. It never +//! scans physical buffers to discover changes. Preparation writes only scratch state and can be +//! aborted; committed CPU mirrors change only after the immutable plan has been serialized. + +use alloc::vec::Vec; +use core::mem; + +use super::{ + identity_index::IdentitySet, + plan_draw::{GlyphDraw, push_glyph_draw}, + plan_input::{ + draw_fields_compatible, draw_span_compatible, indexed_span_bounds, span_bounds, + validate_glyph, validate_input, + }, + plan_packing::{ + MAX_PHYSICAL_BUFFERS, PendingAllocation, PhysicalBufferState, RangeJob, RecordRange, + align_record_range, align_up, apply_writes, buffer_record_alignment, + coalesce_buffer_ranges, collect_range_jobs, execute_run, grown_capacity, + push_pending_allocation, record_alignment, take_allocation, + }, + policy::{ + ALLOCATION_ORDERED_DIRECT, BATCH_MATERIAL, BATCH_TRANSFORM, BufferSchema, CapabilitySetId, + TechniqueId, ValidatedPolicy, + }, + render_plan::{ + BUFFER_ORDERED_DIRECT, BufferRecord, DrawRecord, PATCH_ALLOCATE_OR_RESIZE, PATCH_WRITE, + PatchRecord, PrimitiveRecord, RESOURCE_ACTION_CREATE, RESOURCE_ACTION_RETAIN, + RETIRE_BUFFER, RETIRE_RESOURCE, RETIRE_SLOT_RANGE, RenderPlanView, ResourceRecord, + RetirementRecord, + }, +}; + +pub use super::plan_error::PlanError as OrderedPlanError; + +#[cfg(test)] +use super::render_plan::PRIMITIVE_GLYPH; + +pub use super::plan_input::{PlanGlyph as OrderedGlyph, PlanInput as OrderedPlanInput}; + +const NONE: u32 = u32::MAX; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct BatchKey { + technique: TechniqueId, + program_variant: u16, + program_id: u32, + resource_id: u32, + resource_generation: u32, + resource_kind: u16, + storage_key_mask: u32, + resource_reference: u32, + material_id: u32, + clip_id: u32, + depth_key: u32, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct InstanceState { + stable_id: u32, + content_revision: u32, + input_index: u32, + semantic_change_mask: u16, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct BatchState { + key: BatchKey, + instance_start: u32, + instance_count: u32, + buffer_start: u32, + buffer_count: u16, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct PendingBatch { + state: BatchState, + prior_index: Option, + capacity: u32, + buffer_ids: [u32; MAX_PHYSICAL_BUFFERS], + buffer_generations: [u32; MAX_PHYSICAL_BUFFERS], +} + +#[derive(Clone, Copy)] +struct PrepareContext<'a> { + policy: &'a ValidatedPolicy, + capability_set: CapabilitySetId, + capability: &'a super::policy::CapabilitySet, + input: OrderedPlanInput<'a>, + checkpoint: bool, + publication_generation: u32, +} + +#[derive(Default)] +pub struct OrderedPlanCompiler { + batches: Vec, + instances: Vec, + buffers: Vec, + spare_batches: Vec, + spare_buffers: Vec, + pending_batches: Vec, + pending_instances: Vec, + pending_allocations: Vec, + input_batches: Vec, + input_slots: Vec, + identity_set: IdentitySet, + batch_cursors: Vec, + changed_ranges: Vec, + buffer_ranges: [Vec; MAX_PHYSICAL_BUFFERS], + range_jobs: Vec, + resources: Vec, + plan_buffers: Vec, + primitives: Vec, + draws: Vec, + live_primitives: Vec, + live_draws: Vec, + patches: Vec, + retirements: Vec, + payload: Vec, + next_buffer_id: u32, + pending_next_buffer_id: u32, + policy_fingerprint: u64, + pending_policy_fingerprint: u64, + capability_set: u32, + pending_capability_set: u32, + buffer_id_limit: u32, + publish_bindings: bool, + prepared: bool, + #[cfg(test)] + retained_topology_preparations: u32, +} + +impl OrderedPlanCompiler { + pub(crate) fn with_buffer_id_limit(buffer_id_limit: u32) -> Self { + Self { + buffer_id_limit, + ..Self::default() + } + } + + pub fn prepare( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + input: OrderedPlanInput<'_>, + checkpoint: bool, + publication_generation: u32, + ) -> Result<(), OrderedPlanError> { + self.prepare_internal( + policy, + capability_set, + input, + checkpoint, + publication_generation, + true, + ) + } + + pub(crate) fn prepare_filtered( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + input: OrderedPlanInput<'_>, + checkpoint: bool, + publication_generation: u32, + ) -> Result<(), OrderedPlanError> { + self.prepare_internal( + policy, + capability_set, + input, + checkpoint, + publication_generation, + false, + ) + } + + fn prepare_internal( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + input: OrderedPlanInput<'_>, + checkpoint: bool, + publication_generation: u32, + strict_strategy: bool, + ) -> Result<(), OrderedPlanError> { + if self.prepared { + return Err(OrderedPlanError::AlreadyPrepared); + } + if publication_generation == 0 { + return Err(OrderedPlanError::InvalidIdentity); + } + let capability = policy + .capability_set(capability_set) + .ok_or(OrderedPlanError::CapabilitySetMissing)?; + validate_input(input)?; + self.reset_pending(); + let retained_topology = + !checkpoint && self.prepare_retained_topology(policy, capability_set, input)?; + #[cfg(test)] + if retained_topology { + self.retained_topology_preparations += 1; + } + if !retained_topology { + self.prepare_complete_topology(policy, capability_set, input, strict_strategy)?; + } + self.pending_next_buffer_id = self.next_buffer_id; + self.pending_policy_fingerprint = policy.fingerprint(); + self.pending_capability_set = capability_set.0; + let context = PrepareContext { + policy, + capability_set, + capability, + input, + checkpoint, + publication_generation, + }; + for batch_index in 0..self.pending_batches.len() { + self.prepare_batch(context, batch_index)?; + } + self.prepare_removed_batches(publication_generation)?; + self.compile_bindings(context)?; + self.prepared = true; + Ok(()) + } + + pub(crate) fn has_state(&self) -> bool { + !self.batches.is_empty() + } + + pub(crate) fn publishes_bindings(&self) -> bool { + self.publish_bindings + } + + pub fn plan_view( + &self, + policy_handle: u32, + capability_set: CapabilitySetId, + policy_fingerprint: u64, + ) -> Result, OrderedPlanError> { + self.plan_view_internal(policy_handle, capability_set, policy_fingerprint, false) + } + + pub(crate) fn plan_view_forced( + &self, + policy_handle: u32, + capability_set: CapabilitySetId, + policy_fingerprint: u64, + ) -> Result, OrderedPlanError> { + self.plan_view_internal(policy_handle, capability_set, policy_fingerprint, true) + } + + fn plan_view_internal( + &self, + policy_handle: u32, + capability_set: CapabilitySetId, + policy_fingerprint: u64, + force_bindings: bool, + ) -> Result, OrderedPlanError> { + if !self.prepared { + return Err(OrderedPlanError::NotPrepared); + } + let resources = if self.publish_bindings || force_bindings { + self.resources.as_slice() + } else { + &[] + }; + let buffers = if self.publish_bindings || force_bindings { + self.plan_buffers.as_slice() + } else { + &[] + }; + let primitives = if self.publish_bindings || force_bindings { + self.primitives.as_slice() + } else { + &[] + }; + let draws = if self.publish_bindings || force_bindings { + self.draws.as_slice() + } else { + &[] + }; + Ok(RenderPlanView { + policy_handle, + capability_set: capability_set.0, + policy_fingerprint, + resources, + buffers, + patches: &self.patches, + retirements: &self.retirements, + primitives, + draws, + payload: &self.payload, + ..RenderPlanView::default() + }) + } + + pub fn commit(&mut self) -> Result<(), OrderedPlanError> { + if !self.prepared { + return Err(OrderedPlanError::NotPrepared); + } + mem::swap(&mut self.buffers, &mut self.spare_buffers); + self.buffers.clear(); + for pending_index in 0..self.pending_batches.len() { + let pending = self.pending_batches[pending_index]; + for buffer_index in 0..usize::from(pending.state.buffer_count) { + let id = pending.buffer_ids[buffer_index]; + let generation = pending.buffer_generations[buffer_index]; + if let Some(allocation) = + take_allocation(&mut self.pending_allocations, id, generation) + { + self.buffers.push(allocation.state); + continue; + } + let position = self + .spare_buffers + .iter() + .position(|buffer| buffer.id == id && buffer.generation == generation) + .ok_or(OrderedPlanError::InvalidIdentity)?; + let mut buffer = self.spare_buffers.swap_remove(position); + apply_writes(&mut buffer, &self.patches, &self.payload)?; + self.buffers.push(buffer); + } + } + self.spare_buffers.clear(); + + mem::swap(&mut self.batches, &mut self.spare_batches); + self.batches.clear(); + for pending in &self.pending_batches { + let mut state = pending.state; + state.buffer_start = u32::try_from(self.batches_buffer_start(self.batches.len())) + .map_err(|_| OrderedPlanError::ArithmeticOverflow)?; + self.batches.push(state); + } + self.spare_batches.clear(); + mem::swap(&mut self.instances, &mut self.pending_instances); + self.pending_instances.clear(); + mem::swap(&mut self.live_primitives, &mut self.primitives); + self.primitives.clear(); + mem::swap(&mut self.live_draws, &mut self.draws); + self.draws.clear(); + self.next_buffer_id = self.pending_next_buffer_id; + self.policy_fingerprint = self.pending_policy_fingerprint; + self.capability_set = self.pending_capability_set; + self.prepared = false; + Ok(()) + } + + pub fn abort(&mut self) { + self.prepared = false; + self.pending_allocations.clear(); + } + + pub fn buffer_bytes(&self, id: u32) -> Option<&[u8]> { + self.buffers + .iter() + .find(|buffer| buffer.id == id) + .map(|buffer| buffer.bytes.as_slice()) + } + + pub fn buffer_schema(&self, id: u32) -> Option<(u32, BufferSchema)> { + self.buffers + .iter() + .find(|buffer| buffer.id == id) + .map(|buffer| (buffer.program_id, buffer.schema)) + } + + fn reset_pending(&mut self) { + self.pending_batches.clear(); + self.pending_instances.clear(); + self.pending_allocations.clear(); + self.batch_cursors.clear(); + self.changed_ranges.clear(); + self.resources.clear(); + self.plan_buffers.clear(); + self.primitives.clear(); + self.draws.clear(); + self.patches.clear(); + self.retirements.clear(); + self.payload.clear(); + self.publish_bindings = false; + } + + fn prepare_complete_topology( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + input: OrderedPlanInput<'_>, + strict_strategy: bool, + ) -> Result<(), OrderedPlanError> { + reserve(&mut self.input_batches, input.glyphs.len())?; + reserve(&mut self.input_slots, input.glyphs.len())?; + reserve(&mut self.pending_instances, input.glyphs.len())?; + self.input_batches.resize(input.glyphs.len(), NONE); + self.input_batches.fill(NONE); + self.input_slots.resize(input.glyphs.len(), 0); + self.identity_set.prepare(input.glyphs.len())?; + + for (input_index, glyph) in input.glyphs.iter().copied().enumerate() { + validate_glyph(glyph)?; + if !self.identity_set.insert(glyph.stable_id) { + return Err(OrderedPlanError::DuplicateIdentity); + } + let program = policy + .program(capability_set, glyph.technique, glyph.program_variant) + .ok_or(OrderedPlanError::ProgramMissing)?; + if program.allocation_strategy != ALLOCATION_ORDERED_DIRECT { + if strict_strategy { + return Err(OrderedPlanError::UnsupportedStrategy); + } + continue; + } + let resource_bit = 1_u32 + .checked_shl(u32::from(glyph.resource_kind - 1)) + .ok_or(OrderedPlanError::InvalidResource)?; + if program.resource_kind_mask & resource_bit == 0 { + return Err(OrderedPlanError::InvalidResource); + } + let key = batch_key(program, glyph); + let batch_index = match self + .pending_batches + .iter() + .position(|batch| batch.state.key == key) + { + Some(index) => index, + None => { + reserve(&mut self.pending_batches, 1)?; + let prior_index = self + .batches + .iter() + .position(|batch| batch.key == key) + .map(|index| index as u32); + self.pending_batches.push(PendingBatch { + state: BatchState { + key, + instance_start: 0, + instance_count: 0, + buffer_start: 0, + buffer_count: 0, + }, + prior_index, + capacity: 0, + buffer_ids: [0; MAX_PHYSICAL_BUFFERS], + buffer_generations: [0; MAX_PHYSICAL_BUFFERS], + }); + self.pending_batches.len() - 1 + } + }; + self.pending_batches[batch_index].state.instance_count = self.pending_batches + [batch_index] + .state + .instance_count + .checked_add(1) + .ok_or(OrderedPlanError::ArithmeticOverflow)?; + self.input_batches[input_index] = + u32::try_from(batch_index).map_err(|_| OrderedPlanError::ArithmeticOverflow)?; + } + self.layout_pending_instances(input) + } + + fn prepare_retained_topology( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + input: OrderedPlanInput<'_>, + ) -> Result { + if self.policy_fingerprint != policy.fingerprint() + || self.capability_set != capability_set.0 + || self.input_batches.len() != input.glyphs.len() + || self.input_slots.len() != input.glyphs.len() + || self.instances.len() != input.glyphs.len() + { + return Ok(false); + } + self.identity_set.prepare(input.glyphs.len())?; + for (input_index, glyph) in input.glyphs.iter().copied().enumerate() { + validate_glyph(glyph)?; + if !self.identity_set.insert(glyph.stable_id) { + return Err(OrderedPlanError::DuplicateIdentity); + } + let batch_index = self.input_batches[input_index]; + if batch_index == NONE { + return Ok(false); + } + let batch_index = + usize::try_from(batch_index).map_err(|_| OrderedPlanError::ArithmeticOverflow)?; + let Some(batch) = self.batches.get(batch_index).copied() else { + return Ok(false); + }; + if !batch_key_matches(batch.key, glyph) { + return Ok(false); + } + let slot = self.input_slots[input_index]; + if slot >= batch.instance_count { + return Ok(false); + } + let instance_index = batch + .instance_start + .checked_add(slot) + .ok_or(OrderedPlanError::ArithmeticOverflow)?; + if self + .instances + .get( + usize::try_from(instance_index) + .map_err(|_| OrderedPlanError::ArithmeticOverflow)?, + ) + .is_none_or(|instance| instance.input_index != input_index as u32) + { + return Ok(false); + } + } + + reserve(&mut self.pending_batches, self.batches.len())?; + for (batch_index, batch) in self.batches.iter().copied().enumerate() { + let buffers = self + .buffers + .get(range(batch.buffer_start, u32::from(batch.buffer_count))?) + .ok_or(OrderedPlanError::InvalidIdentity)?; + let mut pending = PendingBatch { + state: batch, + prior_index: Some( + u32::try_from(batch_index).map_err(|_| OrderedPlanError::ArithmeticOverflow)?, + ), + capacity: buffers.first().map_or(0, |buffer| buffer.capacity), + buffer_ids: [0; MAX_PHYSICAL_BUFFERS], + buffer_generations: [0; MAX_PHYSICAL_BUFFERS], + }; + for (buffer_index, buffer) in buffers.iter().enumerate() { + pending.buffer_ids[buffer_index] = buffer.id; + pending.buffer_generations[buffer_index] = buffer.generation; + } + self.pending_batches.push(pending); + } + reserve(&mut self.pending_instances, self.instances.len())?; + self.pending_instances + .resize(self.instances.len(), InstanceState::default()); + for (input_index, glyph) in input.glyphs.iter().copied().enumerate() { + let batch_index = self.input_batches[input_index] as usize; + let destination = self.batches[batch_index] + .instance_start + .checked_add(self.input_slots[input_index]) + .ok_or(OrderedPlanError::ArithmeticOverflow)?; + self.pending_instances[destination as usize] = InstanceState { + stable_id: glyph.stable_id, + content_revision: glyph.content_revision, + input_index: input_index as u32, + semantic_change_mask: input + .semantic_change_masks + .get(input_index) + .copied() + .unwrap_or(super::positioning::ALL_SEMANTIC_CHANGES), + }; + } + Ok(true) + } + + fn layout_pending_instances( + &mut self, + input: OrderedPlanInput<'_>, + ) -> Result<(), OrderedPlanError> { + reserve(&mut self.batch_cursors, self.pending_batches.len())?; + let mut cursor = 0_u32; + for batch in &mut self.pending_batches { + batch.state.instance_start = cursor; + cursor = cursor + .checked_add(batch.state.instance_count) + .ok_or(OrderedPlanError::ArithmeticOverflow)?; + self.batch_cursors.push(batch.state.instance_start); + } + self.pending_instances + .resize(cursor as usize, InstanceState::default()); + for (input_index, glyph) in input.glyphs.iter().enumerate() { + if self.input_batches[input_index] == NONE { + continue; + } + let batch = self.input_batches[input_index] as usize; + let destination = self.batch_cursors[batch] as usize; + self.pending_instances[destination] = InstanceState { + stable_id: glyph.stable_id, + content_revision: glyph.content_revision, + input_index: input_index as u32, + semantic_change_mask: input + .semantic_change_masks + .get(input_index) + .copied() + .unwrap_or(super::positioning::ALL_SEMANTIC_CHANGES), + }; + self.input_slots[input_index] = u32::try_from(destination) + .map_err(|_| OrderedPlanError::ArithmeticOverflow)? + - self.pending_batches[batch].state.instance_start; + self.batch_cursors[batch] = self.batch_cursors[batch] + .checked_add(1) + .ok_or(OrderedPlanError::ArithmeticOverflow)?; + } + Ok(()) + } + + fn prepare_batch( + &mut self, + context: PrepareContext<'_>, + batch_index: usize, + ) -> Result<(), OrderedPlanError> { + let PrepareContext { + policy, + capability_set, + capability, + input, + checkpoint, + publication_generation, + } = context; + let pending = self.pending_batches[batch_index]; + let key = pending.state.key; + let program = policy + .program(capability_set, key.technique, key.program_variant) + .ok_or(OrderedPlanError::ProgramMissing)?; + let prior = pending + .prior_index + .map(|index| self.batches[index as usize]); + let required = pending.state.instance_count; + let prior_capacity = prior + .and_then(|batch| self.buffers.get(batch.buffer_start as usize)) + .map_or(0, |buffer| buffer.capacity); + let capacity = if prior_capacity >= required { + prior_capacity + } else { + grown_capacity(prior_capacity.max(1), required)? + }; + let capacity = align_up( + capacity, + record_alignment(program, capability.update_alignment)?, + )?; + for schema in &program.buffers { + let byte_length = capacity + .checked_mul(u32::from(schema.stride)) + .ok_or(OrderedPlanError::ArithmeticOverflow)?; + if byte_length > capability.max_buffer_bytes { + return Err(OrderedPlanError::CapacityExceeded); + } + } + self.pending_batches[batch_index].capacity = capacity; + + let new_or_resized = prior.is_none() || capacity != prior_capacity; + let prior_instances = match prior { + Some(batch) => self + .instances + .get(range(batch.instance_start, batch.instance_count)?) + .ok_or(OrderedPlanError::InvalidIdentity)?, + None => &[], + }; + let next_instances = &self.pending_instances + [range(pending.state.instance_start, pending.state.instance_count)?]; + collect_changed_ranges( + &mut self.changed_ranges, + prior_instances, + next_instances, + checkpoint || new_or_resized, + )?; + for (schema_index, schema) in program.buffers.iter().copied().enumerate() { + let previous = prior + .and_then(|batch| self.buffers.get(batch.buffer_start as usize + schema_index)) + .map(|buffer| (buffer.id, buffer.generation, buffer.bytes.len())); + let (id, generation) = if let Some((previous_id, previous_generation, _)) = previous { + ( + previous_id, + if new_or_resized { + previous_generation + .checked_add(1) + .ok_or(OrderedPlanError::IdentifierExhausted)? + } else { + previous_generation + }, + ) + } else { + if self.buffer_id_limit != 0 && self.pending_next_buffer_id >= self.buffer_id_limit + { + return Err(OrderedPlanError::IdentifierExhausted); + } + self.pending_next_buffer_id = self + .pending_next_buffer_id + .checked_add(1) + .ok_or(OrderedPlanError::IdentifierExhausted)?; + (self.pending_next_buffer_id, 1) + }; + if checkpoint || new_or_resized { + reserve(&mut self.patches, 1)?; + self.patches.push(PatchRecord { + opcode: PATCH_ALLOCATE_OR_RESIZE, + buffer_id: id, + buffer_generation: generation, + byte_length: capacity + .checked_mul(u32::from(schema.stride)) + .ok_or(OrderedPlanError::ArithmeticOverflow)?, + ..PatchRecord::default() + }); + push_pending_allocation( + &mut self.pending_allocations, + id, + generation, + key.program_id, + schema, + capacity, + )?; + if new_or_resized + && let Some((previous_id, previous_generation, previous_length)) = previous + { + reserve(&mut self.retirements, 1)?; + self.retirements.push(RetirementRecord { + kind: RETIRE_BUFFER, + id: previous_id, + generation: previous_generation, + after_publication_generation: publication_generation, + byte_length: previous_length as u32, + ..RetirementRecord::default() + }); + } + } + self.pending_batches[batch_index].buffer_ids[schema_index] = id; + self.pending_batches[batch_index].buffer_generations[schema_index] = generation; + } + + self.write_changed_ranges( + policy, + capability_set, + capability, + input, + program, + prior, + self.pending_batches[batch_index], + checkpoint || new_or_resized, + )?; + if let Some(prior) = prior + && required < prior.instance_count + { + reserve(&mut self.retirements, usize::from(prior.buffer_count))?; + for buffer in &self.buffers[range(prior.buffer_start, u32::from(prior.buffer_count))?] { + self.retirements.push(RetirementRecord { + kind: RETIRE_SLOT_RANGE, + id: buffer.id, + generation: buffer.generation, + after_publication_generation: publication_generation, + byte_offset: required + .checked_mul(u32::from(buffer.schema.stride)) + .ok_or(OrderedPlanError::ArithmeticOverflow)?, + byte_length: (prior.instance_count - required) + .checked_mul(u32::from(buffer.schema.stride)) + .ok_or(OrderedPlanError::ArithmeticOverflow)?, + ..RetirementRecord::default() + }); + } + } + self.pending_batches[batch_index].state.buffer_count = u16::try_from(program.buffers.len()) + .map_err(|_| OrderedPlanError::ArithmeticOverflow)?; + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + fn write_changed_ranges( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + capability: &super::policy::CapabilitySet, + input: OrderedPlanInput<'_>, + program: &super::policy::ProgramDescriptor, + prior: Option, + pending: PendingBatch, + replace: bool, + ) -> Result<(), OrderedPlanError> { + let next_instances = &self.pending_instances + [range(pending.state.instance_start, pending.state.instance_count)?]; + let prior_instances = match prior { + Some(batch) => self + .instances + .get(range(batch.instance_start, batch.instance_count)?) + .ok_or(OrderedPlanError::InvalidIdentity)?, + None => &[], + }; + for ranges in &mut self.buffer_ranges { + ranges.clear(); + } + for changed in self.changed_ranges.iter().copied() { + let active_buffers = active_buffers_for_range( + policy, + capability_set, + program, + prior_instances, + next_instances, + changed, + replace, + )?; + for (schema_index, schema) in program.buffers.iter().enumerate() { + if active_buffers & (1 << schema_index) == 0 { + continue; + } + reserve(&mut self.buffer_ranges[schema_index], 1)?; + self.buffer_ranges[schema_index].push(align_record_range( + changed, + buffer_record_alignment(schema, capability.update_alignment), + )?); + } + } + for (schema_index, schema) in program.buffers.iter().enumerate() { + coalesce_buffer_ranges( + &mut self.buffer_ranges[schema_index], + u32::from(schema.stride), + capability, + pending.state.instance_count, + )?; + } + collect_range_jobs( + &mut self.range_jobs, + &self.buffer_ranges, + program.buffers.len(), + )?; + for job_index in 0..self.range_jobs.len() { + let RangeJob { + range: aligned, + active_buffers, + } = self.range_jobs[job_index]; + let count = aligned.end - aligned.start; + let mut payload_starts = [0_usize; MAX_PHYSICAL_BUFFERS]; + for (schema_index, schema) in program.buffers.iter().enumerate() { + if active_buffers & (1 << schema_index) == 0 { + continue; + } + let byte_count = usize::try_from(count) + .ok() + .and_then(|value| value.checked_mul(schema.stride())) + .ok_or(OrderedPlanError::ArithmeticOverflow)?; + let payload_start = self.payload.len(); + reserve(&mut self.payload, byte_count)?; + self.payload.resize(payload_start + byte_count, 0); + payload_starts[schema_index] = payload_start; + if !replace && let Some(prior) = prior { + let old_buffer = self + .buffers + .get(prior.buffer_start as usize + schema_index) + .ok_or(OrderedPlanError::InvalidIdentity)?; + let source_start = aligned.start as usize * schema.stride(); + let source_end = source_start + byte_count; + if source_end <= old_buffer.bytes.len() { + self.payload[payload_start..payload_start + byte_count] + .copy_from_slice(&old_buffer.bytes[source_start..source_end]); + } + } + } + + let mut slot = aligned.start; + while slot < aligned.end.min(pending.state.instance_count) { + if instance_unchanged(prior_instances, next_instances, slot, replace) { + slot += 1; + continue; + } + let input_start = next_instances[slot as usize].input_index; + let run_start = slot; + slot += 1; + while slot < aligned.end.min(pending.state.instance_count) + && !instance_unchanged(prior_instances, next_instances, slot, replace) + && next_instances[slot as usize].input_index == input_start + (slot - run_start) + { + slot += 1; + } + execute_run( + policy, + capability_set, + program, + input, + input_start as usize, + slot - run_start, + run_start - aligned.start, + &mut self.payload, + &payload_starts, + count, + active_buffers, + )?; + } + + for (schema_index, schema) in program.buffers.iter().enumerate() { + if active_buffers & (1 << schema_index) == 0 { + continue; + } + let buffer_id = pending.buffer_ids[schema_index]; + let buffer_generation = pending.buffer_generations[schema_index]; + let byte_length = count + .checked_mul(u32::from(schema.stride)) + .ok_or(OrderedPlanError::ArithmeticOverflow)?; + let destination_offset = aligned + .start + .checked_mul(u32::from(schema.stride)) + .ok_or(OrderedPlanError::ArithmeticOverflow)?; + reserve(&mut self.patches, 1)?; + self.patches.push(PatchRecord { + opcode: PATCH_WRITE, + buffer_id, + buffer_generation, + destination_offset, + byte_length, + payload_start: u32::try_from(payload_starts[schema_index]) + .map_err(|_| OrderedPlanError::ArithmeticOverflow)?, + ..PatchRecord::default() + }); + if let Some(allocation) = self.pending_allocations.iter_mut().find(|allocation| { + allocation.state.id == buffer_id + && allocation.state.generation == buffer_generation + }) { + let destination = destination_offset as usize; + let source = payload_starts[schema_index]; + let destination = allocation + .state + .bytes + .get_mut(destination..destination + byte_length as usize) + .ok_or(OrderedPlanError::InvalidIdentity)?; + let source = self + .payload + .get(source..source + byte_length as usize) + .ok_or(OrderedPlanError::InvalidIdentity)?; + destination.copy_from_slice(source); + } + } + } + Ok(()) + } + + fn compile_bindings(&mut self, context: PrepareContext<'_>) -> Result<(), OrderedPlanError> { + for batch_index in 0..self.pending_batches.len() { + let batch = self.pending_batches[batch_index]; + let program = context + .policy + .program( + context.capability_set, + batch.state.key.technique, + batch.state.key.program_variant, + ) + .ok_or(OrderedPlanError::ProgramMissing)?; + if usize::from(batch.state.buffer_count) + > usize::from(context.capability.max_buffers_per_draw) + { + return Err(OrderedPlanError::CapacityExceeded); + } + let buffer_start = self.plan_buffers.len(); + reserve(&mut self.plan_buffers, program.buffers.len())?; + for (schema_index, schema) in program.buffers.iter().copied().enumerate() { + self.plan_buffers.push(BufferRecord { + id: batch.buffer_ids[schema_index], + generation: batch.buffer_generations[schema_index], + program_id: batch.state.key.program_id, + policy_buffer_id: schema.id.0, + scalar_type: schema.scalar as u8, + vector_width: schema.vector_width, + strategy: BUFFER_ORDERED_DIRECT, + flags: schema.usage as u16, + live_records: batch.state.instance_count, + capacity_records: batch.capacity, + byte_length: batch + .capacity + .checked_mul(u32::from(schema.stride)) + .ok_or(OrderedPlanError::ArithmeticOverflow)?, + order_buffer_id: 0, + }); + } + self.pending_batches[batch_index].state.buffer_start = + u32::try_from(buffer_start).map_err(|_| OrderedPlanError::ArithmeticOverflow)?; + } + + for (input_index, glyph) in context.input.glyphs.iter().copied().enumerate() { + if self.input_batches[input_index] == NONE { + continue; + } + if let Some(resource) = self.resources.iter().find(|resource| { + resource.id == glyph.resource_id && resource.generation == glyph.resource_generation + }) { + if resource.technique_id != glyph.technique.0 + || resource.resource_kind != glyph.resource_kind + || resource.reference_id != glyph.resource_reference + { + return Err(OrderedPlanError::InvalidResource); + } + continue; + } + reserve(&mut self.resources, 1)?; + let existing = self.batches.iter().any(|batch| { + batch.key.resource_id == glyph.resource_id + && batch.key.resource_generation == glyph.resource_generation + }); + self.resources.push(ResourceRecord { + id: glyph.resource_id, + generation: glyph.resource_generation, + technique_id: glyph.technique.0, + resource_kind: glyph.resource_kind, + action: if context.checkpoint || !existing { + RESOURCE_ACTION_CREATE + } else { + RESOURCE_ACTION_RETAIN + }, + reference_id: glyph.resource_reference, + ..ResourceRecord::default() + }); + } + + if context.capability.max_resources_per_draw < 1 { + return Err(OrderedPlanError::CapacityExceeded); + } + if context.input.order_independent { + self.compile_independent_draws(context)?; + } else { + self.compile_ordered_draws(context)?; + } + self.publish_bindings = context.checkpoint + || !self.patches.is_empty() + || !self.retirements.is_empty() + || self.primitives != self.live_primitives + || self.draws != self.live_draws; + Ok(()) + } + + fn compile_ordered_draws( + &mut self, + context: PrepareContext<'_>, + ) -> Result<(), OrderedPlanError> { + let mut input_index = 0_usize; + while input_index < context.input.glyphs.len() { + if self.input_batches[input_index] == NONE { + input_index += 1; + continue; + } + let first = context.input.glyphs[input_index]; + let batch_index = self.input_batches[input_index] as usize; + let first_slot = self.input_slots[input_index]; + let program = context + .policy + .program( + context.capability_set, + first.technique, + first.program_variant, + ) + .ok_or(OrderedPlanError::ProgramMissing)?; + let split_material = program.draw_key_mask & BATCH_MATERIAL != 0; + let split_transform = program.draw_key_mask & BATCH_TRANSFORM != 0; + let mut end = input_index + 1; + while end < context.input.glyphs.len() + && end - input_index < usize::from(u16::MAX) + && self.same_draw_span( + context.input.glyphs, + input_index, + end, + batch_index, + first_slot, + split_material, + split_transform, + ) + { + end += 1; + } + let count = u16::try_from(end - input_index) + .map_err(|_| OrderedPlanError::ArithmeticOverflow)?; + let (inline_start, block_start, inline_extent, block_extent) = + span_bounds(&context.input.glyphs[input_index..end])?; + let batch = self.pending_batches[batch_index]; + let resource_start = self + .resources + .iter() + .position(|resource| { + resource.id == first.resource_id + && resource.generation == first.resource_generation + }) + .ok_or(OrderedPlanError::InvalidResource)?; + push_glyph_draw( + &mut self.primitives, + &mut self.draws, + GlyphDraw { + glyph: first, + record_count: count, + buffer_id: batch.buffer_ids[0], + record_index: first_slot, + logical_order: input_index, + semantic_id: if context.input.glyphs[input_index..end] + .iter() + .all(|glyph| glyph.semantic_id == first.semantic_id) + { + first.semantic_id + } else { + 0 + }, + inline_start, + block_start, + inline_extent, + block_extent, + material_id: if split_material { first.material_id } else { 0 }, + transform_id: if split_transform { + first.transform_id + } else { + 0 + }, + buffer_start: batch.state.buffer_start, + buffer_count: u32::from(batch.state.buffer_count), + resource_start, + indirect: false, + program_id: batch.state.key.program_id, + }, + )?; + input_index = end; + } + Ok(()) + } + + fn compile_independent_draws( + &mut self, + context: PrepareContext<'_>, + ) -> Result<(), OrderedPlanError> { + for batch_index in 0..self.pending_batches.len() { + let batch = self.pending_batches[batch_index]; + let program = context + .policy + .program( + context.capability_set, + batch.state.key.technique, + batch.state.key.program_variant, + ) + .ok_or(OrderedPlanError::ProgramMissing)?; + let split_material = program.draw_key_mask & BATCH_MATERIAL != 0; + let split_transform = program.draw_key_mask & BATCH_TRANSFORM != 0; + let instances = &self.pending_instances + [range(batch.state.instance_start, batch.state.instance_count)?]; + let mut start = 0_usize; + while start < instances.len() { + let first_input = instances[start].input_index as usize; + let first = context.input.glyphs[first_input]; + let mut end = start + 1; + while end < instances.len() && end - start < usize::from(u16::MAX) { + let glyph = context.input.glyphs[instances[end].input_index as usize]; + if draw_fields_compatible(first, glyph, split_material, split_transform) { + end += 1; + } else { + break; + } + } + let count = + u16::try_from(end - start).map_err(|_| OrderedPlanError::ArithmeticOverflow)?; + let (inline_start, block_start, inline_extent, block_extent) = indexed_span_bounds( + context.input.glyphs, + instances[start..end] + .iter() + .map(|instance| instance.input_index as usize), + )?; + let resource_start = self + .resources + .iter() + .position(|resource| { + resource.id == first.resource_id + && resource.generation == first.resource_generation + }) + .ok_or(OrderedPlanError::InvalidResource)?; + let semantic_id = if instances[start..end].iter().all(|instance| { + context.input.glyphs[instance.input_index as usize].semantic_id + == first.semantic_id + }) { + first.semantic_id + } else { + 0 + }; + push_glyph_draw( + &mut self.primitives, + &mut self.draws, + GlyphDraw { + glyph: first, + record_count: count, + buffer_id: batch.buffer_ids[0], + record_index: u32::try_from(start) + .map_err(|_| OrderedPlanError::ArithmeticOverflow)?, + logical_order: first_input, + semantic_id, + inline_start, + block_start, + inline_extent, + block_extent, + material_id: if split_material { first.material_id } else { 0 }, + transform_id: if split_transform { + first.transform_id + } else { + 0 + }, + buffer_start: batch.state.buffer_start, + buffer_count: u32::from(batch.state.buffer_count), + resource_start, + indirect: false, + program_id: batch.state.key.program_id, + }, + )?; + start = end; + } + } + self.draws.sort_unstable_by_key(|draw| draw.order_token); + Ok(()) + } + + fn same_draw_span( + &self, + glyphs: &[OrderedGlyph], + start: usize, + next: usize, + batch_index: usize, + first_slot: u32, + split_material: bool, + split_transform: bool, + ) -> bool { + let first = glyphs[start]; + let glyph = glyphs[next]; + draw_span_compatible( + first, + glyph, + self.input_batches[next] as usize == batch_index, + self.input_slots[next] == first_slot + (next - start) as u32, + split_material, + split_transform, + ) + } + + fn prepare_removed_batches( + &mut self, + publication_generation: u32, + ) -> Result<(), OrderedPlanError> { + for batch in &self.batches { + if self + .pending_batches + .iter() + .any(|pending| pending.state.key == batch.key) + { + continue; + } + reserve(&mut self.retirements, usize::from(batch.buffer_count) + 1)?; + let resource_remains = self.pending_batches.iter().any(|pending| { + pending.state.key.resource_id == batch.key.resource_id + && pending.state.key.resource_generation == batch.key.resource_generation + }); + let resource_retired = self.retirements.iter().any(|retirement| { + retirement.kind == RETIRE_RESOURCE + && retirement.id == batch.key.resource_id + && retirement.generation == batch.key.resource_generation + }); + if !resource_remains && !resource_retired { + self.retirements.push(RetirementRecord { + kind: RETIRE_RESOURCE, + id: batch.key.resource_id, + generation: batch.key.resource_generation, + after_publication_generation: publication_generation, + ..RetirementRecord::default() + }); + } + for buffer in &self.buffers[range(batch.buffer_start, u32::from(batch.buffer_count))?] { + self.retirements.push(RetirementRecord { + kind: RETIRE_BUFFER, + id: buffer.id, + generation: buffer.generation, + after_publication_generation: publication_generation, + byte_length: buffer.bytes.len() as u32, + ..RetirementRecord::default() + }); + } + } + Ok(()) + } + + fn batches_buffer_start(&self, batch_count: usize) -> usize { + self.pending_batches[..batch_count] + .iter() + .map(|batch| usize::from(batch.state.buffer_count)) + .sum() + } +} + +fn active_buffers_for_range( + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + program: &super::policy::ProgramDescriptor, + previous: &[InstanceState], + next: &[InstanceState], + changed: RecordRange, + replace: bool, +) -> Result { + let all = (1_u32 << program.buffers.len()) - 1; + if replace { + return Ok(all); + } + let dependencies = policy + .buffer_dependency_masks(capability_set, program.technique, program.variant) + .ok_or(OrderedPlanError::ProgramMissing)?; + let mut active = 0_u32; + for slot in changed.start..changed.end { + let next = next[slot as usize]; + let Some(previous) = previous.get(slot as usize) else { + return Ok(all); + }; + if previous.stable_id != next.stable_id + || next.semantic_change_mask == super::positioning::ALL_SEMANTIC_CHANGES + { + return Ok(all); + } + for (index, &dependency) in dependencies.iter().enumerate() { + if dependency & next.semantic_change_mask != 0 { + active |= 1 << index; + } + } + } + Ok(active) +} + +fn collect_changed_ranges( + ranges: &mut Vec, + previous: &[InstanceState], + next: &[InstanceState], + replace: bool, +) -> Result<(), OrderedPlanError> { + ranges.clear(); + if next.is_empty() { + return Ok(()); + } + if replace { + ranges.push(RecordRange { + start: 0, + end: next.len() as u32, + }); + return Ok(()); + } + let mut start = None; + for (slot, next) in next.iter().enumerate() { + let changed = previous.get(slot).is_none_or(|previous| { + previous.stable_id != next.stable_id + || previous.content_revision != next.content_revision + }); + match (start, changed) { + (None, true) => start = Some(slot as u32), + (Some(first), false) => { + reserve(ranges, 1)?; + ranges.push(RecordRange { + start: first, + end: slot as u32, + }); + start = None; + } + _ => {} + } + } + if let Some(start) = start { + reserve(ranges, 1)?; + ranges.push(RecordRange { + start, + end: next.len() as u32, + }); + } + Ok(()) +} + +fn instance_unchanged( + previous: &[InstanceState], + next: &[InstanceState], + slot: u32, + replace: bool, +) -> bool { + !replace + && previous.get(slot as usize).is_some_and(|previous| { + let next = next[slot as usize]; + previous.stable_id == next.stable_id + && previous.content_revision == next.content_revision + }) +} + +fn range(start: u32, count: u32) -> Result, OrderedPlanError> { + let end = start + .checked_add(count) + .ok_or(OrderedPlanError::ArithmeticOverflow)?; + Ok(start as usize..end as usize) +} + +fn batch_key(program: &super::policy::ProgramDescriptor, glyph: OrderedGlyph) -> BatchKey { + BatchKey { + technique: glyph.technique, + program_variant: glyph.program_variant, + program_id: program.id.0, + resource_id: glyph.resource_id, + resource_generation: glyph.resource_generation, + resource_kind: glyph.resource_kind, + storage_key_mask: program.storage_key_mask, + resource_reference: glyph.resource_reference, + material_id: if program.storage_key_mask & BATCH_MATERIAL != 0 { + glyph.material_id + } else { + 0 + }, + clip_id: if program.storage_key_mask & super::policy::BATCH_CLIP != 0 { + glyph.clip_id + } else { + 0 + }, + depth_key: if program.storage_key_mask & super::policy::BATCH_DEPTH != 0 { + glyph.depth_key + } else { + 0 + }, + } +} + +fn batch_key_matches(key: BatchKey, glyph: OrderedGlyph) -> bool { + key.technique == glyph.technique + && key.program_variant == glyph.program_variant + && key.resource_id == glyph.resource_id + && key.resource_generation == glyph.resource_generation + && key.resource_kind == glyph.resource_kind + && key.resource_reference == glyph.resource_reference + && (key.storage_key_mask & BATCH_MATERIAL == 0 || key.material_id == glyph.material_id) + && (key.storage_key_mask & super::policy::BATCH_CLIP == 0 || key.clip_id == glyph.clip_id) + && (key.storage_key_mask & super::policy::BATCH_DEPTH == 0 + || key.depth_key == glyph.depth_key) +} + +fn reserve(values: &mut Vec, additional: usize) -> Result<(), OrderedPlanError> { + values + .try_reserve(additional) + .map_err(|_| OrderedPlanError::AllocationFailed) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::policy::{ + BATCH_MATERIAL, BATCH_ORDER, BATCH_PROGRAM, BATCH_RESOURCE, BATCH_TECHNIQUE, + BUFFER_USAGE_COPY_DST, BUFFER_USAGE_STORAGE, BufferId, CAP_ORDERED_DIRECT, CapabilitySet, + Operation, PolicyDescriptor, ProgramCapabilities, ProgramDescriptor, ProgramId, ScalarType, + }; + use crate::engine::render_plan_wire::plan_layout; + use alloc::vec; + + const CAPABILITY: CapabilitySetId = CapabilitySetId(1); + const TECHNIQUE: TechniqueId = TechniqueId(1); + + #[test] + fn ordered_direct_uses_identity_revisions_instead_of_scanning_physical_bytes() { + let policy = policy(); + let mut compiler = OrderedPlanCompiler::default(); + let x = [1.0, 2.0, 3.0]; + let glyphs = [glyph(1, 1), glyph(2, 1), glyph(3, 1)]; + prepare(&mut compiler, &policy, &glyphs, &x, true); + let first = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!(first.buffers.len(), 1); + assert_eq!(first.patches.len(), 2); + assert_eq!(first.primitives.len(), 1); + assert_eq!(first.primitives[0].record_count, 3); + assert_eq!(first.draws.len(), 1); + assert_eq!(first.draws[0].buffer_count, 1); + assert_eq!(first.draws[0].resource_count, 1); + assert!(plan_layout(first).unwrap().byte_length > 144); + compiler.commit().unwrap(); + + let changed_x = [1.0, 20.0, 3.0]; + let changed = [glyph(1, 1), glyph(2, 2), glyph(3, 1)]; + prepare(&mut compiler, &policy, &changed, &changed_x, false); + let delta = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!(delta.patches.len(), 1); + assert_eq!(delta.patches[0].destination_offset, 4); + assert_eq!(delta.patches[0].byte_length, 4); + assert_eq!(delta.payload.len(), 4); + assert_eq!(delta.primitives, first_span(3).as_slice()); + assert_eq!(delta.draws.len(), 1); + compiler.commit().unwrap(); + assert_eq!(read_f32(compiler.buffer_bytes(1).unwrap(), 4), 20.0); + } + + #[test] + fn semantic_dependencies_suppress_unrelated_physical_buffer_writes() { + let policy = policy(); + let mut compiler = OrderedPlanCompiler::default(); + let initial = [glyph(1, 1)]; + prepare(&mut compiler, &policy, &initial, &[1.0], true); + compiler.commit().unwrap(); + + let mut block_changed = glyph(1, 2); + block_changed.block_start = 4.0; + compiler + .prepare( + &policy, + CAPABILITY, + OrderedPlanInput { + glyphs: &[block_changed], + semantic_change_masks: &[1 << 1], + f32_fields: &[&[1.0]], + u32_fields: &[], + order_independent: false, + }, + false, + 1, + ) + .unwrap(); + assert!( + compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap() + .patches + .is_empty() + ); + compiler.commit().unwrap(); + + compiler + .prepare( + &policy, + CAPABILITY, + OrderedPlanInput { + glyphs: &[glyph(1, 3)], + semantic_change_masks: &[1], + f32_fields: &[&[2.0]], + u32_fields: &[], + order_independent: false, + }, + false, + 1, + ) + .unwrap(); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!(plan.patches.len(), 1); + assert_eq!(plan.payload, 2.0_f32.to_le_bytes()); + } + + #[test] + fn insertion_rewrites_only_the_ordered_batch_suffix_and_abort_preserves_state() { + let policy = policy(); + let mut compiler = OrderedPlanCompiler::default(); + let initial = [glyph(1, 1), glyph(2, 1), glyph(3, 1)]; + prepare(&mut compiler, &policy, &initial, &[1.0, 2.0, 3.0], true); + compiler.commit().unwrap(); + + let inserted = [glyph(1, 1), glyph(4, 1), glyph(2, 1), glyph(3, 1)]; + prepare( + &mut compiler, + &policy, + &inserted, + &[1.0, 4.0, 2.0, 3.0], + false, + ); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!(plan.patches[0].opcode, PATCH_WRITE); + assert_eq!(plan.patches[0].destination_offset, 4); + compiler.abort(); + assert_eq!(read_f32(compiler.buffer_bytes(1).unwrap(), 4), 2.0); + } + + #[test] + fn no_op_emits_nothing_and_tail_deletion_updates_only_live_metadata() { + let policy = policy(); + let mut compiler = OrderedPlanCompiler::default(); + let initial = [glyph(1, 1), glyph(2, 1), glyph(3, 1)]; + prepare(&mut compiler, &policy, &initial, &[1.0, 2.0, 3.0], true); + compiler.commit().unwrap(); + + prepare( + &mut compiler, + &policy, + &initial, + &[999.0, 999.0, 999.0], + false, + ); + let no_op = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert!(no_op.resources.is_empty()); + assert!(no_op.buffers.is_empty()); + assert!(no_op.patches.is_empty()); + assert!(no_op.primitives.is_empty()); + assert!(no_op.draws.is_empty()); + assert!(no_op.payload.is_empty()); + compiler.commit().unwrap(); + assert_eq!(read_f32(compiler.buffer_bytes(1).unwrap(), 0), 1.0); + + prepare(&mut compiler, &policy, &initial[..2], &[1.0, 2.0], false); + let shrink = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!(shrink.buffers[0].live_records, 2); + assert!(shrink.patches.is_empty()); + assert_eq!(shrink.retirements.len(), 1); + assert_eq!(shrink.retirements[0].byte_offset, 8); + assert_eq!(shrink.retirements[0].byte_length, 4); + } + + #[test] + fn interleaved_resources_compile_to_ordered_spans_with_shared_bindings() { + let policy = policy(); + let mut compiler = OrderedPlanCompiler::default(); + let a1 = glyph(1, 1); + let a2 = glyph(2, 1); + let mut b = glyph(3, 1); + b.resource_id = 12; + b.resource_reference = 100; + let a3 = glyph(4, 1); + let glyphs = [a1, a2, b, a3]; + prepare(&mut compiler, &policy, &glyphs, &[1.0, 2.0, 3.0, 4.0], true); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + + assert_eq!(plan.resources.len(), 2); + assert_eq!(plan.buffers.len(), 2); + assert_eq!(plan.primitives.len(), 3); + assert_eq!(plan.draws.len(), 3); + assert_eq!(plan.primitives[0].record_count, 2); + assert_eq!(plan.primitives[0].record_index, 0); + assert_eq!(plan.primitives[1].resource_id, 12); + assert_eq!(plan.primitives[2].resource_id, 11); + assert_eq!(plan.primitives[2].record_index, 2); + assert_eq!(plan.draws[0].order_token, 0); + assert_eq!(plan.draws[1].order_token, 2); + assert_eq!(plan.draws[2].order_token, 3); + assert!(plan_layout(plan).is_ok()); + } + + #[test] + fn independent_compositing_batches_interleaved_resources() { + let policy = policy(); + let mut compiler = OrderedPlanCompiler::default(); + let a1 = glyph(1, 1); + let a2 = glyph(2, 1); + let mut b = glyph(3, 1); + b.resource_id = 12; + b.resource_reference = 100; + let a3 = glyph(4, 1); + let glyphs = [a1, a2, b, a3]; + compiler + .prepare( + &policy, + CAPABILITY, + OrderedPlanInput { + glyphs: &glyphs, + semantic_change_masks: &[], + f32_fields: &[&[1.0, 2.0, 3.0, 4.0]], + u32_fields: &[], + order_independent: true, + }, + true, + 1, + ) + .unwrap(); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + + assert_eq!(plan.draws.len(), 2); + assert_eq!(plan.primitives.len(), 2); + assert_eq!(plan.primitives[0].resource_id, 11); + assert_eq!(plan.primitives[0].record_count, 3); + assert_eq!(plan.primitives[1].resource_id, 12); + assert_eq!(plan.primitives[1].record_count, 1); + assert_eq!(plan.draws[0].order_token, 0); + assert_eq!(plan.draws[1].order_token, 2); + assert!(plan_layout(plan).is_ok()); + } + + #[test] + fn material_identity_splits_draws_without_splitting_physical_storage() { + let policy = policy(); + let mut compiler = OrderedPlanCompiler::default(); + let first = glyph(1, 1); + let mut second = glyph(2, 1); + second.material_id = 2; + let glyphs = [first, second]; + prepare(&mut compiler, &policy, &glyphs, &[1.0, 2.0], true); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + + assert_eq!(plan.buffers.len(), 1); + assert_eq!(plan.primitives.len(), 2); + assert_eq!(plan.draws.len(), 2); + assert_eq!(plan.draws[0].material_id, 1); + assert_eq!(plan.draws[1].material_id, 2); + assert_eq!(plan.draws[0].buffer_start, plan.draws[1].buffer_start); + assert!(plan_layout(plan).is_ok()); + } + + #[test] + fn policy_selects_draw_split_or_instance_transform_indirection() { + let mut glyphs = [glyph(1, 1), glyph(2, 1)]; + glyphs[1].transform_id = 2; + for (split_transform, expected) in [(true, &[1, 2][..]), (false, &[0][..])] { + let policy = policy_with_options(false, 1024, split_transform); + let mut compiler = OrderedPlanCompiler::default(); + prepare(&mut compiler, &policy, &glyphs, &[1.0, 2.0], true); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!( + plan.draws + .iter() + .map(|draw| draw.transform_id) + .collect::>(), + expected + ); + assert_eq!( + plan.primitives + .iter() + .map(|item| item.record_count) + .sum::(), + 2 + ); + } + } + + #[test] + fn policy_can_partition_physical_storage_by_material() { + let policy = policy_with_material_storage(true); + let mut compiler = OrderedPlanCompiler::default(); + let first = glyph(1, 1); + let mut second = glyph(2, 1); + second.material_id = 2; + let glyphs = [first, second]; + prepare(&mut compiler, &policy, &glyphs, &[1.0, 2.0], true); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + + assert_eq!(plan.buffers.len(), 2); + assert_eq!(plan.draws.len(), 2); + assert_ne!(plan.draws[0].buffer_start, plan.draws[1].buffer_start); + assert!(plan_layout(plan).is_ok()); + } + + #[test] + fn retained_topology_requires_unchanged_physical_storage_membership() { + let policy = policy_with_material_storage(true); + let mut compiler = OrderedPlanCompiler::default(); + let initial = [glyph(1, 1), glyph(2, 1)]; + prepare(&mut compiler, &policy, &initial, &[1.0, 2.0], true); + compiler.commit().unwrap(); + + let content_changed = [glyph(1, 2), glyph(2, 1)]; + prepare( + &mut compiler, + &policy, + &content_changed, + &[10.0, 2.0], + false, + ); + assert_eq!(compiler.retained_topology_preparations, 1); + compiler.commit().unwrap(); + + let mut moved_material = glyph(2, 2); + moved_material.material_id = 2; + let storage_changed = [glyph(1, 2), moved_material]; + prepare( + &mut compiler, + &policy, + &storage_changed, + &[10.0, 20.0], + false, + ); + assert_eq!(compiler.retained_topology_preparations, 1); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!(plan.buffers.len(), 2); + assert_eq!(plan.draws.len(), 2); + } + + #[test] + fn glyph_spans_split_at_the_wire_record_limit() { + let policy = policy_with_limits(false, 512 * 1024); + let mut compiler = OrderedPlanCompiler::default(); + let glyphs: Vec<_> = (1..=u32::from(u16::MAX) + 1) + .map(|stable_id| glyph(stable_id, 1)) + .collect(); + let x = vec![0.0; glyphs.len()]; + prepare(&mut compiler, &policy, &glyphs, &x, true); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + + assert_eq!(plan.primitives.len(), 2); + assert_eq!(plan.primitives[0].record_count, u16::MAX); + assert_eq!(plan.primitives[1].record_count, 1); + assert_eq!(plan.primitives[1].record_index, u32::from(u16::MAX)); + assert_eq!(plan.draws.len(), 2); + assert!(plan_layout(plan).is_ok()); + } + + #[test] + fn repeated_warm_updates_keep_every_glyph_scaled_scratch_capacity() { + let policy = policy(); + let mut compiler = OrderedPlanCompiler::default(); + let initial = [glyph(1, 1), glyph(2, 1), glyph(3, 1), glyph(4, 1)]; + prepare( + &mut compiler, + &policy, + &initial, + &[1.0, 2.0, 3.0, 4.0], + true, + ); + compiler.commit().unwrap(); + let changed = [glyph(1, 1), glyph(2, 2), glyph(3, 1), glyph(4, 1)]; + prepare( + &mut compiler, + &policy, + &changed, + &[1.0, 20.0, 3.0, 4.0], + false, + ); + compiler.commit().unwrap(); + let settled = capacities(&compiler); + let changed_again = [glyph(1, 1), glyph(2, 3), glyph(3, 1), glyph(4, 1)]; + prepare( + &mut compiler, + &policy, + &changed_again, + &[1.0, 21.0, 3.0, 4.0], + false, + ); + compiler.commit().unwrap(); + assert_eq!(capacities(&compiler), settled); + } + + fn prepare( + compiler: &mut OrderedPlanCompiler, + policy: &ValidatedPolicy, + glyphs: &[OrderedGlyph], + x: &[f32], + checkpoint: bool, + ) { + compiler + .prepare( + policy, + CAPABILITY, + OrderedPlanInput { + glyphs, + semantic_change_masks: &[], + f32_fields: &[x], + u32_fields: &[], + order_independent: false, + }, + checkpoint, + 1, + ) + .unwrap(); + } + + fn glyph(stable_id: u32, content_revision: u32) -> OrderedGlyph { + OrderedGlyph { + stable_id, + content_revision, + technique: TECHNIQUE, + program_variant: 0, + resource_id: 11, + resource_generation: 1, + resource_kind: 1, + resource_reference: 99, + semantic_id: 1, + transform_id: 1, + material_id: 1, + clip_id: 0, + depth_key: 0, + inline_start: stable_id as f32, + block_start: 0.0, + inline_extent: 1.0, + block_extent: 1.0, + } + } + + fn policy() -> ValidatedPolicy { + policy_with_material_storage(false) + } + + fn policy_with_material_storage(partition_materials: bool) -> ValidatedPolicy { + policy_with_limits(partition_materials, 1024) + } + + fn policy_with_limits(partition_materials: bool, max_buffer_bytes: u32) -> ValidatedPolicy { + policy_with_options(partition_materials, max_buffer_bytes, true) + } + + fn policy_with_options( + partition_materials: bool, + max_buffer_bytes: u32, + split_transform: bool, + ) -> ValidatedPolicy { + ValidatedPolicy::new(PolicyDescriptor { + capability_sets: vec![CapabilitySet { + id: CAPABILITY, + flags: CAP_ORDERED_DIRECT, + max_buffer_bytes, + update_alignment: 4, + coalesce_gap_bytes: 0, + range_call_penalty_bytes: 0, + max_buffers_per_draw: 1, + max_resources_per_draw: 1, + max_indirect_draws: 0, + fragmentation_budget: 8, + whole_buffer_threshold_basis_points: 10_000, + }], + programs: vec![ProgramDescriptor { + technique: TECHNIQUE, + variant: 0, + id: ProgramId(5), + capability_set: CapabilitySetId(0), + resource_kind_mask: 1, + semantic_view_mask: 0, + storage_key_mask: BATCH_TECHNIQUE + | BATCH_PROGRAM + | BATCH_RESOURCE + | if partition_materials { + BATCH_MATERIAL + } else { + 0 + }, + draw_key_mask: BATCH_TECHNIQUE + | BATCH_PROGRAM + | BATCH_RESOURCE + | BATCH_MATERIAL + | BATCH_ORDER + | if split_transform { BATCH_TRANSFORM } else { 0 }, + allocation_strategy: ALLOCATION_ORDERED_DIRECT, + f32_input_count: 1, + u32_input_count: 0, + inputs: vec![crate::engine::policy::InputSource::semantic(0)], + capabilities: ProgramCapabilities::default(), + buffers: vec![BufferSchema::packed( + BufferId(1), + ScalarType::F32, + 1, + BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST, + 1, + )], + operations: vec![ + Operation::LoadF32 { + target: 0, + field: 0, + }, + Operation::StoreF32 { + source: 0, + buffer: BufferId(1), + lane: 0, + }, + ], + }], + }) + .unwrap() + } + + fn read_f32(bytes: &[u8], offset: usize) -> f32 { + f32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()) + } + + fn first_span(record_count: u16) -> [PrimitiveRecord; 1] { + [PrimitiveRecord { + id: 1, + kind: PRIMITIVE_GLYPH, + technique_id: TECHNIQUE.0, + resource_id: 11, + resource_generation: 1, + program_id: 5, + program_variant: 0, + record_count, + buffer_id: 1, + record_index: 0, + logical_order: 0, + clip_id: 0, + semantic_id: 1, + inline_start: 1.0, + block_start: 0.0, + inline_extent: record_count as f32, + block_extent: 1.0, + ..PrimitiveRecord::default() + }] + } + + fn capacities(compiler: &OrderedPlanCompiler) -> [usize; 18] { + [ + compiler.pending_batches.capacity(), + compiler.pending_instances.capacity(), + compiler.pending_allocations.capacity(), + compiler.input_batches.capacity(), + compiler.input_slots.capacity(), + compiler.identity_set.capacities()[0], + compiler.identity_set.capacities()[1], + compiler.batch_cursors.capacity(), + compiler.changed_ranges.capacity(), + compiler.resources.capacity(), + compiler.plan_buffers.capacity(), + compiler.primitives.capacity(), + compiler.draws.capacity(), + compiler.live_primitives.capacity(), + compiler.live_draws.capacity(), + compiler.patches.capacity(), + compiler.retirements.capacity(), + compiler.payload.capacity(), + ] + } +} diff --git a/packages/text/rust/shaper/src/engine/plan_draw.rs b/packages/text/rust/shaper/src/engine/plan_draw.rs new file mode 100644 index 00000000..78cbcc32 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/plan_draw.rs @@ -0,0 +1,111 @@ +//! Shared glyph primitive and draw-record emission. + +use alloc::vec::Vec; + +use super::{ + plan_input::PlanGlyph, + render_plan::{DrawRecord, PRIMITIVE_GLYPH, PrimitiveRecord}, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PlanDrawError { + AllocationFailed, + ArithmeticOverflow, +} + +#[derive(Clone, Copy)] +pub struct GlyphDraw { + pub glyph: PlanGlyph, + pub program_id: u32, + pub record_count: u16, + pub buffer_id: u32, + pub record_index: u32, + pub logical_order: usize, + pub semantic_id: u32, + pub inline_start: f32, + pub block_start: f32, + pub inline_extent: f32, + pub block_extent: f32, + pub material_id: u32, + pub transform_id: u32, + pub buffer_start: u32, + pub buffer_count: u32, + pub resource_start: usize, + pub indirect: bool, +} + +/// This deliberately remains one out-of-line implementation. Both storage planners emit the +/// same command records after resolving their different physical address spaces. +#[inline(never)] +pub fn push_glyph_draw( + primitives: &mut Vec, + draws: &mut Vec, + emission: GlyphDraw, +) -> Result<(), PlanDrawError> { + let primitive_start = + u32::try_from(primitives.len()).map_err(|_| PlanDrawError::ArithmeticOverflow)?; + let logical_order = + u32::try_from(emission.logical_order).map_err(|_| PlanDrawError::ArithmeticOverflow)?; + let resource_start = + u32::try_from(emission.resource_start).map_err(|_| PlanDrawError::ArithmeticOverflow)?; + let indirect_offset = if emission.indirect { + emission + .record_index + .checked_mul(4) + .ok_or(PlanDrawError::ArithmeticOverflow)? + } else { + 0 + }; + + primitives + .try_reserve(1) + .map_err(|_| PlanDrawError::AllocationFailed)?; + primitives.push(PrimitiveRecord { + id: emission.glyph.stable_id, + kind: PRIMITIVE_GLYPH, + technique_id: emission.glyph.technique.0, + resource_id: emission.glyph.resource_id, + resource_generation: emission.glyph.resource_generation, + program_id: emission.program_id, + program_variant: emission.glyph.program_variant, + record_count: emission.record_count, + buffer_id: emission.buffer_id, + record_index: emission.record_index, + logical_order, + clip_id: emission.glyph.clip_id, + semantic_id: emission.semantic_id, + inline_start: emission.inline_start, + block_start: emission.block_start, + inline_extent: emission.inline_extent, + block_extent: emission.block_extent, + ..PrimitiveRecord::default() + }); + + draws + .try_reserve(1) + .map_err(|_| PlanDrawError::AllocationFailed)?; + draws.push(DrawRecord { + id: emission.glyph.stable_id, + program_id: emission.program_id, + program_variant: emission.glyph.program_variant, + material_id: emission.material_id, + clip_id: emission.glyph.clip_id, + depth_key: emission.glyph.depth_key, + transform_id: emission.transform_id, + primitive_start, + primitive_count: 1, + buffer_start: emission.buffer_start, + buffer_count: emission.buffer_count, + resource_start, + resource_count: 1, + order_token: logical_order, + indirect_buffer_id: if emission.indirect { + emission.buffer_id + } else { + 0 + }, + indirect_offset, + ..DrawRecord::default() + }); + Ok(()) +} diff --git a/packages/text/rust/shaper/src/engine/plan_error.rs b/packages/text/rust/shaper/src/engine/plan_error.rs new file mode 100644 index 00000000..76aecbde --- /dev/null +++ b/packages/text/rust/shaper/src/engine/plan_error.rs @@ -0,0 +1,64 @@ +//! Error contract shared by render-plan storage strategies. + +use super::{ + identity_index::IdentitySetError, plan_draw::PlanDrawError, plan_input::PlanInputError, + plan_packing::PackingError, policy::PolicyExecutionError, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PlanError { + AllocationFailed, + AlreadyPrepared, + NotPrepared, + CapabilitySetMissing, + ProgramMissing, + UnsupportedStrategy, + InvalidInputShape, + InvalidIdentity, + DuplicateIdentity, + InvalidResource, + CapacityExceeded, + IdentifierExhausted, + ArithmeticOverflow, + PolicyExecution(PolicyExecutionError), +} + +impl From for PlanError { + fn from(error: PlanInputError) -> Self { + match error { + PlanInputError::InvalidShape => Self::InvalidInputShape, + PlanInputError::InvalidIdentity => Self::InvalidIdentity, + PlanInputError::InvalidResource => Self::InvalidResource, + } + } +} + +impl From for PlanError { + fn from(error: PackingError) -> Self { + match error { + PackingError::AllocationFailed => Self::AllocationFailed, + PackingError::ArithmeticOverflow => Self::ArithmeticOverflow, + PackingError::CapacityExceeded => Self::CapacityExceeded, + PackingError::InvalidIdentity => Self::InvalidIdentity, + PackingError::Policy(error) => Self::PolicyExecution(error), + } + } +} + +impl From for PlanError { + fn from(error: PlanDrawError) -> Self { + match error { + PlanDrawError::AllocationFailed => Self::AllocationFailed, + PlanDrawError::ArithmeticOverflow => Self::ArithmeticOverflow, + } + } +} + +impl From for PlanError { + fn from(error: IdentitySetError) -> Self { + match error { + IdentitySetError::AllocationFailed => Self::AllocationFailed, + IdentitySetError::ArithmeticOverflow => Self::ArithmeticOverflow, + } + } +} diff --git a/packages/text/rust/shaper/src/engine/plan_input.rs b/packages/text/rust/shaper/src/engine/plan_input.rs new file mode 100644 index 00000000..a7e07ad8 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/plan_input.rs @@ -0,0 +1,163 @@ +//! Allocation-strategy-neutral glyph input for render-plan compilation. + +use super::policy::TechniqueId; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PlanGlyph { + pub stable_id: u32, + pub content_revision: u32, + pub technique: TechniqueId, + pub program_variant: u16, + pub resource_id: u32, + pub resource_generation: u32, + pub resource_kind: u16, + pub resource_reference: u32, + pub semantic_id: u32, + pub transform_id: u32, + pub material_id: u32, + pub clip_id: u32, + pub depth_key: u32, + pub inline_start: f32, + pub block_start: f32, + pub inline_extent: f32, + pub block_extent: f32, +} + +#[derive(Clone, Copy)] +pub struct PlanInput<'a> { + pub glyphs: &'a [PlanGlyph], + pub semantic_change_masks: &'a [u16], + pub f32_fields: &'a [&'a [f32]], + pub u32_fields: &'a [&'a [u32]], + /// The caller guarantees that reordering compatible draws cannot change compositing. + pub order_independent: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PlanInputError { + InvalidShape, + InvalidIdentity, + InvalidResource, +} + +pub fn validate_input(input: PlanInput<'_>) -> Result<(), PlanInputError> { + if u32::try_from(input.glyphs.len()).is_err() + || (!input.semantic_change_masks.is_empty() + && input.semantic_change_masks.len() != input.glyphs.len()) + || input + .f32_fields + .iter() + .any(|field| field.len() != input.glyphs.len()) + || input + .u32_fields + .iter() + .any(|field| field.len() != input.glyphs.len()) + { + return Err(PlanInputError::InvalidShape); + } + Ok(()) +} + +pub fn validate_glyph(glyph: PlanGlyph) -> Result<(), PlanInputError> { + if glyph.stable_id == 0 || glyph.content_revision == 0 || glyph.transform_id == 0 { + return Err(PlanInputError::InvalidIdentity); + } + if glyph.resource_id == 0 + || glyph.resource_generation == 0 + || !(1..=32).contains(&glyph.resource_kind) + { + return Err(PlanInputError::InvalidResource); + } + if !glyph.inline_start.is_finite() + || !glyph.block_start.is_finite() + || !glyph.inline_extent.is_finite() + || !glyph.block_extent.is_finite() + || glyph.inline_extent < 0.0 + || glyph.block_extent < 0.0 + || !(glyph.inline_start + glyph.inline_extent).is_finite() + || !(glyph.block_start + glyph.block_extent).is_finite() + { + return Err(PlanInputError::InvalidShape); + } + Ok(()) +} + +pub fn span_bounds(glyphs: &[PlanGlyph]) -> Result<(f32, f32, f32, f32), PlanInputError> { + let first = glyphs.first().ok_or(PlanInputError::InvalidShape)?; + let mut inline_start = first.inline_start; + let mut block_start = first.block_start; + let mut inline_end = first.inline_start + first.inline_extent; + let mut block_end = first.block_start + first.block_extent; + for glyph in &glyphs[1..] { + inline_start = inline_start.min(glyph.inline_start); + block_start = block_start.min(glyph.block_start); + inline_end = inline_end.max(glyph.inline_start + glyph.inline_extent); + block_end = block_end.max(glyph.block_start + glyph.block_extent); + } + let inline_extent = inline_end - inline_start; + let block_extent = block_end - block_start; + if !inline_extent.is_finite() || !block_extent.is_finite() { + return Err(PlanInputError::InvalidShape); + } + Ok((inline_start, block_start, inline_extent, block_extent)) +} + +pub fn indexed_span_bounds( + glyphs: &[PlanGlyph], + mut indices: impl Iterator, +) -> Result<(f32, f32, f32, f32), PlanInputError> { + let first = glyphs + .get(indices.next().ok_or(PlanInputError::InvalidShape)?) + .ok_or(PlanInputError::InvalidShape)?; + let mut inline_start = first.inline_start; + let mut block_start = first.block_start; + let mut inline_end = first.inline_start + first.inline_extent; + let mut block_end = first.block_start + first.block_extent; + for index in indices { + let glyph = glyphs.get(index).ok_or(PlanInputError::InvalidShape)?; + inline_start = inline_start.min(glyph.inline_start); + block_start = block_start.min(glyph.block_start); + inline_end = inline_end.max(glyph.inline_start + glyph.inline_extent); + block_end = block_end.max(glyph.block_start + glyph.block_extent); + } + let inline_extent = inline_end - inline_start; + let block_extent = block_end - block_start; + if !inline_extent.is_finite() || !block_extent.is_finite() { + return Err(PlanInputError::InvalidShape); + } + Ok((inline_start, block_start, inline_extent, block_extent)) +} + +pub fn draw_fields_compatible( + first: PlanGlyph, + next: PlanGlyph, + split_material: bool, + split_transform: bool, +) -> bool { + (!split_material || next.material_id == first.material_id) + && next.clip_id == first.clip_id + && next.depth_key == first.depth_key + && (!split_transform || next.transform_id == first.transform_id) +} + +/// Tests the shared logical and renderer-key invariants for extending a physical draw span. +/// +/// Storage planners resolve batch membership and physical contiguity differently, so those two +/// facts remain caller-owned. This stays inline because it runs once per candidate glyph. +#[inline(always)] +pub fn draw_span_compatible( + first: PlanGlyph, + next: PlanGlyph, + same_batch: bool, + contiguous: bool, + split_material: bool, + split_transform: bool, +) -> bool { + same_batch + && contiguous + && next.technique == first.technique + && next.program_variant == first.program_variant + && next.resource_id == first.resource_id + && next.resource_generation == first.resource_generation + && draw_fields_compatible(first, next, split_material, split_transform) +} diff --git a/packages/text/rust/shaper/src/engine/plan_packing.rs b/packages/text/rust/shaper/src/engine/plan_packing.rs new file mode 100644 index 00000000..4ec80b79 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/plan_packing.rs @@ -0,0 +1,484 @@ +//! Shared policy execution and capacity arithmetic for retained plan compilers. + +use core::{mem, slice}; + +use super::{ + plan_input::PlanInput, + policy::{ + CapabilitySetId, PhysicalBufferMut, PolicyExecutionError, SemanticInputBatch, + ValidatedPolicy, + }, + render_plan::{PATCH_WRITE, PatchRecord}, +}; + +pub const MAX_PHYSICAL_BUFFERS: usize = 16; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PackingError { + AllocationFailed, + ArithmeticOverflow, + CapacityExceeded, + InvalidIdentity, + Policy(PolicyExecutionError), +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct RecordRange { + pub start: u32, + pub end: u32, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct RangeJob { + pub range: RecordRange, + pub active_buffers: u32, +} + +pub struct PhysicalBufferState { + pub id: u32, + pub generation: u32, + pub program_id: u32, + pub schema: super::policy::BufferSchema, + pub capacity: u32, + pub bytes: alloc::vec::Vec, +} + +pub struct PendingAllocation { + pub state: PhysicalBufferState, +} + +/// Allocates one retained physical buffer through the shared cold growth path. +#[inline(never)] +pub fn push_pending_allocation( + allocations: &mut alloc::vec::Vec, + id: u32, + generation: u32, + program_id: u32, + schema: super::policy::BufferSchema, + capacity: u32, +) -> Result<(), PackingError> { + allocations + .try_reserve(1) + .map_err(|_| PackingError::AllocationFailed)?; + allocations.push(PendingAllocation { + state: PhysicalBufferState::new(id, generation, program_id, schema, capacity)?, + }); + Ok(()) +} + +impl PhysicalBufferState { + pub fn new( + id: u32, + generation: u32, + program_id: u32, + schema: super::policy::BufferSchema, + capacity: u32, + ) -> Result { + let length = usize::try_from(capacity) + .ok() + .and_then(|value| value.checked_mul(schema.stride())) + .ok_or(PackingError::ArithmeticOverflow)?; + let mut bytes = alloc::vec::Vec::new(); + bytes + .try_reserve_exact(length) + .map_err(|_| PackingError::AllocationFailed)?; + bytes.resize(length, 0); + Ok(Self { + id, + generation, + program_id, + schema, + capacity, + bytes, + }) + } +} + +pub fn apply_writes( + buffer: &mut PhysicalBufferState, + patches: &[PatchRecord], + payload: &[u8], +) -> Result<(), PackingError> { + for patch in patches.iter().filter(|patch| { + patch.opcode == PATCH_WRITE + && patch.buffer_id == buffer.id + && patch.buffer_generation == buffer.generation + }) { + let destination = patch.destination_offset as usize; + let source = patch.payload_start as usize; + let length = patch.byte_length as usize; + let destination = buffer + .bytes + .get_mut(destination..destination + length) + .ok_or(PackingError::InvalidIdentity)?; + let source = payload + .get(source..source + length) + .ok_or(PackingError::InvalidIdentity)?; + destination.copy_from_slice(source); + } + Ok(()) +} + +pub fn take_allocation( + allocations: &mut alloc::vec::Vec, + id: u32, + generation: u32, +) -> Option { + allocations + .iter() + .position(|allocation| { + allocation.state.id == id && allocation.state.generation == generation + }) + .map(|index| allocations.swap_remove(index)) +} + +#[allow(clippy::too_many_arguments)] +pub fn execute_run( + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + program: &super::policy::ProgramDescriptor, + input: PlanInput<'_>, + input_index: usize, + record_count: u32, + output_record: u32, + payload: &mut [u8], + payload_starts: &[usize; MAX_PHYSICAL_BUFFERS], + output_records: u32, + active_buffers: u32, +) -> Result<(), PackingError> { + let input_end = input_index + .checked_add(record_count as usize) + .ok_or(PackingError::ArithmeticOverflow)?; + let mut f32_fields: [&[f32]; super::policy::MAX_REGISTERS] = + [&[]; super::policy::MAX_REGISTERS]; + let mut u32_fields: [&[u32]; super::policy::MAX_REGISTERS] = + [&[]; super::policy::MAX_REGISTERS]; + for (target, field) in f32_fields + .iter_mut() + .zip(input.f32_fields.iter()) + .take(usize::from(program.f32_input_count)) + { + *target = &field[input_index..input_end]; + } + for (target, field) in u32_fields + .iter_mut() + .zip(input.u32_fields.iter()) + .take(usize::from(program.u32_input_count)) + { + *target = &field[input_index..input_end]; + } + + let mut outputs: [mem::MaybeUninit>; MAX_PHYSICAL_BUFFERS] = + [const { mem::MaybeUninit::uninit() }; MAX_PHYSICAL_BUFFERS]; + let base = payload.as_mut_ptr(); + for (index, schema) in program.buffers.iter().copied().enumerate() { + let length = if active_buffers & (1 << index) == 0 { + 0 + } else { + output_records as usize * schema.stride() + }; + // SAFETY: the caller sizes mutually disjoint payload segments before this call, and the + // payload cannot reallocate while these temporary views exist. + let start = if length == 0 { + 0 + } else { + payload_starts[index] + }; + let bytes = unsafe { slice::from_raw_parts_mut(base.add(start), length) }; + outputs[index].write(PhysicalBufferMut { schema, bytes }); + } + // SAFETY: the prefix contains exactly one initialized value per declared program buffer. + let outputs = unsafe { + slice::from_raw_parts_mut( + outputs.as_mut_ptr().cast::>(), + program.buffers.len(), + ) + }; + policy + .execute_buffers( + capability_set, + program.technique, + program.variant, + SemanticInputBatch { + f32_fields: &f32_fields[..usize::from(program.f32_input_count)], + u32_fields: &u32_fields[..usize::from(program.u32_input_count)], + record_count: record_count as usize, + }, + output_record as usize, + outputs, + active_buffers, + ) + .map_err(PackingError::Policy) +} + +pub fn grown_capacity(mut capacity: u32, required: u32) -> Result { + while capacity < required { + capacity = capacity + .checked_mul(2) + .ok_or(PackingError::CapacityExceeded)?; + } + Ok(capacity) +} + +pub fn record_alignment( + program: &super::policy::ProgramDescriptor, + byte_alignment: u32, +) -> Result { + program.buffers.iter().try_fold(1_u32, |records, schema| { + lcm(records, buffer_record_alignment(schema, byte_alignment)) + }) +} + +pub fn buffer_record_alignment(schema: &super::policy::BufferSchema, byte_alignment: u32) -> u32 { + byte_alignment / gcd(byte_alignment, u32::from(schema.stride)) +} + +pub fn align_up(value: u32, alignment: u32) -> Result { + value + .checked_add(alignment - 1) + .map(|value| value / alignment * alignment) + .ok_or(PackingError::ArithmeticOverflow) +} + +pub fn align_record_range(range: RecordRange, alignment: u32) -> Result { + let start = range.start / alignment * alignment; + let end = range + .end + .checked_add(alignment - 1) + .map(|value| value / alignment * alignment) + .ok_or(PackingError::ArithmeticOverflow)?; + Ok(RecordRange { start, end }) +} + +pub fn coalesce_buffer_ranges( + ranges: &mut alloc::vec::Vec, + bytes_per_record: u32, + capability: &super::policy::CapabilitySet, + live_records: u32, +) -> Result<(), PackingError> { + if ranges.is_empty() { + return Ok(()); + } + if bytes_per_record == 0 { + return Err(PackingError::InvalidIdentity); + } + let accepted_gap = capability + .coalesce_gap_bytes + .max(capability.range_call_penalty_bytes); + if ranges.len() > 1 { + let mut write = 0; + for read in 1..ranges.len() { + let gap = ranges[read] + .start + .saturating_sub(ranges[write].end) + .saturating_mul(bytes_per_record); + if gap <= accepted_gap { + ranges[write].end = ranges[read].end; + } else { + write += 1; + ranges[write] = ranges[read]; + } + } + ranges.truncate(write + 1); + } + if ranges.len() > usize::from(capability.fragmentation_budget) { + let first = ranges[0].start; + let last = ranges.last().ok_or(PackingError::InvalidIdentity)?.end; + ranges.clear(); + ranges.push(RecordRange { + start: first, + end: last, + }); + } + let upload_records = ranges.iter().try_fold(0_u32, |total, range| { + total + .checked_add(range.end - range.start) + .ok_or(PackingError::ArithmeticOverflow) + })?; + let upload_cost = upload_records + .checked_mul(bytes_per_record) + .and_then(|bytes| { + bytes.checked_add( + (ranges.len() as u32).saturating_mul(capability.range_call_penalty_bytes), + ) + }) + .ok_or(PackingError::ArithmeticOverflow)?; + let full_bytes = live_records + .checked_mul(bytes_per_record) + .ok_or(PackingError::ArithmeticOverflow)?; + if upload_cost.saturating_mul(10_000) + >= full_bytes.saturating_mul(u32::from(capability.whole_buffer_threshold_basis_points)) + { + let record_alignment = + capability.update_alignment / gcd(capability.update_alignment, bytes_per_record); + ranges.clear(); + ranges.push(RecordRange { + start: 0, + end: align_up(live_records, record_alignment)?, + }); + } + Ok(()) +} + +pub fn collect_range_jobs( + jobs: &mut alloc::vec::Vec, + buffer_ranges: &[alloc::vec::Vec; MAX_PHYSICAL_BUFFERS], + buffer_count: usize, +) -> Result<(), PackingError> { + jobs.clear(); + let range_count = buffer_ranges[..buffer_count] + .iter() + .try_fold(0_usize, |total, ranges| total.checked_add(ranges.len())) + .ok_or(PackingError::ArithmeticOverflow)?; + jobs.try_reserve(range_count) + .map_err(|_| PackingError::AllocationFailed)?; + for (buffer_index, ranges) in buffer_ranges[..buffer_count].iter().enumerate() { + for range in ranges.iter().copied() { + if let Some(job) = jobs.iter_mut().find(|job| job.range == range) { + job.active_buffers |= 1 << buffer_index; + } else { + jobs.push(RangeJob { + range, + active_buffers: 1 << buffer_index, + }); + } + } + } + jobs.sort_unstable_by_key(|job| (job.range.start, job.range.end)); + Ok(()) +} + +fn gcd(mut left: u32, mut right: u32) -> u32 { + while right != 0 { + (left, right) = (right, left % right); + } + left +} + +fn lcm(left: u32, right: u32) -> Result { + left.checked_div(gcd(left, right)) + .and_then(|value| value.checked_mul(right)) + .ok_or(PackingError::ArithmeticOverflow) +} + +#[cfg(test)] +mod tests { + use alloc::vec; + + use super::{ + MAX_PHYSICAL_BUFFERS, PackingError, RangeJob, RecordRange, coalesce_buffer_ranges, + collect_range_jobs, + }; + use crate::engine::policy::{CapabilitySet, CapabilitySetId}; + + fn capability() -> CapabilitySet { + CapabilitySet { + id: CapabilitySetId(1), + flags: 0, + max_buffer_bytes: u32::MAX, + update_alignment: 4, + coalesce_gap_bytes: 128, + range_call_penalty_bytes: 256, + max_buffers_per_draw: 16, + max_resources_per_draw: 16, + max_indirect_draws: 1, + fragmentation_budget: 8, + whole_buffer_threshold_basis_points: 7_500, + } + } + + #[test] + fn physical_buffer_stride_controls_gap_coalescing() { + let mut narrow = vec![ + RecordRange { start: 0, end: 1 }, + RecordRange { start: 10, end: 11 }, + ]; + let mut wide = narrow.clone(); + + coalesce_buffer_ranges(&mut narrow, 16, &capability(), 100).unwrap(); + coalesce_buffer_ranges(&mut wide, 64, &capability(), 100).unwrap(); + + assert_eq!(narrow, [RecordRange { start: 0, end: 11 }]); + assert_eq!( + wide, + [ + RecordRange { start: 0, end: 1 }, + RecordRange { start: 10, end: 11 }, + ] + ); + } + + #[test] + fn physical_buffer_cost_promotes_fragmented_and_expensive_updates() { + let mut fragmented = (0..9) + .map(|index| RecordRange { + start: index * 10, + end: index * 10 + 1, + }) + .collect(); + let mut expensive = vec![RecordRange { start: 0, end: 80 }]; + + coalesce_buffer_ranges(&mut fragmented, 16, &capability(), 100).unwrap(); + coalesce_buffer_ranges(&mut expensive, 16, &capability(), 100).unwrap(); + + assert_eq!(fragmented, [RecordRange { start: 0, end: 100 }]); + assert_eq!(expensive, [RecordRange { start: 0, end: 100 }]); + } + + #[test] + fn physical_buffer_cost_rejects_zero_stride_and_arithmetic_overflow() { + let mut range = vec![RecordRange { start: 0, end: 1 }]; + assert_eq!( + coalesce_buffer_ranges(&mut range, 0, &capability(), 1), + Err(PackingError::InvalidIdentity) + ); + + let mut overflow = vec![RecordRange { + start: 0, + end: u32::MAX, + }]; + assert_eq!( + coalesce_buffer_ranges(&mut overflow, 2, &capability(), u32::MAX), + Err(PackingError::ArithmeticOverflow) + ); + } + + #[test] + fn whole_buffer_promotion_preserves_the_declared_byte_alignment() { + let mut aligned = vec![RecordRange { start: 0, end: 2 }]; + let mut capability = capability(); + capability.update_alignment = 16; + + coalesce_buffer_ranges(&mut aligned, 8, &capability, 3).unwrap(); + + assert_eq!(aligned, [RecordRange { start: 0, end: 4 }]); + } + + #[test] + fn identical_per_buffer_ranges_share_one_packing_job() { + let mut ranges: [alloc::vec::Vec; MAX_PHYSICAL_BUFFERS] = + core::array::from_fn(|_| alloc::vec::Vec::new()); + ranges[0].extend([ + RecordRange { start: 0, end: 10 }, + RecordRange { start: 20, end: 21 }, + ]); + ranges[1].push(RecordRange { start: 0, end: 10 }); + let mut jobs = alloc::vec::Vec::new(); + + collect_range_jobs(&mut jobs, &ranges, 2).unwrap(); + + assert_eq!( + jobs, + [ + RangeJob { + range: RecordRange { start: 0, end: 10 }, + active_buffers: 0b11, + }, + RangeJob { + range: RecordRange { start: 20, end: 21 }, + active_buffers: 0b01, + }, + ] + ); + } +} diff --git a/packages/text/rust/shaper/src/engine/policy.rs b/packages/text/rust/shaper/src/engine/policy.rs new file mode 100644 index 00000000..8173d179 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/policy.rs @@ -0,0 +1,2484 @@ +//! Validated renderer policy data and its scalar correctness executor. +//! +//! Policies are intentionally straight-line. The engine owns record iteration, so a policy cannot +//! allocate, loop, branch backward, address arbitrary memory, or mutate semantic layout. + +use alloc::vec::Vec; + +pub const MAX_PROGRAMS: usize = 32; +pub const MAX_CAPABILITY_SETS: usize = 8; +pub const MAX_BUFFERS_PER_PROGRAM: usize = 16; +pub const MAX_OPERATIONS_PER_PROGRAM: usize = 128; +pub const MAX_REGISTERS: usize = 32; +pub const MAX_INPUT_FIELDS_PER_PROGRAM: usize = MAX_REGISTERS * 2; +pub const MAX_VECTOR_WIDTH: u8 = 4; +const MAX_OUTPUT_LANES: usize = MAX_BUFFERS_PER_PROGRAM * MAX_VECTOR_WIDTH as usize; +const NOT_A_STORE: u8 = u8::MAX; + +pub const CAP_STORAGE_BUFFERS: u32 = 1 << 0; +pub const CAP_INDIRECT_DRAWS: u32 = 1 << 1; +pub const CAP_ALIAS_VEC2: u32 = 1 << 2; +pub const CAP_ALIAS_VEC4: u32 = 1 << 3; +pub const CAP_ORDERED_DIRECT: u32 = 1 << 4; +pub const CAP_STABLE_INDIRECT: u32 = 1 << 5; +const CAPABILITY_FLAGS: u32 = CAP_STORAGE_BUFFERS + | CAP_INDIRECT_DRAWS + | CAP_ALIAS_VEC2 + | CAP_ALIAS_VEC4 + | CAP_ORDERED_DIRECT + | CAP_STABLE_INDIRECT; + +pub const BATCH_TECHNIQUE: u32 = 1 << 0; +pub const BATCH_RESOURCE: u32 = 1 << 1; +pub const BATCH_PROGRAM: u32 = 1 << 2; +pub const BATCH_MATERIAL: u32 = 1 << 3; +pub const BATCH_CLIP: u32 = 1 << 4; +pub const BATCH_DEPTH: u32 = 1 << 5; +pub const BATCH_ORDER: u32 = 1 << 6; +pub const BATCH_TRANSFORM: u32 = 1 << 7; +const BATCH_FIELDS: u32 = BATCH_TECHNIQUE + | BATCH_RESOURCE + | BATCH_PROGRAM + | BATCH_MATERIAL + | BATCH_CLIP + | BATCH_DEPTH + | BATCH_ORDER + | BATCH_TRANSFORM; +const STORAGE_KEY_FIELDS: u32 = BATCH_FIELDS & !(BATCH_ORDER | BATCH_TRANSFORM); +const REQUIRED_STORAGE_KEYS: u32 = BATCH_TECHNIQUE | BATCH_RESOURCE | BATCH_PROGRAM; +const REQUIRED_DRAW_KEYS: u32 = REQUIRED_STORAGE_KEYS | BATCH_ORDER; + +pub const BUFFER_USAGE_VERTEX: u32 = 1 << 0; +pub const BUFFER_USAGE_STORAGE: u32 = 1 << 1; +pub const BUFFER_USAGE_COPY_DST: u32 = 1 << 2; +const BUFFER_USAGE_FLAGS: u32 = BUFFER_USAGE_VERTEX | BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST; + +pub const ALLOCATION_ORDERED_DIRECT: u16 = 1; +pub const ALLOCATION_STABLE_INDIRECT: u16 = 2; + +pub const OP_LOAD_F32: u8 = 1; +pub const OP_LOAD_U32: u8 = 2; +pub const OP_CONSTANT_F32: u8 = 3; +pub const OP_CONSTANT_U32: u8 = 4; +pub const OP_ADD_F32: u8 = 5; +pub const OP_SUBTRACT_F32: u8 = 6; +pub const OP_MULTIPLY_F32: u8 = 7; +pub const OP_LESS_THAN_F32: u8 = 8; +pub const OP_SELECT_F32: u8 = 9; +pub const OP_CONVERT_U32_TO_F32: u8 = 10; +pub const OP_STORE_F32: u8 = 11; +pub const OP_STORE_U32: u8 = 12; +pub const OP_STORE_U16: u8 = 13; + +pub const INPUT_SEMANTIC: u8 = 1; +pub const INPUT_GLYPH: u8 = 2; +pub const INPUT_RESOURCE: u8 = 3; +pub const INPUT_STRIKE: u8 = 4; + +const UNINITIALIZED: u8 = 0; +const F32_REGISTER: u8 = 1; +const U32_REGISTER: u8 = 2; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[repr(transparent)] +pub struct TechniqueId(pub u32); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[repr(transparent)] +pub struct ProgramId(pub u32); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[repr(transparent)] +pub struct CapabilitySetId(pub u32); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[repr(transparent)] +pub struct BufferId(pub u16); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum ScalarType { + F32 = 1, + U32 = 2, + U16 = 3, +} + +impl ScalarType { + const fn byte_width(self) -> usize { + match self { + Self::F32 | Self::U32 => 4, + Self::U16 => 2, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BufferSchema { + pub id: BufferId, + pub scalar: ScalarType, + pub vector_width: u8, + pub alignment: u16, + pub stride: u16, + pub usage: u32, + pub capacity_class: u16, +} + +impl BufferSchema { + pub fn stride(self) -> usize { + usize::from(self.stride) + } + + pub const fn packed( + id: BufferId, + scalar: ScalarType, + vector_width: u8, + usage: u32, + capacity_class: u16, + ) -> Self { + let byte_width = scalar.byte_width() as u16 * vector_width as u16; + Self { + id, + scalar, + vector_width, + alignment: scalar.byte_width() as u16, + stride: byte_width, + usage, + capacity_class, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CapabilitySet { + pub id: CapabilitySetId, + pub flags: u32, + pub max_buffer_bytes: u32, + pub update_alignment: u32, + pub coalesce_gap_bytes: u32, + pub range_call_penalty_bytes: u32, + pub max_buffers_per_draw: u16, + pub max_resources_per_draw: u16, + pub max_indirect_draws: u16, + pub fragmentation_budget: u16, + pub whole_buffer_threshold_basis_points: u16, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ProgramCapabilities { + pub paint: u32, + pub compositing: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum InputScope { + Semantic = INPUT_SEMANTIC, + Glyph = INPUT_GLYPH, + Resource = INPUT_RESOURCE, + Strike = INPUT_STRIKE, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct InputSource { + pub scope: InputScope, + pub field: u8, +} + +impl InputSource { + pub const fn semantic(field: u8) -> Self { + Self { + scope: InputScope::Semantic, + field, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Operation { + LoadF32 { + target: u8, + field: u8, + }, + LoadU32 { + target: u8, + field: u8, + }, + ConstantF32 { + target: u8, + bits: u32, + }, + ConstantU32 { + target: u8, + value: u32, + }, + AddF32 { + target: u8, + left: u8, + right: u8, + }, + SubtractF32 { + target: u8, + left: u8, + right: u8, + }, + MultiplyF32 { + target: u8, + left: u8, + right: u8, + }, + LessThanF32 { + target: u8, + left: u8, + right: u8, + }, + SelectF32 { + target: u8, + condition: u8, + when_true: u8, + when_false: u8, + }, + ConvertU32ToF32 { + target: u8, + source: u8, + }, + StoreF32 { + source: u8, + buffer: BufferId, + lane: u8, + }, + StoreU32 { + source: u8, + buffer: BufferId, + lane: u8, + }, + StoreU16 { + source: u8, + buffer: BufferId, + lane: u8, + }, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ProgramDescriptor { + pub technique: TechniqueId, + pub variant: u16, + pub id: ProgramId, + /// Zero makes a program valid for every capability set in this policy. + pub capability_set: CapabilitySetId, + pub resource_kind_mask: u32, + pub semantic_view_mask: u32, + pub storage_key_mask: u32, + pub draw_key_mask: u32, + pub allocation_strategy: u16, + pub f32_input_count: u8, + pub u32_input_count: u8, + /// Ordered F32 sources followed by ordered U32 sources. + pub inputs: Vec, + pub capabilities: ProgramCapabilities, + pub buffers: Vec, + pub operations: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PolicyDescriptor { + pub capability_sets: Vec, + pub programs: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ValidatedPolicy { + capability_sets: Vec, + programs: Vec, + execution: Vec, + fingerprint: u64, +} + +impl ValidatedPolicy { + pub fn new(descriptor: PolicyDescriptor) -> Result { + validate_policy(&descriptor)?; + let mut execution = Vec::new(); + execution + .try_reserve_exact(descriptor.programs.len()) + .map_err(|_| PolicyError::AllocationFailed)?; + for program in &descriptor.programs { + execution.push(ExecutableProgram::new(program)?); + } + Ok(Self { + fingerprint: policy_fingerprint(&descriptor), + capability_sets: descriptor.capability_sets, + programs: descriptor.programs, + execution, + }) + } + + pub fn programs(&self) -> &[ProgramDescriptor] { + &self.programs + } + + pub fn capability_sets(&self) -> &[CapabilitySet] { + &self.capability_sets + } + + pub fn fingerprint(&self) -> u64 { + self.fingerprint + } + + pub fn capability_set(&self, id: CapabilitySetId) -> Option<&CapabilitySet> { + self.capability_sets.iter().find(|set| set.id == id) + } + + pub fn program( + &self, + capability_set: CapabilitySetId, + technique: TechniqueId, + variant: u16, + ) -> Option<&ProgramDescriptor> { + self.programs + .iter() + .find(|program| { + program.capability_set == capability_set + && program.technique == technique + && program.variant == variant + }) + .or_else(|| { + self.programs.iter().find(|program| { + program.capability_set.0 == 0 + && program.technique == technique + && program.variant == variant + }) + }) + } + + pub(crate) fn uniform_allocation_strategy( + &self, + capability_set: CapabilitySetId, + ) -> Option { + let mut strategy = None; + for program in self.programs.iter().filter(|program| { + program.capability_set == capability_set || program.capability_set.0 == 0 + }) { + match strategy { + Some(existing) if existing != program.allocation_strategy => return None, + Some(_) => {} + None => strategy = Some(program.allocation_strategy), + } + } + strategy + } + + pub fn execute( + &self, + capability_set: CapabilitySetId, + technique: TechniqueId, + variant: u16, + inputs: SemanticInputBatch<'_>, + output_start: usize, + outputs: &mut [PhysicalBufferMut<'_>], + ) -> Result<(), PolicyExecutionError> { + if self.capability_set(capability_set).is_none() { + return Err(PolicyExecutionError::CapabilitySetMissing); + } + let program_index = self + .programs + .iter() + .position(|program| { + program.capability_set == capability_set + && program.technique == technique + && program.variant == variant + }) + .or_else(|| { + self.programs.iter().position(|program| { + program.capability_set.0 == 0 + && program.technique == technique + && program.variant == variant + }) + }) + .ok_or(PolicyExecutionError::ProgramMissing)?; + let program = self + .programs + .get(program_index) + .ok_or(PolicyExecutionError::ProgramMissing)?; + let execution = self + .execution + .get(program_index) + .ok_or(PolicyExecutionError::ProgramMissing)?; + let active_buffers = (1_u32 << program.buffers.len()) - 1; + execute_program( + program, + execution, + inputs, + output_start, + outputs, + active_buffers, + ) + } + + pub(crate) fn execute_buffers( + &self, + capability_set: CapabilitySetId, + technique: TechniqueId, + variant: u16, + inputs: SemanticInputBatch<'_>, + output_start: usize, + outputs: &mut [PhysicalBufferMut<'_>], + active_buffers: u32, + ) -> Result<(), PolicyExecutionError> { + if self.capability_set(capability_set).is_none() { + return Err(PolicyExecutionError::CapabilitySetMissing); + } + let program_index = self + .programs + .iter() + .position(|program| { + program.capability_set == capability_set + && program.technique == technique + && program.variant == variant + }) + .or_else(|| { + self.programs.iter().position(|program| { + program.capability_set.0 == 0 + && program.technique == technique + && program.variant == variant + }) + }) + .ok_or(PolicyExecutionError::ProgramMissing)?; + execute_program( + &self.programs[program_index], + &self.execution[program_index], + inputs, + output_start, + outputs, + active_buffers, + ) + } + + pub(crate) fn buffer_dependency_masks( + &self, + capability_set: CapabilitySetId, + technique: TechniqueId, + variant: u16, + ) -> Option<&[u16]> { + let index = self + .programs + .iter() + .position(|program| { + program.capability_set == capability_set + && program.technique == technique + && program.variant == variant + }) + .or_else(|| { + self.programs.iter().position(|program| { + program.capability_set.0 == 0 + && program.technique == technique + && program.variant == variant + }) + })?; + Some(&self.execution.get(index)?.buffer_dependency_masks) + } + + pub(crate) fn input_masks_for_changes( + &self, + capability_set: CapabilitySetId, + technique: TechniqueId, + variant: u16, + semantic_changes: u16, + force_all: bool, + ) -> Option<(u32, u32)> { + let index = self + .programs + .iter() + .position(|program| { + program.capability_set == capability_set + && program.technique == technique + && program.variant == variant + }) + .or_else(|| { + self.programs.iter().position(|program| { + program.capability_set.0 == 0 + && program.technique == technique + && program.variant == variant + }) + })?; + let execution = self.execution.get(index)?; + let program = self.programs.get(index)?; + if force_all || semantic_changes == super::positioning::ALL_SEMANTIC_CHANGES { + return Some(( + low_bits(program.f32_input_count), + low_bits(program.u32_input_count), + )); + } + let all_buffers = (1_u32 << execution.buffer_dependency_masks.len()) - 1; + let active_buffers = execution.buffer_dependency_masks.iter().enumerate().fold( + 0_u32, + |active, (buffer, dependency)| { + active | (u32::from(dependency & semantic_changes != 0) << buffer) + }, + ) & all_buffers; + let mut f32_inputs = 0_u32; + let mut u32_inputs = 0_u32; + for buffer in 0..execution.buffer_dependency_masks.len() { + if active_buffers & (1 << buffer) != 0 { + f32_inputs |= execution.buffer_f32_input_masks[buffer]; + u32_inputs |= execution.buffer_u32_input_masks[buffer]; + } + } + Some((f32_inputs, u32_inputs)) + } +} + +fn low_bits(count: u8) -> u32 { + if count == 0 { + 0 + } else { + u32::MAX >> (32 - u32::from(count)) + } +} + +fn policy_fingerprint(descriptor: &PolicyDescriptor) -> u64 { + let mut fingerprint = 0xcbf2_9ce4_8422_2325_u64; + mix_u32(&mut fingerprint, descriptor.capability_sets.len() as u32); + for set in &descriptor.capability_sets { + mix_u32(&mut fingerprint, set.id.0); + mix_u32(&mut fingerprint, set.flags); + mix_u32(&mut fingerprint, set.max_buffer_bytes); + mix_u32(&mut fingerprint, set.update_alignment); + mix_u32(&mut fingerprint, set.coalesce_gap_bytes); + mix_u32(&mut fingerprint, set.range_call_penalty_bytes); + mix_u32(&mut fingerprint, u32::from(set.max_buffers_per_draw)); + mix_u32(&mut fingerprint, u32::from(set.max_resources_per_draw)); + mix_u32(&mut fingerprint, u32::from(set.max_indirect_draws)); + mix_u32(&mut fingerprint, u32::from(set.fragmentation_budget)); + mix_u32( + &mut fingerprint, + u32::from(set.whole_buffer_threshold_basis_points), + ); + } + mix_u32(&mut fingerprint, descriptor.programs.len() as u32); + for program in &descriptor.programs { + mix_u32(&mut fingerprint, program.technique.0); + mix_u32(&mut fingerprint, program.id.0); + mix_u32(&mut fingerprint, program.capability_set.0); + mix_u32(&mut fingerprint, program.resource_kind_mask); + mix_u32(&mut fingerprint, program.semantic_view_mask); + mix_u32(&mut fingerprint, program.storage_key_mask); + mix_u32(&mut fingerprint, program.draw_key_mask); + mix_u32(&mut fingerprint, u32::from(program.allocation_strategy)); + mix_u32(&mut fingerprint, u32::from(program.variant)); + mix_u32(&mut fingerprint, u32::from(program.f32_input_count)); + mix_u32(&mut fingerprint, u32::from(program.u32_input_count)); + mix_u32(&mut fingerprint, program.inputs.len() as u32); + for input in &program.inputs { + mix_u32(&mut fingerprint, input.scope as u32); + mix_u32(&mut fingerprint, u32::from(input.field)); + } + mix_u32(&mut fingerprint, program.capabilities.paint); + mix_u32(&mut fingerprint, program.capabilities.compositing); + mix_u32(&mut fingerprint, program.buffers.len() as u32); + for buffer in &program.buffers { + mix_u32(&mut fingerprint, u32::from(buffer.id.0)); + mix_u32(&mut fingerprint, buffer.scalar as u32); + mix_u32(&mut fingerprint, u32::from(buffer.vector_width)); + mix_u32(&mut fingerprint, u32::from(buffer.alignment)); + mix_u32(&mut fingerprint, u32::from(buffer.stride)); + mix_u32(&mut fingerprint, buffer.usage); + mix_u32(&mut fingerprint, u32::from(buffer.capacity_class)); + } + mix_u32(&mut fingerprint, program.operations.len() as u32); + for operation in &program.operations { + fingerprint_operation(&mut fingerprint, operation); + } + } + fingerprint +} + +fn fingerprint_operation(fingerprint: &mut u64, operation: &Operation) { + let (opcode, target, operand0, operand1, immediate0, immediate1, immediate2) = match *operation + { + Operation::LoadF32 { target, field } => (OP_LOAD_F32, target, field, 0, 0, 0, 0), + Operation::LoadU32 { target, field } => (OP_LOAD_U32, target, field, 0, 0, 0, 0), + Operation::ConstantF32 { target, bits } => (OP_CONSTANT_F32, target, 0, 0, bits, 0, 0), + Operation::ConstantU32 { target, value } => (OP_CONSTANT_U32, target, 0, 0, value, 0, 0), + Operation::AddF32 { + target, + left, + right, + } => (OP_ADD_F32, target, left, right, 0, 0, 0), + Operation::SubtractF32 { + target, + left, + right, + } => (OP_SUBTRACT_F32, target, left, right, 0, 0, 0), + Operation::MultiplyF32 { + target, + left, + right, + } => (OP_MULTIPLY_F32, target, left, right, 0, 0, 0), + Operation::LessThanF32 { + target, + left, + right, + } => (OP_LESS_THAN_F32, target, left, right, 0, 0, 0), + Operation::SelectF32 { + target, + condition, + when_true, + when_false, + } => ( + OP_SELECT_F32, + target, + condition, + when_true, + u32::from(when_false), + 0, + 0, + ), + Operation::ConvertU32ToF32 { target, source } => { + (OP_CONVERT_U32_TO_F32, target, source, 0, 0, 0, 0) + } + Operation::StoreF32 { + source, + buffer, + lane, + } => (OP_STORE_F32, 0, source, lane, u32::from(buffer.0), 0, 0), + Operation::StoreU32 { + source, + buffer, + lane, + } => (OP_STORE_U32, 0, source, lane, u32::from(buffer.0), 0, 0), + Operation::StoreU16 { + source, + buffer, + lane, + } => (OP_STORE_U16, 0, source, lane, u32::from(buffer.0), 0, 0), + }; + mix_u32( + fingerprint, + u32::from_le_bytes([opcode, target, operand0, operand1]), + ); + mix_u32(fingerprint, immediate0); + mix_u32(fingerprint, immediate1); + mix_u32(fingerprint, immediate2); +} + +fn mix_u32(fingerprint: &mut u64, value: u32) { + for byte in value.to_le_bytes() { + *fingerprint ^= u64::from(byte); + *fingerprint = fingerprint.wrapping_mul(0x0000_0100_0000_01b3); + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ExecutableProgram { + store_buffer_indices: Vec, + buffer_dependency_masks: Vec, + buffer_f32_input_masks: Vec, + buffer_u32_input_masks: Vec, + operation_buffer_masks: Vec, +} + +impl ExecutableProgram { + fn new(program: &ProgramDescriptor) -> Result { + let operation_buffer_masks = operation_buffer_masks(program)?; + let mut store_buffer_indices = Vec::new(); + let mut buffer_dependency_masks = Vec::new(); + let mut buffer_f32_input_masks = Vec::new(); + let mut buffer_u32_input_masks = Vec::new(); + store_buffer_indices + .try_reserve_exact(program.operations.len()) + .map_err(|_| PolicyError::AllocationFailed)?; + buffer_dependency_masks + .try_reserve_exact(program.buffers.len()) + .map_err(|_| PolicyError::AllocationFailed)?; + buffer_f32_input_masks + .try_reserve_exact(program.buffers.len()) + .map_err(|_| PolicyError::AllocationFailed)?; + buffer_u32_input_masks + .try_reserve_exact(program.buffers.len()) + .map_err(|_| PolicyError::AllocationFailed)?; + buffer_dependency_masks.resize(program.buffers.len(), 0); + buffer_f32_input_masks.resize(program.buffers.len(), 0); + buffer_u32_input_masks.resize(program.buffers.len(), 0); + let mut register_dependencies = [0_u16; MAX_REGISTERS]; + let mut register_f32_inputs = [0_u32; MAX_REGISTERS]; + let mut register_u32_inputs = [0_u32; MAX_REGISTERS]; + for operation in &program.operations { + let index = match store_buffer(operation) { + Some(buffer) => program + .buffers + .iter() + .position(|schema| schema.id == buffer) + .ok_or(PolicyError::UnknownBuffer)? + .try_into() + .map_err(|_| PolicyError::TooManyBuffers)?, + None => NOT_A_STORE, + }; + store_buffer_indices.push(index); + propagate_dependencies( + program, + operation, + &mut register_dependencies, + &mut buffer_dependency_masks, + )?; + propagate_input_dependencies( + program, + operation, + &mut register_f32_inputs, + &mut register_u32_inputs, + &mut buffer_f32_input_masks, + &mut buffer_u32_input_masks, + )?; + } + Ok(Self { + store_buffer_indices, + buffer_dependency_masks, + buffer_f32_input_masks, + buffer_u32_input_masks, + operation_buffer_masks, + }) + } +} + +fn operation_buffer_masks(program: &ProgramDescriptor) -> Result, PolicyError> { + let mut masks = Vec::new(); + masks + .try_reserve_exact(program.operations.len()) + .map_err(|_| PolicyError::AllocationFailed)?; + masks.resize(program.operations.len(), 0); + let mut register_consumers = [0_u32; MAX_REGISTERS]; + for (operation_index, operation) in program.operations.iter().enumerate().rev() { + if let Some(buffer) = store_buffer(operation) { + let buffer_index = program + .buffers + .iter() + .position(|schema| schema.id == buffer) + .ok_or(PolicyError::UnknownBuffer)?; + let mask = 1_u32 << buffer_index; + masks[operation_index] = mask; + register_consumers[usize::from(operation_sources(operation)[0])] |= mask; + continue; + } + let Some(target) = operation_target(operation) else { + continue; + }; + let mask = register_consumers[usize::from(target)]; + masks[operation_index] = mask; + register_consumers[usize::from(target)] = 0; + for source in operation_sources(operation) { + if source != u8::MAX { + register_consumers[usize::from(source)] |= mask; + } + } + } + Ok(masks) +} + +fn operation_target(operation: &Operation) -> Option { + match *operation { + Operation::LoadF32 { target, .. } + | Operation::LoadU32 { target, .. } + | Operation::ConstantF32 { target, .. } + | Operation::ConstantU32 { target, .. } + | Operation::AddF32 { target, .. } + | Operation::SubtractF32 { target, .. } + | Operation::MultiplyF32 { target, .. } + | Operation::LessThanF32 { target, .. } + | Operation::SelectF32 { target, .. } + | Operation::ConvertU32ToF32 { target, .. } => Some(target), + Operation::StoreF32 { .. } | Operation::StoreU32 { .. } | Operation::StoreU16 { .. } => { + None + } + } +} + +fn operation_sources(operation: &Operation) -> [u8; 3] { + match *operation { + Operation::AddF32 { left, right, .. } + | Operation::SubtractF32 { left, right, .. } + | Operation::MultiplyF32 { left, right, .. } + | Operation::LessThanF32 { left, right, .. } => [left, right, u8::MAX], + Operation::SelectF32 { + condition, + when_true, + when_false, + .. + } => [condition, when_true, when_false], + Operation::ConvertU32ToF32 { source, .. } + | Operation::StoreF32 { source, .. } + | Operation::StoreU32 { source, .. } + | Operation::StoreU16 { source, .. } => [source, u8::MAX, u8::MAX], + Operation::LoadF32 { .. } + | Operation::LoadU32 { .. } + | Operation::ConstantF32 { .. } + | Operation::ConstantU32 { .. } => [u8::MAX; 3], + } +} + +fn propagate_input_dependencies( + program: &ProgramDescriptor, + operation: &Operation, + f32_registers: &mut [u32; MAX_REGISTERS], + u32_registers: &mut [u32; MAX_REGISTERS], + f32_buffers: &mut [u32], + u32_buffers: &mut [u32], +) -> Result<(), PolicyError> { + match *operation { + Operation::LoadF32 { target, field } => { + f32_registers[usize::from(target)] = 1_u32 << field; + u32_registers[usize::from(target)] = 0; + } + Operation::LoadU32 { target, field } => { + f32_registers[usize::from(target)] = 0; + u32_registers[usize::from(target)] = 1_u32 << field; + } + Operation::ConstantF32 { target, .. } | Operation::ConstantU32 { target, .. } => { + f32_registers[usize::from(target)] = 0; + u32_registers[usize::from(target)] = 0; + } + Operation::AddF32 { + target, + left, + right, + } + | Operation::SubtractF32 { + target, + left, + right, + } + | Operation::MultiplyF32 { + target, + left, + right, + } + | Operation::LessThanF32 { + target, + left, + right, + } => { + f32_registers[usize::from(target)] = + f32_registers[usize::from(left)] | f32_registers[usize::from(right)]; + u32_registers[usize::from(target)] = + u32_registers[usize::from(left)] | u32_registers[usize::from(right)]; + } + Operation::SelectF32 { + target, + condition, + when_true, + when_false, + } => { + f32_registers[usize::from(target)] = f32_registers[usize::from(condition)] + | f32_registers[usize::from(when_true)] + | f32_registers[usize::from(when_false)]; + u32_registers[usize::from(target)] = u32_registers[usize::from(condition)] + | u32_registers[usize::from(when_true)] + | u32_registers[usize::from(when_false)]; + } + Operation::ConvertU32ToF32 { target, source } => { + f32_registers[usize::from(target)] = f32_registers[usize::from(source)]; + u32_registers[usize::from(target)] = u32_registers[usize::from(source)]; + } + Operation::StoreF32 { source, buffer, .. } + | Operation::StoreU32 { source, buffer, .. } + | Operation::StoreU16 { source, buffer, .. } => { + let index = program + .buffers + .iter() + .position(|schema| schema.id == buffer) + .ok_or(PolicyError::UnknownBuffer)?; + f32_buffers[index] |= f32_registers[usize::from(source)]; + u32_buffers[index] |= u32_registers[usize::from(source)]; + } + } + Ok(()) +} + +fn propagate_dependencies( + program: &ProgramDescriptor, + operation: &Operation, + registers: &mut [u16; MAX_REGISTERS], + buffers: &mut [u16], +) -> Result<(), PolicyError> { + match *operation { + Operation::LoadF32 { target, field } => { + registers[usize::from(target)] = + f32_input_dependency(program.inputs[usize::from(field)]); + } + Operation::LoadU32 { target, field } => { + let input = usize::from(program.f32_input_count) + usize::from(field); + registers[usize::from(target)] = u32_input_dependency(program.inputs[input]); + } + Operation::ConstantF32 { target, .. } | Operation::ConstantU32 { target, .. } => { + registers[usize::from(target)] = 0; + } + Operation::AddF32 { + target, + left, + right, + } + | Operation::SubtractF32 { + target, + left, + right, + } + | Operation::MultiplyF32 { + target, + left, + right, + } + | Operation::LessThanF32 { + target, + left, + right, + } => { + registers[usize::from(target)] = + registers[usize::from(left)] | registers[usize::from(right)]; + } + Operation::SelectF32 { + target, + condition, + when_true, + when_false, + } => { + registers[usize::from(target)] = registers[usize::from(condition)] + | registers[usize::from(when_true)] + | registers[usize::from(when_false)]; + } + Operation::ConvertU32ToF32 { target, source } => { + registers[usize::from(target)] = registers[usize::from(source)]; + } + Operation::StoreF32 { source, buffer, .. } + | Operation::StoreU32 { source, buffer, .. } + | Operation::StoreU16 { source, buffer, .. } => { + let index = program + .buffers + .iter() + .position(|schema| schema.id == buffer) + .ok_or(PolicyError::UnknownBuffer)?; + buffers[index] |= registers[usize::from(source)]; + } + } + Ok(()) +} + +fn f32_input_dependency(source: InputSource) -> u16 { + if source.scope != InputScope::Semantic { + return 0; + } + match source.field { + 0..=7 => 1 << source.field, + 8..=11 => 1 << 8, + 12 => 1 << 4, + _ => 0, + } +} + +fn u32_input_dependency(source: InputSource) -> u16 { + if source.scope == InputScope::Semantic && source.field < 6 { + 1 << (8 + source.field) + } else { + 0 + } +} + +#[derive(Clone, Copy)] +pub struct SemanticInputBatch<'a> { + pub f32_fields: &'a [&'a [f32]], + pub u32_fields: &'a [&'a [u32]], + pub record_count: usize, +} + +pub struct PhysicalBufferMut<'a> { + pub schema: BufferSchema, + pub bytes: &'a mut [u8], +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PolicyExecutionError { + CapabilitySetMissing, + ProgramMissing, + InputFieldCount, + InputLength, + OutputBufferCount, + OutputSchema, + OutputCapacity, + NonFiniteOutput, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PolicyError { + AllocationFailed, + EmptyCapabilitySets, + TooManyCapabilitySets, + InvalidCapabilitySetId, + DuplicateCapabilitySetId, + InvalidCapabilityFlags, + InvalidCapabilityLimits, + InvalidUpdateAlignment, + InvalidUploadCostModel, + EmptyPolicy, + TooManyPrograms, + InvalidTechniqueId, + InvalidProgramId, + DuplicateTechniqueVariant, + DuplicateProgramId, + UnknownCapabilitySet, + InvalidResourceKinds, + InvalidBatchKey, + UnsupportedAllocationStrategy, + TooManyInputFields, + InvalidInputSources, + EmptyBuffers, + TooManyBuffers, + InvalidBufferId, + DuplicateBufferId, + InvalidVectorWidth, + InvalidBufferAlignment, + InvalidBufferStride, + InvalidBufferUsage, + InvalidCapacityClass, + EmptyOperations, + TooManyOperations, + InvalidRegister, + UninitializedRegister, + RegisterTypeMismatch, + InvalidInputField, + NonFiniteConstant, + UnknownBuffer, + StoreTypeMismatch, + InvalidStoreLane, + DuplicateStore, + IncompleteBuffer, +} + +fn execute_program( + program: &ProgramDescriptor, + execution: &ExecutableProgram, + inputs: SemanticInputBatch<'_>, + output_start: usize, + outputs: &mut [PhysicalBufferMut<'_>], + active_buffers: u32, +) -> Result<(), PolicyExecutionError> { + validate_execution(program, inputs, output_start, outputs, active_buffers)?; + #[cfg(all(target_arch = "wasm32", feature = "simd128"))] + let completed = unsafe { + execute_simd_records( + program, + execution, + inputs, + output_start, + outputs, + active_buffers, + )? + }; + #[cfg(not(all(target_arch = "wasm32", feature = "simd128")))] + let completed = 0; + for record in completed..inputs.record_count { + execute_record( + program, + execution, + inputs, + output_start + record, + record, + outputs, + active_buffers, + )?; + } + Ok(()) +} + +fn validate_execution( + program: &ProgramDescriptor, + inputs: SemanticInputBatch<'_>, + output_start: usize, + outputs: &[PhysicalBufferMut<'_>], + active_buffers: u32, +) -> Result<(), PolicyExecutionError> { + if inputs.f32_fields.len() != usize::from(program.f32_input_count) + || inputs.u32_fields.len() != usize::from(program.u32_input_count) + { + return Err(PolicyExecutionError::InputFieldCount); + } + if inputs + .f32_fields + .iter() + .any(|field| field.len() != inputs.record_count) + || inputs + .u32_fields + .iter() + .any(|field| field.len() != inputs.record_count) + { + return Err(PolicyExecutionError::InputLength); + } + if outputs.len() != program.buffers.len() { + return Err(PolicyExecutionError::OutputBufferCount); + } + let output_end = output_start + .checked_add(inputs.record_count) + .ok_or(PolicyExecutionError::OutputCapacity)?; + for (index, (output, schema)) in outputs.iter().zip(&program.buffers).enumerate() { + if output.schema != *schema { + return Err(PolicyExecutionError::OutputSchema); + } + if active_buffers & (1 << index) == 0 { + continue; + } + let required = output_end + .checked_mul(schema.stride()) + .ok_or(PolicyExecutionError::OutputCapacity)?; + if output.bytes.len() < required { + return Err(PolicyExecutionError::OutputCapacity); + } + } + Ok(()) +} + +fn execute_record( + program: &ProgramDescriptor, + execution: &ExecutableProgram, + inputs: SemanticInputBatch<'_>, + output_record: usize, + input_record: usize, + outputs: &mut [PhysicalBufferMut<'_>], + active_buffers: u32, +) -> Result<(), PolicyExecutionError> { + let mut registers = [0_u32; MAX_REGISTERS]; + let mut values = [0_u32; MAX_OUTPUT_LANES]; + for (operation_index, operation) in program.operations.iter().enumerate() { + if execution.operation_buffer_masks[operation_index] & active_buffers == 0 { + continue; + } + match *operation { + Operation::LoadF32 { target, field } => { + registers[usize::from(target)] = + inputs.f32_fields[usize::from(field)][input_record].to_bits(); + } + Operation::LoadU32 { target, field } => { + registers[usize::from(target)] = + inputs.u32_fields[usize::from(field)][input_record]; + } + Operation::ConstantF32 { target, bits } => registers[usize::from(target)] = bits, + Operation::ConstantU32 { target, value } => registers[usize::from(target)] = value, + Operation::AddF32 { + target, + left, + right, + } => { + registers[usize::from(target)] = + (register_f32(®isters, left) + register_f32(®isters, right)).to_bits(); + } + Operation::SubtractF32 { + target, + left, + right, + } => { + registers[usize::from(target)] = + (register_f32(®isters, left) - register_f32(®isters, right)).to_bits(); + } + Operation::MultiplyF32 { + target, + left, + right, + } => { + registers[usize::from(target)] = + (register_f32(®isters, left) * register_f32(®isters, right)).to_bits(); + } + Operation::LessThanF32 { + target, + left, + right, + } => { + registers[usize::from(target)] = + u32::from(register_f32(®isters, left) < register_f32(®isters, right)); + } + Operation::SelectF32 { + target, + condition, + when_true, + when_false, + } => { + registers[usize::from(target)] = if registers[usize::from(condition)] != 0 { + registers[usize::from(when_true)] + } else { + registers[usize::from(when_false)] + }; + } + Operation::ConvertU32ToF32 { target, source } => { + registers[usize::from(target)] = (registers[usize::from(source)] as f32).to_bits(); + } + Operation::StoreF32 { source, lane, .. } => { + let bits = registers[usize::from(source)]; + if !f32::from_bits(bits).is_finite() { + return Err(PolicyExecutionError::NonFiniteOutput); + } + values[store_slot(execution, operation_index, lane)] = bits; + } + Operation::StoreU32 { source, lane, .. } | Operation::StoreU16 { source, lane, .. } => { + values[store_slot(execution, operation_index, lane)] = + registers[usize::from(source)]; + } + } + } + for (buffer_index, (schema, output)) in program.buffers.iter().zip(outputs).enumerate() { + if active_buffers & (1 << buffer_index) == 0 { + continue; + } + let record_offset = output_record * schema.stride(); + for lane in 0..schema.vector_width { + let value = values[buffer_index * MAX_VECTOR_WIDTH as usize + usize::from(lane)]; + let lane_offset = record_offset + usize::from(lane) * schema.scalar.byte_width(); + match schema.scalar { + ScalarType::F32 | ScalarType::U32 => { + output.bytes[lane_offset..lane_offset + 4] + .copy_from_slice(&value.to_le_bytes()); + } + ScalarType::U16 => { + output.bytes[lane_offset..lane_offset + 2] + .copy_from_slice(&(value as u16).to_le_bytes()); + } + } + } + } + Ok(()) +} + +#[cfg(all(target_arch = "wasm32", feature = "simd128"))] +unsafe fn execute_simd_records( + program: &ProgramDescriptor, + execution: &ExecutableProgram, + inputs: SemanticInputBatch<'_>, + output_start: usize, + outputs: &mut [PhysicalBufferMut<'_>], + active_buffers: u32, +) -> Result { + use core::arch::wasm32::{ + f32x4_add, f32x4_convert_u32x4, f32x4_lt, f32x4_mul, f32x4_sub, i32x4_ne, u32x4_splat, + v128, v128_and, v128_bitselect, v128_load, + }; + + let completed = inputs.record_count & !3; + for input_record in (0..completed).step_by(4) { + let mut registers = [u32x4_splat(0); MAX_REGISTERS]; + let mut values = [u32x4_splat(0); MAX_OUTPUT_LANES]; + for (operation_index, operation) in program.operations.iter().enumerate() { + if execution.operation_buffer_masks[operation_index] & active_buffers == 0 { + continue; + } + match *operation { + Operation::LoadF32 { target, field } => { + // SAFETY: validation proves this field contains four records from `input_record`. + registers[usize::from(target)] = unsafe { + v128_load( + inputs.f32_fields[usize::from(field)] + .as_ptr() + .add(input_record) + .cast::(), + ) + }; + } + Operation::LoadU32 { target, field } => { + // SAFETY: validation proves this field contains four records from `input_record`. + registers[usize::from(target)] = unsafe { + v128_load( + inputs.u32_fields[usize::from(field)] + .as_ptr() + .add(input_record) + .cast::(), + ) + }; + } + Operation::ConstantF32 { target, bits } => { + registers[usize::from(target)] = u32x4_splat(bits); + } + Operation::ConstantU32 { target, value } => { + registers[usize::from(target)] = u32x4_splat(value); + } + Operation::AddF32 { + target, + left, + right, + } => { + registers[usize::from(target)] = + f32x4_add(registers[usize::from(left)], registers[usize::from(right)]); + } + Operation::SubtractF32 { + target, + left, + right, + } => { + registers[usize::from(target)] = + f32x4_sub(registers[usize::from(left)], registers[usize::from(right)]); + } + Operation::MultiplyF32 { + target, + left, + right, + } => { + registers[usize::from(target)] = + f32x4_mul(registers[usize::from(left)], registers[usize::from(right)]); + } + Operation::LessThanF32 { + target, + left, + right, + } => { + registers[usize::from(target)] = v128_and( + f32x4_lt(registers[usize::from(left)], registers[usize::from(right)]), + u32x4_splat(1), + ); + } + Operation::SelectF32 { + target, + condition, + when_true, + when_false, + } => { + let mask = i32x4_ne(registers[usize::from(condition)], u32x4_splat(0)); + registers[usize::from(target)] = v128_bitselect( + registers[usize::from(when_true)], + registers[usize::from(when_false)], + mask, + ); + } + Operation::ConvertU32ToF32 { target, source } => { + registers[usize::from(target)] = + f32x4_convert_u32x4(registers[usize::from(source)]); + } + Operation::StoreF32 { source, lane, .. } => { + let value = registers[usize::from(source)]; + if !simd_f32_is_finite(value) { + return Err(PolicyExecutionError::NonFiniteOutput); + } + values[store_slot(execution, operation_index, lane)] = value; + } + Operation::StoreU32 { source, lane, .. } + | Operation::StoreU16 { source, lane, .. } => { + values[store_slot(execution, operation_index, lane)] = + registers[usize::from(source)]; + } + } + } + write_simd_outputs( + program, + &values, + output_start + input_record, + outputs, + active_buffers, + ); + } + Ok(completed) +} + +#[cfg(all(target_arch = "wasm32", feature = "simd128"))] +fn write_simd_outputs( + program: &ProgramDescriptor, + values: &[core::arch::wasm32::v128; MAX_OUTPUT_LANES], + output_start: usize, + outputs: &mut [PhysicalBufferMut<'_>], + active_buffers: u32, +) { + use core::arch::wasm32::{i32x4_shuffle, v128_store}; + + for (buffer_index, (schema, output)) in program.buffers.iter().zip(outputs).enumerate() { + if active_buffers & (1 << buffer_index) == 0 { + continue; + } + let first = buffer_index * MAX_VECTOR_WIDTH as usize; + if matches!(schema.scalar, ScalarType::F32 | ScalarType::U32) + && schema.stride() == usize::from(schema.vector_width) * 4 + { + let destination = output_start * schema.stride(); + match schema.vector_width { + 1 => { + // SAFETY: execution validation proves four tightly packed records fit. + unsafe { + v128_store( + output.bytes.as_mut_ptr().add(destination).cast(), + values[first], + ); + } + continue; + } + 2 => { + let first_pair = i32x4_shuffle::<0, 4, 1, 5>(values[first], values[first + 1]); + let second_pair = i32x4_shuffle::<2, 6, 3, 7>(values[first], values[first + 1]); + // SAFETY: execution validation proves four tightly packed records fit. + unsafe { + v128_store( + output.bytes.as_mut_ptr().add(destination).cast(), + first_pair, + ); + v128_store( + output.bytes.as_mut_ptr().add(destination + 16).cast(), + second_pair, + ); + } + continue; + } + 4 => { + let low01 = i32x4_shuffle::<0, 4, 1, 5>(values[first], values[first + 1]); + let low23 = i32x4_shuffle::<0, 4, 1, 5>(values[first + 2], values[first + 3]); + let high01 = i32x4_shuffle::<2, 6, 3, 7>(values[first], values[first + 1]); + let high23 = i32x4_shuffle::<2, 6, 3, 7>(values[first + 2], values[first + 3]); + let records = [ + i32x4_shuffle::<0, 1, 4, 5>(low01, low23), + i32x4_shuffle::<2, 3, 6, 7>(low01, low23), + i32x4_shuffle::<0, 1, 4, 5>(high01, high23), + i32x4_shuffle::<2, 3, 6, 7>(high01, high23), + ]; + for (record, value) in records.into_iter().enumerate() { + // SAFETY: execution validation proves four tightly packed records fit. + unsafe { + v128_store( + output + .bytes + .as_mut_ptr() + .add(destination + record * 16) + .cast(), + value, + ); + } + } + continue; + } + _ => {} + } + } + for lane in 0..schema.vector_width { + let lanes = simd_u32_lanes(values[first + usize::from(lane)]); + for (record, value) in lanes.into_iter().enumerate() { + let lane_offset = (output_start + record) * schema.stride() + + usize::from(lane) * schema.scalar.byte_width(); + match schema.scalar { + ScalarType::F32 | ScalarType::U32 => { + output.bytes[lane_offset..lane_offset + 4] + .copy_from_slice(&value.to_le_bytes()); + } + ScalarType::U16 => { + output.bytes[lane_offset..lane_offset + 2] + .copy_from_slice(&(value as u16).to_le_bytes()); + } + } + } + } + } +} + +#[cfg(all(target_arch = "wasm32", feature = "simd128"))] +fn simd_f32_is_finite(value: core::arch::wasm32::v128) -> bool { + simd_u32_lanes(value) + .into_iter() + .all(|bits| f32::from_bits(bits).is_finite()) +} + +#[cfg(all(target_arch = "wasm32", feature = "simd128"))] +fn simd_u32_lanes(value: core::arch::wasm32::v128) -> [u32; 4] { + // SAFETY: `v128` and four `u32` lanes are both exactly 128 bits; this preserves their bits. + unsafe { core::mem::transmute(value) } +} + +fn register_f32(registers: &[u32; MAX_REGISTERS], register: u8) -> f32 { + f32::from_bits(registers[usize::from(register)]) +} + +fn store_slot(execution: &ExecutableProgram, operation_index: usize, lane: u8) -> usize { + usize::from(execution.store_buffer_indices[operation_index]) * MAX_VECTOR_WIDTH as usize + + usize::from(lane) +} + +fn store_buffer(operation: &Operation) -> Option { + match operation { + Operation::StoreF32 { buffer, .. } + | Operation::StoreU32 { buffer, .. } + | Operation::StoreU16 { buffer, .. } => Some(*buffer), + _ => None, + } +} + +fn validate_policy(descriptor: &PolicyDescriptor) -> Result<(), PolicyError> { + validate_capability_sets(&descriptor.capability_sets)?; + if descriptor.programs.is_empty() { + return Err(PolicyError::EmptyPolicy); + } + if descriptor.programs.len() > MAX_PROGRAMS { + return Err(PolicyError::TooManyPrograms); + } + for (index, program) in descriptor.programs.iter().enumerate() { + if program.technique.0 == 0 { + return Err(PolicyError::InvalidTechniqueId); + } + if program.id.0 == 0 { + return Err(PolicyError::InvalidProgramId); + } + if program.capability_set.0 != 0 + && !descriptor + .capability_sets + .iter() + .any(|set| set.id == program.capability_set) + { + return Err(PolicyError::UnknownCapabilitySet); + } + if program.resource_kind_mask == 0 { + return Err(PolicyError::InvalidResourceKinds); + } + if program.storage_key_mask & !STORAGE_KEY_FIELDS != 0 + || program.storage_key_mask & REQUIRED_STORAGE_KEYS != REQUIRED_STORAGE_KEYS + || program.draw_key_mask & !BATCH_FIELDS != 0 + || program.draw_key_mask & REQUIRED_DRAW_KEYS != REQUIRED_DRAW_KEYS + { + return Err(PolicyError::InvalidBatchKey); + } + if !matches!( + program.allocation_strategy, + ALLOCATION_ORDERED_DIRECT | ALLOCATION_STABLE_INDIRECT + ) { + return Err(PolicyError::UnsupportedAllocationStrategy); + } + let required_capability = if program.allocation_strategy == ALLOCATION_ORDERED_DIRECT { + CAP_ORDERED_DIRECT + } else { + CAP_STABLE_INDIRECT + }; + if descriptor.capability_sets.iter().any(|set| { + (program.capability_set.0 == 0 || program.capability_set == set.id) + && set.flags & required_capability == 0 + }) { + return Err(PolicyError::UnsupportedAllocationStrategy); + } + for previous in &descriptor.programs[..index] { + if previous.capability_set == program.capability_set + && previous.technique == program.technique + && previous.variant == program.variant + { + return Err(PolicyError::DuplicateTechniqueVariant); + } + if previous.id == program.id { + return Err(PolicyError::DuplicateProgramId); + } + } + validate_program(program)?; + } + if descriptor.capability_sets.iter().any(|set| { + !descriptor + .programs + .iter() + .any(|program| program.capability_set.0 == 0 || program.capability_set == set.id) + }) { + return Err(PolicyError::UnknownCapabilitySet); + } + Ok(()) +} + +fn validate_capability_sets(capability_sets: &[CapabilitySet]) -> Result<(), PolicyError> { + if capability_sets.is_empty() { + return Err(PolicyError::EmptyCapabilitySets); + } + if capability_sets.len() > MAX_CAPABILITY_SETS { + return Err(PolicyError::TooManyCapabilitySets); + } + for (index, set) in capability_sets.iter().enumerate() { + if set.id.0 == 0 { + return Err(PolicyError::InvalidCapabilitySetId); + } + if capability_sets[..index] + .iter() + .any(|previous| previous.id == set.id) + { + return Err(PolicyError::DuplicateCapabilitySetId); + } + if set.flags & !CAPABILITY_FLAGS != 0 + || set.flags & (CAP_ORDERED_DIRECT | CAP_STABLE_INDIRECT) == 0 + { + return Err(PolicyError::InvalidCapabilityFlags); + } + if set.max_buffer_bytes == 0 + || set.max_buffers_per_draw == 0 + || usize::from(set.max_buffers_per_draw) > MAX_BUFFERS_PER_PROGRAM + || set.max_resources_per_draw == 0 + || set.fragmentation_budget == 0 + { + return Err(PolicyError::InvalidCapabilityLimits); + } + if !set.update_alignment.is_power_of_two() || set.update_alignment > 256 { + return Err(PolicyError::InvalidUpdateAlignment); + } + if set.coalesce_gap_bytes > set.max_buffer_bytes + || set.range_call_penalty_bytes > set.max_buffer_bytes + || !(1..=10_000).contains(&set.whole_buffer_threshold_basis_points) + { + return Err(PolicyError::InvalidUploadCostModel); + } + if (set.flags & CAP_INDIRECT_DRAWS == 0) != (set.max_indirect_draws == 0) { + return Err(PolicyError::InvalidCapabilityLimits); + } + } + Ok(()) +} + +fn validate_program(program: &ProgramDescriptor) -> Result<(), PolicyError> { + if usize::from(program.f32_input_count) > MAX_REGISTERS + || usize::from(program.u32_input_count) > MAX_REGISTERS + { + return Err(PolicyError::TooManyInputFields); + } + if program.inputs.len() + != usize::from(program.f32_input_count) + usize::from(program.u32_input_count) + { + return Err(PolicyError::InvalidInputSources); + } + if program.buffers.is_empty() { + return Err(PolicyError::EmptyBuffers); + } + if program.buffers.len() > MAX_BUFFERS_PER_PROGRAM { + return Err(PolicyError::TooManyBuffers); + } + for (index, buffer) in program.buffers.iter().enumerate() { + if buffer.id.0 == 0 { + return Err(PolicyError::InvalidBufferId); + } + if buffer.vector_width == 0 || buffer.vector_width > MAX_VECTOR_WIDTH { + return Err(PolicyError::InvalidVectorWidth); + } + if buffer.alignment == 0 || !buffer.alignment.is_power_of_two() || buffer.alignment > 256 { + return Err(PolicyError::InvalidBufferAlignment); + } + let packed_width = buffer + .scalar + .byte_width() + .checked_mul(usize::from(buffer.vector_width)) + .ok_or(PolicyError::InvalidBufferStride)?; + if usize::from(buffer.stride) < packed_width + || usize::from(buffer.stride) % usize::from(buffer.alignment) != 0 + { + return Err(PolicyError::InvalidBufferStride); + } + if buffer.usage == 0 + || buffer.usage & !BUFFER_USAGE_FLAGS != 0 + || buffer.usage & BUFFER_USAGE_COPY_DST == 0 + { + return Err(PolicyError::InvalidBufferUsage); + } + if buffer.capacity_class == 0 { + return Err(PolicyError::InvalidCapacityClass); + } + if program.buffers[..index] + .iter() + .any(|previous| previous.id == buffer.id) + { + return Err(PolicyError::DuplicateBufferId); + } + } + if program.operations.is_empty() { + return Err(PolicyError::EmptyOperations); + } + if program.operations.len() > MAX_OPERATIONS_PER_PROGRAM { + return Err(PolicyError::TooManyOperations); + } + + let mut registers = [UNINITIALIZED; MAX_REGISTERS]; + let mut stored_lanes = [0_u8; MAX_BUFFERS_PER_PROGRAM]; + for operation in &program.operations { + validate_operation(program, operation, &mut registers, &mut stored_lanes)?; + } + for (index, buffer) in program.buffers.iter().enumerate() { + let required = (1_u8 << buffer.vector_width) - 1; + if stored_lanes[index] != required { + return Err(PolicyError::IncompleteBuffer); + } + } + Ok(()) +} + +fn validate_operation( + program: &ProgramDescriptor, + operation: &Operation, + registers: &mut [u8; MAX_REGISTERS], + stored_lanes: &mut [u8; MAX_BUFFERS_PER_PROGRAM], +) -> Result<(), PolicyError> { + match *operation { + Operation::LoadF32 { target, field } => { + if field >= program.f32_input_count { + return Err(PolicyError::InvalidInputField); + } + initialize(registers, target, F32_REGISTER) + } + Operation::LoadU32 { target, field } => { + if field >= program.u32_input_count { + return Err(PolicyError::InvalidInputField); + } + initialize(registers, target, U32_REGISTER) + } + Operation::ConstantF32 { target, bits } => { + if !f32::from_bits(bits).is_finite() { + return Err(PolicyError::NonFiniteConstant); + } + initialize(registers, target, F32_REGISTER) + } + Operation::ConstantU32 { target, .. } => initialize(registers, target, U32_REGISTER), + Operation::AddF32 { + target, + left, + right, + } + | Operation::SubtractF32 { + target, + left, + right, + } + | Operation::MultiplyF32 { + target, + left, + right, + } => { + require(registers, left, F32_REGISTER)?; + require(registers, right, F32_REGISTER)?; + initialize(registers, target, F32_REGISTER) + } + Operation::LessThanF32 { + target, + left, + right, + } => { + require(registers, left, F32_REGISTER)?; + require(registers, right, F32_REGISTER)?; + initialize(registers, target, U32_REGISTER) + } + Operation::SelectF32 { + target, + condition, + when_true, + when_false, + } => { + require(registers, condition, U32_REGISTER)?; + require(registers, when_true, F32_REGISTER)?; + require(registers, when_false, F32_REGISTER)?; + initialize(registers, target, F32_REGISTER) + } + Operation::ConvertU32ToF32 { target, source } => { + require(registers, source, U32_REGISTER)?; + initialize(registers, target, F32_REGISTER) + } + Operation::StoreF32 { + source, + buffer, + lane, + } => { + require(registers, source, F32_REGISTER)?; + validate_store(program, buffer, lane, ScalarType::F32, stored_lanes) + } + Operation::StoreU32 { + source, + buffer, + lane, + } => { + require(registers, source, U32_REGISTER)?; + validate_store(program, buffer, lane, ScalarType::U32, stored_lanes) + } + Operation::StoreU16 { + source, + buffer, + lane, + } => { + require(registers, source, U32_REGISTER)?; + validate_store(program, buffer, lane, ScalarType::U16, stored_lanes) + } + } +} + +fn initialize( + registers: &mut [u8; MAX_REGISTERS], + target: u8, + register_type: u8, +) -> Result<(), PolicyError> { + let slot = registers + .get_mut(usize::from(target)) + .ok_or(PolicyError::InvalidRegister)?; + *slot = register_type; + Ok(()) +} + +fn require( + registers: &[u8; MAX_REGISTERS], + register: u8, + register_type: u8, +) -> Result<(), PolicyError> { + let actual = *registers + .get(usize::from(register)) + .ok_or(PolicyError::InvalidRegister)?; + if actual == UNINITIALIZED { + return Err(PolicyError::UninitializedRegister); + } + if actual != register_type { + return Err(PolicyError::RegisterTypeMismatch); + } + Ok(()) +} + +fn validate_store( + program: &ProgramDescriptor, + buffer: BufferId, + lane: u8, + scalar: ScalarType, + stored_lanes: &mut [u8; MAX_BUFFERS_PER_PROGRAM], +) -> Result<(), PolicyError> { + let index = program + .buffers + .iter() + .position(|candidate| candidate.id == buffer) + .ok_or(PolicyError::UnknownBuffer)?; + let schema = program.buffers[index]; + if schema.scalar != scalar { + return Err(PolicyError::StoreTypeMismatch); + } + if lane >= schema.vector_width { + return Err(PolicyError::InvalidStoreLane); + } + let mask = 1_u8 << lane; + if stored_lanes[index] & mask != 0 { + return Err(PolicyError::DuplicateStore); + } + stored_lanes[index] |= mask; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + + const BITMAP: TechniqueId = TechniqueId(1); + const PROGRAM: ProgramId = ProgramId(1); + const ORIGINS: BufferId = BufferId(1); + const COLORS: BufferId = BufferId(2); + const CAPABILITY: CapabilitySetId = CapabilitySetId(1); + + fn valid_capability_set() -> CapabilitySet { + CapabilitySet { + id: CAPABILITY, + flags: CAP_STORAGE_BUFFERS | CAP_ORDERED_DIRECT | CAP_STABLE_INDIRECT, + max_buffer_bytes: 64 * 1024 * 1024, + update_alignment: 4, + coalesce_gap_bytes: 128, + range_call_penalty_bytes: 256, + max_buffers_per_draw: MAX_BUFFERS_PER_PROGRAM as u16, + max_resources_per_draw: 16, + max_indirect_draws: 0, + fragmentation_budget: 8, + whole_buffer_threshold_basis_points: 7_500, + } + } + + fn descriptor(programs: Vec) -> PolicyDescriptor { + PolicyDescriptor { + capability_sets: vec![valid_capability_set()], + programs, + } + } + + fn valid_program() -> ProgramDescriptor { + ProgramDescriptor { + technique: BITMAP, + variant: 0, + id: PROGRAM, + capability_set: CapabilitySetId(0), + resource_kind_mask: 1, + semantic_view_mask: 0, + storage_key_mask: BATCH_TECHNIQUE | BATCH_PROGRAM | BATCH_RESOURCE, + draw_key_mask: BATCH_TECHNIQUE + | BATCH_PROGRAM + | BATCH_RESOURCE + | BATCH_ORDER + | BATCH_TRANSFORM, + allocation_strategy: ALLOCATION_ORDERED_DIRECT, + f32_input_count: 2, + u32_input_count: 0, + inputs: vec![InputSource::semantic(0), InputSource::semantic(1)], + capabilities: ProgramCapabilities::default(), + buffers: vec![BufferSchema::packed( + ORIGINS, + ScalarType::F32, + 2, + BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST, + 1, + )], + operations: vec![ + Operation::LoadF32 { + target: 0, + field: 0, + }, + Operation::LoadF32 { + target: 1, + field: 1, + }, + Operation::StoreF32 { + source: 0, + buffer: ORIGINS, + lane: 0, + }, + Operation::StoreF32 { + source: 1, + buffer: ORIGINS, + lane: 1, + }, + ], + } + } + + #[test] + fn accepts_complete_straight_line_program() { + let policy = ValidatedPolicy::new(descriptor(vec![valid_program()])).unwrap(); + assert_eq!( + policy.program(CAPABILITY, BITMAP, 0).map(|value| value.id), + Some(PROGRAM) + ); + assert_eq!(policy.program(CAPABILITY, BITMAP, 1), None); + assert_eq!( + policy.buffer_dependency_masks(CAPABILITY, BITMAP, 0), + Some([0b11].as_slice()) + ); + } + + #[test] + fn reports_only_a_truly_uniform_allocation_strategy() { + let ordered = valid_program(); + let uniform = ValidatedPolicy::new(descriptor(vec![ordered.clone()])).unwrap(); + assert_eq!( + uniform.uniform_allocation_strategy(CAPABILITY), + Some(ALLOCATION_ORDERED_DIRECT) + ); + + let mut stable = ordered; + stable.technique = TechniqueId(2); + stable.id = ProgramId(2); + stable.allocation_strategy = ALLOCATION_STABLE_INDIRECT; + let mixed = ValidatedPolicy::new(descriptor(vec![valid_program(), stable])).unwrap(); + assert_eq!(mixed.uniform_allocation_strategy(CAPABILITY), None); + } + + #[test] + fn compiles_each_operation_to_only_its_reachable_buffers() { + let mut program = valid_program(); + program.buffers.push(BufferSchema::packed( + COLORS, + ScalarType::U32, + 1, + BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST, + 1, + )); + program.operations.extend([ + Operation::ConstantU32 { + target: 2, + value: 0xff00_00ff, + }, + Operation::StoreU32 { + source: 2, + buffer: COLORS, + lane: 0, + }, + ]); + let policy = ValidatedPolicy::new(descriptor(vec![program])).unwrap(); + + assert_eq!( + policy.execution[0].operation_buffer_masks, + [1, 1, 1, 1, 2, 2] + ); + } + + #[test] + fn input_sources_are_exact_and_participate_in_policy_identity() { + let program = valid_program(); + let first = ValidatedPolicy::new(descriptor(vec![program.clone()])).unwrap(); + let mut changed = program.clone(); + changed.inputs[1] = InputSource { + scope: InputScope::Glyph, + field: 0, + }; + let second = ValidatedPolicy::new(descriptor(vec![changed])).unwrap(); + assert_ne!(first.fingerprint(), second.fingerprint()); + + let mut missing = program; + missing.inputs.pop(); + assert_eq!( + ValidatedPolicy::new(descriptor(vec![missing])), + Err(PolicyError::InvalidInputSources) + ); + } + + #[test] + fn fingerprints_exact_validated_policy_content() { + let first = ValidatedPolicy::new(descriptor(vec![valid_program()])).unwrap(); + let same = ValidatedPolicy::new(descriptor(vec![valid_program()])).unwrap(); + let mut changed_program = valid_program(); + changed_program.technique = TechniqueId(2); + let changed = ValidatedPolicy::new(descriptor(vec![changed_program])).unwrap(); + assert_eq!(first.fingerprint(), same.fingerprint()); + assert_ne!(first.fingerprint(), changed.fingerprint()); + } + + #[test] + fn rejects_uninitialized_and_wrong_type_registers() { + let mut uninitialized = valid_program(); + uninitialized.operations[2] = Operation::StoreF32 { + source: 4, + buffer: ORIGINS, + lane: 0, + }; + assert_eq!( + ValidatedPolicy::new(descriptor(vec![uninitialized])), + Err(PolicyError::UninitializedRegister) + ); + + let mut wrong_type = valid_program(); + wrong_type.operations[0] = Operation::ConstantU32 { + target: 0, + value: 1, + }; + assert_eq!( + ValidatedPolicy::new(descriptor(vec![wrong_type])), + Err(PolicyError::RegisterTypeMismatch) + ); + } + + #[test] + fn rejects_partial_duplicate_and_out_of_range_stores() { + let mut partial = valid_program(); + partial.operations.pop(); + assert_eq!( + ValidatedPolicy::new(descriptor(vec![partial])), + Err(PolicyError::IncompleteBuffer) + ); + + let mut duplicate = valid_program(); + duplicate.operations[3] = Operation::StoreF32 { + source: 1, + buffer: ORIGINS, + lane: 0, + }; + assert_eq!( + ValidatedPolicy::new(descriptor(vec![duplicate])), + Err(PolicyError::DuplicateStore) + ); + + let mut out_of_range = valid_program(); + out_of_range.operations[3] = Operation::StoreF32 { + source: 1, + buffer: ORIGINS, + lane: 2, + }; + assert_eq!( + ValidatedPolicy::new(descriptor(vec![out_of_range])), + Err(PolicyError::InvalidStoreLane) + ); + } + + #[test] + fn rejects_duplicate_technique_variants_and_program_ids() { + let first = valid_program(); + let mut same_variant = valid_program(); + same_variant.id = ProgramId(2); + assert_eq!( + ValidatedPolicy::new(descriptor(vec![first.clone(), same_variant])), + Err(PolicyError::DuplicateTechniqueVariant) + ); + + let mut duplicate_id = valid_program(); + duplicate_id.technique = TechniqueId(2); + assert_eq!( + ValidatedPolicy::new(descriptor(vec![first, duplicate_id])), + Err(PolicyError::DuplicateProgramId) + ); + } + + #[test] + fn accepts_same_technique_with_distinct_variants() { + let first = valid_program(); + let mut second = valid_program(); + second.variant = 1; + second.id = ProgramId(2); + let policy = ValidatedPolicy::new(descriptor(vec![first, second])).unwrap(); + assert_eq!(policy.programs().len(), 2); + } + + #[test] + fn storage_and_draw_keys_validate_independently() { + let mut missing_storage_resource = valid_program(); + missing_storage_resource.storage_key_mask = BATCH_TECHNIQUE | BATCH_PROGRAM; + assert_eq!( + ValidatedPolicy::new(descriptor(vec![missing_storage_resource])).unwrap_err(), + PolicyError::InvalidBatchKey, + ); + + let mut missing_draw_order = valid_program(); + missing_draw_order.draw_key_mask = BATCH_TECHNIQUE | BATCH_PROGRAM | BATCH_RESOURCE; + assert_eq!( + ValidatedPolicy::new(descriptor(vec![missing_draw_order])).unwrap_err(), + PolicyError::InvalidBatchKey, + ); + + let mut material_partitioned = valid_program(); + material_partitioned.storage_key_mask |= BATCH_MATERIAL; + material_partitioned.draw_key_mask |= BATCH_MATERIAL; + assert!(ValidatedPolicy::new(descriptor(vec![material_partitioned])).is_ok()); + } + + #[test] + fn capability_sets_select_exact_programs_and_reject_invalid_costs() { + let mut webgpu = valid_capability_set(); + webgpu.id = CapabilitySetId(1); + webgpu.flags = CAP_ORDERED_DIRECT; + let mut webgl = valid_capability_set(); + webgl.id = CapabilitySetId(2); + webgl.flags = CAP_STABLE_INDIRECT; + + let mut direct = valid_program(); + direct.capability_set = webgpu.id; + let mut indirect = valid_program(); + indirect.id = ProgramId(2); + indirect.capability_set = webgl.id; + indirect.allocation_strategy = ALLOCATION_STABLE_INDIRECT; + let policy = ValidatedPolicy::new(PolicyDescriptor { + capability_sets: vec![webgpu, webgl], + programs: vec![direct, indirect], + }) + .unwrap(); + assert_eq!( + policy.program(CapabilitySetId(1), BITMAP, 0).unwrap().id, + ProgramId(1) + ); + assert_eq!( + policy.program(CapabilitySetId(2), BITMAP, 0).unwrap().id, + ProgramId(2) + ); + assert_eq!(policy.program(CapabilitySetId(3), BITMAP, 0), None); + + let mut invalid_cost = valid_capability_set(); + invalid_cost.whole_buffer_threshold_basis_points = 10_001; + assert_eq!( + ValidatedPolicy::new(PolicyDescriptor { + capability_sets: vec![invalid_cost], + programs: vec![valid_program()], + }), + Err(PolicyError::InvalidUploadCostModel) + ); + } + + #[test] + fn executor_honors_policy_stride_without_touching_padding() { + let mut program = valid_program(); + program.buffers[0].alignment = 16; + program.buffers[0].stride = 16; + let policy = ValidatedPolicy::new(descriptor(vec![program])).unwrap(); + let x = [1.0, 2.0]; + let y = [3.0, 4.0]; + let fields: [&[f32]; 2] = [&x, &y]; + let mut bytes = [0xa5_u8; 32]; + let mut outputs = [PhysicalBufferMut { + schema: policy.program(CAPABILITY, BITMAP, 0).unwrap().buffers[0], + bytes: &mut bytes, + }]; + policy + .execute( + CAPABILITY, + BITMAP, + 0, + SemanticInputBatch { + f32_fields: &fields, + u32_fields: &[], + record_count: 2, + }, + 0, + &mut outputs, + ) + .unwrap(); + assert_eq!(read_f32(&bytes, 0), 1.0); + assert_eq!(read_f32(&bytes, 4), 3.0); + assert_eq!(&bytes[8..16], &[0xa5; 8]); + assert_eq!(read_f32(&bytes, 16), 2.0); + assert_eq!(read_f32(&bytes, 20), 4.0); + assert_eq!(&bytes[24..32], &[0xa5; 8]); + } + + #[test] + fn scalar_executor_writes_a_bounded_record_range_without_touching_spares() { + let policy = ValidatedPolicy::new(descriptor(vec![valid_program()])).unwrap(); + let x = [1.25, -2.5, 8.0]; + let y = [4.0, 6.5, -9.0]; + let fields: [&[f32]; 2] = [&x, &y]; + let schema = policy.program(CAPABILITY, BITMAP, 0).unwrap().buffers[0]; + let mut bytes = [0x7f_u8; 5 * 8]; + { + let mut outputs = [PhysicalBufferMut { + schema, + bytes: &mut bytes, + }]; + policy + .execute( + CAPABILITY, + BITMAP, + 0, + SemanticInputBatch { + f32_fields: &fields, + u32_fields: &[], + record_count: x.len(), + }, + 1, + &mut outputs, + ) + .unwrap(); + } + assert_eq!(&bytes[..8], &[0x7f; 8]); + assert_eq!(&bytes[32..], &[0x7f; 8]); + for (record, expected) in x.into_iter().zip(y).enumerate() { + let offset = (record + 1) * 8; + assert_eq!(read_f32(&bytes, offset).to_bits(), expected.0.to_bits()); + assert_eq!(read_f32(&bytes, offset + 4).to_bits(), expected.1.to_bits()); + } + } + + #[test] + fn scalar_executor_preserves_typed_arithmetic_selection_and_narrowing() { + let color = BufferId(1); + let object = BufferId(2); + let page = BufferId(3); + let policy = ValidatedPolicy::new(descriptor(vec![ProgramDescriptor { + technique: BITMAP, + variant: 0, + id: PROGRAM, + capability_set: CapabilitySetId(0), + resource_kind_mask: 1, + semantic_view_mask: 0, + storage_key_mask: BATCH_TECHNIQUE | BATCH_PROGRAM | BATCH_RESOURCE, + draw_key_mask: BATCH_TECHNIQUE + | BATCH_PROGRAM + | BATCH_RESOURCE + | BATCH_ORDER + | BATCH_TRANSFORM, + allocation_strategy: ALLOCATION_ORDERED_DIRECT, + f32_input_count: 1, + u32_input_count: 1, + inputs: vec![InputSource::semantic(0), InputSource::semantic(0)], + capabilities: ProgramCapabilities::default(), + buffers: vec![ + BufferSchema::packed( + color, + ScalarType::F32, + 2, + BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST, + 1, + ), + BufferSchema::packed( + object, + ScalarType::U32, + 1, + BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST, + 1, + ), + BufferSchema::packed( + page, + ScalarType::U16, + 1, + BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST, + 1, + ), + ], + operations: vec![ + Operation::LoadF32 { + target: 0, + field: 0, + }, + Operation::ConstantF32 { + target: 1, + bits: 0.0_f32.to_bits(), + }, + Operation::LessThanF32 { + target: 2, + left: 0, + right: 1, + }, + Operation::ConstantF32 { + target: 3, + bits: (-1.0_f32).to_bits(), + }, + Operation::SelectF32 { + target: 4, + condition: 2, + when_true: 3, + when_false: 0, + }, + Operation::LoadU32 { + target: 5, + field: 0, + }, + Operation::ConvertU32ToF32 { + target: 6, + source: 5, + }, + Operation::AddF32 { + target: 7, + left: 4, + right: 6, + }, + Operation::ConstantF32 { + target: 8, + bits: 2.0_f32.to_bits(), + }, + Operation::MultiplyF32 { + target: 9, + left: 0, + right: 8, + }, + Operation::StoreF32 { + source: 7, + buffer: color, + lane: 0, + }, + Operation::StoreF32 { + source: 9, + buffer: color, + lane: 1, + }, + Operation::StoreU32 { + source: 5, + buffer: object, + lane: 0, + }, + Operation::StoreU16 { + source: 5, + buffer: page, + lane: 0, + }, + ], + }])) + .unwrap(); + let signed = [-2.0, 3.0]; + let identifiers = [70_000, 42]; + let f32_fields: [&[f32]; 1] = [&signed]; + let u32_fields: [&[u32]; 1] = [&identifiers]; + let mut colors = [0_u8; 16]; + let mut objects = [0_u8; 8]; + let mut pages = [0_u8; 4]; + let program = policy.program(CAPABILITY, BITMAP, 0).unwrap(); + let mut outputs = [ + PhysicalBufferMut { + schema: program.buffers[0], + bytes: &mut colors, + }, + PhysicalBufferMut { + schema: program.buffers[1], + bytes: &mut objects, + }, + PhysicalBufferMut { + schema: program.buffers[2], + bytes: &mut pages, + }, + ]; + policy + .execute( + CAPABILITY, + BITMAP, + 0, + SemanticInputBatch { + f32_fields: &f32_fields, + u32_fields: &u32_fields, + record_count: 2, + }, + 0, + &mut outputs, + ) + .unwrap(); + assert_eq!(read_f32(&colors, 0), 69_999.0); + assert_eq!(read_f32(&colors, 4), -4.0); + assert_eq!(read_f32(&colors, 8), 45.0); + assert_eq!(read_f32(&colors, 12), 6.0); + assert_eq!(read_u32(&objects, 0), 70_000); + assert_eq!(read_u32(&objects, 4), 42); + assert_eq!(read_u16(&pages, 0), 70_000_u32 as u16); + assert_eq!(read_u16(&pages, 2), 42); + } + + #[test] + fn scalar_executor_rejects_invalid_shapes_before_writing() { + let policy = ValidatedPolicy::new(descriptor(vec![valid_program()])).unwrap(); + let x = [1.0, 2.0]; + let short_y = [3.0]; + let fields: [&[f32]; 2] = [&x, &short_y]; + let mut bytes = [0xa5_u8; 16]; + let mut outputs = [PhysicalBufferMut { + schema: policy.program(CAPABILITY, BITMAP, 0).unwrap().buffers[0], + bytes: &mut bytes, + }]; + assert_eq!( + policy.execute( + CAPABILITY, + BITMAP, + 0, + SemanticInputBatch { + f32_fields: &fields, + u32_fields: &[], + record_count: 2, + }, + 0, + &mut outputs, + ), + Err(PolicyExecutionError::InputLength) + ); + assert_eq!(bytes, [0xa5; 16]); + } + + #[test] + fn policy_and_executor_reject_nonfinite_physical_values() { + let mut constant = valid_program(); + constant.operations[0] = Operation::ConstantF32 { + target: 0, + bits: f32::INFINITY.to_bits(), + }; + assert_eq!( + ValidatedPolicy::new(descriptor(vec![constant])), + Err(PolicyError::NonFiniteConstant) + ); + + let mut overflow = valid_program(); + overflow.operations.insert( + 2, + Operation::MultiplyF32 { + target: 0, + left: 0, + right: 1, + }, + ); + let policy = ValidatedPolicy::new(descriptor(vec![overflow])).unwrap(); + let values = [f32::MAX]; + let fields: [&[f32]; 2] = [&values, &values]; + let mut bytes = [0x5a_u8; 8]; + let mut outputs = [PhysicalBufferMut { + schema: policy.program(CAPABILITY, BITMAP, 0).unwrap().buffers[0], + bytes: &mut bytes, + }]; + assert_eq!( + policy.execute( + CAPABILITY, + BITMAP, + 0, + SemanticInputBatch { + f32_fields: &fields, + u32_fields: &[], + record_count: 1, + }, + 0, + &mut outputs, + ), + Err(PolicyExecutionError::NonFiniteOutput) + ); + assert_eq!(bytes, [0x5a; 8]); + } + + fn read_f32(bytes: &[u8], offset: usize) -> f32 { + f32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()) + } + + fn read_u32(bytes: &[u8], offset: usize) -> u32 { + u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()) + } + + fn read_u16(bytes: &[u8], offset: usize) -> u16 { + u16::from_le_bytes(bytes[offset..offset + 2].try_into().unwrap()) + } +} diff --git a/packages/text/rust/shaper/src/engine/policy_gather.rs b/packages/text/rust/shaper/src/engine/policy_gather.rs new file mode 100644 index 00000000..2abe732b --- /dev/null +++ b/packages/text/rust/shaper/src/engine/policy_gather.rs @@ -0,0 +1,1497 @@ +//! Policy-directed gather from semantic layout and normalized font bindings. + +use alloc::vec::Vec; + +use super::{ + font_binding::{FontRenderBinding, SelectedGlyphBinding}, + frame::{ + SEMANTIC_F32_BLOCK_ORIGIN, SEMANTIC_F32_FOREGROUND_ALPHA, SEMANTIC_F32_FOREGROUND_BLUE, + SEMANTIC_F32_FOREGROUND_GREEN, SEMANTIC_F32_FOREGROUND_RED, SEMANTIC_F32_INLINE_ORIGIN, + SEMANTIC_F32_INVERSE_FONT_SIZE, SEMANTIC_U32_CLUSTER_ID, SEMANTIC_U32_FOREGROUND_RGBA, + }, + plan_input::{PlanGlyph, PlanInput}, + policy::{CapabilitySetId, InputScope, MAX_REGISTERS, ProgramDescriptor, ValidatedPolicy}, + positioning::SemanticGlyph, +}; + +// `fontSize` and `rasterPixelRatio` can select a different baked resource. +// A replacement batch must therefore receive every input stream once even when +// the policy dependency mask would omit unchanged fields from an in-place patch. +const RESOURCE_SELECTION_CHANGES: u16 = (1 << 4) | (1 << 5); + +// Exact Float32 results of the IEC 61966-2-1 sRGB transfer for every byte value. +// Foreground colors stay one compact u32 per glyph while policy output reproduces +// the renderer's prior linear Float32 instance records without a hot-path powf. +const SRGB8_TO_LINEAR_BITS: [u32; 256] = [ + 0x00000000, 0x399f22b4, 0x3a1f22b4, 0x3a6eb40e, 0x3a9f22b4, 0x3ac6eb61, 0x3aeeb40e, 0x3b0b3e5d, + 0x3b1f22b4, 0x3b33070a, 0x3b46eb61, 0x3b5b518e, 0x3b70f18f, 0x3b83e1c6, 0x3b8fe616, 0x3b9c87fd, + 0x3ba9c9b6, 0x3bb7ad6f, 0x3bc6354a, 0x3bd56360, 0x3be539c1, 0x3bf5ba71, 0x3c0373b6, 0x3c0c6153, + 0x3c15a705, 0x3c1f45be, 0x3c293e6b, 0x3c3391f7, 0x3c3e4149, 0x3c494d44, 0x3c54b6c9, 0x3c607eb4, + 0x3c6ca5df, 0x3c792d22, 0x3c830aa9, 0x3c89af9f, 0x3c9085dc, 0x3c978dc6, 0x3c9ec7c2, 0x3ca63433, + 0x3cadd37d, 0x3cb5a602, 0x3cbdac21, 0x3cc5e63a, 0x3cce54ac, 0x3cd6f7d5, 0x3cdfd010, 0x3ce8ddba, + 0x3cf2212d, 0x3cfb9ac3, 0x3d02a56a, 0x3d0798dd, 0x3d0ca7e6, 0x3d11d2af, 0x3d171964, 0x3d1c7c30, + 0x3d21fb3c, 0x3d2796b2, 0x3d2d4ebb, 0x3d332381, 0x3d39152b, 0x3d3f23e4, 0x3d454fd2, 0x3d4b991d, + 0x3d51ffec, 0x3d588468, 0x3d5f26b6, 0x3d65e6fd, 0x3d6cc563, 0x3d73c20e, 0x3d7add24, 0x3d810b65, + 0x3d84b793, 0x3d88732e, 0x3d8c3e48, 0x3d9018f4, 0x3d940344, 0x3d97fd49, 0x3d9c0715, 0x3da020ba, + 0x3da44a4a, 0x3da883d6, 0x3daccd6f, 0x3db12727, 0x3db5910f, 0x3dba0b38, 0x3dbe95b3, 0x3dc33090, + 0x3dc7dbe0, 0x3dcc97b4, 0x3dd1641d, 0x3dd6412b, 0x3ddb2eee, 0x3de02d76, 0x3de53cd4, 0x3dea5d18, + 0x3def8e51, 0x3df4d090, 0x3dfa23e5, 0x3dff885e, 0x3e027f06, 0x3e05427f, 0x3e080ea2, 0x3e0ae377, + 0x3e0dc104, 0x3e10a753, 0x3e13966a, 0x3e168e51, 0x3e198f0f, 0x3e1c98ac, 0x3e1fab30, 0x3e22c6a1, + 0x3e25eb07, 0x3e29186a, 0x3e2c4ed0, 0x3e2f8e42, 0x3e32d6c5, 0x3e362862, 0x3e39831f, 0x3e3ce703, + 0x3e405417, 0x3e43ca60, 0x3e4749e6, 0x3e4ad2af, 0x3e4e64c3, 0x3e520029, 0x3e55a4e7, 0x3e595305, + 0x3e5d0a89, 0x3e60cb7a, 0x3e6495df, 0x3e6869be, 0x3e6c471f, 0x3e702e07, 0x3e741e7e, 0x3e78188b, + 0x3e7c1c33, 0x3e8014bf, 0x3e822039, 0x3e84308b, 0x3e8645b8, 0x3e885fc3, 0x3e8a7eb0, 0x3e8ca281, + 0x3e8ecb3b, 0x3e90f8df, 0x3e932b72, 0x3e9562f6, 0x3e979f6f, 0x3e99e0e0, 0x3e9c274c, 0x3e9e72b6, + 0x3ea0c321, 0x3ea31890, 0x3ea57307, 0x3ea7d288, 0x3eaa3716, 0x3eaca0b6, 0x3eaf0f68, 0x3eb18332, + 0x3eb3fc15, 0x3eb67a14, 0x3eb8fd34, 0x3ebb8576, 0x3ebe12de, 0x3ec0a56e, 0x3ec33d2a, 0x3ec5da14, + 0x3ec87c30, 0x3ecb2380, 0x3ecdd008, 0x3ed081ca, 0x3ed338c9, 0x3ed5f508, 0x3ed8b68a, 0x3edb7d52, + 0x3ede4963, 0x3ee11abf, 0x3ee3f169, 0x3ee6cd65, 0x3ee9aeb5, 0x3eec955b, 0x3eef815c, 0x3ef272b8, + 0x3ef56974, 0x3ef86593, 0x3efb6716, 0x3efe6e00, 0x3f00bd2b, 0x3f02460c, 0x3f03d1a5, 0x3f055ff7, + 0x3f06f104, 0x3f0884cd, 0x3f0a1b54, 0x3f0bb499, 0x3f0d509f, 0x3f0eef65, 0x3f1090ef, 0x3f12353d, + 0x3f13dc50, 0x3f15862a, 0x3f1732cc, 0x3f18e237, 0x3f1a946e, 0x3f1c4970, 0x3f1e0140, 0x3f1fbbde, + 0x3f21794d, 0x3f23398c, 0x3f24fc9f, 0x3f26c285, 0x3f288b41, 0x3f2a56d2, 0x3f2c253c, 0x3f2df67f, + 0x3f2fca9c, 0x3f31a194, 0x3f337b6a, 0x3f35581d, 0x3f3737b0, 0x3f391a24, 0x3f3aff7a, 0x3f3ce7b2, + 0x3f3ed2cf, 0x3f40c0d2, 0x3f42b1bc, 0x3f44a58e, 0x3f469c49, 0x3f4895ef, 0x3f4a9280, 0x3f4c91ff, + 0x3f4e946c, 0x3f5099c9, 0x3f52a216, 0x3f54ad56, 0x3f56bb88, 0x3f58ccaf, 0x3f5ae0cc, 0x3f5cf7df, + 0x3f5f11ea, 0x3f612eef, 0x3f634eee, 0x3f6571e9, 0x3f6797e0, 0x3f69c0d5, 0x3f6becca, 0x3f6e1bbf, + 0x3f704db5, 0x3f7282ae, 0x3f74baab, 0x3f76f5ae, 0x3f7933b6, 0x3f7b74c6, 0x3f7db8de, 0x3f800000, +]; + +pub const DEFAULT_GATHER_RECORD_CAPACITY: usize = 32_768; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LayoutGlyph { + pub stable_id: u32, + pub content_revision: u32, + pub semantic_glyph_index: u32, + pub binding_handle: u32, + pub font_handle: u32, + pub glyph_id: u32, + pub material_id: u32, + pub clip_id: u32, + pub depth_key: u32, + pub font_size: f32, + pub raster_pixel_ratio: f32, + pub inline_start: f32, + pub block_start: f32, + pub inline_extent: f32, + pub block_extent: f32, +} + +#[derive(Clone, Copy)] +pub struct LayoutPlanInput<'a> { + pub transform_id: u32, + pub glyphs: &'a [LayoutGlyph], + pub(crate) semantic_glyphs: &'a [SemanticGlyph], + pub semantic_change_masks: &'a [u16], + pub semantic_f32: &'a [&'a [f32]], + pub semantic_u32: &'a [&'a [u32]], +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GatherError { + AllocationFailed, + InvalidSemanticShape, + FontBindingMissing, + GlyphBindingMissing, + ResourceBindingMissing, + ProgramMissing, + SourceFieldMissing, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RetainedGather { + Complete, + RebuildFrom(usize), +} + +#[derive(Default)] +pub struct PolicyGatherWorkspace { + glyphs: Vec, + source_selected: Vec, + semantic_change_masks: Vec, + f32_fields: Vec>, + u32_fields: Vec>, + retained_cursor: usize, + retained_source_cursor: usize, +} + +#[repr(C, align(16))] +struct AlignedBlock { + values: [T; 4], +} + +struct AlignedField { + blocks: Vec>, + len: usize, +} + +pub struct GatheredPlanInput<'a> { + glyphs: &'a [PlanGlyph], + semantic_change_masks: &'a [u16], + f32_fields: [&'a [f32]; MAX_REGISTERS], + u32_fields: [&'a [u32]; MAX_REGISTERS], + f32_field_count: usize, + u32_field_count: usize, +} + +impl PolicyGatherWorkspace { + pub fn reserve_records(&mut self, record_capacity: usize) -> Result<(), GatherError> { + reserve(&mut self.glyphs, record_capacity)?; + reserve(&mut self.source_selected, record_capacity)?; + reserve(&mut self.semantic_change_masks, record_capacity) + } + + pub fn reserve_policy( + &mut self, + policy: &ValidatedPolicy, + record_capacity: usize, + ) -> Result<(), GatherError> { + let f32_fields = policy + .programs() + .iter() + .map(|program| usize::from(program.f32_input_count)) + .max() + .unwrap_or(0); + let u32_fields = policy + .programs() + .iter() + .map(|program| usize::from(program.u32_input_count)) + .max() + .unwrap_or(0); + reserve_fields(&mut self.f32_fields, f32_fields, record_capacity)?; + reserve_fields(&mut self.u32_fields, u32_fields, record_capacity)?; + self.reserve_records(record_capacity)?; + Ok(()) + } + + pub fn gather<'binding>( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + input: LayoutPlanInput<'_>, + force_all_inputs: bool, + binding_for_font: impl FnMut(u32) -> Option<&'binding FontRenderBinding>, + ) -> Result<(), GatherError> { + self.begin(policy, input.glyphs.len())?; + self.append( + policy, + capability_set, + input, + force_all_inputs, + binding_for_font, + ) + } + + pub fn begin( + &mut self, + policy: &ValidatedPolicy, + record_capacity: usize, + ) -> Result<(), GatherError> { + self.reserve_policy(policy, record_capacity)?; + self.clear(); + Ok(()) + } + + pub fn begin_retained( + &mut self, + policy: &ValidatedPolicy, + record_capacity: usize, + ) -> Result { + self.reserve_policy(policy, record_capacity)?; + self.retained_cursor = 0; + self.retained_source_cursor = 0; + let retained_len = self.glyphs.len(); + Ok(self.semantic_change_masks.len() == retained_len + && self + .f32_fields + .iter() + .all(|field| field.len == retained_len) + && self + .u32_fields + .iter() + .all(|field| field.len == retained_len)) + } + + pub fn append_retained<'binding>( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + input: LayoutPlanInput<'_>, + mut binding_for_font: impl FnMut(u32) -> Option<&'binding FontRenderBinding>, + ) -> Result { + validate_semantic_shape(input)?; + let mut cursor = self.retained_cursor; + let mut source_cursor = self.retained_source_cursor; + let mut cached_font_handle = None; + let mut cached_binding = None; + let mut cached_program = None; + for glyph_index in 0..input.glyphs.len() { + let glyph = input.glyphs[glyph_index]; + let Some(was_selected) = self.source_selected.get(source_cursor).copied() else { + self.retained_cursor = cursor; + self.retained_source_cursor = source_cursor; + return Ok(RetainedGather::RebuildFrom(glyph_index)); + }; + source_cursor += 1; + let change_mask = input + .semantic_change_masks + .get(glyph_index) + .copied() + .unwrap_or(0); + if change_mask == 0 { + if was_selected != 0 { + let Some(previous) = self.glyphs.get(cursor) else { + self.retained_cursor = cursor; + self.retained_source_cursor = source_cursor - 1; + return Ok(RetainedGather::RebuildFrom(glyph_index)); + }; + if previous.stable_id != glyph.stable_id + || previous.transform_id != input.transform_id + { + self.retained_cursor = cursor; + self.retained_source_cursor = source_cursor - 1; + return Ok(RetainedGather::RebuildFrom(glyph_index)); + } + self.semantic_change_masks[cursor] = 0; + cursor += 1; + } + continue; + } + let binding = if cached_font_handle == Some(glyph.binding_handle) { + cached_binding.ok_or(GatherError::FontBindingMissing)? + } else { + let binding = binding_for_font(glyph.binding_handle) + .ok_or(GatherError::FontBindingMissing)?; + cached_font_handle = Some(glyph.binding_handle); + cached_binding = Some(binding); + binding + }; + let selected = + binding.select(glyph.glyph_id, glyph.font_size, glyph.raster_pixel_ratio); + if selected.is_some() != (was_selected != 0) { + self.retained_cursor = cursor; + self.retained_source_cursor = source_cursor - 1; + return Ok(RetainedGather::RebuildFrom(glyph_index)); + } + let Some(selected) = selected else { + continue; + }; + let technique = binding.technique(); + let variant = binding.program_variant(); + let program = match cached_program { + Some((cached_technique, cached_variant, program)) + if cached_technique == technique && cached_variant == variant => + { + program + } + _ => { + let program = policy + .program(capability_set, technique, variant) + .ok_or(GatherError::ProgramMissing)?; + cached_program = Some((technique, variant, program)); + program + } + }; + let next = plan_glyph(input, glyph_index, glyph, binding, selected)?; + let Some(previous) = self.glyphs.get(cursor).copied() else { + self.retained_cursor = cursor; + self.retained_source_cursor = source_cursor - 1; + return Ok(RetainedGather::RebuildFrom(glyph_index)); + }; + if !same_storage_topology(previous, next) { + self.retained_cursor = cursor; + self.retained_source_cursor = source_cursor - 1; + return Ok(RetainedGather::RebuildFrom(glyph_index)); + } + let selection_changed = change_mask & RESOURCE_SELECTION_CHANGES != 0; + let (f32_inputs, u32_inputs) = policy + .input_masks_for_changes( + capability_set, + technique, + variant, + change_mask, + selection_changed, + ) + .ok_or(GatherError::ProgramMissing)?; + self.update_fields( + cursor, + input, + glyph_index, + binding, + selected, + program, + f32_inputs, + u32_inputs, + )?; + self.glyphs[cursor] = next; + self.semantic_change_masks[cursor] = change_mask; + cursor += 1; + } + self.retained_cursor = cursor; + self.retained_source_cursor = source_cursor; + Ok(RetainedGather::Complete) + } + + pub fn finish_retained(&self) -> bool { + self.retained_cursor == self.glyphs.len() + && self.retained_source_cursor == self.source_selected.len() + } + + pub fn truncate_to_retained_prefix(&mut self) { + self.glyphs.truncate(self.retained_cursor); + self.source_selected.truncate(self.retained_source_cursor); + self.semantic_change_masks.truncate(self.retained_cursor); + for field in &mut self.f32_fields { + field.truncate(self.retained_cursor); + } + for field in &mut self.u32_fields { + field.truncate(self.retained_cursor); + } + } + + pub fn append<'binding>( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + input: LayoutPlanInput<'_>, + force_all_inputs: bool, + binding_for_font: impl FnMut(u32) -> Option<&'binding FontRenderBinding>, + ) -> Result<(), GatherError> { + self.append_from( + policy, + capability_set, + input, + 0, + force_all_inputs, + binding_for_font, + ) + } + + pub fn append_from<'binding>( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + input: LayoutPlanInput<'_>, + source_start: usize, + force_all_inputs: bool, + mut binding_for_font: impl FnMut(u32) -> Option<&'binding FontRenderBinding>, + ) -> Result<(), GatherError> { + validate_semantic_shape(input)?; + let remaining = input.glyphs.len().saturating_sub(source_start); + if source_start > input.glyphs.len() { + return Err(GatherError::InvalidSemanticShape); + } + let required = self.glyphs.len().saturating_add(remaining); + let source_required = self.source_selected.len().saturating_add(remaining); + if self.glyphs.capacity() < required + || self.source_selected.capacity() < source_required + || self.semantic_change_masks.capacity() < required + || self + .f32_fields + .iter() + .any(|field| field.capacity() < required) + || self + .u32_fields + .iter() + .any(|field| field.capacity() < required) + { + return Err(GatherError::AllocationFailed); + } + let semantic_changes = if input.semantic_change_masks.len() == input.glyphs.len() { + input + .semantic_change_masks + .iter() + .copied() + .fold(0_u16, |union, mask| union | mask) + } else { + super::positioning::ALL_SEMANTIC_CHANGES + }; + let selection_changed = semantic_changes & RESOURCE_SELECTION_CHANGES != 0; + let mut cached_font_handle = None; + let mut cached_binding = None; + let mut cached_program = None; + for glyph_index in source_start..input.glyphs.len() { + let glyph = input.glyphs[glyph_index]; + let binding = if cached_font_handle == Some(glyph.binding_handle) { + cached_binding.ok_or(GatherError::FontBindingMissing)? + } else { + let binding = binding_for_font(glyph.binding_handle) + .ok_or(GatherError::FontBindingMissing)?; + cached_font_handle = Some(glyph.binding_handle); + cached_binding = Some(binding); + binding + }; + let selected = + binding.select(glyph.glyph_id, glyph.font_size, glyph.raster_pixel_ratio); + self.source_selected.push(u8::from(selected.is_some())); + let Some(selected) = selected else { + continue; + }; + let technique = binding.technique(); + let variant = binding.program_variant(); + let (program, f32_inputs, u32_inputs) = match cached_program { + Some((cached_technique, cached_variant, program, f32_inputs, u32_inputs)) + if cached_technique == technique && cached_variant == variant => + { + (program, f32_inputs, u32_inputs) + } + _ => { + let program = policy + .program(capability_set, technique, variant) + .ok_or(GatherError::ProgramMissing)?; + let (f32_inputs, u32_inputs) = policy + .input_masks_for_changes( + capability_set, + technique, + variant, + semantic_changes, + force_all_inputs || selection_changed, + ) + .ok_or(GatherError::ProgramMissing)?; + cached_program = Some((technique, variant, program, f32_inputs, u32_inputs)); + (program, f32_inputs, u32_inputs) + } + }; + self.gather_fields( + input, + glyph_index, + binding, + selected, + program, + f32_inputs, + u32_inputs, + )?; + self.glyphs + .push(plan_glyph(input, glyph_index, glyph, binding, selected)?); + self.semantic_change_masks.push( + input + .semantic_change_masks + .get(glyph_index) + .copied() + .unwrap_or(super::positioning::ALL_SEMANTIC_CHANGES), + ); + } + Ok(()) + } + + pub fn view(&self) -> GatheredPlanInput<'_> { + let mut f32_fields = [&[][..]; MAX_REGISTERS]; + let mut u32_fields = [&[][..]; MAX_REGISTERS]; + for (target, field) in f32_fields.iter_mut().zip(&self.f32_fields) { + *target = field.as_slice(); + } + for (target, field) in u32_fields.iter_mut().zip(&self.u32_fields) { + *target = field.as_slice(); + } + GatheredPlanInput { + glyphs: &self.glyphs, + semantic_change_masks: &self.semantic_change_masks, + f32_fields, + u32_fields, + f32_field_count: self.f32_fields.len(), + u32_field_count: self.u32_fields.len(), + } + } + + fn gather_fields( + &mut self, + input: LayoutPlanInput<'_>, + glyph_index: usize, + binding: &FontRenderBinding, + selected: SelectedGlyphBinding, + program: &ProgramDescriptor, + required_f32: u32, + required_u32: u32, + ) -> Result<(), GatherError> { + let f32_count = usize::from(program.f32_input_count); + let u32_count = usize::from(program.u32_input_count); + for field in 0..self.f32_fields.len() { + let value = if field < f32_count && required_f32 & (1 << field) != 0 { + let source = program.inputs[field]; + source_f32( + source.scope, + source.field, + input, + glyph_index, + binding, + selected, + )? + } else { + 0.0 + }; + self.f32_fields[field].push(value)?; + } + for field in 0..self.u32_fields.len() { + let value = if field < u32_count && required_u32 & (1 << field) != 0 { + let source = program.inputs[f32_count + field]; + source_u32( + source.scope, + source.field, + input, + glyph_index, + binding, + selected, + )? + } else { + 0 + }; + self.u32_fields[field].push(value)?; + } + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + fn update_fields( + &mut self, + output_index: usize, + input: LayoutPlanInput<'_>, + glyph_index: usize, + binding: &FontRenderBinding, + selected: SelectedGlyphBinding, + program: &ProgramDescriptor, + required_f32: u32, + required_u32: u32, + ) -> Result<(), GatherError> { + let f32_count = usize::from(program.f32_input_count); + let u32_count = usize::from(program.u32_input_count); + for field in 0..f32_count { + if required_f32 & (1 << field) == 0 { + continue; + } + let source = program.inputs[field]; + let value = source_f32( + source.scope, + source.field, + input, + glyph_index, + binding, + selected, + )?; + self.f32_fields[field].set(output_index, value)?; + } + for field in 0..u32_count { + if required_u32 & (1 << field) == 0 { + continue; + } + let source = program.inputs[f32_count + field]; + let value = source_u32( + source.scope, + source.field, + input, + glyph_index, + binding, + selected, + )?; + self.u32_fields[field].set(output_index, value)?; + } + Ok(()) + } + + fn clear(&mut self) { + self.retained_cursor = 0; + self.retained_source_cursor = 0; + self.glyphs.clear(); + self.source_selected.clear(); + self.semantic_change_masks.clear(); + for field in &mut self.f32_fields { + field.clear(); + } + for field in &mut self.u32_fields { + field.clear(); + } + } +} + +impl Default for AlignedField { + fn default() -> Self { + Self { + blocks: Vec::new(), + len: 0, + } + } +} + +impl AlignedField { + fn reserve(&mut self, record_capacity: usize) -> Result<(), GatherError> { + let block_capacity = record_capacity.div_ceil(4); + if self.blocks.capacity() < block_capacity { + self.blocks + .try_reserve_exact(block_capacity.saturating_sub(self.blocks.len())) + .map_err(|_| GatherError::AllocationFailed)?; + } + Ok(()) + } + + fn push(&mut self, value: T) -> Result<(), GatherError> { + let lane = self.len % 4; + if lane == 0 { + if self.blocks.len() == self.blocks.capacity() { + return Err(GatherError::AllocationFailed); + } + self.blocks.push(AlignedBlock { + values: [T::default(); 4], + }); + } + let block = self + .blocks + .last_mut() + .ok_or(GatherError::AllocationFailed)?; + block.values[lane] = value; + self.len += 1; + Ok(()) + } + + fn set(&mut self, index: usize, value: T) -> Result<(), GatherError> { + if index >= self.len { + return Err(GatherError::InvalidSemanticShape); + } + self.blocks[index / 4].values[index % 4] = value; + Ok(()) + } + + fn truncate(&mut self, len: usize) { + self.blocks.truncate(len.div_ceil(4)); + self.len = self.len.min(len); + } + + fn clear(&mut self) { + self.blocks.clear(); + self.len = 0; + } + + fn as_slice(&self) -> &[T] { + debug_assert_eq!( + core::mem::size_of::>(), + core::mem::size_of::() * 4 + ); + // SAFETY: `AlignedBlock` is exactly four contiguous `T` values with no trailing + // padding for the only instantiated 32-bit scalar types. `len` never exceeds the + // initialized block prefix, and blocks cannot move while this shared slice exists. + unsafe { core::slice::from_raw_parts(self.blocks.as_ptr().cast::(), self.len) } + } + + fn capacity(&self) -> usize { + self.blocks.capacity() * 4 + } +} + +impl GatheredPlanInput<'_> { + pub fn plan_input(&self) -> PlanInput<'_> { + PlanInput { + glyphs: self.glyphs, + semantic_change_masks: self.semantic_change_masks, + f32_fields: &self.f32_fields[..self.f32_field_count], + u32_fields: &self.u32_fields[..self.u32_field_count], + order_independent: false, + } + } +} + +fn validate_semantic_shape(input: LayoutPlanInput<'_>) -> Result<(), GatherError> { + if (!input.semantic_change_masks.is_empty() + && input.semantic_change_masks.len() != input.glyphs.len()) + || (!input.semantic_glyphs.is_empty() + && input.glyphs.iter().any(|glyph| { + usize::try_from(glyph.semantic_glyph_index) + .ok() + .and_then(|index| input.semantic_glyphs.get(index)) + .is_none_or(|semantic| semantic.stable_id != glyph.stable_id) + })) + || input.semantic_f32.iter().any(|field| { + field.len() != input.glyphs.len() || field.iter().any(|value| !value.is_finite()) + }) + || input + .semantic_u32 + .iter() + .any(|field| field.len() != input.glyphs.len()) + { + return Err(GatherError::InvalidSemanticShape); + } + Ok(()) +} + +fn plan_glyph( + input: LayoutPlanInput<'_>, + glyph_index: usize, + glyph: LayoutGlyph, + binding: &FontRenderBinding, + selected: SelectedGlyphBinding, +) -> Result { + let resource = binding + .resources() + .get(usize::try_from(selected.resource).map_err(|_| GatherError::ResourceBindingMissing)?) + .ok_or(GatherError::ResourceBindingMissing)?; + let semantic_id = match input + .semantic_u32 + .get(usize::from(SEMANTIC_U32_CLUSTER_ID)) + .and_then(|values| values.get(glyph_index)) + .copied() + { + Some(value) => value, + None if input.semantic_glyphs.is_empty() => glyph.stable_id, + None => return Err(GatherError::SourceFieldMissing), + }; + Ok(PlanGlyph { + stable_id: glyph.stable_id, + content_revision: glyph.content_revision, + technique: binding.technique(), + program_variant: binding.program_variant(), + resource_id: resource.id, + resource_generation: resource.generation, + resource_kind: resource.kind, + resource_reference: resource.reference, + semantic_id, + transform_id: input.transform_id, + material_id: glyph.material_id, + clip_id: glyph.clip_id, + depth_key: glyph.depth_key, + inline_start: glyph.inline_start, + block_start: glyph.block_start, + inline_extent: glyph.inline_extent, + block_extent: glyph.block_extent, + }) +} + +fn same_storage_topology(previous: PlanGlyph, next: PlanGlyph) -> bool { + previous.technique == next.technique + && previous.program_variant == next.program_variant + && previous.resource_id == next.resource_id + && previous.resource_generation == next.resource_generation + && previous.resource_kind == next.resource_kind + && previous.resource_reference == next.resource_reference + && previous.transform_id == next.transform_id + && previous.material_id == next.material_id + && previous.clip_id == next.clip_id + && previous.depth_key == next.depth_key +} + +fn source_f32( + scope: InputScope, + field: u8, + input: LayoutPlanInput<'_>, + glyph_index: usize, + binding: &FontRenderBinding, + selected: SelectedGlyphBinding, +) -> Result { + let (table, row) = match scope { + InputScope::Semantic => { + if let Some(value) = derived_semantic_f32(field, input, glyph_index)? { + return Ok(value); + } + return input + .semantic_f32 + .get(usize::from(field)) + .and_then(|values| values.get(glyph_index)) + .copied() + .ok_or(GatherError::SourceFieldMissing); + } + InputScope::Glyph => ( + binding.glyph_f32(), + binding_row(input.glyphs[glyph_index].glyph_id)?, + ), + InputScope::Strike => (binding.strike_f32(), binding_row(selected.strike_row)?), + InputScope::Resource => (binding.resource_f32(), binding_row(selected.resource)?), + }; + table + .field(field) + .and_then(|values| values.get(row)) + .copied() + .ok_or(GatherError::SourceFieldMissing) +} + +fn derived_semantic_f32( + field: u8, + input: LayoutPlanInput<'_>, + glyph_index: usize, +) -> Result, GatherError> { + if field == SEMANTIC_F32_INLINE_ORIGIN || field == SEMANTIC_F32_BLOCK_ORIGIN { + let semantic_index = input + .glyphs + .get(glyph_index) + .and_then(|glyph| usize::try_from(glyph.semantic_glyph_index).ok()) + .ok_or(GatherError::SourceFieldMissing)?; + let glyph = input + .semantic_glyphs + .get(semantic_index) + .ok_or(GatherError::SourceFieldMissing)?; + return Ok(Some(if field == SEMANTIC_F32_INLINE_ORIGIN { + glyph.inline_origin + } else { + glyph.block_origin + })); + } + if field == SEMANTIC_F32_INVERSE_FONT_SIZE { + let font_size = input + .glyphs + .get(glyph_index) + .map(|glyph| glyph.font_size) + .ok_or(GatherError::SourceFieldMissing)?; + return Ok(Some(1.0 / font_size)); + } + let (shift, srgb) = match field { + SEMANTIC_F32_FOREGROUND_RED => (0, true), + SEMANTIC_F32_FOREGROUND_GREEN => (8, true), + SEMANTIC_F32_FOREGROUND_BLUE => (16, true), + SEMANTIC_F32_FOREGROUND_ALPHA => (24, false), + _ => return Ok(None), + }; + let packed = input + .semantic_u32 + .get(usize::from(SEMANTIC_U32_FOREGROUND_RGBA)) + .and_then(|values| values.get(glyph_index)) + .copied() + .ok_or(GatherError::SourceFieldMissing)?; + let channel = (packed >> shift) & 0xff; + Ok(Some(if srgb { + f32::from_bits(SRGB8_TO_LINEAR_BITS[channel as usize]) + } else { + (f64::from(channel) / 255.0) as f32 + })) +} + +fn source_u32( + scope: InputScope, + field: u8, + input: LayoutPlanInput<'_>, + glyph_index: usize, + binding: &FontRenderBinding, + selected: SelectedGlyphBinding, +) -> Result { + let (table, row) = match scope { + InputScope::Semantic => { + return input + .semantic_u32 + .get(usize::from(field)) + .and_then(|values| values.get(glyph_index)) + .copied() + .ok_or(GatherError::SourceFieldMissing); + } + InputScope::Glyph => ( + binding.glyph_u32(), + binding_row(input.glyphs[glyph_index].glyph_id)?, + ), + InputScope::Strike => (binding.strike_u32(), binding_row(selected.strike_row)?), + InputScope::Resource => (binding.resource_u32(), binding_row(selected.resource)?), + }; + table + .field(field) + .and_then(|values| values.get(row)) + .copied() + .ok_or(GatherError::SourceFieldMissing) +} + +fn binding_row(row: u32) -> Result { + usize::try_from(row).map_err(|_| GatherError::SourceFieldMissing) +} + +fn reserve_fields( + fields: &mut Vec>, + field_count: usize, + record_capacity: usize, +) -> Result<(), GatherError> { + reserve(fields, field_count)?; + while fields.len() < field_count { + fields.push(AlignedField::default()); + } + for field in fields { + field.reserve(record_capacity)?; + } + Ok(()) +} + +fn reserve(values: &mut Vec, capacity: usize) -> Result<(), GatherError> { + if values.capacity() < capacity { + values + .try_reserve_exact(capacity.saturating_sub(values.len())) + .map_err(|_| GatherError::AllocationFailed)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::{ + font_binding::{FieldTable, FontResource, FontStrike}, + policy::{ + ALLOCATION_ORDERED_DIRECT, BATCH_ORDER, BATCH_PROGRAM, BATCH_RESOURCE, BATCH_TECHNIQUE, + BUFFER_USAGE_COPY_DST, BUFFER_USAGE_STORAGE, BufferId, BufferSchema, + CAP_ORDERED_DIRECT, CAP_STORAGE_BUFFERS, CapabilitySet, InputSource, Operation, + PolicyDescriptor, ProgramCapabilities, ProgramDescriptor, ProgramId, ScalarType, + TechniqueId, + }, + render_plan_compiler::RenderPlanCompiler, + }; + use alloc::vec; + + const CAPABILITY: CapabilitySetId = CapabilitySetId(1); + + #[test] + fn derives_policy_color_channels_and_inverse_font_size_without_retained_arrays() { + let mut glyphs = [layout_glyph(1, 0)]; + glyphs[0].semantic_glyph_index = 1; + let semantic_glyphs = [ + SemanticGlyph { + stable_id: 99, + font_handle: 1, + cluster: 0, + glyph_id: 99, + flags: 0, + font_size: 16.0, + inline_origin: 500.0, + block_origin: 500.0, + }, + SemanticGlyph { + stable_id: 1, + font_handle: 1, + cluster: 0, + glyph_id: 1, + flags: 0, + font_size: 16.0, + inline_origin: 12.5, + block_origin: -3.25, + }, + ]; + let foreground = [0xff20_4080]; + let input = LayoutPlanInput { + transform_id: 1, + glyphs: &glyphs, + semantic_glyphs: &semantic_glyphs, + semantic_change_masks: &[], + semantic_f32: &[], + semantic_u32: &[&foreground], + }; + assert_eq!(validate_semantic_shape(input), Ok(())); + assert_eq!( + derived_semantic_f32(SEMANTIC_F32_FOREGROUND_RED, input, 0), + Ok(Some(f32::from_bits(SRGB8_TO_LINEAR_BITS[128]))) + ); + assert_eq!( + derived_semantic_f32(SEMANTIC_F32_FOREGROUND_GREEN, input, 0), + Ok(Some(f32::from_bits(SRGB8_TO_LINEAR_BITS[64]))) + ); + assert_eq!( + derived_semantic_f32(SEMANTIC_F32_FOREGROUND_BLUE, input, 0), + Ok(Some(f32::from_bits(SRGB8_TO_LINEAR_BITS[32]))) + ); + assert_eq!( + derived_semantic_f32(SEMANTIC_F32_FOREGROUND_ALPHA, input, 0), + Ok(Some(1.0)) + ); + assert_eq!( + derived_semantic_f32(SEMANTIC_F32_INVERSE_FONT_SIZE, input, 0), + Ok(Some(1.0 / 16.0)) + ); + assert_eq!( + derived_semantic_f32(SEMANTIC_F32_INLINE_ORIGIN, input, 0), + Ok(Some(12.5)) + ); + assert_eq!( + derived_semantic_f32(SEMANTIC_F32_BLOCK_ORIGIN, input, 0), + Ok(Some(-3.25)) + ); + } + + #[test] + fn gathers_program_specific_sources_without_a_union_record() { + let binding = binding(); + let policy = policy(); + let glyphs = [layout_glyph(1, 0), layout_glyph(2, 1)]; + let semantic_x = [10.0, 20.0]; + let semantic_kind = [100, 200]; + let mut workspace = PolicyGatherWorkspace::default(); + workspace.reserve_policy(&policy, 8).unwrap(); + let capacities = workspace.capacities(); + workspace + .gather( + &policy, + CAPABILITY, + LayoutPlanInput { + transform_id: 1, + glyphs: &glyphs, + semantic_glyphs: &[], + semantic_change_masks: &[], + semantic_f32: &[&semantic_x], + semantic_u32: &[&semantic_kind], + }, + true, + |handle| (handle == 9).then_some(&binding), + ) + .unwrap(); + { + let gathered = workspace.view(); + let input = gathered.plan_input(); + assert_eq!(core::mem::size_of::(), 64); + assert!( + input + .f32_fields + .iter() + .all(|field| (field.as_ptr() as usize).is_multiple_of(16)) + ); + assert!( + input + .u32_fields + .iter() + .all(|field| (field.as_ptr() as usize).is_multiple_of(16)) + ); + assert_eq!(input.glyphs[0].resource_id, 71); + assert_eq!(input.glyphs[1].resource_reference, 901); + assert_eq!(input.f32_fields[0], &[10.0, 20.0]); + assert_eq!(input.f32_fields[1], &[1.0, 2.0]); + assert_eq!(input.f32_fields[2], &[3.0, 4.0]); + assert_eq!(input.f32_fields[3], &[5.0, 5.0]); + assert_eq!(input.u32_fields[0], &[100, 200]); + assert_eq!(input.u32_fields[1], &[11, 12]); + assert_eq!(input.u32_fields[2], &[13, 14]); + assert_eq!(input.u32_fields[3], &[15, 15]); + + let mut compiler = RenderPlanCompiler::default(); + compiler + .prepare(&policy, CAPABILITY, input, true, 1, 0) + .unwrap(); + let plan = compiler + .plan_view(3, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!(plan.draws.len(), 1); + assert_eq!(plan.draws[0].transform_id, 1); + assert_eq!(plan.primitives[0].record_count, 2); + assert_eq!(plan.primitives[0].semantic_id, 0); + assert_eq!(plan.patches.len(), 2); + assert_eq!( + &plan.payload[..8], + &[10.0_f32.to_le_bytes(), 20.0_f32.to_le_bytes(),].concat(), + ); + } + assert_eq!(workspace.capacities(), capacities); + } + + #[test] + fn retained_gather_updates_changed_fields_and_rejects_storage_topology_changes() { + let binding = binding(); + let policy = policy(); + let glyphs = [layout_glyph(1, 0), layout_glyph(2, 1)]; + let initial_x = [10.0, 20.0]; + let semantic_kind = [100, 200]; + let mut workspace = PolicyGatherWorkspace::default(); + workspace + .gather( + &policy, + CAPABILITY, + LayoutPlanInput { + transform_id: 1, + glyphs: &glyphs, + semantic_glyphs: &[], + semantic_change_masks: &[], + semantic_f32: &[&initial_x], + semantic_u32: &[&semantic_kind], + }, + true, + |_| Some(&binding), + ) + .unwrap(); + + let changed_x = [999.0, 25.0]; + let mut changed_glyphs = glyphs; + changed_glyphs[1].stable_id = 3; + changed_glyphs[1].content_revision = 2; + assert!(workspace.begin_retained(&policy, 2).unwrap()); + assert_eq!( + workspace + .append_retained( + &policy, + CAPABILITY, + LayoutPlanInput { + transform_id: 1, + glyphs: &changed_glyphs, + semantic_glyphs: &[], + semantic_change_masks: &[0, 1], + semantic_f32: &[&changed_x], + semantic_u32: &[&semantic_kind], + }, + |_| Some(&binding), + ) + .unwrap(), + RetainedGather::Complete + ); + assert!(workspace.finish_retained()); + let gathered = workspace.view(); + let input = gathered.plan_input(); + assert_eq!(input.glyphs[1].stable_id, 3); + assert_eq!(input.f32_fields[0], [10.0, 25.0]); + assert_eq!(input.f32_fields[1], [1.0, 2.0]); + + let mut changed_topology = changed_glyphs; + changed_topology[1].material_id = 7; + assert!(workspace.begin_retained(&policy, 2).unwrap()); + assert_eq!( + workspace + .append_retained( + &policy, + CAPABILITY, + LayoutPlanInput { + transform_id: 1, + glyphs: &changed_topology, + semantic_glyphs: &[], + semantic_change_masks: &[ + 0, + super::super::positioning::ALL_SEMANTIC_CHANGES + ], + semantic_f32: &[&changed_x], + semantic_u32: &[&semantic_kind], + }, + |_| Some(&binding), + ) + .unwrap(), + RetainedGather::RebuildFrom(1) + ); + workspace.truncate_to_retained_prefix(); + workspace + .append_from( + &policy, + CAPABILITY, + LayoutPlanInput { + transform_id: 1, + glyphs: &changed_topology, + semantic_glyphs: &[], + semantic_change_masks: &[0, crate::engine::positioning::ALL_SEMANTIC_CHANGES], + semantic_f32: &[&changed_x], + semantic_u32: &[&semantic_kind], + }, + 1, + true, + |_| Some(&binding), + ) + .unwrap(); + let gathered = workspace.view(); + let input = gathered.plan_input(); + assert_eq!(input.glyphs.len(), 2); + assert_eq!(input.glyphs[1].material_id, 7); + assert_eq!(input.f32_fields[0], [10.0, 25.0]); + } + + #[test] + fn appends_independent_layouts_into_one_plan_input() { + let binding = binding(); + let policy = policy(); + let first = [layout_glyph(1, 0)]; + let second = [layout_glyph(2, 1)]; + let first_x = [10.0]; + let second_x = [20.0]; + let first_kind = [100]; + let second_kind = [200]; + let mut workspace = PolicyGatherWorkspace::default(); + workspace.begin(&policy, 2).unwrap(); + for input in [ + LayoutPlanInput { + transform_id: 1, + glyphs: &first, + semantic_glyphs: &[], + semantic_change_masks: &[], + semantic_f32: &[&first_x], + semantic_u32: &[&first_kind], + }, + LayoutPlanInput { + transform_id: 2, + glyphs: &second, + semantic_glyphs: &[], + semantic_change_masks: &[], + semantic_f32: &[&second_x], + semantic_u32: &[&second_kind], + }, + ] { + workspace + .append(&policy, CAPABILITY, input, true, |_| Some(&binding)) + .unwrap(); + } + let gathered = workspace.view(); + let input = gathered.plan_input(); + assert_eq!(input.glyphs.len(), 2); + assert_eq!(input.glyphs[0].stable_id, 1); + assert_eq!(input.glyphs[1].stable_id, 2); + assert_eq!(input.glyphs[0].transform_id, 1); + assert_eq!(input.glyphs[1].transform_id, 2); + assert_eq!(input.f32_fields[0], &[10.0, 20.0]); + assert_eq!(input.u32_fields[0], &[100, 200]); + let mut compiler = RenderPlanCompiler::default(); + compiler + .prepare(&policy, CAPABILITY, input, true, 1, 0) + .unwrap(); + let plan = compiler + .plan_view(3, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!( + plan.draws + .iter() + .map(|draw| draw.transform_id) + .collect::>(), + [1, 2] + ); + } + + #[test] + fn changed_gather_reads_only_inputs_reaching_changed_buffers() { + let binding = binding(); + let policy = policy(); + let glyphs = [layout_glyph(1, 0), layout_glyph(2, 1)]; + let semantic_x = [10.0, 20.0]; + let semantic_kind = [100, 200]; + let mut workspace = PolicyGatherWorkspace::default(); + workspace + .gather( + &policy, + CAPABILITY, + LayoutPlanInput { + transform_id: 1, + glyphs: &glyphs, + semantic_glyphs: &[], + semantic_change_masks: &[1, 1], + semantic_f32: &[&semantic_x], + semantic_u32: &[&semantic_kind], + }, + false, + |_| Some(&binding), + ) + .unwrap(); + let gathered = workspace.view(); + let input = gathered.plan_input(); + assert_eq!(input.f32_fields[0], &[10.0, 20.0]); + assert!( + input.f32_fields[1..] + .iter() + .all(|field| *field == [0.0, 0.0]) + ); + assert!(input.u32_fields.iter().all(|field| *field == [0, 0])); + } + + #[test] + fn font_selection_changes_retain_inputs_needed_to_initialize_a_new_resource_batch() { + let binding = binding(); + let policy = policy(); + let glyphs = [layout_glyph(1, 0), layout_glyph(2, 1)]; + let semantic_x = [10.0, 20.0]; + let semantic_kind = [100, 200]; + let mut workspace = PolicyGatherWorkspace::default(); + workspace + .gather( + &policy, + CAPABILITY, + LayoutPlanInput { + transform_id: 1, + glyphs: &glyphs, + semantic_glyphs: &[], + semantic_change_masks: &[ + RESOURCE_SELECTION_CHANGES, + RESOURCE_SELECTION_CHANGES, + ], + semantic_f32: &[&semantic_x], + semantic_u32: &[&semantic_kind], + }, + false, + |_| Some(&binding), + ) + .unwrap(); + + let gathered = workspace.view(); + let input = gathered.plan_input(); + assert_eq!(input.f32_fields[0], semantic_x); + assert_eq!(input.u32_fields[0], semantic_kind); + } + + #[test] + fn missing_program_binding_and_source_are_explicit() { + let binding = binding(); + let policy = policy(); + let glyphs = [layout_glyph(1, 0)]; + let mut workspace = PolicyGatherWorkspace::default(); + assert_eq!( + workspace.gather( + &policy, + CAPABILITY, + LayoutPlanInput { + transform_id: 1, + glyphs: &glyphs, + semantic_glyphs: &[], + semantic_change_masks: &[], + semantic_f32: &[], + semantic_u32: &[], + }, + true, + |_| None, + ), + Err(GatherError::FontBindingMissing) + ); + assert_eq!( + workspace.gather( + &policy, + CapabilitySetId(2), + LayoutPlanInput { + transform_id: 1, + glyphs: &glyphs, + semantic_glyphs: &[], + semantic_change_masks: &[], + semantic_f32: &[], + semantic_u32: &[], + }, + true, + |_| Some(&binding), + ), + Err(GatherError::ProgramMissing) + ); + assert_eq!( + workspace.gather( + &policy, + CAPABILITY, + LayoutPlanInput { + transform_id: 1, + glyphs: &glyphs, + semantic_glyphs: &[], + semantic_change_masks: &[], + semantic_f32: &[], + semantic_u32: &[], + }, + true, + |_| Some(&binding), + ), + Err(GatherError::SourceFieldMissing) + ); + } + + impl PolicyGatherWorkspace { + fn capacities(&self) -> (usize, Vec, Vec) { + ( + self.glyphs.capacity(), + self.f32_fields.iter().map(AlignedField::capacity).collect(), + self.u32_fields.iter().map(AlignedField::capacity).collect(), + ) + } + } + + fn layout_glyph(stable_id: u32, glyph_id: u32) -> LayoutGlyph { + LayoutGlyph { + stable_id, + content_revision: 1, + semantic_glyph_index: glyph_id, + binding_handle: 9, + font_handle: 9, + glyph_id, + material_id: 6, + clip_id: 0, + depth_key: 0, + font_size: 16.0, + raster_pixel_ratio: 2.0, + inline_start: glyph_id as f32 * 10.0, + block_start: 0.0, + inline_extent: 8.0, + block_extent: 16.0, + } + } + + fn binding() -> FontRenderBinding { + FontRenderBinding::new( + TechniqueId(7), + 2, + 2, + vec![FontStrike { ppem: 0 }], + vec![FontResource { + id: 71, + generation: 3, + kind: 2, + reference: 901, + }], + vec![0, 0], + FieldTable::new(2, 1, vec![1.0, 2.0]).unwrap(), + FieldTable::new(2, 1, vec![11, 12]).unwrap(), + FieldTable::new(2, 1, vec![3.0, 4.0]).unwrap(), + FieldTable::new(2, 1, vec![13, 14]).unwrap(), + FieldTable::new(1, 1, vec![5.0]).unwrap(), + FieldTable::new(1, 1, vec![15]).unwrap(), + ) + .unwrap() + } + + fn policy() -> ValidatedPolicy { + ValidatedPolicy::new(PolicyDescriptor { + capability_sets: vec![CapabilitySet { + id: CAPABILITY, + flags: CAP_ORDERED_DIRECT | CAP_STORAGE_BUFFERS, + max_buffer_bytes: 1 << 20, + update_alignment: 4, + coalesce_gap_bytes: 0, + range_call_penalty_bytes: 1, + max_buffers_per_draw: 16, + max_resources_per_draw: 1, + max_indirect_draws: 0, + fragmentation_budget: 8, + whole_buffer_threshold_basis_points: 7_500, + }], + programs: vec![ProgramDescriptor { + technique: TechniqueId(7), + variant: 2, + id: ProgramId(1), + capability_set: CAPABILITY, + resource_kind_mask: 1 << 1, + semantic_view_mask: 0, + storage_key_mask: BATCH_TECHNIQUE | BATCH_PROGRAM | BATCH_RESOURCE, + draw_key_mask: BATCH_TECHNIQUE + | BATCH_PROGRAM + | BATCH_RESOURCE + | BATCH_ORDER + | crate::engine::policy::BATCH_TRANSFORM, + allocation_strategy: ALLOCATION_ORDERED_DIRECT, + f32_input_count: 4, + u32_input_count: 4, + inputs: vec![ + InputSource::semantic(0), + InputSource { + scope: InputScope::Glyph, + field: 0, + }, + InputSource { + scope: InputScope::Strike, + field: 0, + }, + InputSource { + scope: InputScope::Resource, + field: 0, + }, + InputSource::semantic(0), + InputSource { + scope: InputScope::Glyph, + field: 0, + }, + InputSource { + scope: InputScope::Strike, + field: 0, + }, + InputSource { + scope: InputScope::Resource, + field: 0, + }, + ], + capabilities: ProgramCapabilities::default(), + buffers: vec![BufferSchema { + id: BufferId(1), + scalar: ScalarType::F32, + vector_width: 1, + alignment: 4, + stride: 4, + usage: BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST, + capacity_class: 1, + }], + operations: vec![ + Operation::LoadF32 { + target: 0, + field: 0, + }, + Operation::StoreF32 { + source: 0, + buffer: BufferId(1), + lane: 0, + }, + ], + }], + }) + .unwrap() + } +} diff --git a/packages/text/rust/shaper/src/engine/positioning.rs b/packages/text/rust/shaper/src/engine/positioning.rs new file mode 100644 index 00000000..2d39f259 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/positioning.rs @@ -0,0 +1,1583 @@ +//! Retained horizontal glyph positioning and exact content revision assignment. + +use alloc::vec::Vec; + +use crate::{FontGlyphExtents, FontMetrics, bidi::BidiAnalysis}; + +use super::{ + EngineError, + cluster_state::{CLUSTER_HARD_BREAK, ClusterArena}, + flow_composition::{FlowFragment, FlowLayoutArena, FlowLine}, + frame::{ALIGN_CENTER, ALIGN_END, ALIGN_JUSTIFY, ALIGN_START}, + identity_index::{IdentityIndex, IdentityIndexError}, + policy_gather::LayoutGlyph, + shaping_state::{BoundaryShape, BoundaryShapeArena, ShapeArena, ShapingRun}, + style_state::StyleSegment, +}; + +pub(crate) const SEMANTIC_F32_FIELD_COUNT: usize = 6; +pub(crate) const SEMANTIC_F32_CHANGE_FIELD_COUNT: usize = 8; +pub(crate) const SEMANTIC_U32_FIELD_COUNT: usize = 6; +pub(crate) const ALL_SEMANTIC_CHANGES: u16 = + (1 << (SEMANTIC_F32_CHANGE_FIELD_COUNT + SEMANTIC_U32_FIELD_COUNT)) - 1; + +const BIDI_BN: u8 = 9; +const BIDI_B: u8 = 10; +const BIDI_S: u8 = 11; +const BIDI_WS: u8 = 12; +const BIDI_LRE: u8 = 14; +const BIDI_LRO: u8 = 15; +const BIDI_RLE: u8 = 16; +const BIDI_RLO: u8 = 17; +const BIDI_PDF: u8 = 18; +const BIDI_LRI: u8 = 19; +const BIDI_RLI: u8 = 20; +const BIDI_FSI: u8 = 21; +const BIDI_PDI: u8 = 22; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct SemanticGlyph { + pub stable_id: u32, + pub font_handle: u32, + pub cluster: u32, + pub glyph_id: u16, + pub flags: u16, + pub font_size: f32, + pub inline_origin: f32, + pub block_origin: f32, +} + +#[derive(Default)] +pub(crate) struct PositionedGlyphArena { + glyphs: Vec, + line_glyph_starts: Vec, + line_glyph_counts: Vec, + semantic_glyphs: Vec, + semantic_line_glyph_starts: Vec, + semantic_line_glyph_counts: Vec, + semantic_line_inline_extents: Vec, + semantic_change_masks: Vec, + semantic_f32: [Vec; SEMANTIC_F32_FIELD_COUNT], + semantic_u32: [Vec; SEMANTIC_U32_FIELD_COUNT], + visual_clusters: Vec, + visual_levels: Vec, + line_levels: Vec, + recomposed_glyphs: Option, +} + +#[derive(Clone, Copy)] +struct RecomposedGlyphRange { + previous_start: usize, + previous_end: usize, + next_start: usize, + next_end: usize, +} + +impl PositionedGlyphArena { + pub(crate) fn reserve(&mut self, capacity: usize) -> Result<(), EngineError> { + reserve(&mut self.glyphs, capacity)?; + reserve(&mut self.semantic_glyphs, capacity)?; + reserve(&mut self.semantic_change_masks, capacity)?; + for field in &mut self.semantic_f32 { + reserve(field, capacity)?; + } + for field in &mut self.semantic_u32 { + reserve(field, capacity)?; + } + reserve(&mut self.visual_clusters, capacity)?; + reserve(&mut self.visual_levels, capacity)?; + reserve(&mut self.line_levels, capacity) + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn build( + &mut self, + previous: &Self, + flow: &FlowLayoutArena, + text: &[u16], + clusters: &ClusterArena, + runs: &[ShapingRun], + shape: &ShapeArena, + boundary_shape: &BoundaryShapeArena, + styles: &[StyleSegment], + bidi: &BidiAnalysis, + identity_index: &mut IdentityIndex, + next_content_revision: &mut u32, + metrics_for: impl Fn(u32) -> Option + Copy, + extents_for: impl Fn(u32, u32) -> Option + Copy, + ) -> Result<(), EngineError> { + self.clear(); + self.reserve(shape.glyph_ids.len())?; + reserve(&mut self.line_glyph_starts, flow.lines.len())?; + reserve(&mut self.line_glyph_counts, flow.lines.len())?; + reserve(&mut self.semantic_line_glyph_starts, flow.lines.len())?; + reserve(&mut self.semantic_line_glyph_counts, flow.lines.len())?; + reserve(&mut self.semantic_line_inline_extents, flow.lines.len())?; + let visually_ltr = is_trivially_ltr(bidi, runs); + for (line_index, line) in flow.lines.iter().copied().enumerate() { + if flow + .recomposed_line_range() + .is_some_and(|(start, end)| line_index < start || line_index >= end) + { + self.append_retained_line(previous, line_index)?; + continue; + } + let line_glyph_start = self.glyphs.len(); + let semantic_line_start = self.semantic_glyphs.len(); + let fragments = line_fragments(flow, line)?; + if fragments.is_empty() { + self.line_glyph_starts.push( + u32::try_from(line_glyph_start).map_err(|_| EngineError::ResultTooLarge)?, + ); + self.line_glyph_counts.push(0); + self.semantic_line_glyph_starts.push( + u32::try_from(semantic_line_start).map_err(|_| EngineError::ResultTooLarge)?, + ); + self.semantic_line_glyph_counts.push(0); + self.semantic_line_inline_extents.push(0.0); + continue; + } + let first = fragments.first().ok_or(EngineError::InvalidRequest)?; + let last = fragments.last().ok_or(EngineError::InvalidRequest)?; + if !visually_ltr { + prepare_line_levels( + &mut self.line_levels, + bidi, + first.line.text_start, + last.line.text_end, + )?; + } + let final_line = flow + .lines + .get(line_index + 1) + .is_none_or(|next| next.flow_thread_id != line.flow_thread_id); + let inline_start = fragments + .iter() + .map(|fragment| fragment.slot_start) + .fold(f64::INFINITY, f64::min); + let mut inline_end = f64::NEG_INFINITY; + for fragment in fragments.iter().copied() { + let fragment_advance = self.position_fragment( + line, + fragment, + final_line, + text, + clusters, + runs, + shape, + boundary_shape, + styles, + bidi, + visually_ltr, + metrics_for, + extents_for, + )?; + inline_end = inline_end.max(fragment.slot_start + fragment_advance); + } + self.semantic_line_glyph_starts + .push(u32::try_from(semantic_line_start).map_err(|_| EngineError::ResultTooLarge)?); + self.semantic_line_glyph_counts.push( + u32::try_from( + self.semantic_glyphs + .len() + .saturating_sub(semantic_line_start), + ) + .map_err(|_| EngineError::ResultTooLarge)?, + ); + self.semantic_line_inline_extents + .push((inline_end - inline_start).max(0.0)); + self.line_glyph_starts + .push(u32::try_from(line_glyph_start).map_err(|_| EngineError::ResultTooLarge)?); + self.line_glyph_counts.push( + u32::try_from(self.glyphs.len().saturating_sub(line_glyph_start)) + .map_err(|_| EngineError::ResultTooLarge)?, + ); + } + self.recomposed_glyphs = flow + .recomposed_line_range() + .map(|(start, end)| { + Ok(RecomposedGlyphRange { + previous_start: line_span_start(&previous.line_glyph_starts, start)?, + previous_end: line_span_end( + &previous.line_glyph_starts, + &previous.line_glyph_counts, + end, + )?, + next_start: line_span_start(&self.line_glyph_starts, start)?, + next_end: line_span_end(&self.line_glyph_starts, &self.line_glyph_counts, end)?, + }) + }) + .transpose()?; + self.assign_content_revisions(previous, identity_index, next_content_revision) + } + + fn append_retained_line( + &mut self, + previous: &Self, + line_index: usize, + ) -> Result<(), EngineError> { + let glyph_start = usize::try_from( + *previous + .line_glyph_starts + .get(line_index) + .ok_or(EngineError::InvalidRequest)?, + ) + .map_err(|_| EngineError::InvalidRequest)?; + let glyph_count = usize::try_from( + *previous + .line_glyph_counts + .get(line_index) + .ok_or(EngineError::InvalidRequest)?, + ) + .map_err(|_| EngineError::InvalidRequest)?; + let glyph_end = glyph_start + .checked_add(glyph_count) + .ok_or(EngineError::InvalidRequest)?; + let semantic_start = usize::try_from( + *previous + .semantic_line_glyph_starts + .get(line_index) + .ok_or(EngineError::InvalidRequest)?, + ) + .map_err(|_| EngineError::InvalidRequest)?; + let semantic_count = usize::try_from( + *previous + .semantic_line_glyph_counts + .get(line_index) + .ok_or(EngineError::InvalidRequest)?, + ) + .map_err(|_| EngineError::InvalidRequest)?; + let semantic_end = semantic_start + .checked_add(semantic_count) + .ok_or(EngineError::InvalidRequest)?; + self.line_glyph_starts + .push(u32::try_from(self.glyphs.len()).map_err(|_| EngineError::ResultTooLarge)?); + self.line_glyph_counts + .push(u32::try_from(glyph_count).map_err(|_| EngineError::ResultTooLarge)?); + let next_semantic_start = self.semantic_glyphs.len(); + for previous_glyph in previous + .glyphs + .get(glyph_start..glyph_end) + .ok_or(EngineError::InvalidRequest)? + { + let previous_semantic_index = usize::try_from(previous_glyph.semantic_glyph_index) + .map_err(|_| EngineError::InvalidRequest)?; + let line_semantic_index = previous_semantic_index + .checked_sub(semantic_start) + .filter(|index| *index < semantic_count) + .ok_or(EngineError::InvalidRequest)?; + let mut glyph = *previous_glyph; + glyph.semantic_glyph_index = u32::try_from(next_semantic_start + line_semantic_index) + .map_err(|_| EngineError::ResultTooLarge)?; + self.glyphs.push(glyph); + } + for (target, source) in self.semantic_f32.iter_mut().zip(&previous.semantic_f32) { + target.extend_from_slice( + source + .get(glyph_start..glyph_end) + .ok_or(EngineError::InvalidRequest)?, + ); + } + for (target, source) in self.semantic_u32.iter_mut().zip(&previous.semantic_u32) { + target.extend_from_slice( + source + .get(glyph_start..glyph_end) + .ok_or(EngineError::InvalidRequest)?, + ); + } + self.semantic_line_glyph_starts.push( + u32::try_from(self.semantic_glyphs.len()).map_err(|_| EngineError::ResultTooLarge)?, + ); + self.semantic_line_glyph_counts + .push(u32::try_from(semantic_count).map_err(|_| EngineError::ResultTooLarge)?); + self.semantic_line_inline_extents.push( + *previous + .semantic_line_inline_extents + .get(line_index) + .ok_or(EngineError::InvalidRequest)?, + ); + self.semantic_glyphs.extend_from_slice( + previous + .semantic_glyphs + .get(semantic_start..semantic_end) + .ok_or(EngineError::InvalidRequest)?, + ); + Ok(()) + } + + pub(crate) fn clear(&mut self) { + self.glyphs.clear(); + self.line_glyph_starts.clear(); + self.line_glyph_counts.clear(); + self.semantic_glyphs.clear(); + self.semantic_line_glyph_starts.clear(); + self.semantic_line_glyph_counts.clear(); + self.semantic_line_inline_extents.clear(); + self.semantic_change_masks.clear(); + for field in &mut self.semantic_f32 { + field.clear(); + } + for field in &mut self.semantic_u32 { + field.clear(); + } + self.visual_clusters.clear(); + self.visual_levels.clear(); + self.line_levels.clear(); + self.recomposed_glyphs = None; + } + + pub(crate) fn glyphs(&self) -> &[LayoutGlyph] { + &self.glyphs + } + + pub(crate) fn semantic_glyphs(&self) -> &[SemanticGlyph] { + &self.semantic_glyphs + } + + pub(crate) fn semantic_line_glyph_spans(&self) -> (&[u32], &[u32]) { + ( + &self.semantic_line_glyph_starts, + &self.semantic_line_glyph_counts, + ) + } + + pub(crate) fn semantic_line_inline_extents(&self) -> &[f64] { + &self.semantic_line_inline_extents + } + + pub(crate) fn semantic_change_masks(&self) -> &[u16] { + &self.semantic_change_masks + } + + pub(crate) fn semantic_f32(&self) -> [&[f32]; SEMANTIC_F32_FIELD_COUNT] { + core::array::from_fn(|index| self.semantic_f32[index].as_slice()) + } + + pub(crate) fn semantic_u32(&self) -> [&[u32]; SEMANTIC_U32_FIELD_COUNT] { + core::array::from_fn(|index| self.semantic_u32[index].as_slice()) + } + + #[allow(clippy::too_many_arguments)] + fn position_fragment( + &mut self, + line: FlowLine, + fragment: FlowFragment, + final_line: bool, + text: &[u16], + clusters: &ClusterArena, + runs: &[ShapingRun], + shape: &ShapeArena, + boundary_shape: &BoundaryShapeArena, + styles: &[StyleSegment], + bidi: &BidiAnalysis, + visually_ltr: bool, + metrics_for: impl Fn(u32) -> Option + Copy, + extents_for: impl Fn(u32, u32) -> Option + Copy, + ) -> Result { + let cluster_start = usize::try_from(fragment.line.cluster_start) + .map_err(|_| EngineError::InvalidRequest)?; + let cluster_end = + usize::try_from(fragment.line.cluster_end).map_err(|_| EngineError::InvalidRequest)?; + let boundary = if fragment.boundary_index == super::flow_composition::NO_BOUNDARY { + None + } else { + Some( + boundary_shape + .record(fragment.boundary_index) + .ok_or(EngineError::InvalidRequest)?, + ) + }; + let retained_cluster_end = boundary.map_or(cluster_end, |boundary| { + usize::try_from(boundary.cluster_start).unwrap_or(usize::MAX) + }); + if retained_cluster_end > cluster_end { + return Err(EngineError::InvalidRequest); + } + let visual_start = self.visual_clusters.len(); + if !visually_ltr { + for cluster in cluster_start..retained_cluster_end { + if clusters.flags[cluster] & CLUSTER_HARD_BREAK != 0 { + continue; + } + self.visual_clusters + .push(u32::try_from(cluster).map_err(|_| EngineError::ResultTooLarge)?); + self.visual_levels.push(cluster_level( + cluster, + fragment.line.text_start, + clusters, + runs, + &self.line_levels, + )?); + } + reorder_l2( + &mut self.visual_clusters, + &mut self.visual_levels, + visual_start, + ); + } + + let available = (fragment.slot_end - fragment.slot_start - fragment.line.advance).max(0.0); + let paragraph_level = paragraph_level_at(bidi, fragment.line.text_start); + let (justify_spaces, per_space) = justification_adjustment( + line, + fragment, + final_line, + text, + clusters, + cluster_start, + cluster_end, + ); + let offset = if per_space == 0.0 { + alignment_offset(line.align, paragraph_level, available) + } else { + 0.0 + }; + let mut cursor = fragment.slot_start + offset; + let baseline = line.block_start + line.baseline; + let visual_count = if visually_ltr { + retained_cluster_end.saturating_sub(cluster_start) + } else { + self.visual_clusters.len().saturating_sub(visual_start) + }; + for ordinal in 0..visual_count { + let cluster = if visually_ltr { + cluster_start + ordinal + } else { + usize::try_from(self.visual_clusters[visual_start + ordinal]) + .map_err(|_| EngineError::InvalidRequest)? + }; + if clusters.flags[cluster] & CLUSTER_HARD_BREAK != 0 { + continue; + } + let style_index = usize::try_from(clusters.style_indexes[cluster]) + .map_err(|_| EngineError::InvalidRequest)?; + let style = styles + .get(style_index) + .ok_or(EngineError::InvalidRequest)? + .style; + let font_handle = clusters.font_handles[cluster]; + let binding_handle = clusters.binding_handles[cluster]; + let metrics = metrics_for(font_handle).ok_or(EngineError::InvalidRequest)?; + if font_handle == 0 || metrics.units_per_em == 0 { + return Err(EngineError::InvalidRequest); + } + let scale = f64::from(style.font_size) / f64::from(metrics.units_per_em); + let cluster_origin = cursor; + let glyph_start = usize::try_from(clusters.glyph_starts[cluster]) + .map_err(|_| EngineError::InvalidRequest)?; + let glyph_count = usize::try_from(clusters.glyph_counts[cluster]) + .map_err(|_| EngineError::InvalidRequest)?; + for ordinal in 0..glyph_count { + let adjacency = glyph_start + ordinal; + let shaped = usize::try_from( + *clusters + .glyph_indices + .get(adjacency) + .ok_or(EngineError::InvalidRequest)?, + ) + .map_err(|_| EngineError::InvalidRequest)?; + let glyph_id = u32::from( + *shape + .glyph_ids + .get(shaped) + .ok_or(EngineError::InvalidRequest)?, + ); + let x_advance = f64::from( + shape + .x_advances + .get(shaped) + .copied() + .ok_or(EngineError::InvalidRequest)?, + ) + .abs() + * scale; + let x_offset = f64::from( + shape + .x_offsets + .get(shaped) + .copied() + .ok_or(EngineError::InvalidRequest)?, + ) * scale; + let y_offset = f64::from( + shape + .y_offsets + .get(shaped) + .copied() + .ok_or(EngineError::InvalidRequest)?, + ) * scale; + let stable_id = *clusters + .glyph_stable_ids + .get(adjacency) + .ok_or(EngineError::InvalidRequest)?; + let flags = *shape + .glyph_flags + .get(shaped) + .ok_or(EngineError::InvalidRequest)?; + let origin_inline = cursor + x_offset; + let origin_block = baseline - y_offset - f64::from(style.baseline_shift); + self.semantic_glyphs.push(SemanticGlyph { + stable_id, + font_handle, + cluster: *shape + .clusters + .get(shaped) + .ok_or(EngineError::InvalidRequest)?, + glyph_id: u16::try_from(glyph_id).map_err(|_| EngineError::ResultTooLarge)?, + flags, + font_size: style.font_size, + inline_origin: finite_f32(origin_inline)?, + block_origin: finite_f32(origin_block)?, + }); + if let Some(extents) = extents_for(font_handle, glyph_id) { + let semantic_glyph_index = u32::try_from(self.semantic_glyphs.len() - 1) + .map_err(|_| EngineError::ResultTooLarge)?; + let inline_start = origin_inline + f64::from(extents.x_min) * scale; + let block_start = origin_block - f64::from(extents.y_max) * scale; + let inline_extent = f64::from(extents.x_max - extents.x_min) * scale; + let block_extent = f64::from(extents.y_max - extents.y_min) * scale; + self.push_glyph( + LayoutGlyph { + stable_id, + content_revision: 0, + semantic_glyph_index, + binding_handle, + font_handle, + glyph_id, + material_id: style.material_id, + clip_id: line.clip_id, + depth_key: 0, + font_size: style.font_size, + raster_pixel_ratio: style.raster_pixel_ratio, + inline_start: finite_f32(inline_start)?, + block_start: finite_f32(block_start)?, + inline_extent: nonnegative_f32(inline_extent)?, + block_extent: nonnegative_f32(block_extent)?, + }, + style.foreground_rgba, + clusters.stable_ids[cluster], + line.region_id, + line.flow_thread_id, + line.transform_index, + ); + } + cursor += x_advance; + } + cursor = cluster_origin + clusters.advances[cluster]; + if per_space != 0.0 && cluster_is_space(text, clusters, cluster) { + cursor += per_space; + } + } + if let Some(boundary) = boundary { + let _ = self.position_boundary( + line, + boundary, + cursor, + baseline, + text, + clusters, + runs, + styles, + boundary_shape, + metrics_for, + extents_for, + )?; + } + Ok(fragment.line.advance + per_space * f64::from(justify_spaces)) + } + + #[allow(clippy::too_many_arguments)] + fn position_boundary( + &mut self, + line: FlowLine, + boundary: BoundaryShape, + mut cursor: f64, + baseline: f64, + text: &[u16], + clusters: &ClusterArena, + runs: &[ShapingRun], + styles: &[StyleSegment], + arena: &BoundaryShapeArena, + metrics_for: impl Fn(u32) -> Option + Copy, + extents_for: impl Fn(u32, u32) -> Option + Copy, + ) -> Result { + runs.get(usize::try_from(boundary.source_run).map_err(|_| EngineError::InvalidRequest)?) + .ok_or(EngineError::InvalidRequest)?; + let source_cluster = + usize::try_from(boundary.cluster_start).map_err(|_| EngineError::InvalidRequest)?; + let ellipsis_cluster = usize::try_from(boundary.cluster_end) + .map_err(|_| EngineError::InvalidRequest)? + .saturating_sub(1) + .max(source_cluster) + .min(clusters.starts.len().saturating_sub(1)); + cursor = self.position_boundary_span( + line, + cursor, + baseline, + boundary.source_glyph_start, + boundary.source_glyph_count, + boundary.source_binding_handle, + boundary.source_font_handle, + None, + source_cluster, + arena, + text, + clusters, + styles, + metrics_for, + extents_for, + )?; + self.position_boundary_span( + line, + cursor, + baseline, + boundary.ellipsis_glyph_start, + boundary.ellipsis_glyph_count, + boundary.ellipsis_binding_handle, + boundary.ellipsis_font_handle, + Some(boundary.text_end), + ellipsis_cluster, + arena, + text, + clusters, + styles, + metrics_for, + extents_for, + ) + } + + #[allow(clippy::too_many_arguments)] + fn position_boundary_span( + &mut self, + line: FlowLine, + mut cursor: f64, + baseline: f64, + glyph_start: u32, + glyph_count: u32, + binding_handle: u32, + font_handle: u32, + cluster_override: Option, + fallback_cluster: usize, + arena: &BoundaryShapeArena, + text: &[u16], + clusters: &ClusterArena, + styles: &[StyleSegment], + metrics_for: impl Fn(u32) -> Option + Copy, + extents_for: impl Fn(u32, u32) -> Option + Copy, + ) -> Result { + let metrics = metrics_for(font_handle).ok_or(EngineError::InvalidRequest)?; + if font_handle == 0 || metrics.units_per_em == 0 { + return Err(EngineError::InvalidRequest); + } + let start = usize::try_from(glyph_start).map_err(|_| EngineError::InvalidRequest)?; + let end = start + .checked_add(usize::try_from(glyph_count).map_err(|_| EngineError::InvalidRequest)?) + .ok_or(EngineError::InvalidRequest)?; + for glyph in start..end { + let glyph_id = u32::from( + *arena + .shape + .glyph_ids + .get(glyph) + .ok_or(EngineError::InvalidRequest)?, + ); + let shaped_cluster = *arena + .shape + .clusters + .get(glyph) + .ok_or(EngineError::InvalidRequest)?; + let cluster = cluster_override.unwrap_or(shaped_cluster); + let cluster_index = if cluster_override.is_some() { + fallback_cluster + } else { + clusters + .starts + .binary_search(&shaped_cluster) + .unwrap_or(fallback_cluster) + }; + let style_index = usize::try_from( + *clusters + .style_indexes + .get(cluster_index) + .ok_or(EngineError::InvalidRequest)?, + ) + .map_err(|_| EngineError::InvalidRequest)?; + let style = styles + .get(style_index) + .ok_or(EngineError::InvalidRequest)? + .style; + let scale = f64::from(style.font_size) / f64::from(metrics.units_per_em); + let semantic_id = *clusters + .stable_ids + .get(cluster_index) + .ok_or(EngineError::InvalidRequest)?; + let x_advance = f64::from( + arena + .shape + .x_advances + .get(glyph) + .copied() + .ok_or(EngineError::InvalidRequest)?, + ) + .abs() + * scale; + let x_offset = f64::from( + arena + .shape + .x_offsets + .get(glyph) + .copied() + .ok_or(EngineError::InvalidRequest)?, + ) * scale; + let y_offset = f64::from( + arena + .shape + .y_offsets + .get(glyph) + .copied() + .ok_or(EngineError::InvalidRequest)?, + ) * scale; + let stable_id = *arena + .stable_ids + .get(glyph) + .ok_or(EngineError::InvalidRequest)?; + let flags = *arena + .shape + .glyph_flags + .get(glyph) + .ok_or(EngineError::InvalidRequest)?; + let origin_inline = cursor + x_offset; + let origin_block = baseline - y_offset - f64::from(style.baseline_shift); + self.semantic_glyphs.push(SemanticGlyph { + stable_id, + font_handle, + cluster, + glyph_id: u16::try_from(glyph_id).map_err(|_| EngineError::ResultTooLarge)?, + flags, + font_size: style.font_size, + inline_origin: finite_f32(origin_inline)?, + block_origin: finite_f32(origin_block)?, + }); + if let Some(extents) = extents_for(font_handle, glyph_id) { + let semantic_glyph_index = u32::try_from(self.semantic_glyphs.len() - 1) + .map_err(|_| EngineError::ResultTooLarge)?; + let inline_start = origin_inline + f64::from(extents.x_min) * scale; + let block_start = origin_block - f64::from(extents.y_max) * scale; + let inline_extent = f64::from(extents.x_max - extents.x_min) * scale; + let block_extent = f64::from(extents.y_max - extents.y_min) * scale; + self.push_glyph( + LayoutGlyph { + stable_id, + content_revision: 0, + semantic_glyph_index, + binding_handle, + font_handle, + glyph_id, + material_id: style.material_id, + clip_id: line.clip_id, + depth_key: 0, + font_size: style.font_size, + raster_pixel_ratio: style.raster_pixel_ratio, + inline_start: finite_f32(inline_start)?, + block_start: finite_f32(block_start)?, + inline_extent: nonnegative_f32(inline_extent)?, + block_extent: nonnegative_f32(block_extent)?, + }, + style.foreground_rgba, + semantic_id, + line.region_id, + line.flow_thread_id, + line.transform_index, + ); + } + cursor += x_advance; + if cluster_override.is_none() { + let next_cluster = (glyph + 1 < end) + .then(|| arena.shape.clusters.get(glyph + 1).copied()) + .flatten(); + if next_cluster != Some(shaped_cluster) { + cursor += f64::from(style.letter_spacing); + if clusters + .starts + .get(cluster_index) + .and_then(|start| usize::try_from(*start).ok()) + .and_then(|start| text.get(start)) + == Some(&0x20) + { + cursor += f64::from(style.word_spacing); + } + } + } + } + Ok(cursor) + } + + fn push_glyph( + &mut self, + glyph: LayoutGlyph, + foreground: u32, + cluster: u32, + region: u32, + flow_thread: u32, + transform_index: u32, + ) { + self.glyphs.push(glyph); + let f32_values = [ + glyph.inline_start, + glyph.block_start, + glyph.inline_extent, + glyph.block_extent, + glyph.font_size, + glyph.raster_pixel_ratio, + ]; + for (field, value) in self.semantic_f32.iter_mut().zip(f32_values) { + field.push(value); + } + let u32_values = [ + foreground, + cluster, + region, + flow_thread, + transform_index, + glyph.stable_id, + ]; + for (field, value) in self.semantic_u32.iter_mut().zip(u32_values) { + field.push(value); + } + } + + fn assign_content_revisions( + &mut self, + previous: &Self, + index: &mut IdentityIndex, + next_revision: &mut u32, + ) -> Result<(), EngineError> { + self.semantic_change_masks.resize(self.glyphs.len(), 0); + if let Some(range) = self.recomposed_glyphs { + *next_revision = (*next_revision).max(1); + let previous_glyphs = previous + .glyphs + .get(range.previous_start..range.previous_end) + .ok_or(EngineError::InvalidRequest)?; + let next_glyphs = self + .glyphs + .get(range.next_start..range.next_end) + .ok_or(EngineError::InvalidRequest)?; + if previous_glyphs.len() == next_glyphs.len() + && previous_glyphs + .iter() + .zip(next_glyphs) + .all(|(old, next)| old.stable_id == next.stable_id) + { + for offset in 0..next_glyphs.len() { + self.assign_content_revision( + range.next_start + offset, + previous, + Some(range.previous_start + offset), + next_revision, + )?; + } + return Ok(()); + } + index + .prepare(previous_glyphs.len()) + .map_err(identity_index_error)?; + for (offset, glyph) in previous_glyphs.iter().enumerate() { + index + .insert( + glyph.stable_id, + u32::try_from(range.previous_start + offset) + .map_err(|_| EngineError::ResultTooLarge)?, + ) + .map_err(identity_index_error)?; + } + for slot in range.next_start..range.next_end { + let previous_slot = index + .get(self.glyphs[slot].stable_id) + .and_then(|value| usize::try_from(value).ok()); + self.assign_content_revision(slot, previous, previous_slot, next_revision)?; + } + return Ok(()); + } + if self.glyphs.len() == previous.glyphs.len() + && self + .glyphs + .iter() + .zip(&previous.glyphs) + .all(|(next, old)| next.stable_id == old.stable_id) + { + *next_revision = (*next_revision).max(1); + for slot in 0..self.glyphs.len() { + self.assign_content_revision(slot, previous, Some(slot), next_revision)?; + } + return Ok(()); + } + index + .prepare(previous.glyphs.len()) + .map_err(identity_index_error)?; + for (slot, glyph) in previous.glyphs.iter().enumerate() { + index + .insert( + glyph.stable_id, + u32::try_from(slot).map_err(|_| EngineError::ResultTooLarge)?, + ) + .map_err(identity_index_error)?; + } + *next_revision = (*next_revision).max(1); + for slot in 0..self.glyphs.len() { + let previous_slot = index + .get(self.glyphs[slot].stable_id) + .and_then(|value| usize::try_from(value).ok()); + self.assign_content_revision(slot, previous, previous_slot, next_revision)?; + } + Ok(()) + } + + fn assign_content_revision( + &mut self, + slot: usize, + previous: &Self, + previous_slot: Option, + next_revision: &mut u32, + ) -> Result<(), EngineError> { + let change_mask = previous_slot.map_or(ALL_SEMANTIC_CHANGES, |previous_slot| { + self.semantic_change_mask(slot, previous, previous_slot) + }); + let revision = if change_mask == 0 { + previous.glyphs[previous_slot.expect("zero change requires a previous glyph")] + .content_revision + } else { + let revision = *next_revision; + *next_revision = next_revision + .checked_add(1) + .ok_or(EngineError::ResultTooLarge)?; + revision + }; + if revision == 0 { + return Err(EngineError::ResultTooLarge); + } + self.glyphs[slot].content_revision = revision; + *self + .semantic_change_masks + .get_mut(slot) + .ok_or(EngineError::InvalidRequest)? = change_mask; + Ok(()) + } + + fn semantic_change_mask(&self, slot: usize, previous: &Self, previous_slot: usize) -> u16 { + let next = self.glyphs[slot]; + let old = previous.glyphs[previous_slot]; + if next.stable_id != old.stable_id + || next.font_handle != old.font_handle + || next.binding_handle != old.binding_handle + || next.glyph_id != old.glyph_id + || next.material_id != old.material_id + || next.clip_id != old.clip_id + || next.depth_key != old.depth_key + { + return ALL_SEMANTIC_CHANGES; + } + let mut mask = 0_u16; + for field in 0..SEMANTIC_F32_FIELD_COUNT { + if self.semantic_f32[field][slot].to_bits() + != previous.semantic_f32[field][previous_slot].to_bits() + { + mask |= 1 << field; + } + } + let next_semantic = self.semantic_glyphs[self.glyphs[slot].semantic_glyph_index as usize]; + let previous_semantic = + previous.semantic_glyphs[previous.glyphs[previous_slot].semantic_glyph_index as usize]; + if next_semantic.inline_origin.to_bits() != previous_semantic.inline_origin.to_bits() { + mask |= 1 << 6; + } + if next_semantic.block_origin.to_bits() != previous_semantic.block_origin.to_bits() { + mask |= 1 << 7; + } + for field in 0..SEMANTIC_U32_FIELD_COUNT { + if self.semantic_u32[field][slot] != previous.semantic_u32[field][previous_slot] { + mask |= 1 << (SEMANTIC_F32_CHANGE_FIELD_COUNT + field); + } + } + mask + } +} + +fn line_span_start(starts: &[u32], line: usize) -> Result { + starts + .get(line) + .copied() + .and_then(|value| usize::try_from(value).ok()) + .ok_or(EngineError::InvalidRequest) +} + +fn line_span_end(starts: &[u32], counts: &[u32], line_end: usize) -> Result { + let line = line_end.checked_sub(1).ok_or(EngineError::InvalidRequest)?; + let start = line_span_start(starts, line)?; + let count = counts + .get(line) + .copied() + .and_then(|value| usize::try_from(value).ok()) + .ok_or(EngineError::InvalidRequest)?; + start.checked_add(count).ok_or(EngineError::InvalidRequest) +} + +fn line_fragments(flow: &FlowLayoutArena, line: FlowLine) -> Result<&[FlowFragment], EngineError> { + let start = usize::try_from(line.fragment_start).map_err(|_| EngineError::InvalidRequest)?; + let end = start + .checked_add(usize::from(line.fragment_count)) + .ok_or(EngineError::InvalidRequest)?; + flow.fragments + .get(start..end) + .ok_or(EngineError::InvalidRequest) +} + +fn is_trivially_ltr(bidi: &BidiAnalysis, runs: &[ShapingRun]) -> bool { + bidi.levels.iter().all(|level| level & 1 == 0) + && runs.iter().all(|run| !run.style.bidi_override) +} + +fn prepare_line_levels( + target: &mut Vec, + bidi: &BidiAnalysis, + start: u32, + end: u32, +) -> Result<(), EngineError> { + target.clear(); + let start = usize::try_from(start).map_err(|_| EngineError::InvalidRequest)?; + let end = usize::try_from(end).map_err(|_| EngineError::InvalidRequest)?; + let levels = bidi + .levels + .get(start..end) + .ok_or(EngineError::InvalidRequest)?; + reserve(target, levels.len())?; + target.extend_from_slice(levels); + let paragraph = paragraph_level_at(bidi, u32::try_from(start).unwrap_or(u32::MAX)); + let classes = bidi + .classes + .get(start..end) + .ok_or(EngineError::InvalidRequest)?; + let mut reset_from = Some(0usize); + let mut reset_to = None; + let mut previous_level = paragraph; + for index in 0..target.len() { + match classes[index] { + BIDI_B | BIDI_S => { + reset_to = Some(index + 1); + reset_from.get_or_insert(index); + } + BIDI_WS | BIDI_FSI | BIDI_LRI | BIDI_RLI | BIDI_PDI => { + reset_from.get_or_insert(index); + } + BIDI_RLE | BIDI_LRE | BIDI_RLO | BIDI_LRO | BIDI_PDF | BIDI_BN => { + reset_from.get_or_insert(index); + target[index] = previous_level; + } + _ => reset_from = None, + } + if let (Some(from), Some(to)) = (reset_from, reset_to) { + target[from..to].fill(paragraph); + reset_from = None; + reset_to = None; + } + previous_level = target[index]; + } + if let Some(from) = reset_from { + target[from..].fill(paragraph); + } + Ok(()) +} + +fn cluster_level( + cluster: usize, + line_start: u32, + clusters: &ClusterArena, + runs: &[ShapingRun], + line_levels: &[u8], +) -> Result { + let source = + usize::try_from(clusters.source_runs[cluster]).map_err(|_| EngineError::InvalidRequest)?; + let run = runs.get(source).ok_or(EngineError::InvalidRequest)?; + let local = clusters.starts[cluster] + .checked_sub(line_start) + .and_then(|value| usize::try_from(value).ok()) + .ok_or(EngineError::InvalidRequest)?; + let resolved = line_levels.get(local).copied().unwrap_or(run.bidi_level); + if run.style.bidi_override { + Ok(if resolved & 1 == run.direction & 1 { + resolved + } else { + resolved.saturating_add(1) + }) + } else { + Ok(resolved) + } +} + +fn reorder_l2(indices: &mut [u32], levels: &mut [u8], start: usize) { + let range = &levels[start..]; + let maximum = range.iter().copied().max().unwrap_or(0); + let Some(lowest_odd) = range.iter().copied().filter(|level| level & 1 != 0).min() else { + return; + }; + for level in (lowest_odd..=maximum).rev() { + let mut run_start = start; + while run_start < levels.len() { + while run_start < levels.len() && levels[run_start] < level { + run_start += 1; + } + let mut run_end = run_start; + while run_end < levels.len() && levels[run_end] >= level { + run_end += 1; + } + indices[run_start..run_end].reverse(); + levels[run_start..run_end].reverse(); + run_start = run_end; + } + } +} + +fn paragraph_level_at(bidi: &BidiAnalysis, offset: u32) -> u8 { + bidi.paragraph_starts + .iter() + .zip(&bidi.paragraph_ends) + .zip(&bidi.paragraph_levels) + .find_map(|((&start, &end), &level)| (start <= offset && offset < end).then_some(level)) + .or_else(|| bidi.paragraph_levels.last().copied()) + .unwrap_or(0) +} + +fn alignment_offset(align: u8, paragraph_level: u8, available: f64) -> f64 { + match align { + ALIGN_CENTER => available * 0.5, + ALIGN_END if paragraph_level & 1 == 0 => available, + ALIGN_START if paragraph_level & 1 != 0 => available, + _ => 0.0, + } +} + +fn count_justification_spaces( + text: &[u16], + clusters: &ClusterArena, + start: usize, + mut end: usize, +) -> u32 { + while end > start && cluster_is_space(text, clusters, end - 1) { + end -= 1; + } + clusters.starts[start..end] + .iter() + .filter(|&&offset| text.get(offset as usize) == Some(&0x20)) + .count() + .try_into() + .unwrap_or(u32::MAX) +} + +#[allow(clippy::too_many_arguments)] +fn justification_adjustment( + line: FlowLine, + fragment: FlowFragment, + final_line: bool, + text: &[u16], + clusters: &ClusterArena, + cluster_start: usize, + cluster_end: usize, +) -> (u32, f64) { + let spaces = if line.align == ALIGN_JUSTIFY && !fragment.line.hard_break && !final_line { + count_justification_spaces(text, clusters, cluster_start, cluster_end) + } else { + 0 + }; + let available = (fragment.slot_end - fragment.slot_start - fragment.line.advance).max(0.0); + (spaces, justification_space_advance(available, spaces)) +} + +pub(crate) fn positioned_fragment_advance( + line: FlowLine, + fragment: FlowFragment, + final_line: bool, + text: &[u16], + clusters: &ClusterArena, +) -> Result { + let cluster_start = + usize::try_from(fragment.line.cluster_start).map_err(|_| EngineError::InvalidRequest)?; + let cluster_end = + usize::try_from(fragment.line.cluster_end).map_err(|_| EngineError::InvalidRequest)?; + let (spaces, per_space) = justification_adjustment( + line, + fragment, + final_line, + text, + clusters, + cluster_start, + cluster_end, + ); + Ok(fragment.line.advance + per_space * f64::from(spaces)) +} + +fn justification_space_advance(available: f64, space_count: u32) -> f64 { + if space_count == 0 { + 0.0 + } else { + available / f64::from(space_count) + } +} + +fn cluster_is_space(text: &[u16], clusters: &ClusterArena, cluster: usize) -> bool { + clusters + .starts + .get(cluster) + .and_then(|offset| usize::try_from(*offset).ok()) + .and_then(|offset| text.get(offset)) + == Some(&0x20) +} + +fn finite_f32(value: f64) -> Result { + let value = value as f32; + value + .is_finite() + .then_some(value) + .ok_or(EngineError::InvalidRequest) +} + +fn nonnegative_f32(value: f64) -> Result { + let value = finite_f32(value)?; + (value >= 0.0) + .then_some(value) + .ok_or(EngineError::InvalidRequest) +} + +fn identity_index_error(error: IdentityIndexError) -> EngineError { + match error { + IdentityIndexError::AllocationFailed | IdentityIndexError::ArithmeticOverflow => { + EngineError::ResultTooLarge + } + IdentityIndexError::DuplicateIdentity => EngineError::InvalidRequest, + } +} + +fn reserve(values: &mut Vec, capacity: usize) -> Result<(), EngineError> { + if values.capacity() < capacity { + values + .try_reserve_exact(capacity.saturating_sub(values.len())) + .map_err(|_| EngineError::ResultTooLarge)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::{ + cluster_state::CLUSTER_SAFE_BEFORE, flow_composition::NO_BOUNDARY, + line_composition::ComposedLine, style_state::ResolvedStyle, + }; + use alloc::vec; + + #[test] + fn l2_reorders_exact_levels_without_allocating() { + let mut indices = Vec::with_capacity(8); + indices.extend([0, 1, 2, 3, 4]); + let mut levels = Vec::with_capacity(8); + levels.extend([0, 1, 1, 2, 0]); + let capacities = (indices.capacity(), levels.capacity()); + reorder_l2(&mut indices, &mut levels, 0); + assert_eq!(indices, [0, 3, 2, 1, 4]); + assert_eq!(levels, [0, 2, 1, 1, 0]); + assert_eq!((indices.capacity(), levels.capacity()), capacities); + } + + #[test] + fn only_even_unoverridden_runs_skip_visual_reordering() { + let mut bidi = BidiAnalysis { + levels: vec![0, 2, 0], + ..BidiAnalysis::default() + }; + let mut run = ShapingRun { + text_start: 0, + text_end: 3, + script: u32::from_be_bytes(*b"Latn"), + direction: 0, + bidi_level: 0, + style: ResolvedStyle::default(), + }; + assert!(is_trivially_ltr(&bidi, &[run])); + bidi.levels[1] = 1; + assert!(!is_trivially_ltr(&bidi, &[run])); + bidi.levels[1] = 0; + run.style.bidi_override = true; + assert!(!is_trivially_ltr(&bidi, &[run])); + } + + #[test] + fn justification_expands_only_lines_with_expandable_spaces() { + assert_eq!(justification_space_advance(22.0, 2), 11.0); + assert_eq!(justification_space_advance(22.0, 0), 0.0); + } + + #[test] + fn positions_once_and_revisions_only_exact_content_changes() { + let text = vec![0x61, 0x62, 0x0a]; + let style = ResolvedStyle::test_typography(10.0, 1.0, 0.0); + let styles = [StyleSegment { + text_start: 0, + text_end: 3, + style, + }]; + let runs = [ShapingRun { + text_start: 0, + text_end: 2, + script: u32::from_be_bytes(*b"Latn"), + direction: 0, + bidi_level: 0, + style, + }]; + let clusters = ClusterArena { + starts: vec![0, 1, 2], + ends: vec![1, 2, 3], + advances: vec![6.0, 6.0, 0.0], + flags: vec![CLUSTER_SAFE_BEFORE, CLUSTER_SAFE_BEFORE, CLUSTER_HARD_BREAK], + style_indexes: vec![0, 0, 0], + source_runs: vec![0, 0, u32::MAX], + binding_handles: vec![11, 11, 0], + font_handles: vec![1, 1, 0], + stable_ids: vec![10, 20, 30], + glyph_starts: vec![0, 1, 2], + glyph_counts: vec![1, 1, 0], + glyph_indices: vec![0, 1], + glyph_stable_ids: vec![100, 200], + index_at: vec![0, 1, 2, 3], + ..ClusterArena::default() + }; + let shape = ShapeArena { + runs: vec![], + glyph_ids: vec![1, 2], + clusters: vec![0, 1], + x_advances: vec![500, 500], + y_advances: vec![0, 0], + x_offsets: vec![0, 0], + y_offsets: vec![0, 0], + glyph_flags: vec![0, 0], + }; + let bidi = BidiAnalysis { + levels: vec![0, 0, BIDI_B], + classes: vec![0, 0, BIDI_B], + paragraph_starts: vec![0], + paragraph_ends: vec![3], + paragraph_levels: vec![0], + runs: vec![], + }; + let mut flow = FlowLayoutArena { + lines: vec![FlowLine { + flow_thread_id: 7, + region_id: 9, + transform_index: 9, + clip_id: 9, + fragment_start: 0, + fragment_count: 1, + align: ALIGN_CENTER, + block_start: 0.0, + baseline: 8.0, + height: 10.0, + }], + fragments: vec![FlowFragment { + line: ComposedLine { + cluster_start: 0, + cluster_end: 3, + text_start: 0, + text_end: 2, + advance: 12.0, + hard_break: true, + }, + slot_start: 0.0, + slot_end: 20.0, + boundary_index: NO_BOUNDARY, + }], + ..FlowLayoutArena::default() + }; + let metrics = |_| { + Some(FontMetrics { + units_per_em: 1_000, + ascender: 800, + descender: -200, + line_gap: 0, + }) + }; + let extents = |_, _| { + Some(FontGlyphExtents { + x_min: 0, + y_min: 0, + x_max: 500, + y_max: 700, + }) + }; + let mut index = IdentityIndex::default(); + let mut active = PositionedGlyphArena::default(); + let mut next_revision = 1; + active + .build( + &PositionedGlyphArena::default(), + &flow, + &text, + &clusters, + &runs, + &shape, + &BoundaryShapeArena::default(), + &styles, + &bidi, + &mut index, + &mut next_revision, + metrics, + extents, + ) + .unwrap(); + assert_eq!(active.glyphs.len(), 2); + assert_eq!(active.glyphs[0].content_revision, 1); + assert_eq!(active.glyphs[1].content_revision, 2); + assert_eq!(active.glyphs[0].inline_start, 4.0); + assert_eq!(active.glyphs[1].inline_start, 10.0); + assert_eq!(active.glyphs[0].block_start, 1.0); + assert_eq!(active.semantic_glyphs[0].inline_origin, 4.0); + assert_eq!(active.semantic_glyphs[1].inline_origin, 10.0); + assert_eq!(active.semantic_glyphs[0].block_origin, 8.0); + assert_ne!( + active.semantic_f32[1][0], + active.semantic_glyphs[0].block_origin + ); + assert_eq!(active.semantic_u32[0], [u32::MAX, u32::MAX]); + assert_eq!(next_revision, 3); + + let mut pending = PositionedGlyphArena::default(); + pending + .build( + &active, + &flow, + &text, + &clusters, + &runs, + &shape, + &BoundaryShapeArena::default(), + &styles, + &bidi, + &mut index, + &mut next_revision, + metrics, + extents, + ) + .unwrap(); + assert_eq!(pending.glyphs[0].content_revision, 1); + assert_eq!(pending.glyphs[1].content_revision, 2); + assert_eq!(next_revision, 3); + + flow.fragments[0].slot_start = 1.0; + flow.fragments[0].slot_end = 21.0; + pending + .build( + &active, + &flow, + &text, + &clusters, + &runs, + &shape, + &BoundaryShapeArena::default(), + &styles, + &bidi, + &mut index, + &mut next_revision, + metrics, + extents, + ) + .unwrap(); + assert_eq!(pending.glyphs[0].content_revision, 3); + assert_eq!(pending.glyphs[1].content_revision, 4); + assert_eq!(next_revision, 5); + + let mut reordered = PositionedGlyphArena::default(); + reordered.glyphs.extend(active.glyphs.iter().rev().copied()); + reordered + .semantic_glyphs + .extend(active.semantic_glyphs.iter().rev().copied()); + for (index, glyph) in reordered.glyphs.iter_mut().enumerate() { + glyph.semantic_glyph_index = index as u32; + } + for field in 0..SEMANTIC_F32_FIELD_COUNT { + reordered.semantic_f32[field].extend(active.semantic_f32[field].iter().rev().copied()); + } + for field in 0..SEMANTIC_U32_FIELD_COUNT { + reordered.semantic_u32[field].extend(active.semantic_u32[field].iter().rev().copied()); + } + reordered + .assign_content_revisions(&active, &mut index, &mut next_revision) + .unwrap(); + assert_eq!(reordered.glyphs[0].content_revision, 2); + assert_eq!(reordered.glyphs[1].content_revision, 1); + assert_eq!(reordered.semantic_change_masks, [0, 0]); + assert_eq!(next_revision, 5); + } + + #[test] + fn converged_lines_assign_revisions_only_inside_the_recomposed_glyph_range() { + let glyph = |stable_id, revision| LayoutGlyph { + stable_id, + content_revision: revision, + semantic_glyph_index: stable_id - 1, + binding_handle: 1, + font_handle: 1, + glyph_id: stable_id, + material_id: 0, + clip_id: 0, + depth_key: 0, + font_size: 16.0, + raster_pixel_ratio: 1.0, + inline_start: stable_id as f32, + block_start: 0.0, + inline_extent: 8.0, + block_extent: 16.0, + }; + let make_arena = || { + let mut arena = PositionedGlyphArena { + glyphs: vec![glyph(1, 10), glyph(2, 20), glyph(3, 30)], + ..PositionedGlyphArena::default() + }; + arena + .semantic_glyphs + .extend([1, 2, 3].map(|stable_id| SemanticGlyph { + stable_id, + font_handle: 1, + cluster: stable_id, + glyph_id: stable_id as u16, + flags: 0, + font_size: 16.0, + inline_origin: stable_id as f32, + block_origin: 0.0, + })); + for field in &mut arena.semantic_f32 { + field.extend([1.0, 2.0, 3.0]); + } + for field in &mut arena.semantic_u32 { + field.extend([1, 2, 3]); + } + arena + }; + let previous = make_arena(); + let mut next = make_arena(); + next.semantic_f32[0][1] = 4.0; + next.recomposed_glyphs = Some(RecomposedGlyphRange { + previous_start: 1, + previous_end: 2, + next_start: 1, + next_end: 2, + }); + let mut next_revision = 40; + next.assign_content_revisions(&previous, &mut IdentityIndex::default(), &mut next_revision) + .unwrap(); + assert_eq!( + next.glyphs + .iter() + .map(|glyph| glyph.content_revision) + .collect::>(), + [10, 40, 30] + ); + assert_eq!(next.semantic_change_masks, [0, 1, 0]); + assert_eq!(next_revision, 41); + } +} diff --git a/packages/text/rust/shaper/src/engine/render_plan.rs b/packages/text/rust/shaper/src/engine/render_plan.rs new file mode 100644 index 00000000..12a52c3e --- /dev/null +++ b/packages/text/rust/shaper/src/engine/render_plan.rs @@ -0,0 +1,177 @@ +//! Renderer-neutral retained display-list records. +//! +//! These types describe rendering intent and revision-relative resource changes. They deliberately +//! contain no backend command, JavaScript callback, GPU object, or host pointer. + +pub const RESOURCE_ACTION_CREATE: u16 = 1; +pub const RESOURCE_ACTION_UPDATE: u16 = 2; +pub const RESOURCE_ACTION_RETAIN: u16 = 3; + +pub const BUFFER_ORDERED_DIRECT: u16 = 1; +pub const BUFFER_STABLE_INDIRECT: u16 = 2; +/// Reserved non-policy binding ID for the stable-indirect logical-order buffer. +pub const POLICY_BUFFER_ORDER: u16 = u16::MAX; + +pub const PATCH_ALLOCATE_OR_RESIZE: u16 = 1; +pub const PATCH_WRITE: u16 = 2; +pub const PATCH_FILL: u16 = 3; +pub const PATCH_COPY: u16 = 4; +pub const PATCH_RETIRE: u16 = 5; + +pub const PRIMITIVE_GLYPH: u16 = 1; +pub const PRIMITIVE_DECORATION: u16 = 2; +pub const PRIMITIVE_INLINE_OBJECT: u16 = 3; +pub const PRIMITIVE_CLIP: u16 = 4; +pub const PRIMITIVE_POLICY: u16 = 5; + +pub const RETIRE_RESOURCE: u16 = 1; +pub const RETIRE_BUFFER: u16 = 2; +pub const RETIRE_SLOT_RANGE: u16 = 3; +pub const RETIRE_OUTPUT_BYTES: u16 = 4; + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ResourceRecord { + pub id: u32, + pub generation: u32, + pub technique_id: u32, + pub resource_kind: u16, + pub action: u16, + pub flags: u32, + pub reference_id: u32, + pub lower_bound: u32, + pub upper_bound: u32, + pub auxiliary0: u32, + pub auxiliary1: u32, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct BufferRecord { + pub id: u32, + pub generation: u32, + pub program_id: u32, + pub policy_buffer_id: u16, + pub scalar_type: u8, + pub vector_width: u8, + pub strategy: u16, + pub flags: u16, + pub live_records: u32, + pub capacity_records: u32, + pub byte_length: u32, + pub order_buffer_id: u32, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PatchRecord { + pub opcode: u16, + pub flags: u16, + pub buffer_id: u32, + pub buffer_generation: u32, + pub destination_offset: u32, + pub byte_length: u32, + /// Offset into `RenderPlanView::payload`; serialization rebases it to the publication start. + pub payload_start: u32, + pub source_buffer_id: u32, + pub source_offset: u32, + pub fill_value: u32, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct PrimitiveRecord { + pub id: u32, + pub kind: u16, + pub flags: u16, + pub technique_id: u32, + pub resource_id: u32, + pub resource_generation: u32, + pub program_id: u32, + pub program_variant: u16, + /// Number of consecutive physical records represented by this primitive span. + pub record_count: u16, + pub buffer_id: u32, + pub record_index: u32, + pub logical_order: u32, + pub clip_id: u32, + pub semantic_id: u32, + pub inline_start: f32, + pub block_start: f32, + pub inline_extent: f32, + pub block_extent: f32, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct DrawRecord { + pub id: u32, + pub program_id: u32, + pub program_variant: u16, + pub flags: u16, + /// Renderer-owned material/pipeline identity. This is data, never a host callback. + pub material_id: u32, + /// Clip identity shared by every primitive in this packet. + pub clip_id: u32, + /// Caller-defined sortable depth bucket; logical order remains authoritative within a bucket. + pub depth_key: u32, + /// Renderer-owned transform/object identity shared by this packet. + pub transform_id: u32, + pub primitive_start: u32, + pub primitive_count: u32, + pub buffer_start: u32, + pub buffer_count: u32, + pub resource_start: u32, + pub resource_count: u32, + pub order_token: u32, + pub indirect_buffer_id: u32, + pub indirect_offset: u32, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct RetirementRecord { + pub kind: u16, + pub flags: u16, + pub id: u32, + pub generation: u32, + pub after_publication_generation: u32, + pub byte_offset: u32, + pub byte_length: u32, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct DiagnosticRecord { + pub code: u16, + pub severity: u8, + pub phase: u8, + pub subject_id: u32, + pub value0: u32, + pub value1: u32, + pub duration_nanos_low: u32, + pub duration_nanos_high: u32, +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct RenderPlanView<'a> { + pub policy_handle: u32, + pub capability_set: u32, + pub policy_fingerprint: u64, + pub resources: &'a [ResourceRecord], + pub buffers: &'a [BufferRecord], + pub patches: &'a [PatchRecord], + pub primitives: &'a [PrimitiveRecord], + pub draws: &'a [DrawRecord], + pub retirements: &'a [RetirementRecord], + pub diagnostics: &'a [DiagnosticRecord], + pub payload: &'a [u8], +} + +const _: () = assert!(core::mem::size_of::() == 40); +const _: () = assert!(core::mem::size_of::() == 36); +const _: () = assert!(core::mem::size_of::() == 36); +const _: () = assert!(core::mem::size_of::() == 64); +const _: () = assert!(core::mem::size_of::() == 64); +const _: () = assert!(core::mem::size_of::() == 24); +const _: () = assert!(core::mem::size_of::() == 24); diff --git a/packages/text/rust/shaper/src/engine/render_plan_compiler.rs b/packages/text/rust/shaper/src/engine/render_plan_compiler.rs new file mode 100644 index 00000000..d3fa06c1 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/render_plan_compiler.rs @@ -0,0 +1,981 @@ +//! Allocation-strategy dispatcher for retained render-plan compilation. +//! +//! Homogeneous frames delegate directly to one compiler. Mixed frames let both compilers filter +//! the same borrowed semantic inputs, then merge only renderer-facing plan records. No glyph or +//! policy input is copied into a strategy-specific partition. + +use alloc::vec::Vec; + +use super::{ + ordered_plan::OrderedPlanCompiler, + plan_error::PlanError, + plan_input::{PlanInput, PlanInputError, validate_input}, + policy::{ + ALLOCATION_ORDERED_DIRECT, ALLOCATION_STABLE_INDIRECT, CapabilitySetId, + PolicyExecutionError, ValidatedPolicy, + }, + render_plan::{ + BufferRecord, DrawRecord, PATCH_WRITE, PatchRecord, PrimitiveRecord, + RESOURCE_ACTION_RETAIN, RESOURCE_ACTION_UPDATE, RETIRE_RESOURCE, RenderPlanView, + ResourceRecord, RetirementRecord, + }, + stable_plan::StablePlanCompiler, +}; + +const ORDERED_BUFFER_ID_LIMIT: u32 = 0x7fff_ffff; +const STABLE_BUFFER_ID_FLOOR: u32 = ORDERED_BUFFER_ID_LIMIT; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RenderPlanCompilerError { + AllocationFailed, + AlreadyPrepared, + NotPrepared, + CapabilitySetMissing, + ProgramMissing, + UnsupportedStrategy, + InvalidInputShape, + InvalidIdentity, + InvalidResource, + InvalidPlan, + ArithmeticOverflow, + Plan(PlanError), +} + +impl From for RenderPlanCompilerError { + fn from(error: PlanInputError) -> Self { + match error { + PlanInputError::InvalidShape => Self::InvalidInputShape, + PlanInputError::InvalidIdentity => Self::InvalidIdentity, + PlanInputError::InvalidResource => Self::InvalidResource, + } + } +} + +impl From for RenderPlanCompilerError { + fn from(error: PlanError) -> Self { + Self::Plan(error) + } +} + +impl RenderPlanCompilerError { + pub(crate) fn is_result_too_large(self) -> bool { + match self { + Self::AllocationFailed | Self::ArithmeticOverflow => true, + Self::AlreadyPrepared + | Self::NotPrepared + | Self::CapabilitySetMissing + | Self::ProgramMissing + | Self::UnsupportedStrategy + | Self::InvalidInputShape + | Self::InvalidIdentity + | Self::InvalidResource + | Self::InvalidPlan => false, + Self::Plan(error) => plan_result_too_large(error), + } + } +} + +fn policy_result_too_large(error: PolicyExecutionError) -> bool { + match error { + PolicyExecutionError::OutputCapacity => true, + PolicyExecutionError::CapabilitySetMissing + | PolicyExecutionError::ProgramMissing + | PolicyExecutionError::InputFieldCount + | PolicyExecutionError::InputLength + | PolicyExecutionError::OutputBufferCount + | PolicyExecutionError::OutputSchema + | PolicyExecutionError::NonFiniteOutput => false, + } +} + +fn plan_result_too_large(error: PlanError) -> bool { + match error { + PlanError::AllocationFailed + | PlanError::CapacityExceeded + | PlanError::IdentifierExhausted + | PlanError::ArithmeticOverflow => true, + PlanError::AlreadyPrepared + | PlanError::NotPrepared + | PlanError::CapabilitySetMissing + | PlanError::ProgramMissing + | PlanError::UnsupportedStrategy + | PlanError::InvalidInputShape + | PlanError::InvalidIdentity + | PlanError::DuplicateIdentity + | PlanError::InvalidResource => false, + PlanError::PolicyExecution(error) => policy_result_too_large(error), + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum PreparedStrategy { + #[default] + None, + Empty, + Ordered, + Stable, + Mixed, +} + +pub struct RenderPlanCompiler { + ordered: OrderedPlanCompiler, + stable: StablePlanCompiler, + resources: Vec, + buffers: Vec, + patches: Vec, + primitives: Vec, + draws: Vec, + retirements: Vec, + payload: Vec, + prepared_strategy: PreparedStrategy, +} + +impl Default for RenderPlanCompiler { + fn default() -> Self { + Self { + ordered: OrderedPlanCompiler::with_buffer_id_limit(ORDERED_BUFFER_ID_LIMIT), + stable: StablePlanCompiler::with_buffer_id_floor(STABLE_BUFFER_ID_FLOOR), + resources: Vec::new(), + buffers: Vec::new(), + patches: Vec::new(), + primitives: Vec::new(), + draws: Vec::new(), + retirements: Vec::new(), + payload: Vec::new(), + prepared_strategy: PreparedStrategy::None, + } + } +} + +impl RenderPlanCompiler { + pub(crate) fn prepare_reuse(&mut self) -> Result<(), RenderPlanCompilerError> { + if self.prepared_strategy != PreparedStrategy::None { + return Err(RenderPlanCompilerError::AlreadyPrepared); + } + self.clear_merged_plan(); + self.prepared_strategy = PreparedStrategy::Empty; + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + pub fn prepare( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + input: PlanInput<'_>, + checkpoint: bool, + publication_generation: u32, + acknowledged_publication_generation: u32, + ) -> Result<(), RenderPlanCompilerError> { + if self.prepared_strategy != PreparedStrategy::None { + return Err(RenderPlanCompilerError::AlreadyPrepared); + } + if publication_generation == 0 + || acknowledged_publication_generation >= publication_generation + { + return Err(RenderPlanCompilerError::InvalidIdentity); + } + if policy.capability_set(capability_set).is_none() { + return Err(RenderPlanCompilerError::CapabilitySetMissing); + } + validate_input(input)?; + self.clear_merged_plan(); + + let (mut ordered_input, mut stable_input) = (false, false); + match policy.uniform_allocation_strategy(capability_set) { + Some(ALLOCATION_ORDERED_DIRECT) => ordered_input = !input.glyphs.is_empty(), + Some(ALLOCATION_STABLE_INDIRECT) => stable_input = !input.glyphs.is_empty(), + Some(_) => return Err(RenderPlanCompilerError::UnsupportedStrategy), + None => { + for glyph in input.glyphs { + let program = policy + .program(capability_set, glyph.technique, glyph.program_variant) + .ok_or(RenderPlanCompilerError::ProgramMissing)?; + match program.allocation_strategy { + ALLOCATION_ORDERED_DIRECT => ordered_input = true, + ALLOCATION_STABLE_INDIRECT => stable_input = true, + _ => return Err(RenderPlanCompilerError::UnsupportedStrategy), + } + if ordered_input && stable_input { + break; + } + } + } + } + + let prepare_ordered = ordered_input || self.ordered.has_state(); + let prepare_stable = stable_input || self.stable.has_state(); + match (prepare_ordered, prepare_stable) { + (false, false) => { + self.prepared_strategy = PreparedStrategy::Empty; + Ok(()) + } + (true, false) => { + self.ordered.prepare( + policy, + capability_set, + input, + checkpoint, + publication_generation, + )?; + self.prepared_strategy = PreparedStrategy::Ordered; + Ok(()) + } + (false, true) => { + self.stable.prepare( + policy, + capability_set, + input, + checkpoint, + publication_generation, + acknowledged_publication_generation, + )?; + self.prepared_strategy = PreparedStrategy::Stable; + Ok(()) + } + (true, true) => self.prepare_mixed( + policy, + capability_set, + input, + checkpoint, + publication_generation, + acknowledged_publication_generation, + ), + } + } + + pub fn plan_view( + &self, + policy_handle: u32, + capability_set: CapabilitySetId, + policy_fingerprint: u64, + ) -> Result, RenderPlanCompilerError> { + match self.prepared_strategy { + PreparedStrategy::None => Err(RenderPlanCompilerError::NotPrepared), + PreparedStrategy::Empty => Ok(RenderPlanView { + policy_handle, + capability_set: capability_set.0, + policy_fingerprint, + ..RenderPlanView::default() + }), + PreparedStrategy::Ordered => self + .ordered + .plan_view(policy_handle, capability_set, policy_fingerprint) + .map_err(Into::into), + PreparedStrategy::Stable => self + .stable + .plan_view(policy_handle, capability_set, policy_fingerprint) + .map_err(Into::into), + PreparedStrategy::Mixed => Ok(RenderPlanView { + policy_handle, + capability_set: capability_set.0, + policy_fingerprint, + resources: &self.resources, + buffers: &self.buffers, + patches: &self.patches, + primitives: &self.primitives, + draws: &self.draws, + retirements: &self.retirements, + payload: &self.payload, + ..RenderPlanView::default() + }), + } + } + + pub fn commit(&mut self) -> Result<(), RenderPlanCompilerError> { + match self.prepared_strategy { + PreparedStrategy::None => return Err(RenderPlanCompilerError::NotPrepared), + PreparedStrategy::Empty => {} + PreparedStrategy::Ordered => self.ordered.commit()?, + PreparedStrategy::Stable => self.stable.commit()?, + PreparedStrategy::Mixed => { + self.ordered.commit()?; + self.stable.commit()?; + } + } + self.prepared_strategy = PreparedStrategy::None; + Ok(()) + } + + pub fn abort(&mut self) { + match self.prepared_strategy { + PreparedStrategy::Ordered => self.ordered.abort(), + PreparedStrategy::Stable => self.stable.abort(), + PreparedStrategy::Mixed => { + self.ordered.abort(); + self.stable.abort(); + } + PreparedStrategy::None | PreparedStrategy::Empty => {} + } + self.prepared_strategy = PreparedStrategy::None; + } + + pub fn buffer_bytes(&self, id: u32) -> Option<&[u8]> { + if id <= ORDERED_BUFFER_ID_LIMIT { + self.ordered.buffer_bytes(id) + } else { + self.stable.buffer_bytes(id) + } + } + + #[allow(clippy::too_many_arguments)] + fn prepare_mixed( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + input: PlanInput<'_>, + checkpoint: bool, + publication_generation: u32, + acknowledged_publication_generation: u32, + ) -> Result<(), RenderPlanCompilerError> { + if let Err(error) = self.ordered.prepare_filtered( + policy, + capability_set, + input, + checkpoint, + publication_generation, + ) { + self.ordered.abort(); + return Err(error.into()); + } + if let Err(error) = self.stable.prepare_filtered( + policy, + capability_set, + input, + checkpoint, + publication_generation, + acknowledged_publication_generation, + ) { + self.ordered.abort(); + self.stable.abort(); + return Err(error.into()); + } + if let Err(error) = self.merge_prepared(capability_set, policy.fingerprint()) { + self.ordered.abort(); + self.stable.abort(); + return Err(error); + } + self.prepared_strategy = PreparedStrategy::Mixed; + Ok(()) + } + + fn merge_prepared( + &mut self, + capability_set: CapabilitySetId, + policy_fingerprint: u64, + ) -> Result<(), RenderPlanCompilerError> { + let publish_bindings = + self.ordered.publishes_bindings() || self.stable.publishes_bindings(); + if !publish_bindings { + return Ok(()); + } + let ordered = self + .ordered + .plan_view_forced(0, capability_set, policy_fingerprint)?; + let stable = self + .stable + .plan_view_forced(0, capability_set, policy_fingerprint)?; + + append_resources(&mut self.resources, ordered.resources)?; + append_resources(&mut self.resources, stable.resources)?; + append_buffers(&mut self.buffers, ordered.buffers)?; + let stable_buffer_base = self.buffers.len(); + append_buffers(&mut self.buffers, stable.buffers)?; + append_patches( + &mut self.patches, + &mut self.payload, + ordered.patches, + ordered.payload, + )?; + append_patches( + &mut self.patches, + &mut self.payload, + stable.patches, + stable.payload, + )?; + append_retirements(&mut self.retirements, ordered.retirements)?; + append_retirements(&mut self.retirements, stable.retirements)?; + reconcile_live_resource_retirements(&mut self.resources, &mut self.retirements); + merge_draws( + &mut self.primitives, + &mut self.draws, + &self.resources, + ordered, + stable, + stable_buffer_base, + )?; + Ok(()) + } + + fn clear_merged_plan(&mut self) { + self.resources.clear(); + self.buffers.clear(); + self.patches.clear(); + self.primitives.clear(); + self.draws.clear(); + self.retirements.clear(); + self.payload.clear(); + } +} + +fn append_resources( + destination: &mut Vec, + source: &[ResourceRecord], +) -> Result<(), RenderPlanCompilerError> { + reserve(destination, source.len())?; + for &resource in source { + if let Some(existing) = destination.iter_mut().find(|existing| { + existing.id == resource.id && existing.generation == resource.generation + }) { + if !same_resource(*existing, resource) { + return Err(RenderPlanCompilerError::InvalidResource); + } + existing.action = merge_resource_action(existing.action, resource.action); + } else { + destination.push(resource); + } + } + Ok(()) +} + +fn same_resource(left: ResourceRecord, right: ResourceRecord) -> bool { + ResourceRecord { action: 0, ..left } == ResourceRecord { action: 0, ..right } +} + +fn merge_resource_action(left: u16, right: u16) -> u16 { + if left == RESOURCE_ACTION_UPDATE || right == RESOURCE_ACTION_UPDATE { + RESOURCE_ACTION_UPDATE + } else if left == RESOURCE_ACTION_RETAIN || right == RESOURCE_ACTION_RETAIN { + RESOURCE_ACTION_RETAIN + } else { + left + } +} + +fn append_buffers( + destination: &mut Vec, + source: &[BufferRecord], +) -> Result<(), RenderPlanCompilerError> { + reserve(destination, source.len())?; + for &buffer in source { + if destination.iter().any(|existing| existing.id == buffer.id) { + return Err(RenderPlanCompilerError::InvalidPlan); + } + destination.push(buffer); + } + Ok(()) +} + +fn append_patches( + destination: &mut Vec, + payload: &mut Vec, + source: &[PatchRecord], + source_payload: &[u8], +) -> Result<(), RenderPlanCompilerError> { + let payload_base = + u32::try_from(payload.len()).map_err(|_| RenderPlanCompilerError::ArithmeticOverflow)?; + reserve(payload, source_payload.len())?; + payload.extend_from_slice(source_payload); + reserve(destination, source.len())?; + for &patch in source { + let mut rebased = patch; + if patch.opcode == PATCH_WRITE { + rebased.payload_start = patch + .payload_start + .checked_add(payload_base) + .ok_or(RenderPlanCompilerError::ArithmeticOverflow)?; + } + destination.push(rebased); + } + Ok(()) +} + +fn append_retirements( + destination: &mut Vec, + source: &[RetirementRecord], +) -> Result<(), RenderPlanCompilerError> { + reserve(destination, source.len())?; + for &retirement in source { + if !destination.contains(&retirement) { + destination.push(retirement); + } + } + Ok(()) +} + +fn reconcile_live_resource_retirements( + resources: &mut [ResourceRecord], + retirements: &mut Vec, +) { + for resource in &mut *resources { + let remained_live = retirements.iter().any(|retirement| { + retirement.kind == RETIRE_RESOURCE + && retirement.id == resource.id + && retirement.generation == resource.generation + }); + if remained_live && resource.action != RESOURCE_ACTION_UPDATE { + resource.action = RESOURCE_ACTION_RETAIN; + } + } + retirements.retain(|retirement| { + retirement.kind != RETIRE_RESOURCE + || !resources.iter().any(|resource| { + resource.id == retirement.id && resource.generation == retirement.generation + }) + }); +} + +fn merge_draws( + primitives: &mut Vec, + draws: &mut Vec, + resources: &[ResourceRecord], + ordered: RenderPlanView<'_>, + stable: RenderPlanView<'_>, + stable_buffer_base: usize, +) -> Result<(), RenderPlanCompilerError> { + reserve( + primitives, + ordered.primitives.len() + stable.primitives.len(), + )?; + reserve(draws, ordered.draws.len() + stable.draws.len())?; + let mut ordered_index = 0_usize; + let mut stable_index = 0_usize; + while ordered_index < ordered.draws.len() || stable_index < stable.draws.len() { + let take_ordered = match ( + ordered.draws.get(ordered_index), + stable.draws.get(stable_index), + ) { + (Some(left), Some(right)) if left.order_token == right.order_token => { + return Err(RenderPlanCompilerError::InvalidPlan); + } + (Some(left), Some(right)) => left.order_token < right.order_token, + (Some(_), None) => true, + (None, Some(_)) => false, + (None, None) => break, + }; + if take_ordered { + append_draw(primitives, draws, resources, ordered, ordered_index, 0)?; + ordered_index += 1; + } else { + append_draw( + primitives, + draws, + resources, + stable, + stable_index, + stable_buffer_base, + )?; + stable_index += 1; + } + } + Ok(()) +} + +fn append_draw( + primitives: &mut Vec, + draws: &mut Vec, + resources: &[ResourceRecord], + source: RenderPlanView<'_>, + draw_index: usize, + buffer_base: usize, +) -> Result<(), RenderPlanCompilerError> { + let source_draw = *source + .draws + .get(draw_index) + .ok_or(RenderPlanCompilerError::InvalidPlan)?; + if source_draw.resource_count != 1 { + return Err(RenderPlanCompilerError::InvalidPlan); + } + let source_resource = source + .resources + .get(source_draw.resource_start as usize) + .ok_or(RenderPlanCompilerError::InvalidPlan)?; + let resource_start = resources + .iter() + .position(|resource| { + resource.id == source_resource.id && resource.generation == source_resource.generation + }) + .ok_or(RenderPlanCompilerError::InvalidResource)?; + let primitive_start = source_draw.primitive_start as usize; + let primitive_end = primitive_start + .checked_add(source_draw.primitive_count as usize) + .ok_or(RenderPlanCompilerError::ArithmeticOverflow)?; + let source_primitives = source + .primitives + .get(primitive_start..primitive_end) + .ok_or(RenderPlanCompilerError::InvalidPlan)?; + let destination_primitive_start = + u32::try_from(primitives.len()).map_err(|_| RenderPlanCompilerError::ArithmeticOverflow)?; + primitives.extend_from_slice(source_primitives); + let buffer_start = buffer_base + .checked_add(source_draw.buffer_start as usize) + .ok_or(RenderPlanCompilerError::ArithmeticOverflow)?; + let buffer_end = buffer_start + .checked_add(source_draw.buffer_count as usize) + .ok_or(RenderPlanCompilerError::ArithmeticOverflow)?; + let source_buffer_end = source_draw + .buffer_start + .checked_add(source_draw.buffer_count) + .ok_or(RenderPlanCompilerError::ArithmeticOverflow)?; + if source_buffer_end as usize > source.buffers.len() + || buffer_end > buffer_base + source.buffers.len() + { + return Err(RenderPlanCompilerError::InvalidPlan); + } + draws.push(DrawRecord { + primitive_start: destination_primitive_start, + buffer_start: u32::try_from(buffer_start) + .map_err(|_| RenderPlanCompilerError::ArithmeticOverflow)?, + resource_start: u32::try_from(resource_start) + .map_err(|_| RenderPlanCompilerError::ArithmeticOverflow)?, + ..source_draw + }); + Ok(()) +} + +fn reserve(values: &mut Vec, additional: usize) -> Result<(), RenderPlanCompilerError> { + values + .try_reserve(additional) + .map_err(|_| RenderPlanCompilerError::AllocationFailed) +} + +#[cfg(test)] +mod tests { + use alloc::{vec, vec::Vec}; + + use super::*; + use crate::engine::{ + plan_input::PlanGlyph, + policy::{ + BATCH_ORDER, BATCH_PROGRAM, BATCH_RESOURCE, BATCH_TECHNIQUE, BUFFER_USAGE_COPY_DST, + BUFFER_USAGE_STORAGE, BufferId, BufferSchema, CAP_ORDERED_DIRECT, CAP_STABLE_INDIRECT, + CapabilitySet, Operation, PolicyDescriptor, ProgramCapabilities, ProgramDescriptor, + ProgramId, ScalarType, TechniqueId, + }, + render_plan::{BUFFER_ORDERED_DIRECT, BUFFER_STABLE_INDIRECT}, + render_plan_wire::plan_layout, + }; + + const CAPABILITY: CapabilitySetId = CapabilitySetId(1); + const ORDERED: TechniqueId = TechniqueId(1); + const STABLE: TechniqueId = TechniqueId(2); + + #[test] + fn homogeneous_frames_delegate_without_allocating_merge_tables() { + let policy = policy(); + let glyphs = [glyph(1, ORDERED, 0)]; + let x = [1.0]; + let mut compiler = RenderPlanCompiler::default(); + prepare(&mut compiler, &policy, &glyphs, &x, true, 1, 0); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + + assert_eq!(compiler.prepared_strategy, PreparedStrategy::Ordered); + assert!(compiler.resources.is_empty()); + assert_eq!(plan.buffers[0].strategy, BUFFER_ORDERED_DIRECT); + assert_eq!(plan.draws.len(), 1); + } + + #[test] + fn acknowledged_ordered_state_can_publish_an_empty_reuse_transaction() { + let policy = policy(); + let glyphs = [glyph(1, ORDERED, 0)]; + let x = [1.0]; + let mut compiler = RenderPlanCompiler::default(); + prepare(&mut compiler, &policy, &glyphs, &x, true, 1, 0); + let buffer = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap() + .buffers[0] + .id; + compiler.commit().unwrap(); + + compiler.prepare_reuse().unwrap(); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert!(plan.buffers.is_empty()); + assert!(plan.patches.is_empty()); + compiler.commit().unwrap(); + assert!(compiler.buffer_bytes(buffer).is_some()); + } + + #[test] + fn mixed_frames_preserve_global_draw_order_and_disjoint_buffer_namespaces() { + let policy = policy(); + let glyphs = [ + glyph(1, ORDERED, 0), + glyph(2, STABLE, 0), + glyph(3, ORDERED, 0), + glyph(4, STABLE, 0), + ]; + let x = [1.0, 2.0, 3.0, 4.0]; + let mut compiler = RenderPlanCompiler::default(); + prepare(&mut compiler, &policy, &glyphs, &x, true, 1, 0); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + + assert_eq!(compiler.prepared_strategy, PreparedStrategy::Mixed); + assert_eq!(plan.draws.len(), 4); + assert_eq!( + plan.draws + .iter() + .map(|draw| draw.order_token) + .collect::>(), + vec![0, 1, 2, 3] + ); + assert!( + plan.buffers + .iter() + .any(|buffer| buffer.strategy == BUFFER_ORDERED_DIRECT + && buffer.id <= ORDERED_BUFFER_ID_LIMIT) + ); + assert!( + plan.buffers + .iter() + .filter(|buffer| buffer.strategy == BUFFER_STABLE_INDIRECT) + .all(|buffer| buffer.id > ORDERED_BUFFER_ID_LIMIT) + ); + assert_eq!(plan.resources.len(), 2); + assert!(plan_layout(plan).is_ok()); + } + + #[test] + fn changing_allocation_strategy_keeps_a_shared_resource_live() { + let policy = policy_with_shared_technique_variants(); + let mut compiler = RenderPlanCompiler::default(); + let first = [glyph(1, ORDERED, 1)]; + prepare(&mut compiler, &policy, &first, &[1.0], true, 1, 0); + compiler.commit().unwrap(); + + let second = [glyph(1, ORDERED, 0)]; + prepare(&mut compiler, &policy, &second, &[1.0], false, 2, 0); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + + assert_eq!(compiler.prepared_strategy, PreparedStrategy::Mixed); + assert_eq!(plan.resources.len(), 1); + assert_eq!(plan.resources[0].action, RESOURCE_ACTION_RETAIN); + assert!(!plan.retirements.iter().any(|retirement| { + retirement.kind == RETIRE_RESOURCE + && retirement.id == plan.resources[0].id + && retirement.generation == plan.resources[0].generation + })); + } + + #[test] + fn repeated_mixed_updates_publish_no_op_frames_and_settle_merge_capacity() { + let policy = policy(); + let mut glyphs = [ + glyph(1, ORDERED, 0), + glyph(2, STABLE, 0), + glyph(3, ORDERED, 0), + glyph(4, STABLE, 0), + ]; + let mut compiler = RenderPlanCompiler::default(); + prepare( + &mut compiler, + &policy, + &glyphs, + &[1.0, 2.0, 3.0, 4.0], + true, + 1, + 0, + ); + compiler.commit().unwrap(); + + prepare( + &mut compiler, + &policy, + &glyphs, + &[1.0, 2.0, 3.0, 4.0], + false, + 2, + 0, + ); + let no_op = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert!(no_op.resources.is_empty()); + assert!(no_op.buffers.is_empty()); + assert!(no_op.patches.is_empty()); + assert!(no_op.primitives.is_empty()); + assert!(no_op.draws.is_empty()); + assert!(no_op.retirements.is_empty()); + assert!(no_op.payload.is_empty()); + compiler.commit().unwrap(); + + glyphs[1].content_revision = 2; + prepare( + &mut compiler, + &policy, + &glyphs, + &[1.0, 20.0, 3.0, 4.0], + false, + 3, + 0, + ); + let settled = merged_capacities(&compiler); + compiler.commit().unwrap(); + + glyphs[1].content_revision = 3; + prepare( + &mut compiler, + &policy, + &glyphs, + &[1.0, 21.0, 3.0, 4.0], + false, + 4, + 0, + ); + assert_eq!(merged_capacities(&compiler), settled); + } + + #[allow(clippy::too_many_arguments)] + fn prepare( + compiler: &mut RenderPlanCompiler, + policy: &ValidatedPolicy, + glyphs: &[PlanGlyph], + x: &[f32], + checkpoint: bool, + publication_generation: u32, + acknowledged_publication_generation: u32, + ) { + compiler + .prepare( + policy, + CAPABILITY, + PlanInput { + glyphs, + semantic_change_masks: &[], + f32_fields: &[x], + u32_fields: &[], + order_independent: false, + }, + checkpoint, + publication_generation, + acknowledged_publication_generation, + ) + .unwrap(); + } + + fn glyph(stable_id: u32, technique: TechniqueId, variant: u16) -> PlanGlyph { + PlanGlyph { + stable_id, + content_revision: 1, + technique, + program_variant: variant, + resource_id: technique.0 + 10, + resource_generation: 1, + resource_kind: 1, + resource_reference: technique.0 + 90, + semantic_id: 1, + transform_id: 1, + material_id: 1, + clip_id: 0, + depth_key: 0, + inline_start: stable_id as f32, + block_start: 0.0, + inline_extent: 1.0, + block_extent: 1.0, + } + } + + fn policy() -> ValidatedPolicy { + ValidatedPolicy::new(PolicyDescriptor { + capability_sets: vec![capability()], + programs: vec![ + program(ORDERED, 0, ProgramId(1), ALLOCATION_ORDERED_DIRECT), + program(STABLE, 0, ProgramId(2), ALLOCATION_STABLE_INDIRECT), + ], + }) + .unwrap() + } + + fn policy_with_shared_technique_variants() -> ValidatedPolicy { + ValidatedPolicy::new(PolicyDescriptor { + capability_sets: vec![capability()], + programs: vec![ + program(ORDERED, 0, ProgramId(1), ALLOCATION_ORDERED_DIRECT), + program(ORDERED, 1, ProgramId(2), ALLOCATION_STABLE_INDIRECT), + ], + }) + .unwrap() + } + + fn capability() -> CapabilitySet { + CapabilitySet { + id: CAPABILITY, + flags: CAP_ORDERED_DIRECT | CAP_STABLE_INDIRECT, + max_buffer_bytes: 4096, + update_alignment: 4, + coalesce_gap_bytes: 0, + range_call_penalty_bytes: 0, + max_buffers_per_draw: 2, + max_resources_per_draw: 1, + max_indirect_draws: 0, + fragmentation_budget: 8, + whole_buffer_threshold_basis_points: 10_000, + } + } + + fn program( + technique: TechniqueId, + variant: u16, + id: ProgramId, + allocation_strategy: u16, + ) -> ProgramDescriptor { + ProgramDescriptor { + technique, + variant, + id, + capability_set: CapabilitySetId(0), + resource_kind_mask: 1, + semantic_view_mask: 0, + storage_key_mask: BATCH_TECHNIQUE | BATCH_PROGRAM | BATCH_RESOURCE, + draw_key_mask: BATCH_TECHNIQUE + | BATCH_PROGRAM + | BATCH_RESOURCE + | BATCH_ORDER + | crate::engine::policy::BATCH_TRANSFORM, + allocation_strategy, + f32_input_count: 1, + u32_input_count: 0, + inputs: vec![crate::engine::policy::InputSource::semantic(0)], + capabilities: ProgramCapabilities::default(), + buffers: vec![BufferSchema::packed( + BufferId(1), + ScalarType::F32, + 1, + BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST, + 1, + )], + operations: vec![ + Operation::LoadF32 { + target: 0, + field: 0, + }, + Operation::StoreF32 { + source: 0, + buffer: BufferId(1), + lane: 0, + }, + ], + } + } + + fn merged_capacities(compiler: &RenderPlanCompiler) -> [usize; 7] { + [ + compiler.resources.capacity(), + compiler.buffers.capacity(), + compiler.patches.capacity(), + compiler.primitives.capacity(), + compiler.draws.capacity(), + compiler.retirements.capacity(), + compiler.payload.capacity(), + ] + } +} diff --git a/packages/text/rust/shaper/src/engine/render_plan_wire.rs b/packages/text/rust/shaper/src/engine/render_plan_wire.rs new file mode 100644 index 00000000..773a5d61 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/render_plan_wire.rs @@ -0,0 +1,727 @@ +//! Little-endian serialization for immutable render-plan publications. + +use crate::{ + STATUS_INVALID_REQUEST, STATUS_RESULT_TOO_LARGE, + abi_contract::*, + engine::render_plan::{ + BufferRecord, DiagnosticRecord, DrawRecord, PATCH_ALLOCATE_OR_RESIZE, PATCH_COPY, + PATCH_FILL, PATCH_RETIRE, PATCH_WRITE, PRIMITIVE_CLIP, PRIMITIVE_DECORATION, + PRIMITIVE_GLYPH, PRIMITIVE_INLINE_OBJECT, PRIMITIVE_POLICY, PatchRecord, PrimitiveRecord, + RESOURCE_ACTION_CREATE, RESOURCE_ACTION_RETAIN, RESOURCE_ACTION_UPDATE, RETIRE_BUFFER, + RETIRE_OUTPUT_BYTES, RETIRE_RESOURCE, RETIRE_SLOT_RANGE, RenderPlanView, ResourceRecord, + RetirementRecord, + }, + engine::semantic_view::{ + SEMANTIC_CARET, SEMANTIC_CLUSTER, SEMANTIC_FRAGMENT, SEMANTIC_GLYPH, + SEMANTIC_INSERTED_GLYPH, SEMANTIC_LINE, SEMANTIC_PARAGRAPH_MEASUREMENT, SEMANTIC_RUN, + SEMANTIC_SELECTION, SemanticRecord, + }, +}; + +const PAYLOAD_ALIGNMENT: u32 = 16; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct TableSpan { + pub offset: u32, + pub count: u32, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct EncodedPlanLayout { + pub byte_length: u32, + pub payload_offset: u32, + pub semantic_views: TableSpan, + pub resources: TableSpan, + pub buffers: TableSpan, + pub patches: TableSpan, + pub primitives: TableSpan, + pub draws: TableSpan, + pub retirements: TableSpan, + pub diagnostics: TableSpan, +} + +#[cfg(test)] +pub(crate) fn encode_plan( + plan: RenderPlanView<'_>, + output: &mut [u8], +) -> Result { + encode_publication(plan, &[], output) +} + +pub(crate) fn encode_publication( + plan: RenderPlanView<'_>, + semantic_views: &[SemanticRecord], + output: &mut [u8], +) -> Result { + let layout = publication_layout(plan, semantic_views)?; + let byte_length = usize::try_from(layout.byte_length).map_err(|_| STATUS_RESULT_TOO_LARGE)?; + let bytes = output + .get_mut(..byte_length) + .ok_or(STATUS_RESULT_TOO_LARGE)?; + bytes.fill(0); + if !plan.payload.is_empty() { + let start = usize::try_from(layout.payload_offset).map_err(|_| STATUS_RESULT_TOO_LARGE)?; + bytes[start..start + plan.payload.len()].copy_from_slice(plan.payload); + } + write_records( + bytes, + layout.semantic_views, + SEMANTIC_RECORD_SIZE, + semantic_views, + write_semantic, + ); + write_records( + bytes, + layout.resources, + RESOURCE_RECORD_SIZE, + plan.resources, + write_resource, + ); + write_records( + bytes, + layout.buffers, + BUFFER_RECORD_SIZE, + plan.buffers, + write_buffer, + ); + write_patch_records(bytes, layout.patches, layout.payload_offset, plan.patches); + write_records( + bytes, + layout.primitives, + PRIMITIVE_RECORD_SIZE, + plan.primitives, + write_primitive, + ); + write_records( + bytes, + layout.draws, + DRAW_RECORD_SIZE, + plan.draws, + write_draw, + ); + write_records( + bytes, + layout.retirements, + RETIREMENT_RECORD_SIZE, + plan.retirements, + write_retirement, + ); + write_records( + bytes, + layout.diagnostics, + DIAGNOSTIC_RECORD_SIZE, + plan.diagnostics, + write_diagnostic, + ); + Ok(layout) +} + +#[cfg(test)] +pub(crate) fn plan_layout(plan: RenderPlanView<'_>) -> Result { + publication_layout(plan, &[]) +} + +pub(crate) fn publication_layout( + plan: RenderPlanView<'_>, + semantic_views: &[SemanticRecord], +) -> Result { + validate_plan(plan, semantic_views)?; + let mut cursor = ENGINE_RESULT_HEADER_SIZE; + let payload_offset = if plan.payload.is_empty() { + 0 + } else { + cursor = align(cursor, PAYLOAD_ALIGNMENT)?; + let offset = cursor; + cursor = add_bytes(cursor, plan.payload.len(), 1)?; + offset + }; + let semantic_views = add_table( + &mut cursor, + semantic_views.len(), + SEMANTIC_RECORD_SIZE, + SEMANTIC_RECORD_ALIGNMENT, + )?; + let resources = add_table( + &mut cursor, + plan.resources.len(), + RESOURCE_RECORD_SIZE, + RESOURCE_RECORD_ALIGNMENT, + )?; + let buffers = add_table( + &mut cursor, + plan.buffers.len(), + BUFFER_RECORD_SIZE, + BUFFER_RECORD_ALIGNMENT, + )?; + let patches = add_table( + &mut cursor, + plan.patches.len(), + PATCH_RECORD_SIZE, + PATCH_RECORD_ALIGNMENT, + )?; + let primitives = add_table( + &mut cursor, + plan.primitives.len(), + PRIMITIVE_RECORD_SIZE, + PRIMITIVE_RECORD_ALIGNMENT, + )?; + let draws = add_table( + &mut cursor, + plan.draws.len(), + DRAW_RECORD_SIZE, + DRAW_RECORD_ALIGNMENT, + )?; + let retirements = add_table( + &mut cursor, + plan.retirements.len(), + RETIREMENT_RECORD_SIZE, + RETIREMENT_RECORD_ALIGNMENT, + )?; + let diagnostics = add_table( + &mut cursor, + plan.diagnostics.len(), + DIAGNOSTIC_RECORD_SIZE, + DIAGNOSTIC_RECORD_ALIGNMENT, + )?; + Ok(EncodedPlanLayout { + byte_length: cursor, + payload_offset, + semantic_views, + resources, + buffers, + patches, + primitives, + draws, + retirements, + diagnostics, + }) +} + +fn validate_plan(plan: RenderPlanView<'_>, semantic_views: &[SemanticRecord]) -> Result<(), u32> { + if plan.policy_handle == 0 { + return Err(STATUS_INVALID_REQUEST); + } + for record in semantic_views { + if record.id == 0 + || !matches!( + record.kind, + SEMANTIC_LINE + | SEMANTIC_FRAGMENT + | SEMANTIC_RUN + | SEMANTIC_CLUSTER + | SEMANTIC_CARET + | SEMANTIC_SELECTION + | SEMANTIC_INSERTED_GLYPH + | SEMANTIC_PARAGRAPH_MEASUREMENT + | SEMANTIC_GLYPH + ) + || (!matches!(record.kind, SEMANTIC_PARAGRAPH_MEASUREMENT | SEMANTIC_GLYPH) + && record.text_start > record.text_end) + || !finite4( + record.inline_start, + record.block_start, + record.inline_extent, + record.block_extent, + ) + { + return Err(STATUS_INVALID_REQUEST); + } + } + for record in plan.resources { + if record.id == 0 + || record.generation == 0 + || record.technique_id == 0 + || record.resource_kind == 0 + || !matches!( + record.action, + RESOURCE_ACTION_CREATE | RESOURCE_ACTION_UPDATE | RESOURCE_ACTION_RETAIN + ) + || record.lower_bound > record.upper_bound + { + return Err(STATUS_INVALID_REQUEST); + } + } + for record in plan.buffers { + if record.id == 0 + || record.generation == 0 + || record.program_id == 0 + || record.policy_buffer_id == 0 + || !matches!(record.scalar_type, 1..=3) + || !matches!(record.vector_width, 1..=4) + || !matches!(record.strategy, 1..=2) + || record.live_records > record.capacity_records + { + return Err(STATUS_INVALID_REQUEST); + } + } + for record in plan.patches { + if record.buffer_id == 0 + || record.buffer_generation == 0 + || !matches!( + record.opcode, + PATCH_ALLOCATE_OR_RESIZE | PATCH_WRITE | PATCH_FILL | PATCH_COPY | PATCH_RETIRE + ) + { + return Err(STATUS_INVALID_REQUEST); + } + let has_payload = record.opcode == PATCH_WRITE; + if has_payload { + let start = + usize::try_from(record.payload_start).map_err(|_| STATUS_INVALID_REQUEST)?; + let length = usize::try_from(record.byte_length).map_err(|_| STATUS_INVALID_REQUEST)?; + if start + .checked_add(length) + .filter(|end| *end <= plan.payload.len()) + .is_none() + { + return Err(STATUS_INVALID_REQUEST); + } + } else if record.payload_start != 0 { + return Err(STATUS_INVALID_REQUEST); + } + if record.opcode == PATCH_COPY && record.source_buffer_id == 0 { + return Err(STATUS_INVALID_REQUEST); + } + } + for record in plan.primitives { + if record.id == 0 + || !matches!( + record.kind, + PRIMITIVE_GLYPH + | PRIMITIVE_DECORATION + | PRIMITIVE_INLINE_OBJECT + | PRIMITIVE_CLIP + | PRIMITIVE_POLICY + ) + || record.record_count == 0 + || !finite4( + record.inline_start, + record.block_start, + record.inline_extent, + record.block_extent, + ) + { + return Err(STATUS_INVALID_REQUEST); + } + } + for record in plan.draws { + if record.id == 0 + || record.program_id == 0 + || !range_in( + record.primitive_start, + record.primitive_count, + plan.primitives.len(), + ) + || !range_in(record.buffer_start, record.buffer_count, plan.buffers.len()) + || !range_in( + record.resource_start, + record.resource_count, + plan.resources.len(), + ) + { + return Err(STATUS_INVALID_REQUEST); + } + } + for record in plan.retirements { + if record.id == 0 + || record.generation == 0 + || !matches!( + record.kind, + RETIRE_RESOURCE | RETIRE_BUFFER | RETIRE_SLOT_RANGE | RETIRE_OUTPUT_BYTES + ) + { + return Err(STATUS_INVALID_REQUEST); + } + } + Ok(()) +} + +fn add_table( + cursor: &mut u32, + count: usize, + stride: u32, + alignment: u32, +) -> Result { + if count == 0 { + return Ok(TableSpan::default()); + } + *cursor = align(*cursor, alignment)?; + let offset = *cursor; + *cursor = add_bytes(*cursor, count, stride)?; + Ok(TableSpan { + offset, + count: u32::try_from(count).map_err(|_| STATUS_RESULT_TOO_LARGE)?, + }) +} + +fn add_bytes(cursor: u32, count: usize, stride: u32) -> Result { + let count = u32::try_from(count).map_err(|_| STATUS_RESULT_TOO_LARGE)?; + count + .checked_mul(stride) + .and_then(|length| cursor.checked_add(length)) + .ok_or(STATUS_RESULT_TOO_LARGE) +} + +fn align(value: u32, alignment: u32) -> Result { + debug_assert!(alignment.is_power_of_two()); + value + .checked_add(alignment - 1) + .map(|aligned| aligned & !(alignment - 1)) + .ok_or(STATUS_RESULT_TOO_LARGE) +} + +fn range_in(start: u32, count: u32, length: usize) -> bool { + if count == 0 { + return start == 0; + } + start + .checked_add(count) + .and_then(|end| usize::try_from(end).ok()) + .is_some_and(|end| end <= length) +} + +fn finite4(first: f32, second: f32, third: f32, fourth: f32) -> bool { + first.is_finite() && second.is_finite() && third.is_finite() && fourth.is_finite() +} + +fn write_records( + bytes: &mut [u8], + span: TableSpan, + stride: u32, + records: &[Record], + write: fn(&mut [u8], usize, Record), +) { + let start = span.offset as usize; + let stride = stride as usize; + for (index, record) in records.iter().copied().enumerate() { + write(bytes, start + index * stride, record); + } +} + +fn write_patch_records( + bytes: &mut [u8], + span: TableSpan, + payload_offset: u32, + records: &[PatchRecord], +) { + let start = span.offset as usize; + for (index, record) in records.iter().copied().enumerate() { + write_patch( + bytes, + start + index * PATCH_RECORD_SIZE as usize, + record, + payload_offset, + ); + } +} + +fn write_semantic(bytes: &mut [u8], at: usize, value: SemanticRecord) { + u32_at(bytes, at, SEMANTIC_ID, value.id); + u16_at(bytes, at, SEMANTIC_KIND, value.kind); + u16_at(bytes, at, SEMANTIC_FLAGS, value.flags); + u32_at(bytes, at, SEMANTIC_PARENT_ID, value.parent_id); + u32_at(bytes, at, SEMANTIC_TEXT_START, value.text_start); + u32_at(bytes, at, SEMANTIC_TEXT_END, value.text_end); + u32_at(bytes, at, SEMANTIC_ITEM_START, value.item_start); + u32_at(bytes, at, SEMANTIC_ITEM_COUNT, value.item_count); + f32_at(bytes, at, SEMANTIC_INLINE_START, value.inline_start); + f32_at(bytes, at, SEMANTIC_BLOCK_START, value.block_start); + f32_at(bytes, at, SEMANTIC_INLINE_EXTENT, value.inline_extent); + f32_at(bytes, at, SEMANTIC_BLOCK_EXTENT, value.block_extent); +} + +fn write_resource(bytes: &mut [u8], at: usize, value: ResourceRecord) { + u32_at(bytes, at, RESOURCE_ID, value.id); + u32_at(bytes, at, RESOURCE_GENERATION, value.generation); + u32_at(bytes, at, RESOURCE_TECHNIQUE_ID, value.technique_id); + u16_at(bytes, at, RESOURCE_KIND, value.resource_kind); + u16_at(bytes, at, RESOURCE_ACTION, value.action); + u32_at(bytes, at, RESOURCE_FLAGS, value.flags); + u32_at(bytes, at, RESOURCE_REFERENCE_ID, value.reference_id); + u32_at(bytes, at, RESOURCE_LOWER_BOUND, value.lower_bound); + u32_at(bytes, at, RESOURCE_UPPER_BOUND, value.upper_bound); + u32_at(bytes, at, RESOURCE_AUXILIARY0, value.auxiliary0); + u32_at(bytes, at, RESOURCE_AUXILIARY1, value.auxiliary1); +} + +fn write_buffer(bytes: &mut [u8], at: usize, value: BufferRecord) { + u32_at(bytes, at, BUFFER_ID, value.id); + u32_at(bytes, at, BUFFER_GENERATION, value.generation); + u32_at(bytes, at, BUFFER_PROGRAM_ID, value.program_id); + u16_at(bytes, at, BUFFER_POLICY_BUFFER_ID, value.policy_buffer_id); + u8_at(bytes, at, BUFFER_SCALAR_TYPE, value.scalar_type); + u8_at(bytes, at, BUFFER_VECTOR_WIDTH, value.vector_width); + u16_at(bytes, at, BUFFER_STRATEGY, value.strategy); + u16_at(bytes, at, BUFFER_FLAGS, value.flags); + u32_at(bytes, at, BUFFER_LIVE_RECORDS, value.live_records); + u32_at(bytes, at, BUFFER_CAPACITY_RECORDS, value.capacity_records); + u32_at(bytes, at, BUFFER_BYTE_LENGTH, value.byte_length); + u32_at(bytes, at, BUFFER_ORDER_BUFFER_ID, value.order_buffer_id); +} + +fn write_patch(bytes: &mut [u8], at: usize, value: PatchRecord, payload_offset: u32) { + u16_at(bytes, at, PATCH_OPCODE, value.opcode); + u16_at(bytes, at, PATCH_FLAGS, value.flags); + u32_at(bytes, at, PATCH_BUFFER_ID, value.buffer_id); + u32_at(bytes, at, PATCH_BUFFER_GENERATION, value.buffer_generation); + u32_at( + bytes, + at, + PATCH_DESTINATION_OFFSET, + value.destination_offset, + ); + u32_at(bytes, at, PATCH_BYTE_LENGTH, value.byte_length); + let rebased = if value.opcode == PATCH_WRITE { + payload_offset + value.payload_start + } else { + 0 + }; + u32_at(bytes, at, PATCH_PAYLOAD_OFFSET, rebased); + u32_at(bytes, at, PATCH_SOURCE_BUFFER_ID, value.source_buffer_id); + u32_at(bytes, at, PATCH_SOURCE_OFFSET, value.source_offset); + u32_at(bytes, at, PATCH_FILL_VALUE, value.fill_value); +} + +fn write_primitive(bytes: &mut [u8], at: usize, value: PrimitiveRecord) { + u32_at(bytes, at, PRIMITIVE_ID, value.id); + u16_at(bytes, at, PRIMITIVE_KIND, value.kind); + u16_at(bytes, at, PRIMITIVE_FLAGS, value.flags); + u32_at(bytes, at, PRIMITIVE_TECHNIQUE_ID, value.technique_id); + u32_at(bytes, at, PRIMITIVE_RESOURCE_ID, value.resource_id); + u32_at( + bytes, + at, + PRIMITIVE_RESOURCE_GENERATION, + value.resource_generation, + ); + u32_at(bytes, at, PRIMITIVE_PROGRAM_ID, value.program_id); + u16_at(bytes, at, PRIMITIVE_PROGRAM_VARIANT, value.program_variant); + u16_at(bytes, at, PRIMITIVE_RECORD_COUNT, value.record_count); + u32_at(bytes, at, PRIMITIVE_BUFFER_ID, value.buffer_id); + u32_at(bytes, at, PRIMITIVE_RECORD_INDEX, value.record_index); + u32_at(bytes, at, PRIMITIVE_LOGICAL_ORDER, value.logical_order); + u32_at(bytes, at, PRIMITIVE_CLIP_ID, value.clip_id); + u32_at(bytes, at, PRIMITIVE_SEMANTIC_ID, value.semantic_id); + f32_at(bytes, at, PRIMITIVE_INLINE_START, value.inline_start); + f32_at(bytes, at, PRIMITIVE_BLOCK_START, value.block_start); + f32_at(bytes, at, PRIMITIVE_INLINE_EXTENT, value.inline_extent); + f32_at(bytes, at, PRIMITIVE_BLOCK_EXTENT, value.block_extent); +} + +fn write_draw(bytes: &mut [u8], at: usize, value: DrawRecord) { + u32_at(bytes, at, DRAW_ID, value.id); + u32_at(bytes, at, DRAW_PROGRAM_ID, value.program_id); + u16_at(bytes, at, DRAW_PROGRAM_VARIANT, value.program_variant); + u16_at(bytes, at, DRAW_FLAGS, value.flags); + u32_at(bytes, at, DRAW_MATERIAL_ID, value.material_id); + u32_at(bytes, at, DRAW_CLIP_ID, value.clip_id); + u32_at(bytes, at, DRAW_DEPTH_KEY, value.depth_key); + u32_at(bytes, at, DRAW_TRANSFORM_ID, value.transform_id); + u32_at(bytes, at, DRAW_PRIMITIVE_START, value.primitive_start); + u32_at(bytes, at, DRAW_PRIMITIVE_COUNT, value.primitive_count); + u32_at(bytes, at, DRAW_BUFFER_START, value.buffer_start); + u32_at(bytes, at, DRAW_BUFFER_COUNT, value.buffer_count); + u32_at(bytes, at, DRAW_RESOURCE_START, value.resource_start); + u32_at(bytes, at, DRAW_RESOURCE_COUNT, value.resource_count); + u32_at(bytes, at, DRAW_ORDER_TOKEN, value.order_token); + u32_at(bytes, at, DRAW_INDIRECT_BUFFER_ID, value.indirect_buffer_id); + u32_at(bytes, at, DRAW_INDIRECT_OFFSET, value.indirect_offset); +} + +fn write_retirement(bytes: &mut [u8], at: usize, value: RetirementRecord) { + u16_at(bytes, at, RETIREMENT_KIND, value.kind); + u16_at(bytes, at, RETIREMENT_FLAGS, value.flags); + u32_at(bytes, at, RETIREMENT_ID, value.id); + u32_at(bytes, at, RETIREMENT_GENERATION, value.generation); + u32_at( + bytes, + at, + RETIREMENT_AFTER_PUBLICATION_GENERATION, + value.after_publication_generation, + ); + u32_at(bytes, at, RETIREMENT_BYTE_OFFSET, value.byte_offset); + u32_at(bytes, at, RETIREMENT_BYTE_LENGTH, value.byte_length); +} + +fn write_diagnostic(bytes: &mut [u8], at: usize, value: DiagnosticRecord) { + u16_at(bytes, at, DIAGNOSTIC_CODE, value.code); + u8_at(bytes, at, DIAGNOSTIC_SEVERITY, value.severity); + u8_at(bytes, at, DIAGNOSTIC_PHASE, value.phase); + u32_at(bytes, at, DIAGNOSTIC_SUBJECT_ID, value.subject_id); + u32_at(bytes, at, DIAGNOSTIC_VALUE0, value.value0); + u32_at(bytes, at, DIAGNOSTIC_VALUE1, value.value1); + u32_at( + bytes, + at, + DIAGNOSTIC_DURATION_NANOS_LOW, + value.duration_nanos_low, + ); + u32_at( + bytes, + at, + DIAGNOSTIC_DURATION_NANOS_HIGH, + value.duration_nanos_high, + ); +} + +fn u8_at(bytes: &mut [u8], record: usize, field: usize, value: u8) { + bytes[record + field] = value; +} + +fn u16_at(bytes: &mut [u8], record: usize, field: usize, value: u16) { + bytes[record + field..record + field + 2].copy_from_slice(&value.to_le_bytes()); +} + +fn u32_at(bytes: &mut [u8], record: usize, field: usize, value: u32) { + bytes[record + field..record + field + 4].copy_from_slice(&value.to_le_bytes()); +} + +fn f32_at(bytes: &mut [u8], record: usize, field: usize, value: f32) { + u32_at(bytes, record, field, value.to_bits()); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{engine::render_plan::*, wire::read_u32}; + use alloc::vec; + + #[test] + fn serializes_every_table_with_compiler_offsets_and_rebased_payloads() { + let semantic = [SemanticRecord { + id: 1, + kind: SEMANTIC_LINE, + text_end: 4, + item_count: 2, + inline_start: 1.5, + block_start: 2.5, + inline_extent: 80.0, + block_extent: 12.0, + ..SemanticRecord::default() + }]; + let resource = [ResourceRecord { + id: 2, + generation: 3, + technique_id: 4, + resource_kind: 1, + action: RESOURCE_ACTION_CREATE, + reference_id: 5, + upper_bound: 9, + ..ResourceRecord::default() + }]; + let buffer = [BufferRecord { + id: 6, + generation: 7, + program_id: 8, + policy_buffer_id: 9, + scalar_type: 1, + vector_width: 4, + strategy: BUFFER_ORDERED_DIRECT, + live_records: 2, + capacity_records: 4, + byte_length: 64, + ..BufferRecord::default() + }]; + let patch = [PatchRecord { + opcode: PATCH_WRITE, + buffer_id: 6, + buffer_generation: 7, + destination_offset: 16, + byte_length: 4, + payload_start: 1, + ..PatchRecord::default() + }]; + let primitive = [PrimitiveRecord { + id: 10, + kind: PRIMITIVE_GLYPH, + technique_id: 4, + resource_id: 2, + resource_generation: 3, + program_id: 8, + record_count: 1, + buffer_id: 6, + semantic_id: 1, + inline_extent: 8.0, + block_extent: 12.0, + ..PrimitiveRecord::default() + }]; + let draw = [DrawRecord { + id: 11, + program_id: 8, + primitive_count: 1, + buffer_count: 1, + resource_count: 1, + ..DrawRecord::default() + }]; + let retirement = [RetirementRecord { + kind: RETIRE_BUFFER, + id: 12, + generation: 2, + after_publication_generation: 4, + ..RetirementRecord::default() + }]; + let diagnostic = [DiagnosticRecord { + code: 13, + severity: 2, + phase: 3, + value0: 14, + ..DiagnosticRecord::default() + }]; + let plan = RenderPlanView { + policy_handle: 15, + capability_set: 16, + policy_fingerprint: 17, + resources: &resource, + buffers: &buffer, + patches: &patch, + primitives: &primitive, + draws: &draw, + retirements: &retirement, + diagnostics: &diagnostic, + payload: &[0xaa, 0xbb, 0xcc, 0xdd, 0xee], + }; + let expected = publication_layout(plan, &semantic).unwrap(); + let mut bytes = vec![0x7f; expected.byte_length as usize + 16]; + let layout = encode_publication(plan, &semantic, &mut bytes).unwrap(); + assert_eq!(layout, expected); + assert_eq!(layout.payload_offset % PAYLOAD_ALIGNMENT, 0); + assert_eq!( + read_u32( + &bytes, + layout.patches.offset as usize + PATCH_PAYLOAD_OFFSET + ) + .unwrap(), + layout.payload_offset + 1 + ); + assert_eq!( + &bytes[layout.payload_offset as usize..layout.payload_offset as usize + 5], + plan.payload + ); + assert_eq!( + read_u32(&bytes, layout.primitives.offset as usize + PRIMITIVE_ID).unwrap(), + 10 + ); + assert_eq!(bytes[layout.byte_length as usize..], [0x7f; 16]); + } + + #[test] + fn validation_fails_before_touching_destination_bytes() { + let patch = [PatchRecord { + opcode: PATCH_WRITE, + buffer_id: 1, + buffer_generation: 1, + byte_length: 4, + payload_start: 2, + ..PatchRecord::default() + }]; + let plan = RenderPlanView { + policy_handle: 1, + patches: &patch, + payload: &[1, 2, 3], + ..RenderPlanView::default() + }; + let mut bytes = [0xa5; 512]; + assert_eq!(encode_plan(plan, &mut bytes), Err(STATUS_INVALID_REQUEST)); + assert_eq!(bytes, [0xa5; 512]); + } +} diff --git a/packages/text/rust/shaper/src/engine/semantic_view.rs b/packages/text/rust/shaper/src/engine/semantic_view.rs new file mode 100644 index 00000000..f88d0483 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/semantic_view.rs @@ -0,0 +1,33 @@ +//! Demand-shaped semantic records returned only when explicitly requested. +//! +//! Semantic views can share a publication with a render plan, but they are not commands and are +//! never part of `RenderPlanView`. + +pub const SEMANTIC_LINE: u16 = 1; +pub const SEMANTIC_FRAGMENT: u16 = 2; +pub const SEMANTIC_RUN: u16 = 3; +pub const SEMANTIC_CLUSTER: u16 = 4; +pub const SEMANTIC_CARET: u16 = 5; +pub const SEMANTIC_SELECTION: u16 = 6; +pub const SEMANTIC_INSERTED_GLYPH: u16 = 7; +pub const SEMANTIC_PARAGRAPH_MEASUREMENT: u16 = 8; +pub const SEMANTIC_GLYPH: u16 = 9; + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SemanticRecord { + pub id: u32, + pub kind: u16, + pub flags: u16, + pub parent_id: u32, + pub text_start: u32, + pub text_end: u32, + pub item_start: u32, + pub item_count: u32, + pub inline_start: f32, + pub block_start: f32, + pub inline_extent: f32, + pub block_extent: f32, +} + +const _: () = assert!(core::mem::size_of::() == 44); diff --git a/packages/text/rust/shaper/src/engine/semantic_wire.rs b/packages/text/rust/shaper/src/engine/semantic_wire.rs new file mode 100644 index 00000000..f5e8cb2d --- /dev/null +++ b/packages/text/rust/shaper/src/engine/semantic_wire.rs @@ -0,0 +1,2582 @@ +//! Borrowed semantic update records decoded from the compiler-mapped frame ABI. + +use crate::{ + FeatureRecord, STATUS_INVALID_REQUEST, + abi_contract::{ + self as abi, ENGINE_TEXT_MUTATION_DELETE_COUNT, ENGINE_TEXT_MUTATION_ENCODING, + ENGINE_TEXT_MUTATION_INSERT_COUNT, ENGINE_TEXT_MUTATION_INSERT_OFFSET, + ENGINE_TEXT_MUTATION_OPCODE, ENGINE_TEXT_MUTATION_PARAGRAPH_ID, + ENGINE_TEXT_MUTATION_RECORD_ALIGNMENT, ENGINE_TEXT_MUTATION_RECORD_SIZE, + ENGINE_TEXT_MUTATION_RESERVED0, ENGINE_TEXT_MUTATION_TEXT_START, + ENGINE_UPDATE_REQUEST_HEADER_SIZE, + }, + bidi::{DIRECTION_AUTO, DIRECTION_LTR, DIRECTION_RTL}, + engine::frame::{ + ALIGN_CENTER, ALIGN_END, ALIGN_JUSTIFY, ALIGN_START, AXIS_AT_MOST, AXIS_EXACT, + AXIS_UNCONSTRAINED, BASELINE_ALPHABETIC, BASELINE_MIDDLE, BASELINE_TEXT_BOTTOM, + BASELINE_TEXT_TOP, BLOCK_ALIGN_CENTER, BLOCK_ALIGN_END, BLOCK_ALIGN_START, + DECORATION_DASHED, DECORATION_DOTTED, DECORATION_DOUBLE, DECORATION_FLAGS_MASK, + DECORATION_NONE, DECORATION_SOLID, DECORATION_WAVY, EXCLUSION_WRAP_BOTH, + EXCLUSION_WRAP_INLINE_END, EXCLUSION_WRAP_INLINE_START, EXCLUSION_WRAP_LARGEST, + ORIENTATION_MIXED, ORIENTATION_SIDEWAYS, ORIENTATION_UPRIGHT, OVERFLOW_CLIP, + OVERFLOW_ELLIPSIS, OVERFLOW_VISIBLE, PARAGRAPH_MUTATION_REMOVE, PARAGRAPH_MUTATION_UPSERT, + SHAPE_POLYGON, SHAPE_RECTANGLE, STYLE_FIELD_BASELINE_SHIFT, STYLE_FIELD_DECORATION, + STYLE_FIELD_DIRECTION, STYLE_FIELD_FEATURES, STYLE_FIELD_FONT_SIZE, STYLE_FIELD_FONT_STACK, + STYLE_FIELD_FOREGROUND, STYLE_FIELD_LANGUAGE, STYLE_FIELD_LETTER_SPACING, + STYLE_FIELD_LINE_HEIGHT, STYLE_FIELD_MASK, STYLE_FIELD_MATERIAL, + STYLE_FIELD_RASTER_PIXEL_RATIO, STYLE_FIELD_WORD_SPACING, STYLE_FLAG_ROOT, + STYLE_MUTATION_REMOVE, STYLE_MUTATION_UPSERT, TEXT_ENCODING_UTF16_LE, + TEXT_MUTATION_REPLACE_UTF16, UpdateLimits, WRAP_CHARACTER, WRAP_NONE, WRAP_WORD, + WRITING_HORIZONTAL_TB, WRITING_VERTICAL_LR, WRITING_VERTICAL_RL, + }, + valid_language_bytes, valid_tag, + wire::{array, read_f32, read_u16, read_u32}, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ParagraphMutationBatch<'a> { + request: &'a [u8], + records: &'a [u8], +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ParagraphMutation { + Upsert { paragraph_id: u32, order: u32 }, + Remove { paragraph_id: u32 }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct TextMutationBatch<'a> { + request: &'a [u8], + records: &'a [u8], +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct TextMutation<'a> { + pub paragraph_id: u32, + pub text_start: u32, + pub delete_count: u32, + pub insert_utf16_le: &'a [u8], +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct StyleMutationBatch<'a> { + request: &'a [u8], + records: &'a [u8], +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum StyleMutation<'a> { + Remove { paragraph_id: u32, style_id: u32 }, + Upsert(StyleValue<'a>), +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct StyleValue<'a> { + pub paragraph_id: u32, + pub style_id: u32, + pub cascade_order: u32, + pub field_mask: u32, + pub text_start: u32, + pub text_end: u32, + pub font_stack_handle: u32, + pub material_id: u32, + pub language: &'a [u8], + pub features: &'a [u8], + pub font_size: f32, + pub line_height: f32, + pub letter_spacing: f32, + pub word_spacing: f32, + pub baseline_shift: f32, + pub raster_pixel_ratio: f32, + pub direction: u8, + pub foreground_rgba: u32, + pub decoration_rgba: u32, + pub decoration_flags: u32, + pub decoration_style: u8, + pub decoration_thickness: f32, + pub decoration_offset: f32, + pub root: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct GeometryBatch<'a> { + request: &'a [u8], + constraints: &'a [u8], + regions: &'a [u8], + exclusions: &'a [u8], + inline_objects: &'a [u8], +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct FlowConstraint { + pub paragraph_id: u32, + pub flow_thread_id: u32, + pub width: f32, + pub height: f32, + pub viewport_block_start: f32, + pub viewport_block_end: f32, + pub resume_block_offset: f32, + pub max_lines: u32, + pub region_start: u32, + pub resume_cluster: u32, + pub region_count: u16, + pub resume_region: u16, + pub width_mode: u8, + pub height_mode: u8, + pub wrap: u8, + pub align: u8, + pub overflow: u8, + pub block_align: u8, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct FlowRegion { + pub id: u32, + pub geometry_revision: u32, + pub transform_index: u32, + pub vertices_offset: u32, + pub vertex_count: u16, + pub exclusion_start: u16, + pub exclusion_count: u16, + pub shape: u8, + pub writing_mode: u8, + pub text_orientation: u8, + pub inline_start: f32, + pub block_start: f32, + pub inline_end: f32, + pub block_end: f32, + pub clip_inline_start: f32, + pub clip_block_start: f32, + pub clip_inline_end: f32, + pub clip_block_end: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct FlowExclusion { + pub id: u32, + pub region_id: u32, + pub geometry_revision: u32, + pub vertices_offset: u32, + pub vertex_count: u16, + pub shape: u8, + pub wrap_side: u8, + pub inline_start: f32, + pub block_start: f32, + pub inline_end: f32, + pub block_end: f32, + pub margin_inline: f32, + pub margin_block: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct FlowVertex { + pub inline: f32, + pub block: f32, +} + +impl GeometryBatch<'_> { + pub(crate) const fn empty() -> Self { + Self { + request: &[], + constraints: &[], + regions: &[], + exclusions: &[], + inline_objects: &[], + } + } + + pub(crate) fn validate_text_length(self, text_length: usize) -> Result<(), u32> { + let text_length = u32::try_from(text_length).map_err(|_| STATUS_INVALID_REQUEST)?; + for record in self + .constraints + .chunks_exact(abi::ENGINE_CONSTRAINT_RECORD_SIZE as usize) + { + if read_u32(record, abi::ENGINE_CONSTRAINT_RESUME_CLUSTER)? > text_length { + return Err(STATUS_INVALID_REQUEST); + } + } + let mut previous_offset = None; + for record in self + .inline_objects + .chunks_exact(abi::ENGINE_INLINE_OBJECT_RECORD_SIZE as usize) + { + let offset = read_u32(record, abi::ENGINE_INLINE_OBJECT_TEXT_OFFSET)?; + if offset > text_length || previous_offset.is_some_and(|previous| offset <= previous) { + return Err(STATUS_INVALID_REQUEST); + } + previous_offset = Some(offset); + } + Ok(()) + } + + pub(crate) fn constraint_count(self) -> usize { + self.constraints.len() / abi::ENGINE_CONSTRAINT_RECORD_SIZE as usize + } + + pub(crate) fn region_count(self) -> usize { + self.regions.len() / abi::ENGINE_REGION_RECORD_SIZE as usize + } + + pub(crate) fn exclusion_count(self) -> usize { + self.exclusions.len() / abi::ENGINE_EXCLUSION_RECORD_SIZE as usize + } + + pub(crate) fn inline_object_count(self) -> usize { + self.inline_objects.len() / abi::ENGINE_INLINE_OBJECT_RECORD_SIZE as usize + } + + pub(crate) fn is_empty(self) -> bool { + self.constraints.is_empty() && self.inline_objects.is_empty() + } + + pub(crate) fn take_paragraph( + self, + paragraph_id: u32, + constraint_cursor: &mut usize, + inline_object_cursor: &mut usize, + ) -> Result { + let constraints = take_records( + self.constraints, + abi::ENGINE_CONSTRAINT_RECORD_SIZE, + abi::ENGINE_CONSTRAINT_PARAGRAPH_ID, + paragraph_id, + constraint_cursor, + )?; + let inline_objects = take_records( + self.inline_objects, + abi::ENGINE_INLINE_OBJECT_RECORD_SIZE, + abi::ENGINE_INLINE_OBJECT_PARAGRAPH_ID, + paragraph_id, + inline_object_cursor, + )?; + Ok(Self { + request: self.request, + constraints, + regions: self.regions, + exclusions: self.exclusions, + inline_objects, + }) + } + + pub(crate) fn paragraph_id(self, index: usize) -> Option { + if index < self.constraint_count() { + let record = record_at(self.constraints, abi::ENGINE_CONSTRAINT_RECORD_SIZE, index)?; + return read_u32(record, abi::ENGINE_CONSTRAINT_PARAGRAPH_ID).ok(); + } + let inline_index = index.checked_sub(self.constraint_count())?; + let record = record_at( + self.inline_objects, + abi::ENGINE_INLINE_OBJECT_RECORD_SIZE, + inline_index, + )?; + read_u32(record, abi::ENGINE_INLINE_OBJECT_PARAGRAPH_ID).ok() + } + + pub(crate) fn constraint(self, index: usize) -> Option { + let record = record_at(self.constraints, abi::ENGINE_CONSTRAINT_RECORD_SIZE, index)?; + Some(FlowConstraint { + paragraph_id: read_u32(record, abi::ENGINE_CONSTRAINT_PARAGRAPH_ID).ok()?, + flow_thread_id: read_u32(record, abi::ENGINE_CONSTRAINT_FLOW_THREAD_ID).ok()?, + width: read_f32(record, abi::ENGINE_CONSTRAINT_WIDTH).ok()?, + height: read_f32(record, abi::ENGINE_CONSTRAINT_HEIGHT).ok()?, + viewport_block_start: read_f32(record, abi::ENGINE_CONSTRAINT_VIEWPORT_BLOCK_START) + .ok()?, + viewport_block_end: read_f32(record, abi::ENGINE_CONSTRAINT_VIEWPORT_BLOCK_END).ok()?, + resume_block_offset: read_f32(record, abi::ENGINE_CONSTRAINT_RESUME_BLOCK_OFFSET) + .ok()?, + max_lines: read_u32(record, abi::ENGINE_CONSTRAINT_MAX_LINES).ok()?, + region_start: read_u32(record, abi::ENGINE_CONSTRAINT_REGION_START).ok()?, + resume_cluster: read_u32(record, abi::ENGINE_CONSTRAINT_RESUME_CLUSTER).ok()?, + region_count: read_u16(record, abi::ENGINE_CONSTRAINT_REGION_COUNT).ok()?, + resume_region: read_u16(record, abi::ENGINE_CONSTRAINT_RESUME_REGION).ok()?, + width_mode: record[abi::ENGINE_CONSTRAINT_WIDTH_MODE], + height_mode: record[abi::ENGINE_CONSTRAINT_HEIGHT_MODE], + wrap: record[abi::ENGINE_CONSTRAINT_WRAP], + align: record[abi::ENGINE_CONSTRAINT_ALIGN], + overflow: record[abi::ENGINE_CONSTRAINT_OVERFLOW], + block_align: record[abi::ENGINE_CONSTRAINT_BLOCK_ALIGN], + }) + } + + pub(crate) fn region(self, index: usize) -> Option { + let record = record_at(self.regions, abi::ENGINE_REGION_RECORD_SIZE, index)?; + Some(FlowRegion { + id: read_u32(record, abi::ENGINE_REGION_ID).ok()?, + geometry_revision: read_u32(record, abi::ENGINE_REGION_GEOMETRY_REVISION).ok()?, + transform_index: read_u32(record, abi::ENGINE_REGION_TRANSFORM_INDEX).ok()?, + vertices_offset: read_u32(record, abi::ENGINE_REGION_VERTICES_OFFSET).ok()?, + vertex_count: read_u16(record, abi::ENGINE_REGION_VERTEX_COUNT).ok()?, + exclusion_start: read_u16(record, abi::ENGINE_REGION_EXCLUSION_START).ok()?, + exclusion_count: read_u16(record, abi::ENGINE_REGION_EXCLUSION_COUNT).ok()?, + shape: record[abi::ENGINE_REGION_SHAPE], + writing_mode: record[abi::ENGINE_REGION_WRITING_MODE], + text_orientation: record[abi::ENGINE_REGION_TEXT_ORIENTATION], + inline_start: read_f32(record, abi::ENGINE_REGION_INLINE_START).ok()?, + block_start: read_f32(record, abi::ENGINE_REGION_BLOCK_START).ok()?, + inline_end: read_f32(record, abi::ENGINE_REGION_INLINE_END).ok()?, + block_end: read_f32(record, abi::ENGINE_REGION_BLOCK_END).ok()?, + clip_inline_start: read_f32(record, abi::ENGINE_REGION_CLIP_INLINE_START).ok()?, + clip_block_start: read_f32(record, abi::ENGINE_REGION_CLIP_BLOCK_START).ok()?, + clip_inline_end: read_f32(record, abi::ENGINE_REGION_CLIP_INLINE_END).ok()?, + clip_block_end: read_f32(record, abi::ENGINE_REGION_CLIP_BLOCK_END).ok()?, + }) + } + + pub(crate) fn exclusion(self, index: usize) -> Option { + let record = record_at(self.exclusions, abi::ENGINE_EXCLUSION_RECORD_SIZE, index)?; + Some(FlowExclusion { + id: read_u32(record, abi::ENGINE_EXCLUSION_ID).ok()?, + region_id: read_u32(record, abi::ENGINE_EXCLUSION_REGION_ID).ok()?, + geometry_revision: read_u32(record, abi::ENGINE_EXCLUSION_GEOMETRY_REVISION).ok()?, + vertices_offset: read_u32(record, abi::ENGINE_EXCLUSION_VERTICES_OFFSET).ok()?, + vertex_count: read_u16(record, abi::ENGINE_EXCLUSION_VERTEX_COUNT).ok()?, + shape: record[abi::ENGINE_EXCLUSION_SHAPE], + wrap_side: record[abi::ENGINE_EXCLUSION_WRAP_SIDE], + inline_start: read_f32(record, abi::ENGINE_EXCLUSION_INLINE_START).ok()?, + block_start: read_f32(record, abi::ENGINE_EXCLUSION_BLOCK_START).ok()?, + inline_end: read_f32(record, abi::ENGINE_EXCLUSION_INLINE_END).ok()?, + block_end: read_f32(record, abi::ENGINE_EXCLUSION_BLOCK_END).ok()?, + margin_inline: read_f32(record, abi::ENGINE_EXCLUSION_MARGIN_INLINE).ok()?, + margin_block: read_f32(record, abi::ENGINE_EXCLUSION_MARGIN_BLOCK).ok()?, + }) + } + + pub(crate) fn vertex(self, offset: u32, index: usize) -> Option { + let stride = usize::try_from(abi::ENGINE_FLOW_VERTEX_RECORD_SIZE).ok()?; + let byte_offset = usize::try_from(offset) + .ok()? + .checked_add(index.checked_mul(stride)?)?; + let record = self + .request + .get(byte_offset..byte_offset.checked_add(stride)?)?; + Some(FlowVertex { + inline: read_f32(record, abi::ENGINE_FLOW_VERTEX_INLINE).ok()?, + block: read_f32(record, abi::ENGINE_FLOW_VERTEX_BLOCK).ok()?, + }) + } + + pub(crate) fn fingerprint(self) -> u64 { + let mut hash = 0xcbf2_9ce4_8422_2325_u64; + for section in [self.constraints, self.inline_objects] { + mix_bytes(&mut hash, section); + } + for constraint_index in 0..self.constraint_count() { + let Some(constraint) = self.constraint(constraint_index) else { + continue; + }; + let Ok(region_start) = usize::try_from(constraint.region_start) else { + continue; + }; + let Some(region_end) = region_start.checked_add(usize::from(constraint.region_count)) + else { + continue; + }; + for region_index in region_start..region_end { + let Some(record) = + record_at(self.regions, abi::ENGINE_REGION_RECORD_SIZE, region_index) + else { + continue; + }; + mix_record_without_u32(&mut hash, record, abi::ENGINE_REGION_VERTICES_OFFSET); + mix_vertex_payload( + &mut hash, + self.request, + record, + abi::ENGINE_REGION_SHAPE, + abi::ENGINE_REGION_VERTICES_OFFSET, + abi::ENGINE_REGION_VERTEX_COUNT, + ); + let Ok(exclusion_start) = + read_u16(record, abi::ENGINE_REGION_EXCLUSION_START).map(usize::from) + else { + continue; + }; + let Ok(exclusion_count) = read_u16(record, abi::ENGINE_REGION_EXCLUSION_COUNT) + else { + continue; + }; + let Some(exclusion_end) = exclusion_start.checked_add(usize::from(exclusion_count)) + else { + continue; + }; + for exclusion_index in exclusion_start..exclusion_end { + let Some(exclusion) = record_at( + self.exclusions, + abi::ENGINE_EXCLUSION_RECORD_SIZE, + exclusion_index, + ) else { + continue; + }; + mix_record_without_u32( + &mut hash, + exclusion, + abi::ENGINE_EXCLUSION_VERTICES_OFFSET, + ); + mix_vertex_payload( + &mut hash, + self.request, + exclusion, + abi::ENGINE_EXCLUSION_SHAPE, + abi::ENGINE_EXCLUSION_VERTICES_OFFSET, + abi::ENGINE_EXCLUSION_VERTEX_COUNT, + ); + } + } + } + hash + } + + fn overlaps_range(self, range: (usize, usize)) -> Result { + for section in [ + self.constraints, + self.regions, + self.exclusions, + self.inline_objects, + ] { + if !section.is_empty() && overlaps(range, byte_range(self.request, section)?) { + return Ok(true); + } + } + let total = self.regions.len() / abi::ENGINE_REGION_RECORD_SIZE as usize + + self.exclusions.len() / abi::ENGINE_EXCLUSION_RECORD_SIZE as usize; + for index in 0..total { + if indexed_vertex_range(self.request, self.regions, self.exclusions, index)? + .is_some_and(|vertices| overlaps(range, vertices)) + { + return Ok(true); + } + } + Ok(false) + } +} + +impl<'a> ParagraphMutationBatch<'a> { + pub(crate) const fn empty() -> Self { + Self { + request: &[], + records: &[], + } + } + + pub(crate) fn len(self) -> usize { + self.records.len() / abi::ENGINE_PARAGRAPH_MUTATION_RECORD_SIZE as usize + } + + pub(crate) fn get(self, index: usize) -> Option { + let record = record_at( + self.records, + abi::ENGINE_PARAGRAPH_MUTATION_RECORD_SIZE, + index, + )?; + let paragraph_id = read_u32(record, abi::ENGINE_PARAGRAPH_MUTATION_PARAGRAPH_ID).ok()?; + match record[abi::ENGINE_PARAGRAPH_MUTATION_OPCODE] { + PARAGRAPH_MUTATION_UPSERT => Some(ParagraphMutation::Upsert { + paragraph_id, + order: read_u32(record, abi::ENGINE_PARAGRAPH_MUTATION_ORDER).ok()?, + }), + PARAGRAPH_MUTATION_REMOVE => Some(ParagraphMutation::Remove { paragraph_id }), + _ => None, + } + } + + pub(crate) fn validate_disjoint_semantics( + self, + text: TextMutationBatch<'_>, + styles: StyleMutationBatch<'_>, + geometry: GeometryBatch<'_>, + ) -> Result<(), u32> { + if self.records.is_empty() { + return Ok(()); + } + let range = byte_range(self.request, self.records)?; + if text.overlaps_range(range)? + || styles.overlaps_range(range)? + || geometry.overlaps_range(range)? + { + return Err(STATUS_INVALID_REQUEST); + } + Ok(()) + } +} + +impl<'a> TextMutationBatch<'a> { + pub(crate) const fn empty() -> Self { + Self { + request: &[], + records: &[], + } + } + + pub(crate) fn len(self) -> usize { + self.records.len() / ENGINE_TEXT_MUTATION_RECORD_SIZE as usize + } + + pub(crate) fn get(self, index: usize) -> Option> { + let stride = ENGINE_TEXT_MUTATION_RECORD_SIZE as usize; + let start = index.checked_mul(stride)?; + let record = self.records.get(start..start.checked_add(stride)?)?; + let insert_count = read_u32(record, ENGINE_TEXT_MUTATION_INSERT_COUNT).ok()?; + let insert_utf16_le = if insert_count == 0 { + &[] + } else { + array( + self.request, + read_u32(record, ENGINE_TEXT_MUTATION_INSERT_OFFSET).ok()?, + insert_count, + 2, + 2, + ) + .ok()? + }; + Some(TextMutation { + paragraph_id: read_u32(record, ENGINE_TEXT_MUTATION_PARAGRAPH_ID).ok()?, + text_start: read_u32(record, ENGINE_TEXT_MUTATION_TEXT_START).ok()?, + delete_count: read_u32(record, ENGINE_TEXT_MUTATION_DELETE_COUNT).ok()?, + insert_utf16_le, + }) + } + + pub(crate) fn paragraph_id(self, index: usize) -> Option { + self.get(index).map(|mutation| mutation.paragraph_id) + } + + pub(crate) fn take_paragraph(self, paragraph_id: u32, cursor: &mut usize) -> Result { + Ok(Self { + request: self.request, + records: take_records( + self.records, + ENGINE_TEXT_MUTATION_RECORD_SIZE, + ENGINE_TEXT_MUTATION_PARAGRAPH_ID, + paragraph_id, + cursor, + )?, + }) + } + + pub(crate) fn validate_disjoint_geometry(self, geometry: GeometryBatch<'_>) -> Result<(), u32> { + if !self.records.is_empty() + && geometry.overlaps_range(byte_range(self.request, self.records)?)? + { + return Err(STATUS_INVALID_REQUEST); + } + for record in self + .records + .chunks_exact(ENGINE_TEXT_MUTATION_RECORD_SIZE as usize) + { + if let Some(range) = text_payload_range(self.request, record)? + && geometry.overlaps_range(range)? + { + return Err(STATUS_INVALID_REQUEST); + } + } + Ok(()) + } + + fn overlaps_range(self, range: (usize, usize)) -> Result { + if !self.records.is_empty() && overlaps(range, byte_range(self.request, self.records)?) { + return Ok(true); + } + for record in self + .records + .chunks_exact(ENGINE_TEXT_MUTATION_RECORD_SIZE as usize) + { + if text_payload_range(self.request, record)? + .is_some_and(|payload| overlaps(range, payload)) + { + return Ok(true); + } + } + Ok(false) + } +} + +impl<'a> StyleMutationBatch<'a> { + pub(crate) const fn empty() -> Self { + Self { + request: &[], + records: &[], + } + } + + pub(crate) fn len(self) -> usize { + self.records.len() / abi::ENGINE_STYLE_MUTATION_RECORD_SIZE as usize + } + + pub(crate) fn get(self, index: usize) -> Option> { + let stride = abi::ENGINE_STYLE_MUTATION_RECORD_SIZE as usize; + let start = index.checked_mul(stride)?; + let record = self.records.get(start..start.checked_add(stride)?)?; + let style_id = read_u32(record, abi::ENGINE_STYLE_MUTATION_STYLE_ID).ok()?; + let paragraph_id = read_u32(record, abi::ENGINE_STYLE_MUTATION_PARAGRAPH_ID).ok()?; + if record[abi::ENGINE_STYLE_MUTATION_OPCODE] == STYLE_MUTATION_REMOVE { + return Some(StyleMutation::Remove { + paragraph_id, + style_id, + }); + } + let language_length = + u32::from(read_u16(record, abi::ENGINE_STYLE_MUTATION_LANGUAGE_LENGTH).ok()?); + let language = if language_length == 0 { + &[] + } else { + array( + self.request, + read_u32(record, abi::ENGINE_STYLE_MUTATION_LANGUAGE_OFFSET).ok()?, + language_length, + 1, + 1, + ) + .ok()? + }; + let feature_count = + u32::from(read_u16(record, abi::ENGINE_STYLE_MUTATION_FEATURE_COUNT).ok()?); + let features = if feature_count == 0 { + &[] + } else { + array( + self.request, + read_u32(record, abi::ENGINE_STYLE_MUTATION_FEATURES_OFFSET).ok()?, + feature_count, + abi::FEATURE_RECORD_SIZE, + abi::FEATURE_RECORD_ALIGNMENT, + ) + .ok()? + }; + Some(StyleMutation::Upsert(StyleValue { + paragraph_id, + style_id, + cascade_order: read_u32(record, abi::ENGINE_STYLE_MUTATION_CASCADE_ORDER).ok()?, + field_mask: read_u32(record, abi::ENGINE_STYLE_MUTATION_FIELD_MASK).ok()?, + text_start: read_u32(record, abi::ENGINE_STYLE_MUTATION_TEXT_START).ok()?, + text_end: read_u32(record, abi::ENGINE_STYLE_MUTATION_TEXT_END).ok()?, + font_stack_handle: read_u32(record, abi::ENGINE_STYLE_MUTATION_FONT_STACK_HANDLE) + .ok()?, + material_id: read_u32(record, abi::ENGINE_STYLE_MUTATION_MATERIAL_ID).ok()?, + language, + features, + font_size: read_f32(record, abi::ENGINE_STYLE_MUTATION_FONT_SIZE).ok()?, + line_height: read_f32(record, abi::ENGINE_STYLE_MUTATION_LINE_HEIGHT).ok()?, + letter_spacing: read_f32(record, abi::ENGINE_STYLE_MUTATION_LETTER_SPACING).ok()?, + word_spacing: read_f32(record, abi::ENGINE_STYLE_MUTATION_WORD_SPACING).ok()?, + baseline_shift: read_f32(record, abi::ENGINE_STYLE_MUTATION_BASELINE_SHIFT).ok()?, + raster_pixel_ratio: read_f32(record, abi::ENGINE_STYLE_MUTATION_RASTER_PIXEL_RATIO) + .ok()?, + direction: record[abi::ENGINE_STYLE_MUTATION_DIRECTION], + foreground_rgba: read_u32(record, abi::ENGINE_STYLE_MUTATION_FOREGROUND_RGBA).ok()?, + decoration_rgba: read_u32(record, abi::ENGINE_STYLE_MUTATION_DECORATION_RGBA).ok()?, + decoration_flags: read_u32(record, abi::ENGINE_STYLE_MUTATION_DECORATION_FLAGS).ok()?, + decoration_style: record[abi::ENGINE_STYLE_MUTATION_DECORATION_STYLE], + decoration_thickness: read_f32(record, abi::ENGINE_STYLE_MUTATION_DECORATION_THICKNESS) + .ok()?, + decoration_offset: read_f32(record, abi::ENGINE_STYLE_MUTATION_DECORATION_OFFSET) + .ok()?, + root: record[abi::ENGINE_STYLE_MUTATION_FLAGS] == STYLE_FLAG_ROOT, + })) + } + + pub(crate) fn paragraph_id(self, index: usize) -> Option { + match self.get(index)? { + StyleMutation::Remove { paragraph_id, .. } + | StyleMutation::Upsert(StyleValue { paragraph_id, .. }) => Some(paragraph_id), + } + } + + pub(crate) fn take_paragraph(self, paragraph_id: u32, cursor: &mut usize) -> Result { + Ok(Self { + request: self.request, + records: take_records( + self.records, + abi::ENGINE_STYLE_MUTATION_RECORD_SIZE, + abi::ENGINE_STYLE_MUTATION_PARAGRAPH_ID, + paragraph_id, + cursor, + )?, + }) + } + + pub(crate) fn feature(value: StyleValue<'_>, index: usize) -> Option { + let stride = abi::FEATURE_RECORD_SIZE as usize; + let start = index.checked_mul(stride)?; + let record = value.features.get(start..start.checked_add(stride)?)?; + Some(FeatureRecord { + tag: read_u32(record, abi::FEATURE_TAG).ok()?, + value: read_u32(record, abi::FEATURE_VALUE).ok()?, + start: read_u32(record, abi::FEATURE_START).ok()?, + end: read_u32(record, abi::FEATURE_END).ok()?, + }) + } + + pub(crate) fn validate_disjoint_semantics( + self, + text: TextMutationBatch<'_>, + geometry: GeometryBatch<'_>, + ) -> Result<(), u32> { + if !self.records.is_empty() { + let records = byte_range(self.request, self.records)?; + if text.overlaps_range(records)? || geometry.overlaps_range(records)? { + return Err(STATUS_INVALID_REQUEST); + } + } + for record in self + .records + .chunks_exact(abi::ENGINE_STYLE_MUTATION_RECORD_SIZE as usize) + { + for range in style_payload_ranges(self.request, record)? + .into_iter() + .flatten() + { + if text.overlaps_range(range)? || geometry.overlaps_range(range)? { + return Err(STATUS_INVALID_REQUEST); + } + } + } + Ok(()) + } + + fn overlaps_range(self, range: (usize, usize)) -> Result { + if !self.records.is_empty() && overlaps(range, byte_range(self.request, self.records)?) { + return Ok(true); + } + for record in self + .records + .chunks_exact(abi::ENGINE_STYLE_MUTATION_RECORD_SIZE as usize) + { + if style_payload_ranges(self.request, record)? + .into_iter() + .flatten() + .any(|payload| overlaps(range, payload)) + { + return Ok(true); + } + } + Ok(false) + } +} + +pub(crate) fn parse_style_mutations( + request: &[u8], + offset: u32, + count: u32, +) -> Result, u32> { + if count == 0 { + return if offset == 0 { + Ok(StyleMutationBatch::empty()) + } else { + Err(STATUS_INVALID_REQUEST) + }; + } + let records = record_table( + request, + offset, + count, + abi::ENGINE_STYLE_MUTATION_RECORD_SIZE, + abi::ENGINE_STYLE_MUTATION_RECORD_ALIGNMENT, + )?; + let records_range = byte_range(request, records)?; + let mut previous_payload_end = records_range.1; + for record in records.chunks_exact(abi::ENGINE_STYLE_MUTATION_RECORD_SIZE as usize) { + let current = style_payload_ranges(request, record)?; + for range in current.into_iter().flatten() { + if range.0 < previous_payload_end { + return Err(STATUS_INVALID_REQUEST); + } + previous_payload_end = range.1; + } + } + for record in records.chunks_exact(abi::ENGINE_STYLE_MUTATION_RECORD_SIZE as usize) { + validate_style_record(request, record)?; + } + Ok(StyleMutationBatch { request, records }) +} + +fn style_payload_ranges(request: &[u8], record: &[u8]) -> Result<[Option<(usize, usize)>; 2], u32> { + let mut ranges = [None, None]; + for (index, (offset_field, count_field, stride, alignment)) in [ + ( + abi::ENGINE_STYLE_MUTATION_LANGUAGE_OFFSET, + abi::ENGINE_STYLE_MUTATION_LANGUAGE_LENGTH, + 1, + 1, + ), + ( + abi::ENGINE_STYLE_MUTATION_FEATURES_OFFSET, + abi::ENGINE_STYLE_MUTATION_FEATURE_COUNT, + abi::FEATURE_RECORD_SIZE, + abi::FEATURE_RECORD_ALIGNMENT, + ), + ] + .into_iter() + .enumerate() + { + let count = u32::from(read_u16(record, count_field)?); + if count != 0 { + let payload = array( + request, + read_u32(record, offset_field)?, + count, + stride, + alignment, + )?; + ranges[index] = Some(byte_range(request, payload)?); + } + } + Ok(ranges) +} + +fn validate_style_record(request: &[u8], record: &[u8]) -> Result<(), u32> { + let opcode = byte(record, abi::ENGINE_STYLE_MUTATION_OPCODE)?; + let style_id = read_u32(record, abi::ENGINE_STYLE_MUTATION_STYLE_ID)?; + let paragraph_id = read_u32(record, abi::ENGINE_STYLE_MUTATION_PARAGRAPH_ID)?; + if style_id == 0 || paragraph_id == 0 { + return Err(STATUS_INVALID_REQUEST); + } + if opcode == STYLE_MUTATION_REMOVE { + for (index, value) in record.iter().copied().enumerate() { + let identity = index == abi::ENGINE_STYLE_MUTATION_OPCODE + || (abi::ENGINE_STYLE_MUTATION_STYLE_ID..abi::ENGINE_STYLE_MUTATION_STYLE_ID + 4) + .contains(&index) + || (abi::ENGINE_STYLE_MUTATION_PARAGRAPH_ID + ..abi::ENGINE_STYLE_MUTATION_PARAGRAPH_ID + 4) + .contains(&index); + if !identity && value != 0 { + return Err(STATUS_INVALID_REQUEST); + } + } + return Ok(()); + } + if opcode != STYLE_MUTATION_UPSERT { + return Err(STATUS_INVALID_REQUEST); + } + let flags = byte(record, abi::ENGINE_STYLE_MUTATION_FLAGS)?; + let root = flags == STYLE_FLAG_ROOT; + if flags & !STYLE_FLAG_ROOT != 0 { + return Err(STATUS_INVALID_REQUEST); + } + let field_mask = read_u32(record, abi::ENGINE_STYLE_MUTATION_FIELD_MASK)?; + if field_mask & !STYLE_FIELD_MASK != 0 { + return Err(STATUS_INVALID_REQUEST); + } + let text_start = read_u32(record, abi::ENGINE_STYLE_MUTATION_TEXT_START)?; + let text_end = read_u32(record, abi::ENGINE_STYLE_MUTATION_TEXT_END)?; + if text_start > text_end || (!root && text_start == text_end) { + return Err(STATUS_INVALID_REQUEST); + } + validate_stated_u32( + record, + field_mask, + STYLE_FIELD_FONT_STACK, + abi::ENGINE_STYLE_MUTATION_FONT_STACK_HANDLE, + true, + )?; + validate_stated_u32( + record, + field_mask, + STYLE_FIELD_MATERIAL, + abi::ENGINE_STYLE_MUTATION_MATERIAL_ID, + true, + )?; + validate_style_payload(request, record, field_mask, text_start, text_end)?; + validate_style_float( + record, + field_mask, + STYLE_FIELD_FONT_SIZE, + abi::ENGINE_STYLE_MUTATION_FONT_SIZE, + true, + )?; + validate_style_float( + record, + field_mask, + STYLE_FIELD_LINE_HEIGHT, + abi::ENGINE_STYLE_MUTATION_LINE_HEIGHT, + true, + )?; + for (field, offset) in [ + ( + STYLE_FIELD_LETTER_SPACING, + abi::ENGINE_STYLE_MUTATION_LETTER_SPACING, + ), + ( + STYLE_FIELD_WORD_SPACING, + abi::ENGINE_STYLE_MUTATION_WORD_SPACING, + ), + ( + STYLE_FIELD_BASELINE_SHIFT, + abi::ENGINE_STYLE_MUTATION_BASELINE_SHIFT, + ), + ] { + validate_style_float(record, field_mask, field, offset, false)?; + } + validate_style_float( + record, + field_mask, + STYLE_FIELD_RASTER_PIXEL_RATIO, + abi::ENGINE_STYLE_MUTATION_RASTER_PIXEL_RATIO, + true, + )?; + if field_mask & STYLE_FIELD_RASTER_PIXEL_RATIO != 0 && !root { + return Err(STATUS_INVALID_REQUEST); + } + let direction = byte(record, abi::ENGINE_STYLE_MUTATION_DIRECTION)?; + if field_mask & STYLE_FIELD_DIRECTION == 0 { + if direction != 0 { + return Err(STATUS_INVALID_REQUEST); + } + } else if !matches!(direction, DIRECTION_AUTO | DIRECTION_LTR | DIRECTION_RTL) { + return Err(STATUS_INVALID_REQUEST); + } + if field_mask & STYLE_FIELD_FOREGROUND == 0 + && read_u32(record, abi::ENGINE_STYLE_MUTATION_FOREGROUND_RGBA)? != 0 + { + return Err(STATUS_INVALID_REQUEST); + } + validate_decoration(record, field_mask)?; + Ok(()) +} + +fn validate_style_payload( + request: &[u8], + record: &[u8], + field_mask: u32, + text_start: u32, + text_end: u32, +) -> Result<(), u32> { + let language_offset = read_u32(record, abi::ENGINE_STYLE_MUTATION_LANGUAGE_OFFSET)?; + let language_length = u32::from(read_u16( + record, + abi::ENGINE_STYLE_MUTATION_LANGUAGE_LENGTH, + )?); + if field_mask & STYLE_FIELD_LANGUAGE == 0 { + if language_offset != 0 || language_length != 0 { + return Err(STATUS_INVALID_REQUEST); + } + } else { + let language = array(request, language_offset, language_length, 1, 1)?; + if language.is_empty() || !valid_language_bytes(language) { + return Err(STATUS_INVALID_REQUEST); + } + } + let features_offset = read_u32(record, abi::ENGINE_STYLE_MUTATION_FEATURES_OFFSET)?; + let feature_count = u32::from(read_u16(record, abi::ENGINE_STYLE_MUTATION_FEATURE_COUNT)?); + if field_mask & STYLE_FIELD_FEATURES == 0 { + if features_offset != 0 || feature_count != 0 { + return Err(STATUS_INVALID_REQUEST); + } + return Ok(()); + } + if feature_count == 0 { + return if features_offset == 0 { + Ok(()) + } else { + Err(STATUS_INVALID_REQUEST) + }; + } + let features = array( + request, + features_offset, + feature_count, + abi::FEATURE_RECORD_SIZE, + abi::FEATURE_RECORD_ALIGNMENT, + )?; + for feature in features.chunks_exact(abi::FEATURE_RECORD_SIZE as usize) { + let start = read_u32(feature, abi::FEATURE_START)?; + let end = read_u32(feature, abi::FEATURE_END)?; + if !valid_tag(read_u32(feature, abi::FEATURE_TAG)?) + || start >= end + || start < text_start + || end > text_end + { + return Err(STATUS_INVALID_REQUEST); + } + } + Ok(()) +} + +fn validate_stated_u32( + record: &[u8], + field_mask: u32, + field: u32, + offset: usize, + positive: bool, +) -> Result<(), u32> { + let value = read_u32(record, offset)?; + if field_mask & field == 0 { + if value != 0 { + return Err(STATUS_INVALID_REQUEST); + } + } else if positive && value == 0 { + return Err(STATUS_INVALID_REQUEST); + } + Ok(()) +} + +fn validate_style_float( + record: &[u8], + field_mask: u32, + field: u32, + offset: usize, + positive: bool, +) -> Result<(), u32> { + if field_mask & field == 0 { + return if read_u32(record, offset)? == 0 { + Ok(()) + } else { + Err(STATUS_INVALID_REQUEST) + }; + } + let value = read_f32(record, offset)?; + if !value.is_finite() || (positive && value <= 0.0) { + Err(STATUS_INVALID_REQUEST) + } else { + Ok(()) + } +} + +fn validate_decoration(record: &[u8], field_mask: u32) -> Result<(), u32> { + let style = byte(record, abi::ENGINE_STYLE_MUTATION_DECORATION_STYLE)?; + let rgba = read_u32(record, abi::ENGINE_STYLE_MUTATION_DECORATION_RGBA)?; + let flags = read_u32(record, abi::ENGINE_STYLE_MUTATION_DECORATION_FLAGS)?; + let thickness_bits = read_u32(record, abi::ENGINE_STYLE_MUTATION_DECORATION_THICKNESS)?; + let offset_bits = read_u32(record, abi::ENGINE_STYLE_MUTATION_DECORATION_OFFSET)?; + if field_mask & STYLE_FIELD_DECORATION == 0 { + return if style == 0 && rgba == 0 && flags == 0 && thickness_bits == 0 && offset_bits == 0 { + Ok(()) + } else { + Err(STATUS_INVALID_REQUEST) + }; + } + if !matches!( + style, + DECORATION_NONE + | DECORATION_SOLID + | DECORATION_DOUBLE + | DECORATION_DOTTED + | DECORATION_DASHED + | DECORATION_WAVY + ) || flags & !DECORATION_FLAGS_MASK != 0 + { + return Err(STATUS_INVALID_REQUEST); + } + let thickness = read_f32(record, abi::ENGINE_STYLE_MUTATION_DECORATION_THICKNESS)?; + let offset = read_f32(record, abi::ENGINE_STYLE_MUTATION_DECORATION_OFFSET)?; + if !thickness.is_finite() || thickness < 0.0 || !offset.is_finite() { + return Err(STATUS_INVALID_REQUEST); + } + if style == DECORATION_NONE + && (flags != 0 || rgba != 0 || thickness_bits != 0 || offset_bits != 0) + { + return Err(STATUS_INVALID_REQUEST); + } + Ok(()) +} + +pub(crate) fn parse_paragraph_mutations( + request: &[u8], + offset: u32, + count: u32, +) -> Result, u32> { + if count == 0 { + return if offset == 0 { + Ok(ParagraphMutationBatch::empty()) + } else { + Err(STATUS_INVALID_REQUEST) + }; + } + if offset < ENGINE_UPDATE_REQUEST_HEADER_SIZE { + return Err(STATUS_INVALID_REQUEST); + } + let records = array( + request, + offset, + count, + abi::ENGINE_PARAGRAPH_MUTATION_RECORD_SIZE, + abi::ENGINE_PARAGRAPH_MUTATION_RECORD_ALIGNMENT, + )?; + for (index, record) in records + .chunks_exact(abi::ENGINE_PARAGRAPH_MUTATION_RECORD_SIZE as usize) + .enumerate() + { + let opcode = byte(record, abi::ENGINE_PARAGRAPH_MUTATION_OPCODE)?; + let paragraph_id = read_u32(record, abi::ENGINE_PARAGRAPH_MUTATION_PARAGRAPH_ID)?; + let order = read_u32(record, abi::ENGINE_PARAGRAPH_MUTATION_ORDER)?; + if paragraph_id == 0 + || byte(record, abi::ENGINE_PARAGRAPH_MUTATION_FLAGS)? != 0 + || read_u16(record, abi::ENGINE_PARAGRAPH_MUTATION_RESERVED0)? != 0 + || !matches!( + opcode, + PARAGRAPH_MUTATION_UPSERT | PARAGRAPH_MUTATION_REMOVE + ) + || (opcode == PARAGRAPH_MUTATION_REMOVE && order != 0) + || prior_u32_duplicate( + records, + abi::ENGINE_PARAGRAPH_MUTATION_RECORD_SIZE, + abi::ENGINE_PARAGRAPH_MUTATION_PARAGRAPH_ID, + index, + paragraph_id, + )? + || (opcode == PARAGRAPH_MUTATION_UPSERT + && prior_upsert_order_duplicate(records, index, order)?) + { + return Err(STATUS_INVALID_REQUEST); + } + } + Ok(ParagraphMutationBatch { request, records }) +} + +fn prior_upsert_order_duplicate(records: &[u8], index: usize, order: u32) -> Result { + for record in records[..index * abi::ENGINE_PARAGRAPH_MUTATION_RECORD_SIZE as usize] + .chunks_exact(abi::ENGINE_PARAGRAPH_MUTATION_RECORD_SIZE as usize) + { + if byte(record, abi::ENGINE_PARAGRAPH_MUTATION_OPCODE)? == PARAGRAPH_MUTATION_UPSERT + && read_u32(record, abi::ENGINE_PARAGRAPH_MUTATION_ORDER)? == order + { + return Ok(true); + } + } + Ok(false) +} + +pub(crate) fn parse_text_mutations( + request: &[u8], + offset: u32, + count: u32, +) -> Result, u32> { + if count == 0 { + return if offset == 0 { + Ok(TextMutationBatch::empty()) + } else { + Err(STATUS_INVALID_REQUEST) + }; + } + if offset < ENGINE_UPDATE_REQUEST_HEADER_SIZE { + return Err(STATUS_INVALID_REQUEST); + } + let records = array( + request, + offset, + count, + ENGINE_TEXT_MUTATION_RECORD_SIZE, + ENGINE_TEXT_MUTATION_RECORD_ALIGNMENT, + )?; + let record_start = offset as usize; + let record_end = record_start + .checked_add(records.len()) + .ok_or(STATUS_INVALID_REQUEST)?; + for (index, record) in records + .chunks_exact(ENGINE_TEXT_MUTATION_RECORD_SIZE as usize) + .enumerate() + { + if record[ENGINE_TEXT_MUTATION_OPCODE] != TEXT_MUTATION_REPLACE_UTF16 + || record[ENGINE_TEXT_MUTATION_ENCODING] != TEXT_ENCODING_UTF16_LE + || read_u16(record, ENGINE_TEXT_MUTATION_RESERVED0)? != 0 + || read_u32(record, ENGINE_TEXT_MUTATION_PARAGRAPH_ID)? == 0 + { + return Err(STATUS_INVALID_REQUEST); + } + let insert_count = read_u32(record, ENGINE_TEXT_MUTATION_INSERT_COUNT)?; + let insert_offset = read_u32(record, ENGINE_TEXT_MUTATION_INSERT_OFFSET)?; + if insert_count == 0 { + if insert_offset != 0 { + return Err(STATUS_INVALID_REQUEST); + } + continue; + } + let payload = array(request, insert_offset, insert_count, 2, 2)?; + let payload_range = byte_range(request, payload)?; + if overlaps(payload_range, (record_start, record_end)) { + return Err(STATUS_INVALID_REQUEST); + } + for previous in records[..index * ENGINE_TEXT_MUTATION_RECORD_SIZE as usize] + .chunks_exact(ENGINE_TEXT_MUTATION_RECORD_SIZE as usize) + { + if text_payload_range(request, previous)? + .is_some_and(|range| overlaps(payload_range, range)) + { + return Err(STATUS_INVALID_REQUEST); + } + } + } + Ok(TextMutationBatch { request, records }) +} + +fn text_payload_range(request: &[u8], record: &[u8]) -> Result, u32> { + let count = read_u32(record, ENGINE_TEXT_MUTATION_INSERT_COUNT)?; + if count == 0 { + return Ok(None); + } + let payload = array( + request, + read_u32(record, ENGINE_TEXT_MUTATION_INSERT_OFFSET)?, + count, + 2, + 2, + )?; + Ok(Some(byte_range(request, payload)?)) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn parse_geometry( + request: &[u8], + constraints_offset: u32, + constraint_count: u32, + regions_offset: u32, + region_count: u32, + exclusions_offset: u32, + exclusion_count: u32, + inline_objects_offset: u32, + inline_object_count: u32, + limits: UpdateLimits, +) -> Result, u32> { + if region_count > limits.max_regions + || exclusion_count > limits.max_exclusions + || inline_object_count > limits.max_inline_objects + || constraint_count > limits.max_regions + { + return Err(STATUS_INVALID_REQUEST); + } + let all_empty = constraint_count == 0 + && region_count == 0 + && exclusion_count == 0 + && inline_object_count == 0; + if all_empty { + return if constraints_offset == 0 + && regions_offset == 0 + && exclusions_offset == 0 + && inline_objects_offset == 0 + { + Ok(GeometryBatch::empty()) + } else { + Err(STATUS_INVALID_REQUEST) + }; + } + if constraint_count == 0 || region_count == 0 { + return Err(STATUS_INVALID_REQUEST); + } + let constraints = record_table( + request, + constraints_offset, + constraint_count, + abi::ENGINE_CONSTRAINT_RECORD_SIZE, + abi::ENGINE_CONSTRAINT_RECORD_ALIGNMENT, + )?; + let regions = record_table( + request, + regions_offset, + region_count, + abi::ENGINE_REGION_RECORD_SIZE, + abi::ENGINE_REGION_RECORD_ALIGNMENT, + )?; + let exclusions = record_table( + request, + exclusions_offset, + exclusion_count, + abi::ENGINE_EXCLUSION_RECORD_SIZE, + abi::ENGINE_EXCLUSION_RECORD_ALIGNMENT, + )?; + let inline_objects = record_table( + request, + inline_objects_offset, + inline_object_count, + abi::ENGINE_INLINE_OBJECT_RECORD_SIZE, + abi::ENGINE_INLINE_OBJECT_RECORD_ALIGNMENT, + )?; + let fixed = [constraints, regions, exclusions, inline_objects]; + reject_overlapping_slices(request, &fixed)?; + validate_constraints(constraints, region_count, limits)?; + validate_regions(request, regions, exclusions, &fixed)?; + validate_exclusions(request, exclusions, regions, &fixed)?; + validate_inline_objects(inline_objects)?; + reject_overlapping_vertex_payloads(request, regions, exclusions)?; + Ok(GeometryBatch { + request, + constraints, + regions, + exclusions, + inline_objects, + }) +} + +fn record_table( + request: &[u8], + offset: u32, + count: u32, + stride: u32, + alignment: u32, +) -> Result<&[u8], u32> { + if count == 0 { + return if offset == 0 { + Ok(&[]) + } else { + Err(STATUS_INVALID_REQUEST) + }; + } + if offset < abi::ENGINE_UPDATE_REQUEST_HEADER_SIZE { + return Err(STATUS_INVALID_REQUEST); + } + array(request, offset, count, stride, alignment) +} + +fn record_at(records: &[u8], stride: u32, index: usize) -> Option<&[u8]> { + let stride = usize::try_from(stride).ok()?; + let start = index.checked_mul(stride)?; + records.get(start..start.checked_add(stride)?) +} + +fn take_records<'a>( + records: &'a [u8], + stride: u32, + paragraph_field: usize, + paragraph_id: u32, + cursor: &mut usize, +) -> Result<&'a [u8], u32> { + let stride = usize::try_from(stride).map_err(|_| STATUS_INVALID_REQUEST)?; + let count = records.len() / stride; + if *cursor > count { + return Err(STATUS_INVALID_REQUEST); + } + let start = *cursor; + while *cursor < count { + let record = record_at( + records, + u32::try_from(stride).map_err(|_| STATUS_INVALID_REQUEST)?, + *cursor, + ) + .ok_or(STATUS_INVALID_REQUEST)?; + if read_u32(record, paragraph_field)? != paragraph_id { + break; + } + *cursor += 1; + } + records + .get(start * stride..*cursor * stride) + .ok_or(STATUS_INVALID_REQUEST) +} + +fn validate_constraints( + constraints: &[u8], + region_count: u32, + limits: UpdateLimits, +) -> Result<(), u32> { + for (index, record) in constraints + .chunks_exact(abi::ENGINE_CONSTRAINT_RECORD_SIZE as usize) + .enumerate() + { + if read_u32(record, abi::ENGINE_CONSTRAINT_PARAGRAPH_ID)? == 0 { + return Err(STATUS_INVALID_REQUEST); + } + let flow_thread_id = read_u32(record, abi::ENGINE_CONSTRAINT_FLOW_THREAD_ID)?; + if flow_thread_id == 0 + || prior_u32_duplicate( + constraints, + abi::ENGINE_CONSTRAINT_RECORD_SIZE, + abi::ENGINE_CONSTRAINT_FLOW_THREAD_ID, + index, + flow_thread_id, + )? + { + return Err(STATUS_INVALID_REQUEST); + } + let width_mode = byte(record, abi::ENGINE_CONSTRAINT_WIDTH_MODE)?; + let height_mode = byte(record, abi::ENGINE_CONSTRAINT_HEIGHT_MODE)?; + let width = finite(record, abi::ENGINE_CONSTRAINT_WIDTH)?; + let height = finite(record, abi::ENGINE_CONSTRAINT_HEIGHT)?; + if !valid_axis(width_mode, width) || !valid_axis(height_mode, height) { + return Err(STATUS_INVALID_REQUEST); + } + let viewport_start = finite(record, abi::ENGINE_CONSTRAINT_VIEWPORT_BLOCK_START)?; + let viewport_end = finite(record, abi::ENGINE_CONSTRAINT_VIEWPORT_BLOCK_END)?; + if viewport_start > viewport_end + || !finite(record, abi::ENGINE_CONSTRAINT_RESUME_BLOCK_OFFSET)?.is_finite() + || read_u32(record, abi::ENGINE_CONSTRAINT_MAX_LINES)? > limits.max_lines + || !matches!( + byte(record, abi::ENGINE_CONSTRAINT_WRAP)?, + WRAP_NONE | WRAP_WORD | WRAP_CHARACTER + ) + || !matches!( + byte(record, abi::ENGINE_CONSTRAINT_ALIGN)?, + ALIGN_START | ALIGN_CENTER | ALIGN_END | ALIGN_JUSTIFY + ) + || !matches!( + byte(record, abi::ENGINE_CONSTRAINT_OVERFLOW)?, + OVERFLOW_VISIBLE | OVERFLOW_CLIP | OVERFLOW_ELLIPSIS + ) + || !matches!( + byte(record, abi::ENGINE_CONSTRAINT_BLOCK_ALIGN)?, + BLOCK_ALIGN_START | BLOCK_ALIGN_CENTER | BLOCK_ALIGN_END + ) + || read_u16(record, abi::ENGINE_CONSTRAINT_FLAGS)? != 0 + { + return Err(STATUS_INVALID_REQUEST); + } + let region_start = read_u32(record, abi::ENGINE_CONSTRAINT_REGION_START)?; + let selected_count = u32::from(read_u16(record, abi::ENGINE_CONSTRAINT_REGION_COUNT)?); + let resume_region = u32::from(read_u16(record, abi::ENGINE_CONSTRAINT_RESUME_REGION)?); + if selected_count == 0 + || region_start + .checked_add(selected_count) + .is_none_or(|end| end > region_count) + || resume_region > selected_count + { + return Err(STATUS_INVALID_REQUEST); + } + } + Ok(()) +} + +fn validate_regions( + request: &[u8], + regions: &[u8], + exclusions: &[u8], + fixed: &[&[u8]], +) -> Result<(), u32> { + let exclusion_total = exclusions.len() / abi::ENGINE_EXCLUSION_RECORD_SIZE as usize; + for (index, record) in regions + .chunks_exact(abi::ENGINE_REGION_RECORD_SIZE as usize) + .enumerate() + { + let id = read_u32(record, abi::ENGINE_REGION_ID)?; + let transform_index = read_u32(record, abi::ENGINE_REGION_TRANSFORM_INDEX)?; + let shape = byte(record, abi::ENGINE_REGION_SHAPE)?; + if id == 0 + || transform_index == 0 + || prior_u32_duplicate( + regions, + abi::ENGINE_REGION_RECORD_SIZE, + abi::ENGINE_REGION_ID, + index, + id, + )? + || read_u16(record, abi::ENGINE_REGION_FLAGS)? != 0 + || byte(record, abi::ENGINE_REGION_RESERVED0)? != 0 + || !matches!( + byte(record, abi::ENGINE_REGION_WRITING_MODE)?, + WRITING_HORIZONTAL_TB | WRITING_VERTICAL_RL | WRITING_VERTICAL_LR + ) + || !matches!( + byte(record, abi::ENGINE_REGION_TEXT_ORIENTATION)?, + ORIENTATION_MIXED | ORIENTATION_UPRIGHT | ORIENTATION_SIDEWAYS + ) + { + return Err(STATUS_INVALID_REQUEST); + } + let region_bounds = nonempty_block_bounds( + record, + abi::ENGINE_REGION_INLINE_START, + abi::ENGINE_REGION_BLOCK_START, + abi::ENGINE_REGION_INLINE_END, + abi::ENGINE_REGION_BLOCK_END, + )?; + let clip = nonempty_block_bounds( + record, + abi::ENGINE_REGION_CLIP_INLINE_START, + abi::ENGINE_REGION_CLIP_BLOCK_START, + abi::ENGINE_REGION_CLIP_INLINE_END, + abi::ENGINE_REGION_CLIP_BLOCK_END, + )?; + if clip.0 < region_bounds.0 + || clip.1 < region_bounds.1 + || clip.2 > region_bounds.2 + || clip.3 > region_bounds.3 + || (region_bounds.0 == region_bounds.2 && shape != SHAPE_RECTANGLE) + { + return Err(STATUS_INVALID_REQUEST); + } + validate_shape( + request, + record, + abi::ENGINE_REGION_SHAPE, + abi::ENGINE_REGION_VERTICES_OFFSET, + abi::ENGINE_REGION_VERTEX_COUNT, + region_bounds, + fixed, + )?; + let exclusion_start = usize::from(read_u16(record, abi::ENGINE_REGION_EXCLUSION_START)?); + let exclusion_count = usize::from(read_u16(record, abi::ENGINE_REGION_EXCLUSION_COUNT)?); + let end = exclusion_start + .checked_add(exclusion_count) + .ok_or(STATUS_INVALID_REQUEST)?; + if end > exclusion_total { + return Err(STATUS_INVALID_REQUEST); + } + for exclusion in exclusions[exclusion_start * abi::ENGINE_EXCLUSION_RECORD_SIZE as usize + ..end * abi::ENGINE_EXCLUSION_RECORD_SIZE as usize] + .chunks_exact(abi::ENGINE_EXCLUSION_RECORD_SIZE as usize) + { + if read_u32(exclusion, abi::ENGINE_EXCLUSION_REGION_ID)? != id { + return Err(STATUS_INVALID_REQUEST); + } + } + } + Ok(()) +} + +fn validate_exclusions( + request: &[u8], + exclusions: &[u8], + regions: &[u8], + fixed: &[&[u8]], +) -> Result<(), u32> { + for (index, record) in exclusions + .chunks_exact(abi::ENGINE_EXCLUSION_RECORD_SIZE as usize) + .enumerate() + { + let id = read_u32(record, abi::ENGINE_EXCLUSION_ID)?; + let region_id = read_u32(record, abi::ENGINE_EXCLUSION_REGION_ID)?; + if id == 0 + || prior_u32_duplicate( + exclusions, + abi::ENGINE_EXCLUSION_RECORD_SIZE, + abi::ENGINE_EXCLUSION_ID, + index, + id, + )? + || !contains_u32( + regions, + abi::ENGINE_REGION_RECORD_SIZE, + abi::ENGINE_REGION_ID, + region_id, + )? + || read_u16(record, abi::ENGINE_EXCLUSION_FLAGS)? != 0 + || read_u16(record, abi::ENGINE_EXCLUSION_RESERVED0)? != 0 + || !matches!( + byte(record, abi::ENGINE_EXCLUSION_WRAP_SIDE)?, + EXCLUSION_WRAP_BOTH + | EXCLUSION_WRAP_INLINE_START + | EXCLUSION_WRAP_INLINE_END + | EXCLUSION_WRAP_LARGEST + ) + { + return Err(STATUS_INVALID_REQUEST); + } + let shape_bounds = bounds( + record, + abi::ENGINE_EXCLUSION_INLINE_START, + abi::ENGINE_EXCLUSION_BLOCK_START, + abi::ENGINE_EXCLUSION_INLINE_END, + abi::ENGINE_EXCLUSION_BLOCK_END, + )?; + if finite(record, abi::ENGINE_EXCLUSION_MARGIN_INLINE)? < 0.0 + || finite(record, abi::ENGINE_EXCLUSION_MARGIN_BLOCK)? < 0.0 + { + return Err(STATUS_INVALID_REQUEST); + } + validate_shape( + request, + record, + abi::ENGINE_EXCLUSION_SHAPE, + abi::ENGINE_EXCLUSION_VERTICES_OFFSET, + abi::ENGINE_EXCLUSION_VERTEX_COUNT, + shape_bounds, + fixed, + )?; + } + Ok(()) +} + +fn validate_inline_objects(records: &[u8]) -> Result<(), u32> { + for (index, record) in records + .chunks_exact(abi::ENGINE_INLINE_OBJECT_RECORD_SIZE as usize) + .enumerate() + { + let id = read_u32(record, abi::ENGINE_INLINE_OBJECT_ID)?; + if read_u32(record, abi::ENGINE_INLINE_OBJECT_PARAGRAPH_ID)? == 0 + || id == 0 + || prior_u32_duplicate( + records, + abi::ENGINE_INLINE_OBJECT_RECORD_SIZE, + abi::ENGINE_INLINE_OBJECT_ID, + index, + id, + )? + || read_u32(record, abi::ENGINE_INLINE_OBJECT_RESOURCE_ID)? == 0 + || read_u32(record, abi::ENGINE_INLINE_OBJECT_RESOURCE_GENERATION)? == 0 + || finite(record, abi::ENGINE_INLINE_OBJECT_INLINE_EXTENT)? < 0.0 + || finite(record, abi::ENGINE_INLINE_OBJECT_BLOCK_EXTENT)? < 0.0 + || !finite(record, abi::ENGINE_INLINE_OBJECT_BASELINE_OFFSET)?.is_finite() + || !finite(record, abi::ENGINE_INLINE_OBJECT_MARGIN_INLINE_START)?.is_finite() + || !finite(record, abi::ENGINE_INLINE_OBJECT_MARGIN_INLINE_END)?.is_finite() + || !finite(record, abi::ENGINE_INLINE_OBJECT_MARGIN_BLOCK_START)?.is_finite() + || !finite(record, abi::ENGINE_INLINE_OBJECT_MARGIN_BLOCK_END)?.is_finite() + || !matches!( + byte(record, abi::ENGINE_INLINE_OBJECT_BASELINE_ALIGNMENT)?, + BASELINE_ALPHABETIC | BASELINE_TEXT_TOP | BASELINE_MIDDLE | BASELINE_TEXT_BOTTOM + ) + || byte(record, abi::ENGINE_INLINE_OBJECT_FLAGS)? != 0 + || read_u16(record, abi::ENGINE_INLINE_OBJECT_RESERVED0)? != 0 + { + return Err(STATUS_INVALID_REQUEST); + } + } + Ok(()) +} + +fn validate_shape( + request: &[u8], + record: &[u8], + shape_offset: usize, + vertices_offset: usize, + vertex_count_offset: usize, + shape_bounds: (f32, f32, f32, f32), + fixed: &[&[u8]], +) -> Result<(), u32> { + let shape = byte(record, shape_offset)?; + let offset = read_u32(record, vertices_offset)?; + let count = u32::from(read_u16(record, vertex_count_offset)?); + match shape { + SHAPE_RECTANGLE if offset == 0 && count == 0 => Ok(()), + SHAPE_POLYGON if count >= 3 => { + let vertices = record_table( + request, + offset, + count, + abi::ENGINE_FLOW_VERTEX_RECORD_SIZE, + abi::ENGINE_FLOW_VERTEX_RECORD_ALIGNMENT, + )?; + reject_payload_overlap(request, vertices, fixed)?; + for vertex in vertices.chunks_exact(abi::ENGINE_FLOW_VERTEX_RECORD_SIZE as usize) { + let inline = finite(vertex, abi::ENGINE_FLOW_VERTEX_INLINE)?; + let block = finite(vertex, abi::ENGINE_FLOW_VERTEX_BLOCK)?; + if inline < shape_bounds.0 + || block < shape_bounds.1 + || inline > shape_bounds.2 + || block > shape_bounds.3 + { + return Err(STATUS_INVALID_REQUEST); + } + } + Ok(()) + } + _ => Err(STATUS_INVALID_REQUEST), + } +} + +fn bounds( + record: &[u8], + inline_start: usize, + block_start: usize, + inline_end: usize, + block_end: usize, +) -> Result<(f32, f32, f32, f32), u32> { + let values = ( + finite(record, inline_start)?, + finite(record, block_start)?, + finite(record, inline_end)?, + finite(record, block_end)?, + ); + if values.0 >= values.2 || values.1 >= values.3 { + Err(STATUS_INVALID_REQUEST) + } else { + Ok(values) + } +} + +fn nonempty_block_bounds( + record: &[u8], + inline_start: usize, + block_start: usize, + inline_end: usize, + block_end: usize, +) -> Result<(f32, f32, f32, f32), u32> { + let values = ( + finite(record, inline_start)?, + finite(record, block_start)?, + finite(record, inline_end)?, + finite(record, block_end)?, + ); + if values.0 > values.2 || values.1 >= values.3 { + Err(STATUS_INVALID_REQUEST) + } else { + Ok(values) + } +} + +fn valid_axis(mode: u8, value: f32) -> bool { + match mode { + AXIS_UNCONSTRAINED => value == 0.0, + AXIS_AT_MOST | AXIS_EXACT => value >= 0.0, + _ => false, + } +} + +fn finite(record: &[u8], offset: usize) -> Result { + let value = read_f32(record, offset)?; + if value.is_finite() { + Ok(value) + } else { + Err(STATUS_INVALID_REQUEST) + } +} + +fn byte(record: &[u8], offset: usize) -> Result { + record.get(offset).copied().ok_or(STATUS_INVALID_REQUEST) +} + +fn prior_u32_duplicate( + records: &[u8], + stride: u32, + field: usize, + index: usize, + value: u32, +) -> Result { + for record in records[..index * stride as usize].chunks_exact(stride as usize) { + if read_u32(record, field)? == value { + return Ok(true); + } + } + Ok(false) +} + +fn contains_u32(records: &[u8], stride: u32, field: usize, value: u32) -> Result { + for record in records.chunks_exact(stride as usize) { + if read_u32(record, field)? == value { + return Ok(true); + } + } + Ok(false) +} + +fn reject_overlapping_slices(request: &[u8], slices: &[&[u8]]) -> Result<(), u32> { + for (index, slice) in slices.iter().enumerate() { + if slice.is_empty() { + continue; + } + let current = byte_range(request, slice)?; + for other in &slices[..index] { + if !other.is_empty() && overlaps(current, byte_range(request, other)?) { + return Err(STATUS_INVALID_REQUEST); + } + } + } + Ok(()) +} + +fn reject_payload_overlap(request: &[u8], payload: &[u8], fixed: &[&[u8]]) -> Result<(), u32> { + let payload = byte_range(request, payload)?; + for table in fixed { + if !table.is_empty() && overlaps(payload, byte_range(request, table)?) { + return Err(STATUS_INVALID_REQUEST); + } + } + Ok(()) +} + +fn reject_overlapping_vertex_payloads( + request: &[u8], + regions: &[u8], + exclusions: &[u8], +) -> Result<(), u32> { + let total = regions.len() / abi::ENGINE_REGION_RECORD_SIZE as usize + + exclusions.len() / abi::ENGINE_EXCLUSION_RECORD_SIZE as usize; + for index in 0..total { + let Some(current) = indexed_vertex_range(request, regions, exclusions, index)? else { + continue; + }; + for previous in 0..index { + if indexed_vertex_range(request, regions, exclusions, previous)? + .is_some_and(|range| overlaps(current, range)) + { + return Err(STATUS_INVALID_REQUEST); + } + } + } + Ok(()) +} + +fn indexed_vertex_range( + request: &[u8], + regions: &[u8], + exclusions: &[u8], + index: usize, +) -> Result, u32> { + let region_count = regions.len() / abi::ENGINE_REGION_RECORD_SIZE as usize; + let (record, shape, offset, count) = if index < region_count { + let start = index * abi::ENGINE_REGION_RECORD_SIZE as usize; + ( + ®ions[start..start + abi::ENGINE_REGION_RECORD_SIZE as usize], + abi::ENGINE_REGION_SHAPE, + abi::ENGINE_REGION_VERTICES_OFFSET, + abi::ENGINE_REGION_VERTEX_COUNT, + ) + } else { + let start = (index - region_count) * abi::ENGINE_EXCLUSION_RECORD_SIZE as usize; + ( + &exclusions[start..start + abi::ENGINE_EXCLUSION_RECORD_SIZE as usize], + abi::ENGINE_EXCLUSION_SHAPE, + abi::ENGINE_EXCLUSION_VERTICES_OFFSET, + abi::ENGINE_EXCLUSION_VERTEX_COUNT, + ) + }; + if byte(record, shape)? != SHAPE_POLYGON { + return Ok(None); + } + let vertices = array( + request, + read_u32(record, offset)?, + u32::from(read_u16(record, count)?), + abi::ENGINE_FLOW_VERTEX_RECORD_SIZE, + abi::ENGINE_FLOW_VERTEX_RECORD_ALIGNMENT, + )?; + Ok(Some(byte_range(request, vertices)?)) +} + +fn byte_range(request: &[u8], slice: &[u8]) -> Result<(usize, usize), u32> { + let request_start = request.as_ptr() as usize; + let start = (slice.as_ptr() as usize) + .checked_sub(request_start) + .ok_or(STATUS_INVALID_REQUEST)?; + let end = start + .checked_add(slice.len()) + .ok_or(STATUS_INVALID_REQUEST)?; + if end > request.len() { + Err(STATUS_INVALID_REQUEST) + } else { + Ok((start, end)) + } +} + +fn overlaps(left: (usize, usize), right: (usize, usize)) -> bool { + left.0 < right.1 && right.0 < left.1 +} + +fn mix_bytes(hash: &mut u64, bytes: &[u8]) { + for byte in bytes { + *hash ^= u64::from(*byte); + *hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } +} + +fn mix_record_without_u32(hash: &mut u64, record: &[u8], offset: usize) { + mix_bytes(hash, &record[..offset]); + mix_bytes(hash, &record[offset + 4..]); +} + +fn mix_vertex_payload( + hash: &mut u64, + request: &[u8], + record: &[u8], + shape: usize, + offset: usize, + count: usize, +) { + if byte(record, shape).ok() != Some(SHAPE_POLYGON) { + return; + } + if let (Ok(offset), Ok(count)) = (read_u32(record, offset), read_u16(record, count)) + && let Ok(vertices) = array( + request, + offset, + u32::from(count), + abi::ENGINE_FLOW_VERTEX_RECORD_SIZE, + abi::ENGINE_FLOW_VERTEX_RECORD_ALIGNMENT, + ) + { + mix_bytes(hash, vertices); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::flow_geometry::{FlowGeometryArena, InlineSlot, InlineSlotArena}; + use crate::{ + abi_contract::{ + ENGINE_TEXT_MUTATION_DELETE_COUNT, ENGINE_TEXT_MUTATION_ENCODING, + ENGINE_TEXT_MUTATION_INSERT_COUNT, ENGINE_TEXT_MUTATION_INSERT_OFFSET, + ENGINE_TEXT_MUTATION_OPCODE, ENGINE_TEXT_MUTATION_TEXT_START, + }, + wire::write_u32, + }; + use alloc::vec; + + #[test] + fn validates_explicit_paragraph_order_and_removal_records() { + let offset = ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize; + let stride = abi::ENGINE_PARAGRAPH_MUTATION_RECORD_SIZE as usize; + let mut bytes = vec![0; offset + 2 * stride]; + bytes[offset + abi::ENGINE_PARAGRAPH_MUTATION_OPCODE] = PARAGRAPH_MUTATION_UPSERT; + write_u32( + &mut bytes, + offset + abi::ENGINE_PARAGRAPH_MUTATION_PARAGRAPH_ID, + 7, + ); + write_u32(&mut bytes, offset + abi::ENGINE_PARAGRAPH_MUTATION_ORDER, 3); + let second = offset + stride; + bytes[second + abi::ENGINE_PARAGRAPH_MUTATION_OPCODE] = PARAGRAPH_MUTATION_REMOVE; + write_u32( + &mut bytes, + second + abi::ENGINE_PARAGRAPH_MUTATION_PARAGRAPH_ID, + 8, + ); + let batch = parse_paragraph_mutations(&bytes, offset as u32, 2).unwrap(); + assert_eq!( + batch.get(0), + Some(ParagraphMutation::Upsert { + paragraph_id: 7, + order: 3, + }) + ); + assert_eq!( + batch.get(1), + Some(ParagraphMutation::Remove { paragraph_id: 8 }) + ); + + write_u32(&mut bytes, second + abi::ENGINE_PARAGRAPH_MUTATION_ORDER, 1); + assert!(parse_paragraph_mutations(&bytes, offset as u32, 2).is_err()); + write_u32(&mut bytes, second + abi::ENGINE_PARAGRAPH_MUTATION_ORDER, 0); + bytes[second + abi::ENGINE_PARAGRAPH_MUTATION_OPCODE] = PARAGRAPH_MUTATION_UPSERT; + write_u32(&mut bytes, second + abi::ENGINE_PARAGRAPH_MUTATION_ORDER, 3); + assert!(parse_paragraph_mutations(&bytes, offset as u32, 2).is_err()); + } + + #[test] + fn validates_borrowed_style_snapshots_and_canonical_removals() { + let bytes = valid_style_bytes(); + let batch = parse_style_mutations(&bytes, STYLE_OFFSET as u32, 1).unwrap(); + let StyleMutation::Upsert(style) = batch.get(0).unwrap() else { + panic!("upsert"); + }; + assert_eq!(style.style_id, 7); + assert_eq!(style.language, b"en"); + assert_eq!( + StyleMutationBatch::feature(style, 0).unwrap().tag, + u32::from_be_bytes(*b"kern") + ); + + let mut removal = vec![0; STYLE_OFFSET + abi::ENGINE_STYLE_MUTATION_RECORD_SIZE as usize]; + removal[STYLE_OFFSET + abi::ENGINE_STYLE_MUTATION_OPCODE] = STYLE_MUTATION_REMOVE; + write_u32( + &mut removal, + STYLE_OFFSET + abi::ENGINE_STYLE_MUTATION_STYLE_ID, + 7, + ); + write_u32( + &mut removal, + STYLE_OFFSET + abi::ENGINE_STYLE_MUTATION_PARAGRAPH_ID, + 1, + ); + assert!(parse_style_mutations(&removal, STYLE_OFFSET as u32, 1).is_ok()); + removal[STYLE_OFFSET + abi::ENGINE_STYLE_MUTATION_DIRECTION] = DIRECTION_LTR; + assert!(parse_style_mutations(&removal, STYLE_OFFSET as u32, 1).is_err()); + } + + #[test] + fn rejects_unstated_noncanonical_and_aliased_style_data() { + let mut unstated = valid_style_bytes(); + write_f32( + &mut unstated, + STYLE_OFFSET + abi::ENGINE_STYLE_MUTATION_WORD_SPACING, + 1.0, + ); + assert!(parse_style_mutations(&unstated, STYLE_OFFSET as u32, 1).is_err()); + + let mut density_span = valid_style_bytes(); + density_span[STYLE_OFFSET + abi::ENGINE_STYLE_MUTATION_FLAGS] = 0; + assert!(parse_style_mutations(&density_span, STYLE_OFFSET as u32, 1).is_err()); + + let mut aliased = valid_style_bytes(); + write_u32( + &mut aliased, + STYLE_OFFSET + abi::ENGINE_STYLE_MUTATION_LANGUAGE_OFFSET, + STYLE_OFFSET as u32, + ); + assert!(parse_style_mutations(&aliased, STYLE_OFFSET as u32, 1).is_err()); + } + + const STYLE_OFFSET: usize = abi::ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize; + + fn valid_style_bytes() -> Vec { + let language_offset = STYLE_OFFSET + abi::ENGINE_STYLE_MUTATION_RECORD_SIZE as usize; + let features_offset = (language_offset + 2 + 3) & !3; + let mut bytes = vec![0; features_offset + abi::FEATURE_RECORD_SIZE as usize]; + let record = STYLE_OFFSET; + bytes[record + abi::ENGINE_STYLE_MUTATION_OPCODE] = STYLE_MUTATION_UPSERT; + bytes[record + abi::ENGINE_STYLE_MUTATION_FLAGS] = STYLE_FLAG_ROOT; + write_u32(&mut bytes, record + abi::ENGINE_STYLE_MUTATION_STYLE_ID, 7); + write_u32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_PARAGRAPH_ID, + 1, + ); + write_u32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_CASCADE_ORDER, + 3, + ); + write_u32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_FIELD_MASK, + STYLE_FIELD_FONT_STACK + | STYLE_FIELD_LANGUAGE + | STYLE_FIELD_FEATURES + | STYLE_FIELD_FONT_SIZE + | STYLE_FIELD_LINE_HEIGHT + | STYLE_FIELD_RASTER_PIXEL_RATIO, + ); + write_u32(&mut bytes, record + abi::ENGINE_STYLE_MUTATION_TEXT_END, 4); + write_u32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_FONT_STACK_HANDLE, + 9, + ); + write_u32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_LANGUAGE_OFFSET, + language_offset as u32, + ); + write_u16( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_LANGUAGE_LENGTH, + 2, + ); + write_u16( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_FEATURE_COUNT, + 1, + ); + write_u32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_FEATURES_OFFSET, + features_offset as u32, + ); + write_f32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_FONT_SIZE, + 16.0, + ); + write_f32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_LINE_HEIGHT, + 1.2, + ); + write_f32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_RASTER_PIXEL_RATIO, + 2.0, + ); + bytes[language_offset..language_offset + 2].copy_from_slice(b"en"); + write_u32( + &mut bytes, + features_offset + abi::FEATURE_TAG, + u32::from_be_bytes(*b"kern"), + ); + write_u32(&mut bytes, features_offset + abi::FEATURE_VALUE, 1); + write_u32(&mut bytes, features_offset + abi::FEATURE_END, 4); + bytes + } + + #[test] + fn validates_and_borrows_utf16_replacements_without_decoding_objects() { + let record_offset = ENGINE_UPDATE_REQUEST_HEADER_SIZE; + let payload_offset = record_offset + ENGINE_TEXT_MUTATION_RECORD_SIZE; + let mut bytes = vec![0; payload_offset as usize + 4]; + let record = &mut bytes[record_offset as usize..payload_offset as usize]; + record[ENGINE_TEXT_MUTATION_OPCODE] = TEXT_MUTATION_REPLACE_UTF16; + record[ENGINE_TEXT_MUTATION_ENCODING] = TEXT_ENCODING_UTF16_LE; + write_u32(record, ENGINE_TEXT_MUTATION_PARAGRAPH_ID, 1); + write_u32(record, ENGINE_TEXT_MUTATION_TEXT_START, 2); + write_u32(record, ENGINE_TEXT_MUTATION_DELETE_COUNT, 1); + write_u32(record, ENGINE_TEXT_MUTATION_INSERT_OFFSET, payload_offset); + write_u32(record, ENGINE_TEXT_MUTATION_INSERT_COUNT, 2); + bytes[payload_offset as usize..payload_offset as usize + 2] + .copy_from_slice(&0x0061_u16.to_le_bytes()); + bytes[payload_offset as usize + 2..payload_offset as usize + 4] + .copy_from_slice(&0xd83d_u16.to_le_bytes()); + + let batch = parse_text_mutations(&bytes, record_offset, 1).unwrap(); + assert_eq!(batch.len(), 1); + assert_eq!( + batch.get(0), + Some(TextMutation { + paragraph_id: 1, + text_start: 2, + delete_count: 1, + insert_utf16_le: &[0x61, 0x00, 0x3d, 0xd8], + }) + ); + } + + #[test] + fn consumes_contiguous_paragraph_spans_without_copying_records() { + let offset = ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize; + let stride = ENGINE_TEXT_MUTATION_RECORD_SIZE as usize; + let mut bytes = vec![0; offset + 3 * stride]; + for (index, paragraph_id) in [7, 7, 9].into_iter().enumerate() { + let record = offset + index * stride; + bytes[record + ENGINE_TEXT_MUTATION_OPCODE] = TEXT_MUTATION_REPLACE_UTF16; + bytes[record + ENGINE_TEXT_MUTATION_ENCODING] = TEXT_ENCODING_UTF16_LE; + write_u32( + &mut bytes, + record + ENGINE_TEXT_MUTATION_PARAGRAPH_ID, + paragraph_id, + ); + } + let batch = parse_text_mutations(&bytes, offset as u32, 3).unwrap(); + let mut cursor = 0; + let first = batch.take_paragraph(7, &mut cursor).unwrap(); + assert_eq!(first.len(), 2); + assert_eq!(cursor, 2); + let empty = batch.take_paragraph(8, &mut cursor).unwrap(); + assert_eq!(empty.len(), 0); + assert_eq!(cursor, 2); + let last = batch.take_paragraph(9, &mut cursor).unwrap(); + assert_eq!(last.len(), 1); + assert_eq!(cursor, 3); + + let style_bytes = valid_style_bytes(); + let styles = parse_style_mutations(&style_bytes, STYLE_OFFSET as u32, 1).unwrap(); + let mut style_cursor = 0; + assert_eq!( + styles.take_paragraph(2, &mut style_cursor).unwrap().len(), + 0 + ); + assert_eq!( + styles.take_paragraph(1, &mut style_cursor).unwrap().len(), + 1 + ); + assert_eq!(style_cursor, 1); + + let geometry_bytes = valid_geometry_bytes(); + let geometry = parse_valid_geometry(&geometry_bytes).unwrap(); + let (mut constraint_cursor, mut inline_cursor) = (0, 0); + let absent = geometry + .take_paragraph(2, &mut constraint_cursor, &mut inline_cursor) + .unwrap(); + assert_eq!(absent.constraint_count(), 0); + assert_eq!(absent.inline_object_count(), 0); + let present = geometry + .take_paragraph(1, &mut constraint_cursor, &mut inline_cursor) + .unwrap(); + assert_eq!(present.constraint_count(), 1); + assert_eq!(present.inline_object_count(), 1); + assert_eq!((constraint_cursor, inline_cursor), (1, 1)); + } + + #[test] + fn rejects_noncanonical_empty_and_overlapping_payloads() { + assert!(parse_text_mutations(&[], 4, 0).is_err()); + let record_offset = ENGINE_UPDATE_REQUEST_HEADER_SIZE; + let mut bytes = vec![0; record_offset as usize + ENGINE_TEXT_MUTATION_RECORD_SIZE as usize]; + let record = &mut bytes[record_offset as usize..]; + record[ENGINE_TEXT_MUTATION_OPCODE] = TEXT_MUTATION_REPLACE_UTF16; + record[ENGINE_TEXT_MUTATION_ENCODING] = TEXT_ENCODING_UTF16_LE; + write_u32(record, ENGINE_TEXT_MUTATION_INSERT_OFFSET, record_offset); + write_u32(record, ENGINE_TEXT_MUTATION_INSERT_COUNT, 1); + assert!(parse_text_mutations(&bytes, record_offset, 1).is_err()); + } + + #[test] + fn validates_one_call_rectangle_flow_and_text_anchored_objects() { + let bytes = valid_geometry_bytes(); + let geometry = parse_valid_geometry(&bytes).unwrap(); + geometry.validate_text_length(0).unwrap(); + assert_ne!(geometry.fingerprint(), 0); + assert_eq!(geometry.constraint_count(), 1); + assert_eq!(geometry.constraint(0).unwrap().flow_thread_id, 1); + assert_eq!(geometry.region(0).unwrap().inline_end, 100.0); + assert_eq!(geometry.exclusion(0).unwrap().region_id, 1); + + let mut polygon = bytes.clone(); + polygon.resize( + polygon.len() + 3 * abi::ENGINE_FLOW_VERTEX_RECORD_SIZE as usize, + 0, + ); + polygon[REGION_OFFSET + abi::ENGINE_REGION_SHAPE] = SHAPE_POLYGON; + write_u32( + &mut polygon, + REGION_OFFSET + abi::ENGINE_REGION_VERTICES_OFFSET, + GEOMETRY_LENGTH as u32, + ); + write_u16( + &mut polygon, + REGION_OFFSET + abi::ENGINE_REGION_VERTEX_COUNT, + 3, + ); + for (index, (inline, block)) in [(0.0, 0.0), (100.0, 0.0), (0.0, 100.0)] + .into_iter() + .enumerate() + { + let offset = GEOMETRY_LENGTH + index * abi::ENGINE_FLOW_VERTEX_RECORD_SIZE as usize; + write_f32( + &mut polygon, + offset + abi::ENGINE_FLOW_VERTEX_INLINE, + inline, + ); + write_f32(&mut polygon, offset + abi::ENGINE_FLOW_VERTEX_BLOCK, block); + } + let polygon_geometry = parse_valid_geometry(&polygon).unwrap(); + assert_ne!(polygon_geometry.fingerprint(), geometry.fingerprint()); + let mut retained = FlowGeometryArena::default(); + retained.build(polygon_geometry).unwrap(); + assert_eq!(retained.constraints.len(), 1); + assert_eq!(retained.regions.len(), 1); + assert_eq!(retained.exclusions.len(), 1); + assert_eq!(retained.vertices.len(), 3); + assert_eq!(retained.regions[0].vertex_start, 0); + assert_eq!( + retained.vertices[2], + FlowVertex { + inline: 0.0, + block: 100.0 + } + ); + retained.regions[0].record.exclusion_count = 0; + let mut polygon_slots = InlineSlotArena::default(); + assert_eq!( + polygon_slots + .resolve_band(&retained, 0, 0.0, 50.0, 4) + .unwrap(), + [InlineSlot { + start: 0.0, + end: 50.0, + }] + ); + let mut rectangle = FlowGeometryArena::default(); + rectangle.build(geometry).unwrap(); + let mut slots = InlineSlotArena::default(); + assert_eq!( + slots + .resolve_rectangle_band(&rectangle, 0, 20.0, 30.0, 4) + .unwrap(), + [ + InlineSlot { + start: 0.0, + end: 20.0, + }, + InlineSlot { + start: 40.0, + end: 100.0, + }, + ] + ); + let mut relocated_polygon = polygon[..GEOMETRY_LENGTH].to_vec(); + relocated_polygon.resize(GEOMETRY_LENGTH + 8, 0); + relocated_polygon.extend_from_slice(&polygon[GEOMETRY_LENGTH..]); + write_u32( + &mut relocated_polygon, + REGION_OFFSET + abi::ENGINE_REGION_VERTICES_OFFSET, + (GEOMETRY_LENGTH + 8) as u32, + ); + assert_eq!( + parse_valid_geometry(&relocated_polygon) + .unwrap() + .fingerprint(), + polygon_geometry.fingerprint(), + "request placement is not semantic geometry", + ); + + let mut zero_width = valid_geometry_bytes(); + write_f32( + &mut zero_width, + CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_WIDTH, + 0.0, + ); + write_u16( + &mut zero_width, + REGION_OFFSET + abi::ENGINE_REGION_EXCLUSION_COUNT, + 0, + ); + write_f32( + &mut zero_width, + REGION_OFFSET + abi::ENGINE_REGION_INLINE_END, + 0.0, + ); + write_f32( + &mut zero_width, + REGION_OFFSET + abi::ENGINE_REGION_CLIP_INLINE_END, + 0.0, + ); + let zero_width = parse_valid_geometry(&zero_width).unwrap(); + let mut retained_zero_width = FlowGeometryArena::default(); + retained_zero_width.build(zero_width).unwrap(); + assert_eq!( + InlineSlotArena::default() + .resolve_band(&retained_zero_width, 0, 0.0, 10.0, 1) + .unwrap(), + [InlineSlot { + start: 0.0, + end: 0.0, + }], + "a zero-width host measurement still composes one-cluster lines", + ); + + let mut outside_text = bytes.clone(); + let inline = INLINE_OFFSET + abi::ENGINE_INLINE_OBJECT_TEXT_OFFSET; + write_u32(&mut outside_text, inline, 1); + assert!( + parse_valid_geometry(&outside_text) + .unwrap() + .validate_text_length(0) + .is_err() + ); + } + + #[test] + fn rejects_invalid_geometry_relationships_and_payload_aliasing() { + let mut wrong_region = valid_geometry_bytes(); + write_u32( + &mut wrong_region, + EXCLUSION_OFFSET + abi::ENGINE_EXCLUSION_REGION_ID, + 9, + ); + assert!(parse_valid_geometry(&wrong_region).is_err()); + + let mut nonfinite = valid_geometry_bytes(); + write_f32( + &mut nonfinite, + EXCLUSION_OFFSET + abi::ENGINE_EXCLUSION_MARGIN_INLINE, + f32::NAN, + ); + assert!(parse_valid_geometry(&nonfinite).is_err()); + + let mut overlapping_polygon = valid_geometry_bytes(); + overlapping_polygon[REGION_OFFSET + abi::ENGINE_REGION_SHAPE] = SHAPE_POLYGON; + write_u32( + &mut overlapping_polygon, + REGION_OFFSET + abi::ENGINE_REGION_VERTICES_OFFSET, + CONSTRAINT_OFFSET as u32, + ); + write_u16( + &mut overlapping_polygon, + REGION_OFFSET + abi::ENGINE_REGION_VERTEX_COUNT, + 3, + ); + assert!(parse_valid_geometry(&overlapping_polygon).is_err()); + + let mut cross_section_alias = valid_geometry_bytes(); + let mutation_offset = cross_section_alias.len(); + cross_section_alias.resize( + mutation_offset + ENGINE_TEXT_MUTATION_RECORD_SIZE as usize, + 0, + ); + cross_section_alias[mutation_offset + ENGINE_TEXT_MUTATION_OPCODE] = + TEXT_MUTATION_REPLACE_UTF16; + cross_section_alias[mutation_offset + ENGINE_TEXT_MUTATION_ENCODING] = + TEXT_ENCODING_UTF16_LE; + write_u32( + &mut cross_section_alias, + mutation_offset + ENGINE_TEXT_MUTATION_PARAGRAPH_ID, + 1, + ); + write_u32( + &mut cross_section_alias, + mutation_offset + ENGINE_TEXT_MUTATION_INSERT_OFFSET, + CONSTRAINT_OFFSET as u32, + ); + write_u32( + &mut cross_section_alias, + mutation_offset + ENGINE_TEXT_MUTATION_INSERT_COUNT, + 2, + ); + let text = parse_text_mutations(&cross_section_alias, mutation_offset as u32, 1).unwrap(); + let geometry = parse_valid_geometry(&cross_section_alias).unwrap(); + assert!(text.validate_disjoint_geometry(geometry).is_err()); + + assert!( + parse_geometry( + &valid_geometry_bytes(), + CONSTRAINT_OFFSET as u32, + 1, + REGION_OFFSET as u32, + 1, + EXCLUSION_OFFSET as u32, + 1, + INLINE_OFFSET as u32, + 1, + UpdateLimits { + max_regions: 1, + max_exclusions: 0, + ..limits() + }, + ) + .is_err() + ); + } + + const CONSTRAINT_OFFSET: usize = abi::ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize; + const REGION_OFFSET: usize = CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_RECORD_SIZE as usize; + const EXCLUSION_OFFSET: usize = REGION_OFFSET + abi::ENGINE_REGION_RECORD_SIZE as usize; + const INLINE_OFFSET: usize = EXCLUSION_OFFSET + abi::ENGINE_EXCLUSION_RECORD_SIZE as usize; + const GEOMETRY_LENGTH: usize = INLINE_OFFSET + abi::ENGINE_INLINE_OBJECT_RECORD_SIZE as usize; + + fn parse_valid_geometry(bytes: &[u8]) -> Result, u32> { + parse_geometry( + bytes, + CONSTRAINT_OFFSET as u32, + 1, + REGION_OFFSET as u32, + 1, + EXCLUSION_OFFSET as u32, + 1, + INLINE_OFFSET as u32, + 1, + limits(), + ) + } + + fn limits() -> UpdateLimits { + UpdateLimits { + max_paragraphs: 4, + max_clusters: 16, + max_lines: 16, + max_regions: 4, + max_exclusions: 4, + max_inline_objects: 4, + max_slots_per_band: 4, + max_output_bytes: 4096, + } + } + + fn valid_geometry_bytes() -> Vec { + let mut bytes = vec![0; GEOMETRY_LENGTH]; + write_u32( + &mut bytes, + CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_PARAGRAPH_ID, + 1, + ); + write_u32( + &mut bytes, + CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_FLOW_THREAD_ID, + 1, + ); + write_f32( + &mut bytes, + CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_WIDTH, + 100.0, + ); + write_f32( + &mut bytes, + CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_HEIGHT, + 100.0, + ); + write_f32( + &mut bytes, + CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_VIEWPORT_BLOCK_END, + 100.0, + ); + write_u32( + &mut bytes, + CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_MAX_LINES, + 16, + ); + write_u16( + &mut bytes, + CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_REGION_COUNT, + 1, + ); + bytes[CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_WIDTH_MODE] = AXIS_EXACT; + bytes[CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_HEIGHT_MODE] = AXIS_EXACT; + bytes[CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_WRAP] = WRAP_WORD; + bytes[CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_ALIGN] = ALIGN_START; + bytes[CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_OVERFLOW] = OVERFLOW_CLIP; + bytes[CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_BLOCK_ALIGN] = BLOCK_ALIGN_START; + + write_u32(&mut bytes, REGION_OFFSET + abi::ENGINE_REGION_ID, 1); + write_u32( + &mut bytes, + REGION_OFFSET + abi::ENGINE_REGION_GEOMETRY_REVISION, + 1, + ); + write_u32( + &mut bytes, + REGION_OFFSET + abi::ENGINE_REGION_TRANSFORM_INDEX, + 1, + ); + write_u16( + &mut bytes, + REGION_OFFSET + abi::ENGINE_REGION_EXCLUSION_COUNT, + 1, + ); + bytes[REGION_OFFSET + abi::ENGINE_REGION_SHAPE] = SHAPE_RECTANGLE; + bytes[REGION_OFFSET + abi::ENGINE_REGION_WRITING_MODE] = WRITING_HORIZONTAL_TB; + bytes[REGION_OFFSET + abi::ENGINE_REGION_TEXT_ORIENTATION] = ORIENTATION_MIXED; + for field in [ + abi::ENGINE_REGION_INLINE_END, + abi::ENGINE_REGION_BLOCK_END, + abi::ENGINE_REGION_CLIP_INLINE_END, + abi::ENGINE_REGION_CLIP_BLOCK_END, + ] { + write_f32(&mut bytes, REGION_OFFSET + field, 100.0); + } + + write_u32(&mut bytes, EXCLUSION_OFFSET + abi::ENGINE_EXCLUSION_ID, 2); + write_u32( + &mut bytes, + EXCLUSION_OFFSET + abi::ENGINE_EXCLUSION_REGION_ID, + 1, + ); + write_u32( + &mut bytes, + EXCLUSION_OFFSET + abi::ENGINE_EXCLUSION_GEOMETRY_REVISION, + 1, + ); + bytes[EXCLUSION_OFFSET + abi::ENGINE_EXCLUSION_SHAPE] = SHAPE_RECTANGLE; + bytes[EXCLUSION_OFFSET + abi::ENGINE_EXCLUSION_WRAP_SIDE] = EXCLUSION_WRAP_BOTH; + write_f32( + &mut bytes, + EXCLUSION_OFFSET + abi::ENGINE_EXCLUSION_INLINE_START, + 20.0, + ); + write_f32( + &mut bytes, + EXCLUSION_OFFSET + abi::ENGINE_EXCLUSION_BLOCK_START, + 20.0, + ); + write_f32( + &mut bytes, + EXCLUSION_OFFSET + abi::ENGINE_EXCLUSION_INLINE_END, + 40.0, + ); + write_f32( + &mut bytes, + EXCLUSION_OFFSET + abi::ENGINE_EXCLUSION_BLOCK_END, + 40.0, + ); + + write_u32( + &mut bytes, + INLINE_OFFSET + abi::ENGINE_INLINE_OBJECT_PARAGRAPH_ID, + 1, + ); + write_u32(&mut bytes, INLINE_OFFSET + abi::ENGINE_INLINE_OBJECT_ID, 3); + write_u32( + &mut bytes, + INLINE_OFFSET + abi::ENGINE_INLINE_OBJECT_CONTENT_REVISION, + 1, + ); + write_u32( + &mut bytes, + INLINE_OFFSET + abi::ENGINE_INLINE_OBJECT_MATERIAL_ID, + 1, + ); + write_u32( + &mut bytes, + INLINE_OFFSET + abi::ENGINE_INLINE_OBJECT_RESOURCE_ID, + 4, + ); + write_u32( + &mut bytes, + INLINE_OFFSET + abi::ENGINE_INLINE_OBJECT_RESOURCE_GENERATION, + 1, + ); + write_f32( + &mut bytes, + INLINE_OFFSET + abi::ENGINE_INLINE_OBJECT_INLINE_EXTENT, + 10.0, + ); + write_f32( + &mut bytes, + INLINE_OFFSET + abi::ENGINE_INLINE_OBJECT_BLOCK_EXTENT, + 10.0, + ); + bytes[INLINE_OFFSET + abi::ENGINE_INLINE_OBJECT_BASELINE_ALIGNMENT] = BASELINE_ALPHABETIC; + bytes + } + + fn write_u16(bytes: &mut [u8], offset: usize, value: u16) { + bytes[offset..offset + 2].copy_from_slice(&value.to_le_bytes()); + } + + fn write_f32(bytes: &mut [u8], offset: usize, value: f32) { + write_u32(bytes, offset, value.to_bits()); + } +} diff --git a/packages/text/rust/shaper/src/engine/shaping_state.rs b/packages/text/rust/shaper/src/engine/shaping_state.rs new file mode 100644 index 00000000..d61af55f --- /dev/null +++ b/packages/text/rust/shaper/src/engine/shaping_state.rs @@ -0,0 +1,662 @@ +use alloc::vec::Vec; + +use crate::{ + bidi::BidiAnalysis, + unicode::{COMMON_SCRIPT, UnicodeAnalysis}, +}; + +use super::{ + EngineError, + style_state::{ResolvedStyle, StyleArena, StyleSegment}, +}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct ShapingRun { + pub text_start: u32, + pub text_end: u32, + pub script: u32, + pub direction: u8, + pub bidi_level: u8, + pub style: ResolvedStyle, +} + +#[derive(Default)] +pub(crate) struct ShapingRunArena { + runs: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ShapedRun { + pub source_run: u32, + pub binding_handle: u32, + pub font_handle: u32, + pub text_start: u32, + pub text_end: u32, + pub glyph_start: u32, + pub glyph_count: u32, +} + +#[derive(Default)] +pub(crate) struct ShapeArena { + pub runs: Vec, + pub glyph_ids: Vec, + pub clusters: Vec, + pub x_advances: Vec, + pub y_advances: Vec, + pub x_offsets: Vec, + pub y_offsets: Vec, + pub glyph_flags: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct BoundaryShape { + pub flow_thread_id: u32, + pub source_run: u32, + pub cluster_start: u32, + pub cluster_end: u32, + pub text_end: u32, + pub source_binding_handle: u32, + pub source_font_handle: u32, + pub ellipsis_binding_handle: u32, + pub ellipsis_font_handle: u32, + pub source_glyph_start: u32, + pub source_glyph_count: u32, + pub ellipsis_glyph_start: u32, + pub ellipsis_glyph_count: u32, +} + +#[derive(Default)] +pub(crate) struct BoundaryShapeArena { + pub records: Vec, + pub shape: ShapeArena, + pub stable_ids: Vec, +} + +impl ShapingRunArena { + pub(crate) fn reserve(&mut self, capacity: usize) -> Result<(), EngineError> { + if self.runs.capacity() < capacity { + self.runs + .try_reserve_exact(capacity.saturating_sub(self.runs.len())) + .map_err(|_| EngineError::ResultTooLarge)?; + } + Ok(()) + } + + pub(crate) fn build( + &mut self, + text: &[u16], + styles: &[StyleSegment], + style_storage: &StyleArena, + unicode: &UnicodeAnalysis, + bidi: &BidiAnalysis, + ) -> Result<(), EngineError> { + self.runs.clear(); + if styles.is_empty() { + return Ok(()); + } + if text.is_empty() { + if let Some(segment) = styles.first() { + let level = bidi + .levels + .first() + .or_else(|| bidi.paragraph_levels.first()) + .copied() + .unwrap_or(0); + self.push( + ShapingRun { + text_start: segment.text_start, + text_end: segment.text_start, + script: COMMON_SCRIPT, + direction: direction(segment.style, level), + bidi_level: forced_level(segment.style, level), + style: segment.style, + }, + style_storage, + )?; + } + return Ok(()); + } + let scripts = unicode.script_items(); + let mut style_index = 0usize; + let mut script_index = 0usize; + let mut bidi_index = 0usize; + while style_index < styles.len() + && script_index < scripts.len() + && bidi_index < bidi.runs.len() + { + let style = styles[style_index]; + let script = scripts[script_index]; + let bidi_run = bidi.runs[bidi_index]; + let start = style + .text_start + .max(script.text_start) + .max(bidi_run.text_start); + let end = style.text_end.min(script.text_end).min(bidi_run.text_end); + if start < end { + self.push_drawable_fragments( + text, + start, + end, + ShapingRun { + text_start: start, + text_end: end, + script: script.script, + direction: direction(style.style, bidi_run.level), + bidi_level: forced_level(style.style, bidi_run.level), + style: style.style, + }, + style_storage, + )?; + } + let boundary = style.text_end.min(script.text_end).min(bidi_run.text_end); + if boundary <= start { + return Err(EngineError::InvalidRequest); + } + if style.text_end == boundary { + style_index += 1; + } + if script.text_end == boundary { + script_index += 1; + } + if bidi_run.text_end == boundary { + bidi_index += 1; + } + } + if style_index != styles.len() + || script_index != scripts.len() + || bidi_index != bidi.runs.len() + { + return Err(EngineError::InvalidRequest); + } + Ok(()) + } + + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn runs(&self) -> &[ShapingRun] { + &self.runs + } + + pub(crate) fn clear(&mut self) { + self.runs.clear(); + } + + fn push_drawable_fragments( + &mut self, + text: &[u16], + start: u32, + end: u32, + template: ShapingRun, + style_storage: &StyleArena, + ) -> Result<(), EngineError> { + let mut fragment_start = usize::try_from(start).map_err(|_| EngineError::InvalidRequest)?; + let mut offset = fragment_start; + let end = usize::try_from(end).map_err(|_| EngineError::InvalidRequest)?; + while offset < end { + let unit = *text.get(offset).ok_or(EngineError::InvalidRequest)?; + let hard_break = matches!(unit, 0x0a | 0x0b | 0x0c | 0x0d | 0x85 | 0x2028 | 0x2029); + if hard_break { + if fragment_start < offset { + self.push( + ShapingRun { + text_start: u32::try_from(fragment_start) + .map_err(|_| EngineError::ResultTooLarge)?, + text_end: u32::try_from(offset) + .map_err(|_| EngineError::ResultTooLarge)?, + ..template + }, + style_storage, + )?; + } + offset += 1; + fragment_start = offset; + } else { + offset += if (0xd800..=0xdbff).contains(&unit) { + 2 + } else { + 1 + }; + } + } + if fragment_start < end { + self.push( + ShapingRun { + text_start: u32::try_from(fragment_start) + .map_err(|_| EngineError::ResultTooLarge)?, + text_end: u32::try_from(end).map_err(|_| EngineError::ResultTooLarge)?, + ..template + }, + style_storage, + )?; + } + Ok(()) + } + + fn push(&mut self, run: ShapingRun, style_storage: &StyleArena) -> Result<(), EngineError> { + if let Some(previous) = self.runs.last_mut() + && previous.text_end == run.text_start + && previous.script == run.script + && previous.direction == run.direction + && previous.bidi_level == run.bidi_level + && style_storage.same_layout_style(previous.style, run.style) + { + previous.text_end = run.text_end; + return Ok(()); + } + self.runs + .try_reserve(1) + .map_err(|_| EngineError::ResultTooLarge)?; + self.runs.push(run); + Ok(()) + } +} + +impl ShapeArena { + pub(crate) fn reserve(&mut self, capacity: usize) -> Result<(), EngineError> { + reserve_vec(&mut self.runs, capacity)?; + reserve_vec(&mut self.glyph_ids, capacity)?; + reserve_vec(&mut self.clusters, capacity)?; + reserve_vec(&mut self.x_advances, capacity)?; + reserve_vec(&mut self.y_advances, capacity)?; + reserve_vec(&mut self.x_offsets, capacity)?; + reserve_vec(&mut self.y_offsets, capacity)?; + reserve_vec(&mut self.glyph_flags, capacity) + } + + pub(crate) fn clear(&mut self) { + self.runs.clear(); + self.glyph_ids.clear(); + self.clusters.clear(); + self.x_advances.clear(); + self.y_advances.clear(); + self.x_offsets.clear(); + self.y_offsets.clear(); + self.glyph_flags.clear(); + } + + pub(crate) fn append( + &mut self, + source_run: usize, + font_handle: u32, + binding_handle: u32, + text_start: u32, + text_end: u32, + shaped: &harfrust::GlyphBuffer, + ) -> Result<(), u32> { + let glyph_start = + u32::try_from(self.glyph_ids.len()).map_err(|_| crate::STATUS_RESULT_TOO_LARGE)?; + let glyph_count = + u32::try_from(shaped.len()).map_err(|_| crate::STATUS_RESULT_TOO_LARGE)?; + self.reserve(self.glyph_ids.len().saturating_add(shaped.len())) + .map_err(|_| crate::STATUS_RESULT_TOO_LARGE)?; + self.runs.push(ShapedRun { + source_run: u32::try_from(source_run).map_err(|_| crate::STATUS_RESULT_TOO_LARGE)?, + binding_handle, + font_handle, + text_start, + text_end, + glyph_start, + glyph_count, + }); + for (info, position) in shaped.glyph_infos().iter().zip(shaped.glyph_positions()) { + self.glyph_ids + .push(u16::try_from(info.glyph_id).map_err(|_| crate::STATUS_RESULT_TOO_LARGE)?); + self.clusters.push(info.cluster); + self.x_advances.push(position.x_advance); + self.y_advances.push(position.y_advance); + self.x_offsets.push(position.x_offset); + self.y_offsets.push(position.y_offset); + self.glyph_flags.push( + u16::try_from(info.flags().to_bits()) + .map_err(|_| crate::STATUS_RESULT_TOO_LARGE)?, + ); + } + Ok(()) + } + + pub(crate) fn append_from( + &mut self, + source: &Self, + run_index: usize, + ) -> Result<(u32, u32), EngineError> { + let run = *source + .runs + .get(run_index) + .ok_or(EngineError::InvalidRequest)?; + let source_start = + usize::try_from(run.glyph_start).map_err(|_| EngineError::InvalidRequest)?; + let source_end = source_start + .checked_add(usize::try_from(run.glyph_count).map_err(|_| EngineError::InvalidRequest)?) + .ok_or(EngineError::InvalidRequest)?; + let glyph_start = + u32::try_from(self.glyph_ids.len()).map_err(|_| EngineError::ResultTooLarge)?; + self.reserve( + self.glyph_ids + .len() + .saturating_add(source_end.saturating_sub(source_start)), + )?; + self.runs.push(ShapedRun { glyph_start, ..run }); + self.glyph_ids.extend_from_slice( + source + .glyph_ids + .get(source_start..source_end) + .ok_or(EngineError::InvalidRequest)?, + ); + self.clusters.extend_from_slice( + source + .clusters + .get(source_start..source_end) + .ok_or(EngineError::InvalidRequest)?, + ); + self.x_advances.extend_from_slice( + source + .x_advances + .get(source_start..source_end) + .ok_or(EngineError::InvalidRequest)?, + ); + self.y_advances.extend_from_slice( + source + .y_advances + .get(source_start..source_end) + .ok_or(EngineError::InvalidRequest)?, + ); + self.x_offsets.extend_from_slice( + source + .x_offsets + .get(source_start..source_end) + .ok_or(EngineError::InvalidRequest)?, + ); + self.y_offsets.extend_from_slice( + source + .y_offsets + .get(source_start..source_end) + .ok_or(EngineError::InvalidRequest)?, + ); + self.glyph_flags.extend_from_slice( + source + .glyph_flags + .get(source_start..source_end) + .ok_or(EngineError::InvalidRequest)?, + ); + Ok((glyph_start, run.glyph_count)) + } + + pub(crate) fn append_text_range_from( + &mut self, + source: &Self, + run_index: usize, + source_run: u32, + text_start: u32, + text_end: u32, + text_delta: i64, + ) -> Result<(), EngineError> { + if text_start > text_end { + return Err(EngineError::InvalidRequest); + } + if text_start == text_end { + return Ok(()); + } + let run = *source + .runs + .get(run_index) + .ok_or(EngineError::InvalidRequest)?; + let run_start = + usize::try_from(run.glyph_start).map_err(|_| EngineError::InvalidRequest)?; + let run_end = run_start + .checked_add(usize::try_from(run.glyph_count).map_err(|_| EngineError::InvalidRequest)?) + .ok_or(EngineError::InvalidRequest)?; + let mut selected_start = None; + let mut selected_end = 0usize; + let mut selection_finished = false; + for glyph in run_start..run_end { + let cluster = *source + .clusters + .get(glyph) + .ok_or(EngineError::InvalidRequest)?; + if cluster >= text_start && cluster < text_end { + if selection_finished { + return Err(EngineError::InvalidRequest); + } + selected_start.get_or_insert(glyph); + selected_end = glyph + 1; + } else if selected_start.is_some() { + selection_finished = true; + } + } + let (selected_start, selected_end) = + selected_start.map_or((run_start, run_start), |start| (start, selected_end)); + let glyph_start = + u32::try_from(self.glyph_ids.len()).map_err(|_| EngineError::ResultTooLarge)?; + let glyph_count = u32::try_from(selected_end - selected_start) + .map_err(|_| EngineError::ResultTooLarge)?; + self.reserve( + self.glyph_ids + .len() + .saturating_add(selected_end - selected_start), + )?; + self.runs.push(ShapedRun { + source_run, + text_start: shifted_offset(text_start, text_delta)?, + text_end: shifted_offset(text_end, text_delta)?, + glyph_start, + glyph_count, + ..run + }); + self.glyph_ids + .extend_from_slice(&source.glyph_ids[selected_start..selected_end]); + for &cluster in &source.clusters[selected_start..selected_end] { + self.clusters.push(shifted_offset(cluster, text_delta)?); + } + self.x_advances + .extend_from_slice(&source.x_advances[selected_start..selected_end]); + self.y_advances + .extend_from_slice(&source.y_advances[selected_start..selected_end]); + self.x_offsets + .extend_from_slice(&source.x_offsets[selected_start..selected_end]); + self.y_offsets + .extend_from_slice(&source.y_offsets[selected_start..selected_end]); + self.glyph_flags + .extend_from_slice(&source.glyph_flags[selected_start..selected_end]); + Ok(()) + } +} + +fn shifted_offset(value: u32, delta: i64) -> Result { + let shifted = i64::from(value) + .checked_add(delta) + .ok_or(EngineError::ResultTooLarge)?; + u32::try_from(shifted).map_err(|_| EngineError::ResultTooLarge) +} + +impl BoundaryShapeArena { + pub(crate) fn clear(&mut self) { + self.records.clear(); + self.shape.clear(); + self.stable_ids.clear(); + } + + pub(crate) fn reserve(&mut self, glyph_capacity: usize) -> Result<(), EngineError> { + reserve_vec(&mut self.records, glyph_capacity.min(16))?; + self.shape.reserve(glyph_capacity)?; + reserve_vec(&mut self.stable_ids, glyph_capacity) + } + + pub(crate) fn record(&self, index: u32) -> Option { + usize::try_from(index) + .ok() + .and_then(|index| self.records.get(index)) + .copied() + } +} + +fn reserve_vec(values: &mut Vec, capacity: usize) -> Result<(), EngineError> { + if values.capacity() < capacity { + values + .try_reserve_exact(capacity.saturating_sub(values.len())) + .map_err(|_| EngineError::ResultTooLarge)?; + } + Ok(()) +} + +fn direction(style: ResolvedStyle, level: u8) -> u8 { + if style.bidi_override { + u8::from(style.direction == 2) + } else { + level & 1 + } +} + +fn forced_level(style: ResolvedStyle, level: u8) -> u8 { + let direction = direction(style, level); + if level & 1 == direction { + level + } else { + level.saturating_add(1) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + bidi::{DIRECTION_LTR, analyze}, + unicode::UnicodeAnalysis, + }; + + #[test] + fn intersects_style_script_and_bidi_and_skips_hard_breaks() { + let text: Vec = "abc\nאבג".encode_utf16().collect(); + let mut unicode = UnicodeAnalysis::default(); + unicode.analyze(&text).unwrap(); + let bidi = analyze(&text, DIRECTION_LTR).unwrap(); + let base = ResolvedStyle::default(); + let mut override_style = base; + override_style.direction = 1; + override_style.bidi_override = true; + let styles = [ + StyleSegment { + text_start: 0, + text_end: 4, + style: base, + }, + StyleSegment { + text_start: 4, + text_end: text.len() as u32, + style: override_style, + }, + ]; + let mut runs = ShapingRunArena::default(); + let style_storage = StyleArena::default(); + runs.reserve(16).unwrap(); + runs.build(&text, &styles, &style_storage, &unicode, &bidi) + .unwrap(); + assert_eq!( + runs.runs() + .iter() + .map(|run| (run.text_start, run.text_end, run.direction, run.bidi_level)) + .collect::>(), + vec![(0, 3, 0, 0), (4, 7, 0, 2)] + ); + } + + #[test] + fn paint_only_style_boundaries_do_not_split_shaping_runs() { + let text: Vec = "a b".encode_utf16().collect(); + let mut unicode = UnicodeAnalysis::default(); + unicode.analyze(&text).unwrap(); + let bidi = analyze(&text, DIRECTION_LTR).unwrap(); + let base = ResolvedStyle::default(); + let mut painted = base; + painted.material_id = 7; + painted.raster_pixel_ratio = 2.0; + painted.foreground_rgba = 0xff00ffff; + painted.decoration_flags = 1; + let styles = [ + StyleSegment { + text_start: 0, + text_end: 1, + style: painted, + }, + StyleSegment { + text_start: 1, + text_end: text.len() as u32, + style: base, + }, + ]; + let mut runs = ShapingRunArena::default(); + runs.build(&text, &styles, &StyleArena::default(), &unicode, &bidi) + .unwrap(); + + assert_eq!(runs.runs().len(), 1); + assert_eq!((runs.runs()[0].text_start, runs.runs()[0].text_end), (0, 3)); + } + + #[test] + fn metric_style_boundaries_still_split_shaping_runs() { + let text: Vec = "ab".encode_utf16().collect(); + let mut unicode = UnicodeAnalysis::default(); + unicode.analyze(&text).unwrap(); + let bidi = analyze(&text, DIRECTION_LTR).unwrap(); + let base = ResolvedStyle::default(); + let mut larger = base; + larger.font_size = 24.0; + let styles = [ + StyleSegment { + text_start: 0, + text_end: 1, + style: base, + }, + StyleSegment { + text_start: 1, + text_end: 2, + style: larger, + }, + ]; + let mut runs = ShapingRunArena::default(); + runs.build(&text, &styles, &StyleArena::default(), &unicode, &bidi) + .unwrap(); + + assert_eq!(runs.runs().len(), 2); + } + + #[test] + fn copies_and_rebases_one_contiguous_text_range_in_shaping_order() { + let source = ShapeArena { + runs: vec![ShapedRun { + source_run: 7, + binding_handle: 11, + font_handle: 13, + text_start: 0, + text_end: 4, + glyph_start: 0, + glyph_count: 4, + }], + glyph_ids: vec![30, 20, 10, 0], + clusters: vec![3, 2, 1, 0], + x_advances: vec![3, 2, 1, 0], + y_advances: vec![0; 4], + x_offsets: vec![0; 4], + y_offsets: vec![0; 4], + glyph_flags: vec![0, 2, 0, 0], + }; + let mut destination = ShapeArena::default(); + destination + .append_text_range_from(&source, 0, 5, 1, 3, 2) + .unwrap(); + assert_eq!(destination.glyph_ids, [20, 10]); + assert_eq!(destination.clusters, [4, 3]); + assert_eq!(destination.x_advances, [2, 1]); + assert_eq!(destination.glyph_flags, [2, 0]); + assert_eq!( + destination.runs, + [ShapedRun { + source_run: 5, + binding_handle: 11, + font_handle: 13, + text_start: 3, + text_end: 5, + glyph_start: 0, + glyph_count: 2, + }] + ); + } +} diff --git a/packages/text/rust/shaper/src/engine/stable_order.rs b/packages/text/rust/shaper/src/engine/stable_order.rs new file mode 100644 index 00000000..b181033e --- /dev/null +++ b/packages/text/rust/shaper/src/engine/stable_order.rs @@ -0,0 +1,689 @@ +//! Transactional fixed-chunk order storage for stable-indirect glyph records. + +use alloc::vec::Vec; + +pub const ORDER_CHUNK_RECORDS: u32 = 64; +const SCRATCH_NONE: u32 = u32::MAX; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct OrderEntry { + pub stable_id: u32, + pub record_slot: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ChunkState { + slot: u32, + len: u16, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PendingChunk { + pub slot: u32, + pub len: u16, + scratch_start: u32, +} + +impl PendingChunk { + pub const fn record_start(self) -> u32 { + self.slot * ORDER_CHUNK_RECORDS + } + + pub const fn changed(self) -> bool { + self.scratch_start != SCRATCH_NONE + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ChunkedOrderError { + AllocationFailed, + AlreadyPrepared, + NotPrepared, + ArithmeticOverflow, + InvalidIdentity, +} + +#[derive(Default)] +pub struct ChunkedOrder { + chunks: Vec, + entries: Vec, + free_chunks: Vec, + pending_chunks: Vec, + pending_entries: Vec, + pending_retired: Vec, + allocated_free_chunks: Vec, + next_chunk_slot: u32, + pending_next_chunk_slot: u32, + capacity_chunks: u32, + pending_capacity_chunks: u32, + pending_rebased: bool, + prepared: bool, +} + +impl ChunkedOrder { + pub fn prepare(&mut self, desired: &[OrderEntry]) -> Result<(), ChunkedOrderError> { + if self.prepared { + return Err(ChunkedOrderError::AlreadyPrepared); + } + let result = (|| { + validate_desired(desired)?; + self.pending_chunks.clear(); + self.pending_entries.clear(); + self.pending_retired.clear(); + self.allocated_free_chunks.clear(); + self.pending_next_chunk_slot = self.next_chunk_slot; + self.pending_capacity_chunks = self.capacity_chunks; + self.pending_rebased = false; + + let old_len = self + .chunks + .iter() + .try_fold(0_usize, |total, chunk| { + total.checked_add(usize::from(chunk.len)) + }) + .ok_or(ChunkedOrderError::ArithmeticOverflow)?; + let common_prefix = self.common_prefix(desired); + let common_suffix = self.common_suffix(desired, common_prefix, old_len); + + let mut prefix_chunk_count = 0_usize; + let mut prefix_entries = 0_usize; + while let Some(chunk) = self.chunks.get(prefix_chunk_count) { + let next = prefix_entries + usize::from(chunk.len); + if next > common_prefix { + break; + } + prefix_entries = next; + prefix_chunk_count += 1; + } + + let old_changed_end = old_len - common_suffix; + let mut suffix_chunk_start = self.chunks.len(); + let mut suffix_entries = 0_usize; + let mut chunk_start = old_len; + while suffix_chunk_start > prefix_chunk_count { + let candidate = self.chunks[suffix_chunk_start - 1]; + chunk_start -= usize::from(candidate.len); + if chunk_start < old_changed_end { + break; + } + suffix_chunk_start -= 1; + suffix_entries += usize::from(candidate.len); + } + + let desired_chunks = desired.len().div_ceil(ORDER_CHUNK_RECORDS as usize); + let pending_chunk_bound = self + .chunks + .len() + .checked_add(desired_chunks) + .and_then(|count| count.checked_add(2)) + .ok_or(ChunkedOrderError::ArithmeticOverflow)?; + reserve(&mut self.pending_chunks, pending_chunk_bound)?; + reserve(&mut self.pending_retired, self.chunks.len())?; + for chunk in &self.chunks[..prefix_chunk_count] { + self.pending_chunks.push(PendingChunk { + slot: chunk.slot, + len: chunk.len, + scratch_start: SCRATCH_NONE, + }); + } + + let rebuild_end = desired + .len() + .checked_sub(suffix_entries) + .ok_or(ChunkedOrderError::ArithmeticOverflow)?; + let rebuild = desired + .get(prefix_entries..rebuild_end) + .ok_or(ChunkedOrderError::InvalidIdentity)?; + let old_rebuild_len = suffix_chunk_start - prefix_chunk_count; + let required_chunks = rebuild.len().div_ceil(ORDER_CHUNK_RECORDS as usize); + let mut rebuilt_offset = 0_usize; + for index in 0..required_chunks { + let remaining = rebuild.len() - rebuilt_offset; + let remaining_chunks = required_chunks - index; + let len = remaining.div_ceil(remaining_chunks); + let next_offset = rebuilt_offset + len; + let entries = &rebuild[rebuilt_offset..next_offset]; + let slot = if index < old_rebuild_len { + self.chunks[prefix_chunk_count + index].slot + } else { + self.allocate_chunk()? + }; + self.push_pending_chunk(slot, entries)?; + rebuilt_offset = next_offset; + } + for index in required_chunks.min(old_rebuild_len)..old_rebuild_len { + let slot = self.chunks[prefix_chunk_count + index].slot; + reserve(&mut self.pending_retired, 1)?; + self.pending_retired.push(slot); + } + + for chunk in &self.chunks[suffix_chunk_start..] { + self.pending_chunks.push(PendingChunk { + slot: chunk.slot, + len: chunk.len, + scratch_start: SCRATCH_NONE, + }); + } + self.grow_capacity()?; + if self.pending_capacity_chunks != self.capacity_chunks { + self.mark_every_chunk_changed()?; + } + let required_entries = usize::try_from(self.pending_capacity_chunks) + .ok() + .and_then(|chunks| chunks.checked_mul(ORDER_CHUNK_RECORDS as usize)) + .ok_or(ChunkedOrderError::ArithmeticOverflow)?; + let additional_entries = required_entries.saturating_sub(self.entries.len()); + reserve(&mut self.entries, additional_entries)?; + let additional_chunks = self.pending_chunks.len().saturating_sub(self.chunks.len()); + reserve(&mut self.chunks, additional_chunks)?; + self.prepared = true; + Ok(()) + })(); + if result.is_err() { + self.abort(); + } + result + } + + pub fn pending_chunks(&self) -> Result<&[PendingChunk], ChunkedOrderError> { + if !self.prepared { + return Err(ChunkedOrderError::NotPrepared); + } + Ok(&self.pending_chunks) + } + + /// Replaces a fragmented pending order with dense full chunks in a fresh buffer generation. + pub fn rebase(&mut self, desired: &[OrderEntry]) -> Result<(), ChunkedOrderError> { + if !self.prepared { + return Err(ChunkedOrderError::NotPrepared); + } + validate_desired(desired)?; + self.pending_chunks.clear(); + self.pending_entries.clear(); + self.pending_retired.clear(); + for slot in self.allocated_free_chunks.drain(..) { + self.free_chunks.push(slot); + } + let required_chunks = desired.len().div_ceil(ORDER_CHUNK_RECORDS as usize); + reserve(&mut self.pending_chunks, required_chunks)?; + reserve(&mut self.pending_entries, desired.len())?; + self.pending_rebased = true; + for (slot, entries) in desired.chunks(ORDER_CHUNK_RECORDS as usize).enumerate() { + let slot = u32::try_from(slot).map_err(|_| ChunkedOrderError::ArithmeticOverflow)?; + self.push_pending_chunk(slot, entries)?; + } + self.pending_next_chunk_slot = + u32::try_from(required_chunks).map_err(|_| ChunkedOrderError::ArithmeticOverflow)?; + self.pending_capacity_chunks = 0; + self.grow_capacity()?; + let required_entries = usize::try_from(self.pending_capacity_chunks) + .ok() + .and_then(|chunks| chunks.checked_mul(ORDER_CHUNK_RECORDS as usize)) + .ok_or(ChunkedOrderError::ArithmeticOverflow)?; + let additional_entries = required_entries.saturating_sub(self.entries.len()); + reserve(&mut self.entries, additional_entries)?; + let additional_chunks = required_chunks.saturating_sub(self.chunks.len()); + reserve(&mut self.chunks, additional_chunks)?; + Ok(()) + } + + pub fn span_count(&self) -> Result { + if !self.prepared { + return Err(ChunkedOrderError::NotPrepared); + } + let mut spans = 0_u32; + let mut expected = None; + for chunk in &self.pending_chunks { + let start = chunk.record_start(); + if expected != Some(start) { + spans = spans + .checked_add(1) + .ok_or(ChunkedOrderError::ArithmeticOverflow)?; + } + expected = start.checked_add(u32::from(chunk.len)); + } + Ok(spans) + } + + pub fn rebased(&self) -> Result { + if !self.prepared { + return Err(ChunkedOrderError::NotPrepared); + } + Ok(self.pending_rebased) + } + + pub fn entries(&self, chunk: PendingChunk) -> Result<&[OrderEntry], ChunkedOrderError> { + if !self.prepared { + return Err(ChunkedOrderError::NotPrepared); + } + let len = usize::from(chunk.len); + if chunk.changed() { + let start = chunk.scratch_start as usize; + return self + .pending_entries + .get(start..start + len) + .ok_or(ChunkedOrderError::InvalidIdentity); + } + self.committed_entries(chunk.slot, chunk.len) + } + + pub fn retired_chunks(&self) -> Result<&[u32], ChunkedOrderError> { + if !self.prepared { + return Err(ChunkedOrderError::NotPrepared); + } + Ok(&self.pending_retired) + } + + /// Makes consumer-acknowledged chunk slots available to a later transaction. + /// + /// Retired chunks deliberately do not enter the free list during `commit`: the consumer may + /// still be reading an older publication. The owner calls this only after that publication's + /// retirement fence has been acknowledged. + pub fn reclaim_chunks(&mut self, chunks: &[u32]) -> Result<(), ChunkedOrderError> { + if self.prepared { + return Err(ChunkedOrderError::AlreadyPrepared); + } + for &slot in chunks { + if slot >= self.next_chunk_slot + || self.chunks.iter().any(|chunk| chunk.slot == slot) + || self.free_chunks.contains(&slot) + { + return Err(ChunkedOrderError::InvalidIdentity); + } + } + reserve(&mut self.free_chunks, chunks.len())?; + self.free_chunks.extend_from_slice(chunks); + Ok(()) + } + + pub fn capacity_records(&self) -> Result { + if !self.prepared { + return Err(ChunkedOrderError::NotPrepared); + } + self.pending_capacity_chunks + .checked_mul(ORDER_CHUNK_RECORDS) + .ok_or(ChunkedOrderError::ArithmeticOverflow) + } + + pub fn grew(&self) -> Result { + if !self.prepared { + return Err(ChunkedOrderError::NotPrepared); + } + Ok(self.pending_capacity_chunks != self.capacity_chunks) + } + + pub fn commit(&mut self) -> Result<(), ChunkedOrderError> { + if !self.prepared { + return Err(ChunkedOrderError::NotPrepared); + } + let required = usize::try_from(self.pending_capacity_chunks) + .ok() + .and_then(|chunks| chunks.checked_mul(ORDER_CHUNK_RECORDS as usize)) + .ok_or(ChunkedOrderError::ArithmeticOverflow)?; + self.entries.resize(required, OrderEntry::default()); + for chunk in self + .pending_chunks + .iter() + .copied() + .filter(|chunk| chunk.changed()) + { + let source_start = chunk.scratch_start as usize; + let destination_start = chunk.record_start() as usize; + let len = usize::from(chunk.len); + let source = self + .pending_entries + .get(source_start..source_start + len) + .ok_or(ChunkedOrderError::InvalidIdentity)?; + let destination = self + .entries + .get_mut(destination_start..destination_start + len) + .ok_or(ChunkedOrderError::InvalidIdentity)?; + destination.copy_from_slice(source); + } + self.chunks.clear(); + self.chunks + .extend(self.pending_chunks.iter().map(|chunk| ChunkState { + slot: chunk.slot, + len: chunk.len, + })); + self.next_chunk_slot = self.pending_next_chunk_slot; + self.capacity_chunks = self.pending_capacity_chunks; + if self.pending_rebased { + self.free_chunks.clear(); + } + self.pending_chunks.clear(); + self.pending_entries.clear(); + self.pending_retired.clear(); + self.allocated_free_chunks.clear(); + self.pending_rebased = false; + self.prepared = false; + Ok(()) + } + + pub fn abort(&mut self) { + for slot in self.allocated_free_chunks.drain(..) { + self.free_chunks.push(slot); + } + self.pending_chunks.clear(); + self.pending_entries.clear(); + self.pending_retired.clear(); + self.pending_rebased = false; + self.prepared = false; + } + + #[cfg(test)] + pub fn scratch_capacities(&self) -> [usize; 8] { + [ + self.chunks.capacity(), + self.entries.capacity(), + self.free_chunks.capacity(), + self.pending_chunks.capacity(), + self.pending_entries.capacity(), + self.pending_retired.capacity(), + self.allocated_free_chunks.capacity(), + usize::try_from(self.capacity_chunks).unwrap_or(usize::MAX), + ] + } + + fn common_prefix(&self, desired: &[OrderEntry]) -> usize { + let mut matched = 0_usize; + for chunk in &self.chunks { + let Ok(entries) = self.committed_entries(chunk.slot, chunk.len) else { + return matched; + }; + for entry in entries { + if desired.get(matched) != Some(entry) { + return matched; + } + matched += 1; + } + } + matched + } + + fn common_suffix(&self, desired: &[OrderEntry], prefix: usize, old_len: usize) -> usize { + let mut matched = 0_usize; + for chunk in self.chunks.iter().rev() { + let Ok(entries) = self.committed_entries(chunk.slot, chunk.len) else { + return matched; + }; + for entry in entries.iter().rev() { + if prefix + matched >= old_len.min(desired.len()) + || desired.get(desired.len() - 1 - matched) != Some(entry) + { + return matched; + } + matched += 1; + } + } + matched + } + + fn committed_entries(&self, slot: u32, len: u16) -> Result<&[OrderEntry], ChunkedOrderError> { + let start = usize::try_from(slot) + .ok() + .and_then(|slot| slot.checked_mul(ORDER_CHUNK_RECORDS as usize)) + .ok_or(ChunkedOrderError::ArithmeticOverflow)?; + self.entries + .get(start..start + usize::from(len)) + .ok_or(ChunkedOrderError::InvalidIdentity) + } + + fn allocate_chunk(&mut self) -> Result { + if let Some(slot) = self.free_chunks.pop() { + reserve(&mut self.allocated_free_chunks, 1)?; + self.allocated_free_chunks.push(slot); + return Ok(slot); + } + let slot = self.pending_next_chunk_slot; + self.pending_next_chunk_slot = self + .pending_next_chunk_slot + .checked_add(1) + .ok_or(ChunkedOrderError::ArithmeticOverflow)?; + Ok(slot) + } + + fn push_pending_chunk( + &mut self, + slot: u32, + entries: &[OrderEntry], + ) -> Result<(), ChunkedOrderError> { + let len = + u16::try_from(entries.len()).map_err(|_| ChunkedOrderError::ArithmeticOverflow)?; + let unchanged = !self.pending_rebased + && self + .chunks + .iter() + .find(|chunk| chunk.slot == slot && chunk.len == len) + .and_then(|chunk| self.committed_entries(chunk.slot, chunk.len).ok()) + == Some(entries); + let scratch_start = if unchanged { + SCRATCH_NONE + } else { + let start = u32::try_from(self.pending_entries.len()) + .map_err(|_| ChunkedOrderError::ArithmeticOverflow)?; + reserve(&mut self.pending_entries, entries.len())?; + self.pending_entries.extend_from_slice(entries); + start + }; + self.pending_chunks.push(PendingChunk { + slot, + len, + scratch_start, + }); + Ok(()) + } + + fn grow_capacity(&mut self) -> Result<(), ChunkedOrderError> { + if self.pending_next_chunk_slot == 0 { + self.pending_capacity_chunks = 0; + return Ok(()); + } + let mut capacity = self.pending_capacity_chunks.max(1); + while capacity < self.pending_next_chunk_slot { + capacity = capacity + .checked_mul(2) + .ok_or(ChunkedOrderError::ArithmeticOverflow)?; + } + self.pending_capacity_chunks = capacity; + Ok(()) + } + + fn mark_every_chunk_changed(&mut self) -> Result<(), ChunkedOrderError> { + for index in 0..self.pending_chunks.len() { + if self.pending_chunks[index].changed() { + continue; + } + let chunk = self.pending_chunks[index]; + let committed_start = usize::try_from(chunk.slot) + .ok() + .and_then(|slot| slot.checked_mul(ORDER_CHUNK_RECORDS as usize)) + .ok_or(ChunkedOrderError::ArithmeticOverflow)?; + let committed_end = committed_start + .checked_add(usize::from(chunk.len)) + .ok_or(ChunkedOrderError::ArithmeticOverflow)?; + if committed_end > self.entries.len() { + return Err(ChunkedOrderError::InvalidIdentity); + } + let start = u32::try_from(self.pending_entries.len()) + .map_err(|_| ChunkedOrderError::ArithmeticOverflow)?; + reserve(&mut self.pending_entries, usize::from(chunk.len))?; + let entries = &self.entries[committed_start..committed_end]; + self.pending_entries.extend_from_slice(entries); + self.pending_chunks[index].scratch_start = start; + } + Ok(()) + } +} + +fn validate_desired(desired: &[OrderEntry]) -> Result<(), ChunkedOrderError> { + if desired + .iter() + .any(|entry| entry.stable_id == 0 || entry.record_slot == u32::MAX) + { + return Err(ChunkedOrderError::InvalidIdentity); + } + Ok(()) +} + +fn reserve(values: &mut Vec, additional: usize) -> Result<(), ChunkedOrderError> { + values + .try_reserve(additional) + .map_err(|_| ChunkedOrderError::AllocationFailed) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + + #[test] + fn localized_insert_rewrites_one_chunk_until_it_splits() { + let mut order = ChunkedOrder::default(); + let initial: Vec<_> = (1..=64).map(entry).collect(); + order.prepare(&initial).unwrap(); + assert!(order.grew().unwrap()); + assert_eq!(order.capacity_records().unwrap(), 64); + assert_eq!(changed_chunks(&order), vec![0]); + order.commit().unwrap(); + + let mut inserted = initial.clone(); + inserted.insert( + 32, + OrderEntry { + stable_id: 100, + record_slot: 100, + }, + ); + order.prepare(&inserted).unwrap(); + assert_eq!(order.pending_chunks().unwrap().len(), 2); + assert_eq!(changed_chunks(&order), vec![0, 1]); + assert_eq!(flatten(&order), inserted); + } + + #[test] + fn insertion_at_a_chunk_boundary_preserves_both_neighbor_chunks() { + let mut order = ChunkedOrder::default(); + // Establish spare physical capacity so this assertion measures order reconciliation rather + // than the intentionally full rewrite required by an order-buffer resize. + let capacity_seed: Vec<_> = (1..=129).map(entry).collect(); + order.prepare(&capacity_seed).unwrap(); + order.commit().unwrap(); + let initial: Vec<_> = (1..=128).map(entry).collect(); + order.prepare(&initial).unwrap(); + order.commit().unwrap(); + + let mut inserted = initial.clone(); + inserted.insert( + 64, + OrderEntry { + stable_id: 200, + record_slot: 200, + }, + ); + order.prepare(&inserted).unwrap(); + assert_eq!(order.pending_chunks().unwrap().len(), 3); + assert_eq!(changed_chunks(&order).len(), 1); + assert_eq!(flatten(&order), inserted); + } + + #[test] + fn deletion_retires_unused_chunks_and_abort_preserves_committed_order() { + let mut order = ChunkedOrder::default(); + let initial: Vec<_> = (1..=128).map(entry).collect(); + order.prepare(&initial).unwrap(); + order.commit().unwrap(); + + let shortened = &initial[..32]; + order.prepare(shortened).unwrap(); + assert_eq!(order.retired_chunks().unwrap().len(), 1); + order.abort(); + order.prepare(&initial).unwrap(); + assert!(changed_chunks(&order).is_empty()); + assert_eq!(flatten(&order), initial); + } + + #[test] + fn retired_chunks_are_reused_only_after_explicit_reclamation() { + let mut order = ChunkedOrder::default(); + let initial: Vec<_> = (1..=128).map(entry).collect(); + order.prepare(&initial).unwrap(); + order.commit().unwrap(); + + order.prepare(&initial[..32]).unwrap(); + let retired = order.retired_chunks().unwrap().to_vec(); + assert_eq!(retired.len(), 1); + order.commit().unwrap(); + + let expanded: Vec<_> = (1..=96).map(entry).collect(); + order.prepare(&expanded).unwrap(); + let allocated_before_ack = order.pending_chunks().unwrap()[1].slot; + order.abort(); + assert_ne!(allocated_before_ack, retired[0]); + + order.reclaim_chunks(&retired).unwrap(); + order.prepare(&expanded).unwrap(); + assert_eq!(order.pending_chunks().unwrap()[1].slot, retired[0]); + } + + #[test] + fn arbitrary_reorder_is_correct_even_when_it_rewrites_every_chunk() { + let mut order = ChunkedOrder::default(); + let initial: Vec<_> = (1..=130).map(entry).collect(); + order.prepare(&initial).unwrap(); + order.commit().unwrap(); + let mut reversed = initial.clone(); + reversed.reverse(); + order.prepare(&reversed).unwrap(); + assert_eq!(flatten(&order), reversed); + assert_eq!(changed_chunks(&order).len(), 3); + } + + #[test] + fn rebase_restores_one_dense_span_transactionally() { + let mut order = ChunkedOrder::default(); + let initial: Vec<_> = (1..=128).map(entry).collect(); + order.prepare(&initial).unwrap(); + order.commit().unwrap(); + let mut inserted = initial.clone(); + inserted.insert( + 64, + OrderEntry { + stable_id: 200, + record_slot: 200, + }, + ); + order.prepare(&inserted).unwrap(); + assert!(order.span_count().unwrap() > 1); + order.rebase(&inserted).unwrap(); + assert!(order.rebased().unwrap()); + assert_eq!(order.span_count().unwrap(), 1); + assert_eq!(flatten(&order), inserted); + } + + fn entry(stable_id: u32) -> OrderEntry { + OrderEntry { + stable_id, + record_slot: stable_id - 1, + } + } + + fn changed_chunks(order: &ChunkedOrder) -> Vec { + order + .pending_chunks() + .unwrap() + .iter() + .copied() + .filter(|chunk| chunk.changed()) + .map(|chunk| chunk.slot) + .collect() + } + + fn flatten(order: &ChunkedOrder) -> Vec { + let mut entries = Vec::new(); + for chunk in order.pending_chunks().unwrap().iter().copied() { + entries.extend_from_slice(order.entries(chunk).unwrap()); + } + entries + } +} diff --git a/packages/text/rust/shaper/src/engine/stable_plan.rs b/packages/text/rust/shaper/src/engine/stable_plan.rs new file mode 100644 index 00000000..09b2f7ad --- /dev/null +++ b/packages/text/rust/shaper/src/engine/stable_plan.rs @@ -0,0 +1,2353 @@ +//! Retained stable-indirect physical storage and chunked logical-order plan compilation. +//! +//! Semantic identities retain physical record slots across edits. Logical order is a separate +//! fixed-chunk `u32` index buffer, so an insertion writes one new physical record and only the +//! affected order chunks. Deleted storage remains quarantined until the renderer acknowledges the +//! publication fence that made it unreachable. + +use alloc::vec::Vec; +use core::mem; + +use super::{ + identity_index::IdentitySet, + plan_draw::{GlyphDraw, push_glyph_draw}, + plan_input::{ + draw_fields_compatible, draw_span_compatible, indexed_span_bounds, span_bounds, + validate_glyph, validate_input, + }, + plan_packing::{ + MAX_PHYSICAL_BUFFERS, PendingAllocation, PhysicalBufferState, RangeJob, RecordRange, + align_record_range, align_up, apply_writes, buffer_record_alignment, + coalesce_buffer_ranges, collect_range_jobs, execute_run, grown_capacity, + push_pending_allocation, record_alignment, take_allocation, + }, + policy::{ + ALLOCATION_STABLE_INDIRECT, BATCH_MATERIAL, BATCH_TRANSFORM, BUFFER_USAGE_COPY_DST, + BUFFER_USAGE_STORAGE, BufferId, BufferSchema, CapabilitySetId, ScalarType, TechniqueId, + ValidatedPolicy, + }, + render_plan::{ + BUFFER_STABLE_INDIRECT, BufferRecord, DrawRecord, PATCH_ALLOCATE_OR_RESIZE, PATCH_WRITE, + POLICY_BUFFER_ORDER, PatchRecord, PrimitiveRecord, RESOURCE_ACTION_CREATE, + RESOURCE_ACTION_RETAIN, RETIRE_BUFFER, RETIRE_RESOURCE, RETIRE_SLOT_RANGE, RenderPlanView, + ResourceRecord, RetirementRecord, + }, + stable_order::{ + ChunkedOrder, ChunkedOrderError, ORDER_CHUNK_RECORDS, OrderEntry, PendingChunk, + }, + stable_pool::{SlotIdentity, StablePoolError, StableSlotPool}, +}; + +pub use super::plan_error::PlanError as StablePlanError; + +pub use super::plan_input::{PlanGlyph as StableGlyph, PlanInput as StablePlanInput}; + +const NONE: u32 = u32::MAX; + +impl From for StablePlanError { + fn from(error: StablePoolError) -> Self { + match error { + StablePoolError::AllocationFailed => Self::AllocationFailed, + StablePoolError::AlreadyPrepared => Self::AlreadyPrepared, + StablePoolError::NotPrepared => Self::NotPrepared, + StablePoolError::InvalidIdentity => Self::InvalidIdentity, + StablePoolError::DuplicateIdentity => Self::DuplicateIdentity, + StablePoolError::IdentifierExhausted => Self::IdentifierExhausted, + StablePoolError::ArithmeticOverflow => Self::ArithmeticOverflow, + } + } +} + +impl From for StablePlanError { + fn from(error: ChunkedOrderError) -> Self { + match error { + ChunkedOrderError::AllocationFailed => Self::AllocationFailed, + ChunkedOrderError::AlreadyPrepared => Self::AlreadyPrepared, + ChunkedOrderError::NotPrepared => Self::NotPrepared, + ChunkedOrderError::ArithmeticOverflow => Self::ArithmeticOverflow, + ChunkedOrderError::InvalidIdentity => Self::InvalidIdentity, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct BatchKey { + technique: TechniqueId, + program_variant: u16, + program_id: u32, + resource_id: u32, + resource_generation: u32, + resource_kind: u16, + resource_reference: u32, + material_id: u32, + clip_id: u32, + depth_key: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct QuarantinedChunk { + slot: u32, + after_publication_generation: u32, +} + +struct StableBatch { + key: BatchKey, + slots: StableSlotPool, + order: ChunkedOrder, + buffers: Vec, + spare_buffers: Vec, + order_buffer: Option, + quarantined_chunks: Vec, + pending_retired_chunks: Vec, + reclaim_scratch: Vec, + active: bool, +} + +impl StableBatch { + fn new(key: BatchKey) -> Self { + Self { + key, + slots: StableSlotPool::default(), + order: ChunkedOrder::default(), + buffers: Vec::new(), + spare_buffers: Vec::new(), + order_buffer: None, + quarantined_chunks: Vec::new(), + pending_retired_chunks: Vec::new(), + reclaim_scratch: Vec::new(), + active: false, + } + } + + fn acknowledge(&mut self, through_generation: u32) -> Result<(), StablePlanError> { + self.slots.acknowledge(through_generation)?; + self.reclaim_scratch.clear(); + let count = self + .quarantined_chunks + .iter() + .filter(|entry| entry.after_publication_generation <= through_generation) + .count(); + reserve(&mut self.reclaim_scratch, count)?; + self.reclaim_scratch.extend( + self.quarantined_chunks + .iter() + .filter(|entry| entry.after_publication_generation <= through_generation) + .map(|entry| entry.slot), + ); + self.order.reclaim_chunks(&self.reclaim_scratch)?; + self.quarantined_chunks + .retain(|entry| entry.after_publication_generation > through_generation); + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct PendingBatch { + batch_index: u32, + item_start: u32, + item_count: u32, + cursor: u32, + capacity: u32, + buffer_start: u32, + buffer_count: u16, + buffer_ids: [u32; MAX_PHYSICAL_BUFFERS], + buffer_generations: [u32; MAX_PHYSICAL_BUFFERS], + order_buffer_id: u32, + order_buffer_generation: u32, + order_capacity: u32, +} + +impl PendingBatch { + fn new(batch_index: u32) -> Self { + Self { + batch_index, + item_start: 0, + item_count: 0, + cursor: 0, + capacity: 0, + buffer_start: 0, + buffer_count: 0, + buffer_ids: [0; MAX_PHYSICAL_BUFFERS], + buffer_generations: [0; MAX_PHYSICAL_BUFFERS], + order_buffer_id: 0, + order_buffer_generation: 0, + order_capacity: 0, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct SlotWrite { + slot: u32, + input_index: u32, + changed: bool, +} + +#[derive(Clone, Copy)] +struct PrepareContext<'a> { + policy: &'a ValidatedPolicy, + capability_set: CapabilitySetId, + capability: &'a super::policy::CapabilitySet, + input: StablePlanInput<'a>, + checkpoint: bool, + publication_generation: u32, +} + +#[derive(Default)] +pub struct StablePlanCompiler { + batches: Vec, + committed_batch_count: usize, + pending_batches: Vec, + batch_pending_indices: Vec, + input_batches: Vec, + input_slots: Vec, + input_order_records: Vec, + batch_input_indices: Vec, + batch_identities: Vec, + order_entries: Vec, + order_chunk_scratch: Vec, + slot_writes: Vec, + changed_ranges: Vec, + buffer_ranges: [Vec; MAX_PHYSICAL_BUFFERS], + range_jobs: Vec, + identity_set: IdentitySet, + pending_allocations: Vec, + resources: Vec, + plan_buffers: Vec, + primitives: Vec, + draws: Vec, + live_primitives: Vec, + live_draws: Vec, + patches: Vec, + retirements: Vec, + payload: Vec, + next_buffer_id: u32, + pending_next_buffer_id: u32, + pending_publication_generation: u32, + publish_bindings: bool, + prepared: bool, +} + +impl StablePlanCompiler { + pub(crate) fn with_buffer_id_floor(last_assigned_id: u32) -> Self { + Self { + next_buffer_id: last_assigned_id, + pending_next_buffer_id: last_assigned_id, + ..Self::default() + } + } + + #[allow(clippy::too_many_arguments)] + pub fn prepare( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + input: StablePlanInput<'_>, + checkpoint: bool, + publication_generation: u32, + acknowledged_publication_generation: u32, + ) -> Result<(), StablePlanError> { + self.prepare_with_strategy_filter( + policy, + capability_set, + input, + checkpoint, + publication_generation, + acknowledged_publication_generation, + true, + ) + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn prepare_filtered( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + input: StablePlanInput<'_>, + checkpoint: bool, + publication_generation: u32, + acknowledged_publication_generation: u32, + ) -> Result<(), StablePlanError> { + self.prepare_with_strategy_filter( + policy, + capability_set, + input, + checkpoint, + publication_generation, + acknowledged_publication_generation, + false, + ) + } + + #[allow(clippy::too_many_arguments)] + fn prepare_with_strategy_filter( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + input: StablePlanInput<'_>, + checkpoint: bool, + publication_generation: u32, + acknowledged_publication_generation: u32, + strict_strategy: bool, + ) -> Result<(), StablePlanError> { + if self.prepared { + return Err(StablePlanError::AlreadyPrepared); + } + if publication_generation == 0 + || acknowledged_publication_generation >= publication_generation + { + return Err(StablePlanError::InvalidIdentity); + } + self.committed_batch_count = self.batches.len(); + let result = self.prepare_inner( + policy, + capability_set, + input, + checkpoint, + publication_generation, + acknowledged_publication_generation, + strict_strategy, + ); + if result.is_err() { + self.abort(); + } + result + } + + #[allow(clippy::too_many_arguments)] + fn prepare_inner( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + input: StablePlanInput<'_>, + checkpoint: bool, + publication_generation: u32, + acknowledged_publication_generation: u32, + strict_strategy: bool, + ) -> Result<(), StablePlanError> { + let capability = policy + .capability_set(capability_set) + .ok_or(StablePlanError::CapabilitySetMissing)?; + validate_input(input)?; + for batch in &mut self.batches { + // GPU completion is external monotonic state, not part of the publication transaction. + // Reclamation therefore intentionally survives a later prepare failure or abort. + batch.acknowledge(acknowledged_publication_generation)?; + } + self.reset_pending(); + self.pending_publication_generation = publication_generation; + self.identity_set.prepare(input.glyphs.len())?; + reserve(&mut self.input_batches, input.glyphs.len())?; + reserve(&mut self.input_slots, input.glyphs.len())?; + reserve(&mut self.input_order_records, input.glyphs.len())?; + self.input_batches.resize(input.glyphs.len(), NONE); + self.input_batches.fill(NONE); + self.input_slots.resize(input.glyphs.len(), 0); + self.input_order_records.resize(input.glyphs.len(), 0); + self.batch_pending_indices.resize(self.batches.len(), NONE); + + for (input_index, glyph) in input.glyphs.iter().copied().enumerate() { + validate_glyph(glyph)?; + if !self.identity_set.insert(glyph.stable_id) { + return Err(StablePlanError::DuplicateIdentity); + } + let program = policy + .program(capability_set, glyph.technique, glyph.program_variant) + .ok_or(StablePlanError::ProgramMissing)?; + if program.allocation_strategy != ALLOCATION_STABLE_INDIRECT { + if strict_strategy { + return Err(StablePlanError::UnsupportedStrategy); + } + continue; + } + let resource_bit = 1_u32 + .checked_shl(u32::from(glyph.resource_kind - 1)) + .ok_or(StablePlanError::InvalidResource)?; + if program.resource_kind_mask & resource_bit == 0 { + return Err(StablePlanError::InvalidResource); + } + let key = BatchKey { + technique: glyph.technique, + program_variant: glyph.program_variant, + program_id: program.id.0, + resource_id: glyph.resource_id, + resource_generation: glyph.resource_generation, + resource_kind: glyph.resource_kind, + resource_reference: glyph.resource_reference, + material_id: if program.storage_key_mask & BATCH_MATERIAL != 0 { + glyph.material_id + } else { + 0 + }, + clip_id: if program.storage_key_mask & super::policy::BATCH_CLIP != 0 { + glyph.clip_id + } else { + 0 + }, + depth_key: if program.storage_key_mask & super::policy::BATCH_DEPTH != 0 { + glyph.depth_key + } else { + 0 + }, + }; + let batch_index = match self.batches.iter().position(|batch| batch.key == key) { + Some(index) => index, + None => { + reserve(&mut self.batches, 1)?; + self.batches.push(StableBatch::new(key)); + reserve(&mut self.batch_pending_indices, 1)?; + self.batch_pending_indices.push(NONE); + self.batches.len() - 1 + } + }; + let pending_index = if self.batch_pending_indices[batch_index] == NONE { + reserve(&mut self.pending_batches, 1)?; + let index = self.pending_batches.len(); + self.pending_batches.push(PendingBatch::new( + u32::try_from(batch_index).map_err(|_| StablePlanError::ArithmeticOverflow)?, + )); + self.batch_pending_indices[batch_index] = + u32::try_from(index).map_err(|_| StablePlanError::ArithmeticOverflow)?; + index + } else { + self.batch_pending_indices[batch_index] as usize + }; + self.pending_batches[pending_index].item_count = self.pending_batches[pending_index] + .item_count + .checked_add(1) + .ok_or(StablePlanError::ArithmeticOverflow)?; + self.input_batches[input_index] = + u32::try_from(pending_index).map_err(|_| StablePlanError::ArithmeticOverflow)?; + } + + self.layout_batch_inputs(input)?; + let context = PrepareContext { + policy, + capability_set, + capability, + input, + checkpoint, + publication_generation, + }; + self.pending_next_buffer_id = self.next_buffer_id; + for batch_index in 0..self.batches.len() { + self.prepare_batch_identity( + batch_index, + publication_generation, + capability.fragmentation_budget, + )?; + if self.batch_pending_indices[batch_index] != NONE { + let pending_index = self.batch_pending_indices[batch_index] as usize; + self.prepare_batch_storage(context, pending_index)?; + } else if self.batches[batch_index].active { + self.prepare_removed_batch(batch_index, publication_generation)?; + } + } + self.compile_bindings(context)?; + self.prepared = true; + Ok(()) + } + + pub(crate) fn has_state(&self) -> bool { + self.batches.iter().any(|batch| { + batch.active + || batch.slots.has_quarantined_slots() + || !batch.quarantined_chunks.is_empty() + }) + } + + pub(crate) fn publishes_bindings(&self) -> bool { + self.publish_bindings + } + + pub fn plan_view( + &self, + policy_handle: u32, + capability_set: CapabilitySetId, + policy_fingerprint: u64, + ) -> Result, StablePlanError> { + self.plan_view_internal(policy_handle, capability_set, policy_fingerprint, false) + } + + pub(crate) fn plan_view_forced( + &self, + policy_handle: u32, + capability_set: CapabilitySetId, + policy_fingerprint: u64, + ) -> Result, StablePlanError> { + self.plan_view_internal(policy_handle, capability_set, policy_fingerprint, true) + } + + fn plan_view_internal( + &self, + policy_handle: u32, + capability_set: CapabilitySetId, + policy_fingerprint: u64, + force_bindings: bool, + ) -> Result, StablePlanError> { + if !self.prepared { + return Err(StablePlanError::NotPrepared); + } + Ok(RenderPlanView { + policy_handle, + capability_set: capability_set.0, + policy_fingerprint, + resources: if self.publish_bindings || force_bindings { + &self.resources + } else { + &[] + }, + buffers: if self.publish_bindings || force_bindings { + &self.plan_buffers + } else { + &[] + }, + patches: &self.patches, + retirements: &self.retirements, + primitives: if self.publish_bindings || force_bindings { + &self.primitives + } else { + &[] + }, + draws: if self.publish_bindings || force_bindings { + &self.draws + } else { + &[] + }, + payload: &self.payload, + ..RenderPlanView::default() + }) + } + + pub fn commit(&mut self) -> Result<(), StablePlanError> { + if !self.prepared { + return Err(StablePlanError::NotPrepared); + } + for batch_index in 0..self.batches.len() { + let pending_index = self.batch_pending_indices[batch_index]; + self.commit_batch_storage(batch_index, pending_index)?; + let batch = &mut self.batches[batch_index]; + batch.slots.commit()?; + let order_rebased = batch.order.rebased()?; + if order_rebased { + batch.quarantined_chunks.clear(); + } + batch + .quarantined_chunks + .extend( + batch + .pending_retired_chunks + .iter() + .map(|&slot| QuarantinedChunk { + slot, + after_publication_generation: self.pending_publication_generation, + }), + ); + batch.pending_retired_chunks.clear(); + batch.order.commit()?; + batch.active = pending_index != NONE; + } + mem::swap(&mut self.live_primitives, &mut self.primitives); + self.primitives.clear(); + mem::swap(&mut self.live_draws, &mut self.draws); + self.draws.clear(); + self.next_buffer_id = self.pending_next_buffer_id; + self.prepared = false; + Ok(()) + } + + pub fn abort(&mut self) { + for batch in &mut self.batches { + batch.slots.abort(); + batch.order.abort(); + batch.pending_retired_chunks.clear(); + } + self.batches.truncate(self.committed_batch_count); + self.pending_allocations.clear(); + self.prepared = false; + } + + pub fn buffer_bytes(&self, id: u32) -> Option<&[u8]> { + self.batches.iter().find_map(|batch| { + batch + .buffers + .iter() + .find(|buffer| buffer.id == id) + .or_else(|| batch.order_buffer.as_ref().filter(|buffer| buffer.id == id)) + .map(|buffer| buffer.bytes.as_slice()) + }) + } + + fn reset_pending(&mut self) { + self.pending_batches.clear(); + self.batch_pending_indices.clear(); + self.batch_input_indices.clear(); + self.batch_identities.clear(); + self.order_entries.clear(); + self.order_chunk_scratch.clear(); + self.slot_writes.clear(); + self.changed_ranges.clear(); + self.pending_allocations.clear(); + self.resources.clear(); + self.plan_buffers.clear(); + self.primitives.clear(); + self.draws.clear(); + self.patches.clear(); + self.retirements.clear(); + self.payload.clear(); + self.publish_bindings = false; + } + + fn layout_batch_inputs(&mut self, input: StablePlanInput<'_>) -> Result<(), StablePlanError> { + reserve(&mut self.batch_input_indices, input.glyphs.len())?; + reserve(&mut self.batch_identities, input.glyphs.len())?; + let item_count = self + .pending_batches + .iter() + .try_fold(0_usize, |total, pending| { + total.checked_add(pending.item_count as usize) + }) + .ok_or(StablePlanError::ArithmeticOverflow)?; + self.batch_input_indices.resize(item_count, 0); + self.batch_identities.resize( + item_count, + SlotIdentity { + stable_id: 0, + content_revision: 0, + }, + ); + let mut cursor = 0_u32; + for pending in &mut self.pending_batches { + pending.item_start = cursor; + pending.cursor = cursor; + cursor = cursor + .checked_add(pending.item_count) + .ok_or(StablePlanError::ArithmeticOverflow)?; + } + for (input_index, glyph) in input.glyphs.iter().copied().enumerate() { + if self.input_batches[input_index] == NONE { + continue; + } + let pending_index = self.input_batches[input_index] as usize; + let destination = self.pending_batches[pending_index].cursor as usize; + self.batch_input_indices[destination] = + u32::try_from(input_index).map_err(|_| StablePlanError::ArithmeticOverflow)?; + self.batch_identities[destination] = SlotIdentity { + stable_id: glyph.stable_id, + content_revision: glyph.content_revision, + }; + self.pending_batches[pending_index].cursor = self.pending_batches[pending_index] + .cursor + .checked_add(1) + .ok_or(StablePlanError::ArithmeticOverflow)?; + } + Ok(()) + } + + fn prepare_batch_identity( + &mut self, + batch_index: usize, + publication_generation: u32, + fragmentation_budget: u16, + ) -> Result<(), StablePlanError> { + let pending_index = self.batch_pending_indices[batch_index]; + let identities = if pending_index == NONE { + &[][..] + } else { + let pending = self.pending_batches[pending_index as usize]; + self.batch_identities + .get(range(pending.item_start, pending.item_count)?) + .ok_or(StablePlanError::InvalidIdentity)? + }; + let batch = &mut self.batches[batch_index]; + if !batch + .slots + .prepare_retained(identities, publication_generation)? + { + batch.slots.prepare(identities, publication_generation)?; + } + let assignments = batch.slots.assignments()?; + self.order_entries.clear(); + reserve(&mut self.order_entries, assignments.len())?; + for assignment in assignments { + self.order_entries.push(OrderEntry { + stable_id: assignment.stable_id, + record_slot: assignment.slot, + }); + } + batch.order.prepare(&self.order_entries)?; + if batch.order.span_count()? > u32::from(fragmentation_budget) { + batch.order.rebase(&self.order_entries)?; + } + batch.pending_retired_chunks.clear(); + reserve( + &mut batch.pending_retired_chunks, + batch.order.retired_chunks()?.len(), + )?; + batch + .pending_retired_chunks + .extend_from_slice(batch.order.retired_chunks()?); + reserve( + &mut batch.quarantined_chunks, + batch.pending_retired_chunks.len(), + )?; + + if pending_index != NONE { + let pending = self.pending_batches[pending_index as usize]; + for (offset, assignment) in assignments.iter().enumerate() { + let item = pending.item_start as usize + offset; + let input_index = self.batch_input_indices[item] as usize; + self.input_slots[input_index] = assignment.slot; + } + let mut item_offset = 0_usize; + for chunk in batch.order.pending_chunks()?.iter().copied() { + for offset in 0..usize::from(chunk.len) { + let input_index = + self.batch_input_indices[pending.item_start as usize + item_offset]; + self.input_order_records[input_index as usize] = chunk + .record_start() + .checked_add(offset as u32) + .ok_or(StablePlanError::ArithmeticOverflow)?; + item_offset += 1; + } + } + if item_offset != assignments.len() { + return Err(StablePlanError::InvalidIdentity); + } + } + Ok(()) + } + + fn prepare_batch_storage( + &mut self, + context: PrepareContext<'_>, + pending_index: usize, + ) -> Result<(), StablePlanError> { + let pending = self.pending_batches[pending_index]; + let batch_index = pending.batch_index as usize; + let key = self.batches[batch_index].key; + let program = context + .policy + .program(context.capability_set, key.technique, key.program_variant) + .ok_or(StablePlanError::ProgramMissing)?; + let required_slots = self.batches[batch_index].slots.required_slots()?; + let previous_capacity = self.batches[batch_index] + .buffers + .first() + .map_or(0, |buffer| buffer.capacity); + let capacity = align_up( + if previous_capacity >= required_slots { + previous_capacity + } else { + grown_capacity(previous_capacity.max(1), required_slots)? + }, + record_alignment(program, context.capability.update_alignment)?, + )?; + for schema in &program.buffers { + if capacity + .checked_mul(u32::from(schema.stride)) + .ok_or(StablePlanError::ArithmeticOverflow)? + > context.capability.max_buffer_bytes + { + return Err(StablePlanError::CapacityExceeded); + } + } + self.pending_batches[pending_index].capacity = capacity; + let resized = self.batches[batch_index].buffers.is_empty() || capacity != previous_capacity; + let replace = context.checkpoint || resized; + reserve( + &mut self.batches[batch_index].spare_buffers, + program.buffers.len(), + )?; + for (schema_index, schema) in program.buffers.iter().copied().enumerate() { + let previous = self.batches[batch_index] + .buffers + .get(schema_index) + .map(|buffer| (buffer.id, buffer.generation, buffer.bytes.len())); + let (id, generation) = self.next_buffer_identity(previous, resized)?; + self.pending_batches[pending_index].buffer_ids[schema_index] = id; + self.pending_batches[pending_index].buffer_generations[schema_index] = generation; + if replace { + push_pending_allocation( + &mut self.pending_allocations, + id, + generation, + key.program_id, + schema, + capacity, + )?; + reserve(&mut self.patches, 1)?; + self.patches.push(PatchRecord { + opcode: PATCH_ALLOCATE_OR_RESIZE, + buffer_id: id, + buffer_generation: generation, + byte_length: capacity + .checked_mul(u32::from(schema.stride)) + .ok_or(StablePlanError::ArithmeticOverflow)?, + ..PatchRecord::default() + }); + if let Some((old_id, old_generation, old_length)) = previous + && (generation != old_generation || id != old_id) + { + self.retire_buffer( + old_id, + old_generation, + old_length, + context.publication_generation, + )?; + } + } + } + self.pending_batches[pending_index].buffer_count = u16::try_from(program.buffers.len()) + .map_err(|_| StablePlanError::ArithmeticOverflow)?; + self.write_physical_records(context, pending_index, program, replace)?; + self.prepare_order_buffer(context, pending_index)?; + self.retire_removed_slots(context, pending_index)?; + Ok(()) + } + + fn write_physical_records( + &mut self, + context: PrepareContext<'_>, + pending_index: usize, + program: &super::policy::ProgramDescriptor, + replace: bool, + ) -> Result<(), StablePlanError> { + let pending = self.pending_batches[pending_index]; + let batch = &self.batches[pending.batch_index as usize]; + let assignments = batch.slots.assignments()?; + self.slot_writes.clear(); + reserve(&mut self.slot_writes, assignments.len())?; + for (offset, assignment) in assignments.iter().copied().enumerate() { + self.slot_writes.push(SlotWrite { + slot: assignment.slot, + input_index: self.batch_input_indices[pending.item_start as usize + offset], + changed: assignment.changed, + }); + } + self.slot_writes.sort_unstable_by_key(|write| write.slot); + self.changed_ranges.clear(); + reserve(&mut self.changed_ranges, self.slot_writes.len())?; + for write in &self.slot_writes { + if replace || write.changed { + self.changed_ranges.push(RecordRange { + start: write.slot, + end: write + .slot + .checked_add(1) + .ok_or(StablePlanError::ArithmeticOverflow)?, + }); + } + } + let required_slots = batch.slots.required_slots()?; + for ranges in &mut self.buffer_ranges { + ranges.clear(); + } + for changed in self.changed_ranges.iter().copied() { + let active_buffers = stable_active_buffers( + context.policy, + context.capability_set, + program, + context.input, + &self.slot_writes, + changed, + replace, + )?; + for (schema_index, schema) in program.buffers.iter().enumerate() { + if active_buffers & (1 << schema_index) == 0 { + continue; + } + reserve(&mut self.buffer_ranges[schema_index], 1)?; + self.buffer_ranges[schema_index].push(align_record_range( + changed, + buffer_record_alignment(schema, context.capability.update_alignment), + )?); + } + } + for (schema_index, schema) in program.buffers.iter().enumerate() { + coalesce_buffer_ranges( + &mut self.buffer_ranges[schema_index], + u32::from(schema.stride), + context.capability, + required_slots, + )?; + } + collect_range_jobs( + &mut self.range_jobs, + &self.buffer_ranges, + program.buffers.len(), + )?; + for job_index in 0..self.range_jobs.len() { + let RangeJob { + range: changed, + active_buffers, + } = self.range_jobs[job_index]; + let count = changed.end - changed.start; + let mut payload_starts = [0_usize; MAX_PHYSICAL_BUFFERS]; + for (schema_index, schema) in program.buffers.iter().enumerate() { + if active_buffers & (1 << schema_index) == 0 { + continue; + } + let byte_count = count as usize * schema.stride(); + let payload_start = self.payload.len(); + reserve(&mut self.payload, byte_count)?; + self.payload.resize(payload_start + byte_count, 0); + payload_starts[schema_index] = payload_start; + if !replace { + let old = self.batches[pending.batch_index as usize] + .buffers + .get(schema_index) + .ok_or(StablePlanError::InvalidIdentity)?; + let source_start = changed.start as usize * schema.stride(); + let source_end = source_start + byte_count; + let source = old + .bytes + .get(source_start..source_end) + .ok_or(StablePlanError::InvalidIdentity)?; + self.payload[payload_start..payload_start + byte_count].copy_from_slice(source); + } + } + let mut write_index = self + .slot_writes + .partition_point(|write| write.slot < changed.start); + while write_index < self.slot_writes.len() + && self.slot_writes[write_index].slot < changed.end + { + if !replace && !self.slot_writes[write_index].changed { + write_index += 1; + continue; + } + let first = self.slot_writes[write_index]; + let mut end = write_index + 1; + while end < self.slot_writes.len() + && self.slot_writes[end].slot == first.slot + (end - write_index) as u32 + && self.slot_writes[end].input_index + == first.input_index + (end - write_index) as u32 + && (replace || self.slot_writes[end].changed) + && self.slot_writes[end].slot < changed.end + { + end += 1; + } + execute_run( + context.policy, + context.capability_set, + program, + context.input, + first.input_index as usize, + (end - write_index) as u32, + first.slot - changed.start, + &mut self.payload, + &payload_starts, + count, + active_buffers, + )?; + write_index = end; + } + for (schema_index, schema) in program.buffers.iter().enumerate() { + if active_buffers & (1 << schema_index) == 0 { + continue; + } + reserve(&mut self.patches, 1)?; + self.patches.push(PatchRecord { + opcode: PATCH_WRITE, + buffer_id: pending.buffer_ids[schema_index], + buffer_generation: pending.buffer_generations[schema_index], + destination_offset: changed + .start + .checked_mul(u32::from(schema.stride)) + .ok_or(StablePlanError::ArithmeticOverflow)?, + byte_length: count + .checked_mul(u32::from(schema.stride)) + .ok_or(StablePlanError::ArithmeticOverflow)?, + payload_start: u32::try_from(payload_starts[schema_index]) + .map_err(|_| StablePlanError::ArithmeticOverflow)?, + ..PatchRecord::default() + }); + } + } + Ok(()) + } + + fn prepare_order_buffer( + &mut self, + context: PrepareContext<'_>, + pending_index: usize, + ) -> Result<(), StablePlanError> { + let pending = self.pending_batches[pending_index]; + let batch_index = pending.batch_index as usize; + let order_capacity = self.batches[batch_index].order.capacity_records()?; + if order_capacity + .checked_mul(4) + .ok_or(StablePlanError::ArithmeticOverflow)? + > context.capability.max_buffer_bytes + { + return Err(StablePlanError::CapacityExceeded); + } + let previous = self.batches[batch_index] + .order_buffer + .as_ref() + .map(|buffer| { + ( + buffer.id, + buffer.generation, + buffer.capacity, + buffer.bytes.len(), + ) + }); + let regenerated = previous.is_none_or(|(_, _, capacity, _)| capacity != order_capacity) + || self.batches[batch_index].order.rebased()?; + let replace = context.checkpoint || regenerated; + let identity = previous.map(|(id, generation, _, length)| (id, generation, length)); + let (id, generation) = self.next_buffer_identity(identity, regenerated)?; + self.pending_batches[pending_index].order_buffer_id = id; + self.pending_batches[pending_index].order_buffer_generation = generation; + self.pending_batches[pending_index].order_capacity = order_capacity; + if replace { + let key = self.batches[batch_index].key; + push_pending_allocation( + &mut self.pending_allocations, + id, + generation, + key.program_id, + order_schema(), + order_capacity, + )?; + reserve(&mut self.patches, 1)?; + self.patches.push(PatchRecord { + opcode: PATCH_ALLOCATE_OR_RESIZE, + buffer_id: id, + buffer_generation: generation, + byte_length: order_capacity + .checked_mul(4) + .ok_or(StablePlanError::ArithmeticOverflow)?, + ..PatchRecord::default() + }); + if let Some((old_id, old_generation, _, old_length)) = previous + && generation != old_generation + { + self.retire_buffer( + old_id, + old_generation, + old_length, + context.publication_generation, + )?; + } + } + self.order_chunk_scratch.clear(); + let chunk_count = self.batches[batch_index].order.pending_chunks()?.len(); + reserve(&mut self.order_chunk_scratch, chunk_count)?; + self.order_chunk_scratch.extend( + self.batches[batch_index] + .order + .pending_chunks()? + .iter() + .copied() + .filter(|chunk| replace || chunk.changed()), + ); + self.changed_ranges.clear(); + reserve(&mut self.changed_ranges, self.order_chunk_scratch.len())?; + let order_alignment = + buffer_record_alignment(&order_schema(), context.capability.update_alignment); + for chunk in self.order_chunk_scratch.iter().copied() { + self.changed_ranges.push(align_record_range( + RecordRange { + start: chunk.record_start(), + end: chunk + .record_start() + .checked_add(u32::from(chunk.len)) + .ok_or(StablePlanError::ArithmeticOverflow)?, + }, + order_alignment, + )?); + } + self.changed_ranges + .sort_unstable_by_key(|range| range.start); + coalesce_buffer_ranges( + &mut self.changed_ranges, + 4, + context.capability, + order_capacity, + )?; + for range_index in 0..self.changed_ranges.len() { + let range = self.changed_ranges[range_index]; + let payload_start = self.payload.len(); + let byte_length = usize::try_from(range.end - range.start) + .ok() + .and_then(|count| count.checked_mul(4)) + .ok_or(StablePlanError::ArithmeticOverflow)?; + reserve(&mut self.payload, byte_length)?; + self.payload.resize(payload_start + byte_length, 0); + if !replace { + let source_start = usize::try_from(range.start) + .ok() + .and_then(|start| start.checked_mul(4)) + .ok_or(StablePlanError::ArithmeticOverflow)?; + let source_end = source_start + .checked_add(byte_length) + .ok_or(StablePlanError::ArithmeticOverflow)?; + let source = self.batches[batch_index] + .order_buffer + .as_ref() + .and_then(|buffer| buffer.bytes.get(source_start..source_end)) + .ok_or(StablePlanError::InvalidIdentity)?; + self.payload[payload_start..payload_start + byte_length].copy_from_slice(source); + } + for chunk_index in 0..self.order_chunk_scratch.len() { + let chunk = self.order_chunk_scratch[chunk_index]; + let chunk_start = chunk.record_start(); + if chunk_start < range.start || chunk_start >= range.end { + continue; + } + let destination = payload_start + + usize::try_from(chunk_start - range.start) + .ok() + .and_then(|start| start.checked_mul(4)) + .ok_or(StablePlanError::ArithmeticOverflow)?; + let entries = self.batches[batch_index].order.entries(chunk)?; + for (entry_index, entry) in entries.iter().enumerate() { + let start = destination + entry_index * 4; + self.payload[start..start + 4] + .copy_from_slice(&entry.record_slot.to_le_bytes()); + } + } + reserve(&mut self.patches, 1)?; + self.patches.push(PatchRecord { + opcode: PATCH_WRITE, + buffer_id: id, + buffer_generation: generation, + destination_offset: range + .start + .checked_mul(4) + .ok_or(StablePlanError::ArithmeticOverflow)?, + byte_length: u32::try_from(byte_length) + .map_err(|_| StablePlanError::ArithmeticOverflow)?, + payload_start: u32::try_from(payload_start) + .map_err(|_| StablePlanError::ArithmeticOverflow)?, + ..PatchRecord::default() + }); + } + Ok(()) + } + + fn retire_removed_slots( + &mut self, + context: PrepareContext<'_>, + pending_index: usize, + ) -> Result<(), StablePlanError> { + let pending = self.pending_batches[pending_index]; + let batch = &self.batches[pending.batch_index as usize]; + let slot_retirements = batch + .slots + .retired_slots()? + .len() + .checked_mul(batch.buffers.len()) + .ok_or(StablePlanError::ArithmeticOverflow)?; + let order_retirements = if batch.order_buffer.is_some() { + batch.order.retired_chunks()?.len() + } else { + 0 + }; + let retirement_count = slot_retirements + .checked_add(order_retirements) + .ok_or(StablePlanError::ArithmeticOverflow)?; + reserve(&mut self.retirements, retirement_count)?; + for &slot in batch.slots.retired_slots()? { + for buffer in &batch.buffers { + self.retirements.push(RetirementRecord { + kind: RETIRE_SLOT_RANGE, + id: buffer.id, + generation: buffer.generation, + after_publication_generation: context.publication_generation, + byte_offset: slot + .checked_mul(u32::from(buffer.schema.stride)) + .ok_or(StablePlanError::ArithmeticOverflow)?, + byte_length: u32::from(buffer.schema.stride), + ..RetirementRecord::default() + }); + } + } + if let Some(order_buffer) = &batch.order_buffer { + for &slot in batch.order.retired_chunks()? { + self.retirements.push(RetirementRecord { + kind: RETIRE_SLOT_RANGE, + id: order_buffer.id, + generation: order_buffer.generation, + after_publication_generation: context.publication_generation, + byte_offset: slot + .checked_mul(ORDER_CHUNK_RECORDS) + .and_then(|value| value.checked_mul(4)) + .ok_or(StablePlanError::ArithmeticOverflow)?, + byte_length: ORDER_CHUNK_RECORDS * 4, + ..RetirementRecord::default() + }); + } + } + Ok(()) + } + + fn prepare_removed_batch( + &mut self, + batch_index: usize, + publication_generation: u32, + ) -> Result<(), StablePlanError> { + let key = self.batches[batch_index].key; + if !self.batches.iter().enumerate().any(|(index, candidate)| { + index != batch_index + && self.batch_pending_indices[index] != NONE + && candidate.key.resource_id == key.resource_id + && candidate.key.resource_generation == key.resource_generation + }) && !self.retirements.iter().any(|retirement| { + retirement.kind == RETIRE_RESOURCE + && retirement.id == key.resource_id + && retirement.generation == key.resource_generation + }) { + reserve(&mut self.retirements, 1)?; + self.retirements.push(RetirementRecord { + kind: RETIRE_RESOURCE, + id: key.resource_id, + generation: key.resource_generation, + after_publication_generation: publication_generation, + ..RetirementRecord::default() + }); + } + for buffer_index in 0..self.batches[batch_index].buffers.len() { + let buffer = &self.batches[batch_index].buffers[buffer_index]; + let (id, generation, byte_length) = (buffer.id, buffer.generation, buffer.bytes.len()); + self.retire_buffer(id, generation, byte_length, publication_generation)?; + } + if let Some((id, generation, byte_length)) = self.batches[batch_index] + .order_buffer + .as_ref() + .map(|buffer| (buffer.id, buffer.generation, buffer.bytes.len())) + { + self.retire_buffer(id, generation, byte_length, publication_generation)?; + } + Ok(()) + } + + fn compile_bindings(&mut self, context: PrepareContext<'_>) -> Result<(), StablePlanError> { + for pending_index in 0..self.pending_batches.len() { + let mut pending = self.pending_batches[pending_index]; + let batch = &self.batches[pending.batch_index as usize]; + let program = context + .policy + .program( + context.capability_set, + batch.key.technique, + batch.key.program_variant, + ) + .ok_or(StablePlanError::ProgramMissing)?; + let binding_count = program.buffers.len() + 1; + if binding_count > usize::from(context.capability.max_buffers_per_draw) { + return Err(StablePlanError::CapacityExceeded); + } + pending.buffer_start = u32::try_from(self.plan_buffers.len()) + .map_err(|_| StablePlanError::ArithmeticOverflow)?; + reserve(&mut self.plan_buffers, binding_count)?; + for (schema_index, schema) in program.buffers.iter().copied().enumerate() { + self.plan_buffers.push(BufferRecord { + id: pending.buffer_ids[schema_index], + generation: pending.buffer_generations[schema_index], + program_id: batch.key.program_id, + policy_buffer_id: schema.id.0, + scalar_type: schema.scalar as u8, + vector_width: schema.vector_width, + strategy: BUFFER_STABLE_INDIRECT, + flags: schema.usage as u16, + live_records: pending.item_count, + capacity_records: pending.capacity, + byte_length: pending + .capacity + .checked_mul(u32::from(schema.stride)) + .ok_or(StablePlanError::ArithmeticOverflow)?, + order_buffer_id: pending.order_buffer_id, + }); + } + self.plan_buffers.push(BufferRecord { + id: pending.order_buffer_id, + generation: pending.order_buffer_generation, + program_id: batch.key.program_id, + policy_buffer_id: POLICY_BUFFER_ORDER, + scalar_type: ScalarType::U32 as u8, + vector_width: 1, + strategy: BUFFER_STABLE_INDIRECT, + flags: (BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST) as u16, + live_records: pending.item_count, + capacity_records: pending.order_capacity, + byte_length: pending + .order_capacity + .checked_mul(4) + .ok_or(StablePlanError::ArithmeticOverflow)?, + order_buffer_id: 0, + }); + pending.buffer_count = + u16::try_from(binding_count).map_err(|_| StablePlanError::ArithmeticOverflow)?; + self.pending_batches[pending_index] = pending; + } + self.compile_resources(context)?; + self.compile_draws(context)?; + self.publish_bindings = context.checkpoint + || !self.patches.is_empty() + || !self.retirements.is_empty() + || self.primitives != self.live_primitives + || self.draws != self.live_draws; + Ok(()) + } + + fn compile_resources(&mut self, context: PrepareContext<'_>) -> Result<(), StablePlanError> { + for (input_index, glyph) in context.input.glyphs.iter().copied().enumerate() { + if self.input_batches[input_index] == NONE { + continue; + } + if let Some(resource) = self.resources.iter().find(|resource| { + resource.id == glyph.resource_id && resource.generation == glyph.resource_generation + }) { + if resource.technique_id != glyph.technique.0 + || resource.resource_kind != glyph.resource_kind + || resource.reference_id != glyph.resource_reference + { + return Err(StablePlanError::InvalidResource); + } + continue; + } + let existing = self.batches.iter().any(|batch| { + batch.active + && batch.key.resource_id == glyph.resource_id + && batch.key.resource_generation == glyph.resource_generation + }); + reserve(&mut self.resources, 1)?; + self.resources.push(ResourceRecord { + id: glyph.resource_id, + generation: glyph.resource_generation, + technique_id: glyph.technique.0, + resource_kind: glyph.resource_kind, + action: if context.checkpoint || !existing { + RESOURCE_ACTION_CREATE + } else { + RESOURCE_ACTION_RETAIN + }, + reference_id: glyph.resource_reference, + ..ResourceRecord::default() + }); + } + Ok(()) + } + + fn compile_draws(&mut self, context: PrepareContext<'_>) -> Result<(), StablePlanError> { + if context.capability.max_resources_per_draw < 1 { + return Err(StablePlanError::CapacityExceeded); + } + if context.input.order_independent { + self.compile_independent_draws(context) + } else { + self.compile_ordered_draws(context) + } + } + + fn compile_ordered_draws( + &mut self, + context: PrepareContext<'_>, + ) -> Result<(), StablePlanError> { + let mut input_index = 0_usize; + while input_index < context.input.glyphs.len() { + if self.input_batches[input_index] == NONE { + input_index += 1; + continue; + } + let first = context.input.glyphs[input_index]; + let pending_index = self.input_batches[input_index] as usize; + let pending = self.pending_batches[pending_index]; + let batch = &self.batches[pending.batch_index as usize]; + let program = context + .policy + .program( + context.capability_set, + first.technique, + first.program_variant, + ) + .ok_or(StablePlanError::ProgramMissing)?; + let split_material = program.draw_key_mask & BATCH_MATERIAL != 0; + let split_transform = program.draw_key_mask & BATCH_TRANSFORM != 0; + let first_record = self.input_order_records[input_index]; + let mut end = input_index + 1; + while end < context.input.glyphs.len() + && end - input_index < usize::from(u16::MAX) + && self.same_draw_span( + context.input.glyphs, + input_index, + end, + pending_index, + first_record, + split_material, + split_transform, + ) + { + end += 1; + } + let count = u16::try_from(end - input_index) + .map_err(|_| StablePlanError::ArithmeticOverflow)?; + let (inline_start, block_start, inline_extent, block_extent) = + span_bounds(&context.input.glyphs[input_index..end])?; + let resource_start = self + .resources + .iter() + .position(|resource| { + resource.id == first.resource_id + && resource.generation == first.resource_generation + }) + .ok_or(StablePlanError::InvalidResource)?; + push_glyph_draw( + &mut self.primitives, + &mut self.draws, + GlyphDraw { + glyph: first, + program_id: batch.key.program_id, + record_count: count, + buffer_id: pending.order_buffer_id, + record_index: first_record, + logical_order: input_index, + semantic_id: if context.input.glyphs[input_index..end] + .iter() + .all(|glyph| glyph.semantic_id == first.semantic_id) + { + first.semantic_id + } else { + 0 + }, + inline_start, + block_start, + inline_extent, + block_extent, + material_id: if split_material { first.material_id } else { 0 }, + transform_id: if split_transform { + first.transform_id + } else { + 0 + }, + buffer_start: pending.buffer_start, + buffer_count: u32::from(pending.buffer_count), + resource_start, + indirect: true, + }, + )?; + input_index = end; + } + Ok(()) + } + + fn compile_independent_draws( + &mut self, + context: PrepareContext<'_>, + ) -> Result<(), StablePlanError> { + for pending_index in 0..self.pending_batches.len() { + let pending = self.pending_batches[pending_index]; + let key = self.batches[pending.batch_index as usize].key; + let program = context + .policy + .program(context.capability_set, key.technique, key.program_variant) + .ok_or(StablePlanError::ProgramMissing)?; + let split_material = program.draw_key_mask & BATCH_MATERIAL != 0; + let split_transform = program.draw_key_mask & BATCH_TRANSFORM != 0; + let input_indices = + &self.batch_input_indices[range(pending.item_start, pending.item_count)?]; + let mut start = 0_usize; + while start < input_indices.len() { + let first_input = input_indices[start] as usize; + let first = context.input.glyphs[first_input]; + let first_record = self.input_order_records[first_input]; + let mut end = start + 1; + while end < input_indices.len() && end - start < usize::from(u16::MAX) { + let input_index = input_indices[end] as usize; + let glyph = context.input.glyphs[input_index]; + if self.input_order_records[input_index] == first_record + (end - start) as u32 + && draw_fields_compatible(first, glyph, split_material, split_transform) + { + end += 1; + } else { + break; + } + } + let count = + u16::try_from(end - start).map_err(|_| StablePlanError::ArithmeticOverflow)?; + let (inline_start, block_start, inline_extent, block_extent) = indexed_span_bounds( + context.input.glyphs, + input_indices[start..end] + .iter() + .map(|input_index| *input_index as usize), + )?; + let resource_start = self + .resources + .iter() + .position(|resource| { + resource.id == first.resource_id + && resource.generation == first.resource_generation + }) + .ok_or(StablePlanError::InvalidResource)?; + let semantic_id = if input_indices[start..end].iter().all(|input_index| { + context.input.glyphs[*input_index as usize].semantic_id == first.semantic_id + }) { + first.semantic_id + } else { + 0 + }; + push_glyph_draw( + &mut self.primitives, + &mut self.draws, + GlyphDraw { + glyph: first, + program_id: key.program_id, + record_count: count, + buffer_id: pending.order_buffer_id, + record_index: first_record, + logical_order: first_input, + semantic_id, + inline_start, + block_start, + inline_extent, + block_extent, + material_id: if split_material { first.material_id } else { 0 }, + transform_id: if split_transform { + first.transform_id + } else { + 0 + }, + buffer_start: pending.buffer_start, + buffer_count: u32::from(pending.buffer_count), + resource_start, + indirect: true, + }, + )?; + start = end; + } + } + self.draws.sort_unstable_by_key(|draw| draw.order_token); + Ok(()) + } + + fn same_draw_span( + &self, + glyphs: &[StableGlyph], + start: usize, + next: usize, + pending_index: usize, + first_record: u32, + split_material: bool, + split_transform: bool, + ) -> bool { + let first = glyphs[start]; + let glyph = glyphs[next]; + draw_span_compatible( + first, + glyph, + self.input_batches[next] as usize == pending_index, + self.input_order_records[next] == first_record + (next - start) as u32, + split_material, + split_transform, + ) + } + + fn next_buffer_identity( + &mut self, + previous: Option<(u32, u32, usize)>, + replace: bool, + ) -> Result<(u32, u32), StablePlanError> { + if let Some((id, generation, _)) = previous { + return Ok(( + id, + if replace { + generation + .checked_add(1) + .ok_or(StablePlanError::IdentifierExhausted)? + } else { + generation + }, + )); + } + self.pending_next_buffer_id = self + .pending_next_buffer_id + .checked_add(1) + .ok_or(StablePlanError::IdentifierExhausted)?; + Ok((self.pending_next_buffer_id, 1)) + } + + fn retire_buffer( + &mut self, + id: u32, + generation: u32, + byte_length: usize, + publication_generation: u32, + ) -> Result<(), StablePlanError> { + reserve(&mut self.retirements, 1)?; + self.retirements.push(RetirementRecord { + kind: RETIRE_BUFFER, + id, + generation, + after_publication_generation: publication_generation, + byte_length: u32::try_from(byte_length) + .map_err(|_| StablePlanError::ArithmeticOverflow)?, + ..RetirementRecord::default() + }); + Ok(()) + } + + fn commit_batch_storage( + &mut self, + batch_index: usize, + pending_index: u32, + ) -> Result<(), StablePlanError> { + if pending_index == NONE { + self.batches[batch_index].buffers.clear(); + self.batches[batch_index].order_buffer = None; + return Ok(()); + } + let pending = self.pending_batches[pending_index as usize]; + let batch = &mut self.batches[batch_index]; + mem::swap(&mut batch.buffers, &mut batch.spare_buffers); + batch.buffers.clear(); + for index in 0..usize::from(pending.buffer_count.saturating_sub(1)) { + let id = pending.buffer_ids[index]; + let generation = pending.buffer_generations[index]; + let mut buffer = if let Some(allocation) = + take_allocation(&mut self.pending_allocations, id, generation) + { + allocation.state + } else { + let position = batch + .spare_buffers + .iter() + .position(|buffer| buffer.id == id && buffer.generation == generation) + .ok_or(StablePlanError::InvalidIdentity)?; + batch.spare_buffers.swap_remove(position) + }; + apply_writes(&mut buffer, &self.patches, &self.payload)?; + batch.buffers.push(buffer); + } + batch.spare_buffers.clear(); + let mut order_buffer = if let Some(allocation) = take_allocation( + &mut self.pending_allocations, + pending.order_buffer_id, + pending.order_buffer_generation, + ) { + allocation.state + } else { + batch + .order_buffer + .take() + .filter(|buffer| { + buffer.id == pending.order_buffer_id + && buffer.generation == pending.order_buffer_generation + }) + .ok_or(StablePlanError::InvalidIdentity)? + }; + apply_writes(&mut order_buffer, &self.patches, &self.payload)?; + batch.order_buffer = Some(order_buffer); + Ok(()) + } +} + +fn stable_active_buffers( + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + program: &super::policy::ProgramDescriptor, + input: StablePlanInput<'_>, + writes: &[SlotWrite], + changed: RecordRange, + replace: bool, +) -> Result { + let all = (1_u32 << program.buffers.len()) - 1; + if replace { + return Ok(all); + } + let dependencies = policy + .buffer_dependency_masks(capability_set, program.technique, program.variant) + .ok_or(StablePlanError::ProgramMissing)?; + let mut active = 0_u32; + for write in writes_in_range(writes, changed) + .iter() + .filter(|write| write.changed) + { + let mask = input + .semantic_change_masks + .get(write.input_index as usize) + .copied() + .unwrap_or(super::positioning::ALL_SEMANTIC_CHANGES); + if mask == super::positioning::ALL_SEMANTIC_CHANGES { + return Ok(all); + } + for (index, &dependency) in dependencies.iter().enumerate() { + if dependency & mask != 0 { + active |= 1 << index; + } + } + } + Ok(active) +} + +fn writes_in_range(writes: &[SlotWrite], range: RecordRange) -> &[SlotWrite] { + let start = writes.partition_point(|write| write.slot < range.start); + let end = writes.partition_point(|write| write.slot < range.end); + &writes[start..end] +} + +fn order_schema() -> BufferSchema { + BufferSchema::packed( + BufferId(POLICY_BUFFER_ORDER), + ScalarType::U32, + 1, + BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST, + 1, + ) +} + +fn range(start: u32, count: u32) -> Result, StablePlanError> { + let end = start + .checked_add(count) + .ok_or(StablePlanError::ArithmeticOverflow)?; + Ok(start as usize..end as usize) +} + +fn reserve(values: &mut Vec, additional: usize) -> Result<(), StablePlanError> { + values + .try_reserve(additional) + .map_err(|_| StablePlanError::AllocationFailed) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::policy::{ + BATCH_ORDER, BATCH_PROGRAM, BATCH_RESOURCE, BATCH_TECHNIQUE, CAP_STABLE_INDIRECT, + CapabilitySet, Operation, PolicyDescriptor, ProgramCapabilities, ProgramDescriptor, + ProgramId, + }; + use crate::engine::render_plan_wire::plan_layout; + use alloc::vec; + + const CAPABILITY: CapabilitySetId = CapabilitySetId(1); + const TECHNIQUE: TechniqueId = TechniqueId(1); + + #[test] + fn dependency_queries_slice_only_the_requested_sorted_slots() { + let writes = [ + SlotWrite { + slot: 2, + input_index: 20, + changed: true, + }, + SlotWrite { + slot: 4, + input_index: 40, + changed: false, + }, + SlotWrite { + slot: 7, + input_index: 70, + changed: true, + }, + ]; + + let selected = writes_in_range(&writes, RecordRange { start: 3, end: 7 }); + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].slot, 4); + } + + #[test] + fn insertion_writes_one_new_physical_record_and_one_order_chunk() { + let policy = policy(false); + let mut compiler = StablePlanCompiler::default(); + let initial = [glyph(1, 1), glyph(2, 1), glyph(3, 1)]; + prepare( + &mut compiler, + &policy, + &initial, + &[1.0, 2.0, 3.0], + true, + 1, + 0, + ); + let first = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!(first.buffers.len(), 2); + assert_eq!(first.patches.len(), 4); + assert_eq!(first.primitives.len(), 1); + assert_eq!(first.primitives[0].record_count, 3); + assert_eq!( + first.primitives[0].buffer_id, + first.draws[0].indirect_buffer_id + ); + assert_eq!(first.draws[0].buffer_count, 2); + assert!(plan_layout(first).is_ok(), "{:?}", plan_layout(first)); + compiler.commit().unwrap(); + + let inserted = [glyph(1, 1), glyph(4, 1), glyph(2, 1), glyph(3, 1)]; + prepare( + &mut compiler, + &policy, + &inserted, + &[1.0, 4.0, 2.0, 3.0], + false, + 2, + 0, + ); + let delta = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!(delta.patches.len(), 2); + assert_eq!(delta.patches[0].destination_offset, 12); + assert_eq!(delta.patches[0].byte_length, 4); + assert_eq!(delta.patches[1].destination_offset, 0); + assert_eq!(delta.patches[1].byte_length, 16); + assert_eq!(delta.payload.len(), 20); + assert_eq!(compiler.input_slots, [0, 3, 1, 2]); + compiler.commit().unwrap(); + assert_eq!(read_f32(compiler.buffer_bytes(1).unwrap(), 12), 4.0); + assert_eq!(read_u32(compiler.buffer_bytes(2).unwrap(), 4), 3); + } + + #[test] + fn stable_semantic_dependencies_suppress_unrelated_physical_buffer_writes() { + let policy = policy(false); + let mut compiler = StablePlanCompiler::default(); + prepare(&mut compiler, &policy, &[glyph(1, 1)], &[1.0], true, 1, 0); + compiler.commit().unwrap(); + + let mut block_changed = glyph(1, 2); + block_changed.block_start = 4.0; + compiler + .prepare( + &policy, + CAPABILITY, + StablePlanInput { + glyphs: &[block_changed], + semantic_change_masks: &[1 << 1], + f32_fields: &[&[1.0]], + u32_fields: &[], + order_independent: false, + }, + false, + 2, + 0, + ) + .unwrap(); + assert!( + compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap() + .patches + .is_empty() + ); + compiler.commit().unwrap(); + + compiler + .prepare( + &policy, + CAPABILITY, + StablePlanInput { + glyphs: &[glyph(1, 3)], + semantic_change_masks: &[1], + f32_fields: &[&[2.0]], + u32_fields: &[], + order_independent: false, + }, + false, + 3, + 0, + ) + .unwrap(); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!(plan.patches.len(), 1); + assert_eq!(plan.payload, 2.0_f32.to_le_bytes()); + } + + #[test] + fn reorder_changes_only_the_indirection_buffer() { + let policy = policy(false); + let mut compiler = StablePlanCompiler::default(); + let initial = [glyph(1, 1), glyph(2, 1), glyph(3, 1)]; + prepare( + &mut compiler, + &policy, + &initial, + &[1.0, 2.0, 3.0], + true, + 1, + 0, + ); + compiler.commit().unwrap(); + let reordered = [glyph(3, 1), glyph(1, 1), glyph(2, 1)]; + prepare( + &mut compiler, + &policy, + &reordered, + &[3.0, 1.0, 2.0], + false, + 2, + 0, + ); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!(plan.patches.len(), 1); + assert_eq!(plan.patches[0].buffer_id, 2); + assert_eq!(plan.payload.len(), 12); + } + + #[test] + fn no_op_publishes_nothing_and_abort_preserves_physical_bytes() { + let policy = policy(false); + let mut compiler = StablePlanCompiler::default(); + let initial = [glyph(1, 1), glyph(2, 1)]; + prepare(&mut compiler, &policy, &initial, &[1.0, 2.0], true, 1, 0); + compiler.commit().unwrap(); + prepare(&mut compiler, &policy, &initial, &[99.0, 99.0], false, 2, 0); + let no_op = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert!(no_op.resources.is_empty()); + assert!(no_op.buffers.is_empty()); + assert!(no_op.patches.is_empty()); + assert!(no_op.primitives.is_empty()); + assert!(no_op.draws.is_empty()); + compiler.commit().unwrap(); + + let changed = [glyph(1, 2), glyph(2, 1)]; + prepare(&mut compiler, &policy, &changed, &[10.0, 2.0], false, 3, 0); + compiler.abort(); + assert_eq!(read_f32(compiler.buffer_bytes(1).unwrap(), 0), 1.0); + } + + #[test] + fn deleted_slots_wait_for_an_explicit_renderer_fence() { + let policy = policy(false); + let mut compiler = StablePlanCompiler::default(); + let initial = [glyph(1, 1), glyph(2, 1), glyph(3, 1)]; + prepare( + &mut compiler, + &policy, + &initial, + &[1.0, 2.0, 3.0], + true, + 1, + 0, + ); + compiler.commit().unwrap(); + prepare( + &mut compiler, + &policy, + &initial[..2], + &[1.0, 2.0], + false, + 2, + 0, + ); + assert!( + compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap() + .retirements + .iter() + .any(|retirement| retirement.kind == RETIRE_SLOT_RANGE) + ); + compiler.commit().unwrap(); + + let added = [glyph(1, 1), glyph(2, 1), glyph(4, 1)]; + prepare( + &mut compiler, + &policy, + &added, + &[1.0, 2.0, 4.0], + false, + 3, + 0, + ); + assert_eq!(compiler.input_slots[2], 3); + compiler.abort(); + prepare( + &mut compiler, + &policy, + &added, + &[1.0, 2.0, 4.0], + false, + 3, + 2, + ); + assert_eq!(compiler.input_slots[2], 2); + } + + #[test] + fn an_inactive_batch_stays_live_only_until_its_quarantine_is_acknowledged() { + let policy = policy(false); + let mut compiler = StablePlanCompiler::default(); + let initial = [glyph(1, 1)]; + prepare(&mut compiler, &policy, &initial, &[1.0], true, 1, 0); + compiler.commit().unwrap(); + + prepare(&mut compiler, &policy, &[], &[], false, 2, 0); + compiler.commit().unwrap(); + assert!(compiler.has_state()); + + prepare(&mut compiler, &policy, &[], &[], false, 3, 2); + compiler.commit().unwrap(); + assert!(!compiler.has_state()); + } + + #[test] + fn interleaved_resources_keep_ordered_draws_over_separate_stable_pools() { + let policy = policy(false); + let mut compiler = StablePlanCompiler::default(); + let a1 = glyph(1, 1); + let a2 = glyph(2, 1); + let mut b = glyph(3, 1); + b.resource_id = 12; + b.resource_reference = 100; + let a3 = glyph(4, 1); + let glyphs = [a1, a2, b, a3]; + prepare( + &mut compiler, + &policy, + &glyphs, + &[1.0, 2.0, 3.0, 4.0], + true, + 1, + 0, + ); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!(plan.resources.len(), 2); + assert_eq!(plan.buffers.len(), 4); + assert_eq!(plan.primitives.len(), 3); + assert_eq!(plan.draws.len(), 3); + assert_eq!(plan.primitives[0].record_count, 2); + assert_eq!(plan.draws[0].order_token, 0); + assert_eq!(plan.draws[1].order_token, 2); + assert_eq!(plan.draws[2].order_token, 3); + assert!(plan_layout(plan).is_ok(), "{:?}", plan_layout(plan)); + } + + #[test] + fn independent_compositing_batches_interleaved_stable_resources() { + let policy = policy(false); + let mut compiler = StablePlanCompiler::default(); + let a1 = glyph(1, 1); + let a2 = glyph(2, 1); + let mut b = glyph(3, 1); + b.resource_id = 12; + b.resource_reference = 100; + let a3 = glyph(4, 1); + let glyphs = [a1, a2, b, a3]; + compiler + .prepare( + &policy, + CAPABILITY, + StablePlanInput { + glyphs: &glyphs, + semantic_change_masks: &[], + f32_fields: &[&[1.0, 2.0, 3.0, 4.0]], + u32_fields: &[], + order_independent: true, + }, + true, + 1, + 0, + ) + .unwrap(); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + + assert_eq!(plan.draws.len(), 2); + assert_eq!(plan.primitives.len(), 2); + assert_eq!(plan.primitives[0].resource_id, 11); + assert_eq!(plan.primitives[0].record_count, 3); + assert_eq!(plan.primitives[1].resource_id, 12); + assert_eq!(plan.primitives[1].record_count, 1); + assert_eq!(plan.draws[0].order_token, 0); + assert_eq!(plan.draws[1].order_token, 2); + assert!(plan_layout(plan).is_ok(), "{:?}", plan_layout(plan)); + } + + #[test] + fn material_splits_draws_without_splitting_stable_storage() { + let policy = policy(false); + let mut compiler = StablePlanCompiler::default(); + let first = glyph(1, 1); + let mut second = glyph(2, 1); + second.material_id = 2; + let glyphs = [first, second]; + prepare(&mut compiler, &policy, &glyphs, &[1.0, 2.0], true, 1, 0); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!(plan.buffers.len(), 2); + assert_eq!(plan.draws.len(), 2); + assert_eq!(plan.draws[0].material_id, 1); + assert_eq!(plan.draws[1].material_id, 2); + assert_eq!(plan.draws[0].buffer_start, plan.draws[1].buffer_start); + } + + #[test] + fn policy_can_partition_stable_storage_by_material() { + let policy = policy(true); + let mut compiler = StablePlanCompiler::default(); + let first = glyph(1, 1); + let mut second = glyph(2, 1); + second.material_id = 2; + let glyphs = [first, second]; + prepare(&mut compiler, &policy, &glyphs, &[1.0, 2.0], true, 1, 0); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!(plan.buffers.len(), 4); + assert_eq!(plan.draws.len(), 2); + assert_ne!(plan.draws[0].buffer_start, plan.draws[1].buffer_start); + } + + #[test] + fn repeated_warm_updates_keep_all_glyph_scaled_scratch_capacity() { + let policy = policy(false); + let mut compiler = StablePlanCompiler::default(); + let initial = [glyph(1, 1), glyph(2, 1), glyph(3, 1), glyph(4, 1)]; + prepare( + &mut compiler, + &policy, + &initial, + &[1.0, 2.0, 3.0, 4.0], + true, + 1, + 0, + ); + compiler.commit().unwrap(); + let changed = [glyph(1, 1), glyph(2, 2), glyph(3, 1), glyph(4, 1)]; + prepare( + &mut compiler, + &policy, + &changed, + &[1.0, 20.0, 3.0, 4.0], + false, + 2, + 0, + ); + compiler.commit().unwrap(); + let settled = capacities(&compiler); + let changed_again = [glyph(1, 1), glyph(2, 3), glyph(3, 1), glyph(4, 1)]; + prepare( + &mut compiler, + &policy, + &changed_again, + &[1.0, 21.0, 3.0, 4.0], + false, + 3, + 0, + ); + compiler.commit().unwrap(); + assert_eq!(capacities(&compiler), settled); + } + + #[test] + fn fragmentation_budget_rebases_only_the_order_buffer() { + let policy = policy_with_budget(false, 1); + let mut compiler = StablePlanCompiler::default(); + let seeded: Vec<_> = (1..=129).map(|id| glyph(id, 1)).collect(); + let seeded_x: Vec<_> = seeded.iter().map(|glyph| glyph.stable_id as f32).collect(); + prepare(&mut compiler, &policy, &seeded, &seeded_x, true, 1, 0); + compiler.commit().unwrap(); + let initial = &seeded[..127]; + let initial_x = &seeded_x[..127]; + prepare(&mut compiler, &policy, initial, initial_x, false, 2, 0); + compiler.commit().unwrap(); + + let mut inserted = initial.to_vec(); + inserted.insert(64, glyph(200, 1)); + let inserted_x: Vec<_> = inserted + .iter() + .map(|glyph| glyph.stable_id as f32) + .collect(); + prepare(&mut compiler, &policy, &inserted, &inserted_x, false, 3, 0); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!(plan.primitives.len(), 1); + assert_eq!(plan.primitives[0].record_count, 128); + assert_eq!(plan.buffers[0].generation, 1); + assert_eq!(plan.buffers[1].generation, 2); + assert!(plan.retirements.iter().any(|retirement| { + retirement.kind == RETIRE_BUFFER + && retirement.id == plan.buffers[1].id + && retirement.generation == 1 + })); + assert!(!plan.patches.iter().any(|patch| { + patch.opcode == PATCH_ALLOCATE_OR_RESIZE && patch.buffer_id == plan.buffers[0].id + })); + } + + #[allow(clippy::too_many_arguments)] + fn prepare( + compiler: &mut StablePlanCompiler, + policy: &ValidatedPolicy, + glyphs: &[StableGlyph], + x: &[f32], + checkpoint: bool, + publication_generation: u32, + acknowledged_publication_generation: u32, + ) { + compiler + .prepare( + policy, + CAPABILITY, + StablePlanInput { + glyphs, + semantic_change_masks: &[], + f32_fields: &[x], + u32_fields: &[], + order_independent: false, + }, + checkpoint, + publication_generation, + acknowledged_publication_generation, + ) + .unwrap(); + } + + fn glyph(stable_id: u32, content_revision: u32) -> StableGlyph { + StableGlyph { + stable_id, + content_revision, + technique: TECHNIQUE, + program_variant: 0, + resource_id: 11, + resource_generation: 1, + resource_kind: 1, + resource_reference: 99, + semantic_id: 1, + transform_id: 1, + material_id: 1, + clip_id: 0, + depth_key: 0, + inline_start: stable_id as f32, + block_start: 0.0, + inline_extent: 1.0, + block_extent: 1.0, + } + } + + fn policy(partition_materials: bool) -> ValidatedPolicy { + policy_with_budget(partition_materials, 8) + } + + fn policy_with_budget(partition_materials: bool, fragmentation_budget: u16) -> ValidatedPolicy { + ValidatedPolicy::new(PolicyDescriptor { + capability_sets: vec![CapabilitySet { + id: CAPABILITY, + flags: CAP_STABLE_INDIRECT, + max_buffer_bytes: 1024, + update_alignment: 4, + coalesce_gap_bytes: 0, + range_call_penalty_bytes: 0, + max_buffers_per_draw: 2, + max_resources_per_draw: 1, + max_indirect_draws: 0, + fragmentation_budget, + whole_buffer_threshold_basis_points: 10_000, + }], + programs: vec![ProgramDescriptor { + technique: TECHNIQUE, + variant: 0, + id: ProgramId(5), + capability_set: CapabilitySetId(0), + resource_kind_mask: 1, + semantic_view_mask: 0, + storage_key_mask: BATCH_TECHNIQUE + | BATCH_PROGRAM + | BATCH_RESOURCE + | if partition_materials { + BATCH_MATERIAL + } else { + 0 + }, + draw_key_mask: BATCH_TECHNIQUE + | BATCH_PROGRAM + | BATCH_RESOURCE + | BATCH_MATERIAL + | BATCH_ORDER + | crate::engine::policy::BATCH_TRANSFORM, + allocation_strategy: ALLOCATION_STABLE_INDIRECT, + f32_input_count: 1, + u32_input_count: 0, + inputs: vec![crate::engine::policy::InputSource::semantic(0)], + capabilities: ProgramCapabilities::default(), + buffers: vec![BufferSchema::packed( + BufferId(1), + ScalarType::F32, + 1, + BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST, + 1, + )], + operations: vec![ + Operation::LoadF32 { + target: 0, + field: 0, + }, + Operation::StoreF32 { + source: 0, + buffer: BufferId(1), + lane: 0, + }, + ], + }], + }) + .unwrap() + } + + fn read_f32(bytes: &[u8], offset: usize) -> f32 { + f32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()) + } + + fn read_u32(bytes: &[u8], offset: usize) -> u32 { + u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()) + } + + fn capacities(compiler: &StablePlanCompiler) -> Vec { + let mut values = vec![ + compiler.batches.capacity(), + compiler.pending_batches.capacity(), + compiler.batch_pending_indices.capacity(), + compiler.input_batches.capacity(), + compiler.input_slots.capacity(), + compiler.input_order_records.capacity(), + compiler.batch_input_indices.capacity(), + compiler.batch_identities.capacity(), + compiler.order_entries.capacity(), + compiler.order_chunk_scratch.capacity(), + compiler.slot_writes.capacity(), + compiler.changed_ranges.capacity(), + compiler.identity_set.capacities()[0], + compiler.identity_set.capacities()[1], + compiler.pending_allocations.capacity(), + compiler.resources.capacity(), + compiler.plan_buffers.capacity(), + compiler.primitives.capacity(), + compiler.draws.capacity(), + compiler.live_primitives.capacity(), + compiler.live_draws.capacity(), + compiler.patches.capacity(), + compiler.retirements.capacity(), + compiler.payload.capacity(), + ]; + for batch in &compiler.batches { + values.extend_from_slice(&batch.slots.scratch_capacities()); + values.extend_from_slice(&batch.order.scratch_capacities()); + values.extend_from_slice(&[ + batch.buffers.capacity(), + batch.spare_buffers.capacity(), + batch.quarantined_chunks.capacity(), + batch.pending_retired_chunks.capacity(), + batch.reclaim_scratch.capacity(), + ]); + } + values + } +} diff --git a/packages/text/rust/shaper/src/engine/stable_pool.rs b/packages/text/rust/shaper/src/engine/stable_pool.rs new file mode 100644 index 00000000..4a0b99b1 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/stable_pool.rs @@ -0,0 +1,568 @@ +//! Transactional stable physical-slot assignment with acknowledgment-gated reuse. + +use alloc::vec::Vec; + +use super::identity_index::{IdentityIndex, IdentityIndexError}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SlotIdentity { + pub stable_id: u32, + pub content_revision: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SlotAssignment { + pub stable_id: u32, + pub content_revision: u32, + pub slot: u32, + pub changed: bool, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct SlotState { + stable_id: u32, + content_revision: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct QuarantinedSlot { + slot: u32, + after_publication_generation: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StablePoolError { + AllocationFailed, + AlreadyPrepared, + NotPrepared, + InvalidIdentity, + DuplicateIdentity, + IdentifierExhausted, + ArithmeticOverflow, +} + +#[derive(Default)] +pub struct StableSlotPool { + slots: Vec, + free_slots: Vec, + quarantine: Vec, + assignments: Vec, + retired_slots: Vec, + allocated_free_slots: Vec, + identity_index: IdentityIndex, + seen_slots: Vec, + seen_epoch: u32, + pending_slot_count: u32, + committed_live_count: u32, + pending_live_count: u32, + pending_publication_generation: u32, + prepared: bool, +} + +impl StableSlotPool { + /// Releases slots whose renderer fence has completed. + /// + /// Acknowledgment is monotonic state independent of a later prepare/abort transaction. + pub fn acknowledge(&mut self, through_generation: u32) -> Result<(), StablePoolError> { + if self.prepared { + return Err(StablePoolError::AlreadyPrepared); + } + let reclaim_count = self + .quarantine + .iter() + .filter(|entry| entry.after_publication_generation <= through_generation) + .count(); + reserve(&mut self.free_slots, reclaim_count)?; + let mut write = 0_usize; + for read in 0..self.quarantine.len() { + let entry = self.quarantine[read]; + if entry.after_publication_generation <= through_generation { + self.free_slots.push(entry.slot); + } else { + self.quarantine[write] = entry; + write += 1; + } + } + self.quarantine.truncate(write); + Ok(()) + } + + pub fn has_quarantined_slots(&self) -> bool { + !self.quarantine.is_empty() + } + + pub fn prepare( + &mut self, + desired: &[SlotIdentity], + publication_generation: u32, + ) -> Result<(), StablePoolError> { + if self.prepared { + return Err(StablePoolError::AlreadyPrepared); + } + if publication_generation == 0 || u32::try_from(desired.len()).is_err() { + return Err(StablePoolError::InvalidIdentity); + } + self.begin_prepare(desired.len(), publication_generation)?; + let result = (|| { + self.prepare_identity_map(desired.len())?; + self.prepare_seen_slots()?; + reserve(&mut self.assignments, desired.len())?; + + for slot in 0..self.slots.len() { + let stable_id = self.slots[slot].stable_id; + if stable_id != 0 { + self.insert_committed_identity(stable_id, slot as u32)?; + } + } + + for identity in desired.iter().copied() { + if identity.stable_id == 0 || identity.content_revision == 0 { + return Err(StablePoolError::InvalidIdentity); + } + let existing = self.find_identity(identity.stable_id); + let slot = match existing { + Some(slot) => { + if self.slot_seen(slot)? { + return Err(StablePoolError::DuplicateIdentity); + } + slot + } + None => { + let slot = self.allocate_slot()?; + self.insert_new_identity(identity.stable_id, slot)?; + slot + } + }; + self.mark_slot_seen(slot)?; + let changed = self.slots.get(slot as usize).is_none_or(|state| { + state.stable_id != identity.stable_id + || state.content_revision != identity.content_revision + }); + self.assignments.push(SlotAssignment { + stable_id: identity.stable_id, + content_revision: identity.content_revision, + slot, + changed, + }); + } + + reserve(&mut self.retired_slots, self.slots.len())?; + for (slot, state) in self.slots.iter().enumerate() { + if state.stable_id != 0 && !self.slot_seen(slot as u32)? { + self.retired_slots.push(slot as u32); + } + } + reserve(&mut self.quarantine, self.retired_slots.len())?; + let required_slots = usize::try_from(self.pending_slot_count) + .map_err(|_| StablePoolError::ArithmeticOverflow)?; + let additional_slots = required_slots.saturating_sub(self.slots.len()); + reserve(&mut self.slots, additional_slots)?; + self.prepared = true; + Ok(()) + })(); + if result.is_err() { + self.abort(); + } + result + } + + /// Reuses the committed identity index when the desired set contains exactly the same live + /// identities. Reordering and content-revision changes are allowed; insertion, replacement, + /// or deletion falls back to [`Self::prepare`]. + /// + /// This keeps the common retained update to one desired-identity pass. The complete path must + /// rebuild the index because structural updates can leave acknowledgment-gated stale mappings + /// behind after commit. + pub fn prepare_retained( + &mut self, + desired: &[SlotIdentity], + publication_generation: u32, + ) -> Result { + if self.prepared { + return Err(StablePoolError::AlreadyPrepared); + } + if publication_generation == 0 || u32::try_from(desired.len()).is_err() { + return Err(StablePoolError::InvalidIdentity); + } + if desired.len() != self.committed_live_count as usize { + return Ok(false); + } + + self.begin_prepare(desired.len(), publication_generation)?; + self.prepare_seen_slots()?; + reserve(&mut self.assignments, desired.len())?; + + for identity in desired.iter().copied() { + if identity.stable_id == 0 || identity.content_revision == 0 { + self.finish_transaction(); + return Err(StablePoolError::InvalidIdentity); + } + let Some(slot) = self.find_identity(identity.stable_id) else { + self.finish_transaction(); + return Ok(false); + }; + let Some(state) = self.slots.get(slot as usize).copied() else { + self.finish_transaction(); + return Ok(false); + }; + if state.stable_id != identity.stable_id { + self.finish_transaction(); + return Ok(false); + } + if self.slot_seen(slot)? { + self.finish_transaction(); + return Err(StablePoolError::DuplicateIdentity); + } + self.mark_slot_seen(slot)?; + self.assignments.push(SlotAssignment { + stable_id: identity.stable_id, + content_revision: identity.content_revision, + slot, + changed: state.content_revision != identity.content_revision, + }); + } + + self.prepared = true; + Ok(true) + } + + pub fn assignments(&self) -> Result<&[SlotAssignment], StablePoolError> { + if !self.prepared { + return Err(StablePoolError::NotPrepared); + } + Ok(&self.assignments) + } + + pub fn retired_slots(&self) -> Result<&[u32], StablePoolError> { + if !self.prepared { + return Err(StablePoolError::NotPrepared); + } + Ok(&self.retired_slots) + } + + pub fn required_slots(&self) -> Result { + if !self.prepared { + return Err(StablePoolError::NotPrepared); + } + Ok(self.pending_slot_count) + } + + pub fn live_count(&self) -> Result { + if !self.prepared { + return Err(StablePoolError::NotPrepared); + } + u32::try_from(self.assignments.len()).map_err(|_| StablePoolError::ArithmeticOverflow) + } + + pub fn commit(&mut self) -> Result<(), StablePoolError> { + if !self.prepared { + return Err(StablePoolError::NotPrepared); + } + self.slots + .resize(self.pending_slot_count as usize, SlotState::default()); + for &slot in &self.retired_slots { + self.slots[slot as usize] = SlotState::default(); + self.quarantine.push(QuarantinedSlot { + slot, + after_publication_generation: self.pending_publication_generation, + }); + } + for assignment in &self.assignments { + self.slots[assignment.slot as usize] = SlotState { + stable_id: assignment.stable_id, + content_revision: assignment.content_revision, + }; + } + self.committed_live_count = self.pending_live_count; + self.finish_transaction(); + Ok(()) + } + + pub fn abort(&mut self) { + for slot in self.allocated_free_slots.drain(..) { + self.free_slots.push(slot); + } + self.finish_transaction(); + } + + #[cfg(test)] + pub fn scratch_capacities(&self) -> [usize; 10] { + let identity = self.identity_index.capacities(); + [ + self.slots.capacity(), + self.free_slots.capacity(), + self.quarantine.capacity(), + self.assignments.capacity(), + self.retired_slots.capacity(), + self.allocated_free_slots.capacity(), + identity[0], + identity[1], + identity[2], + self.seen_slots.capacity(), + ] + } + + fn finish_transaction(&mut self) { + self.assignments.clear(); + self.retired_slots.clear(); + self.allocated_free_slots.clear(); + self.prepared = false; + } + + fn begin_prepare( + &mut self, + desired_count: usize, + publication_generation: u32, + ) -> Result<(), StablePoolError> { + self.assignments.clear(); + self.retired_slots.clear(); + self.allocated_free_slots.clear(); + self.pending_slot_count = + u32::try_from(self.slots.len()).map_err(|_| StablePoolError::ArithmeticOverflow)?; + self.pending_live_count = + u32::try_from(desired_count).map_err(|_| StablePoolError::ArithmeticOverflow)?; + self.pending_publication_generation = publication_generation; + Ok(()) + } + + fn prepare_identity_map(&mut self, desired_count: usize) -> Result<(), StablePoolError> { + let live_count = self + .slots + .iter() + .filter(|state| state.stable_id != 0) + .count(); + let required = live_count + .checked_add(desired_count) + .ok_or(StablePoolError::ArithmeticOverflow)?; + self.identity_index + .prepare(required) + .map_err(identity_index_error) + } + + fn prepare_seen_slots(&mut self) -> Result<(), StablePoolError> { + if self.seen_slots.len() < self.slots.len() { + let additional = self.slots.len() - self.seen_slots.len(); + reserve(&mut self.seen_slots, additional)?; + self.seen_slots.resize(self.slots.len(), 0); + } + self.seen_epoch = next_epoch(&mut self.seen_slots, self.seen_epoch); + Ok(()) + } + + fn insert_committed_identity( + &mut self, + identity: u32, + slot: u32, + ) -> Result<(), StablePoolError> { + self.identity_index + .insert(identity, slot) + .map_err(identity_index_error) + } + + fn insert_new_identity(&mut self, identity: u32, slot: u32) -> Result<(), StablePoolError> { + self.identity_index + .insert(identity, slot) + .map_err(identity_index_error) + } + + fn find_identity(&self, identity: u32) -> Option { + self.identity_index.get(identity) + } + + fn allocate_slot(&mut self) -> Result { + if let Some(slot) = self.free_slots.pop() { + reserve(&mut self.allocated_free_slots, 1)?; + self.allocated_free_slots.push(slot); + return Ok(slot); + } + let slot = self.pending_slot_count; + self.pending_slot_count = self + .pending_slot_count + .checked_add(1) + .ok_or(StablePoolError::IdentifierExhausted)?; + Ok(slot) + } + + fn slot_seen(&self, slot: u32) -> Result { + let index = usize::try_from(slot).map_err(|_| StablePoolError::ArithmeticOverflow)?; + Ok(self.seen_slots.get(index).copied() == Some(self.seen_epoch)) + } + + fn mark_slot_seen(&mut self, slot: u32) -> Result<(), StablePoolError> { + let required = usize::try_from(slot) + .ok() + .and_then(|slot| slot.checked_add(1)) + .ok_or(StablePoolError::ArithmeticOverflow)?; + if self.seen_slots.len() < required { + let additional = required - self.seen_slots.len(); + reserve(&mut self.seen_slots, additional)?; + self.seen_slots.resize(required, 0); + } + self.seen_slots[required - 1] = self.seen_epoch; + Ok(()) + } +} + +fn next_epoch(values: &mut [u32], current: u32) -> u32 { + match current.checked_add(1) { + Some(epoch) => epoch, + None => { + values.fill(0); + 1 + } + } +} + +fn identity_index_error(error: IdentityIndexError) -> StablePoolError { + match error { + IdentityIndexError::AllocationFailed => StablePoolError::AllocationFailed, + IdentityIndexError::ArithmeticOverflow => StablePoolError::ArithmeticOverflow, + IdentityIndexError::DuplicateIdentity => StablePoolError::DuplicateIdentity, + } +} + +fn reserve(values: &mut Vec, additional: usize) -> Result<(), StablePoolError> { + values + .try_reserve(additional) + .map_err(|_| StablePoolError::AllocationFailed) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + + #[test] + fn insertion_preserves_existing_slots_and_marks_only_new_content_dirty() { + let mut pool = StableSlotPool::default(); + pool.prepare(&identities(&[(1, 1), (2, 1), (3, 1)]), 1) + .unwrap(); + assert_eq!(slots(&pool), vec![0, 1, 2]); + pool.commit().unwrap(); + + pool.prepare(&identities(&[(1, 1), (4, 1), (2, 1), (3, 1)]), 2) + .unwrap(); + assert_eq!(slots(&pool), vec![0, 3, 1, 2]); + assert_eq!(changed_slots(&pool), vec![3]); + } + + #[test] + fn deletion_quarantines_slots_until_the_renderer_acknowledges_the_fence() { + let mut pool = StableSlotPool::default(); + pool.prepare(&identities(&[(1, 1), (2, 1)]), 1).unwrap(); + pool.commit().unwrap(); + + pool.prepare(&identities(&[(1, 1)]), 2).unwrap(); + assert_eq!(pool.retired_slots().unwrap(), &[1]); + pool.commit().unwrap(); + + pool.prepare(&identities(&[(1, 1), (3, 1)]), 3).unwrap(); + assert_eq!(slots(&pool), vec![0, 2]); + pool.abort(); + + pool.acknowledge(1).unwrap(); + pool.prepare(&identities(&[(1, 1), (3, 1)]), 3).unwrap(); + assert_eq!(slots(&pool), vec![0, 2]); + pool.abort(); + + pool.acknowledge(2).unwrap(); + pool.prepare(&identities(&[(1, 1), (3, 1)]), 3).unwrap(); + assert_eq!(slots(&pool), vec![0, 1]); + } + + #[test] + fn content_revision_changes_rewrite_the_same_slot() { + let mut pool = StableSlotPool::default(); + pool.prepare(&identities(&[(1, 1), (2, 1)]), 1).unwrap(); + pool.commit().unwrap(); + pool.prepare(&identities(&[(2, 2), (1, 1)]), 2).unwrap(); + assert_eq!(slots(&pool), vec![1, 0]); + assert_eq!(changed_slots(&pool), vec![1]); + } + + #[test] + fn retained_prepare_reuses_slots_for_revision_changes_and_reordering() { + let mut pool = StableSlotPool::default(); + pool.prepare(&identities(&[(1, 1), (2, 1), (3, 1)]), 1) + .unwrap(); + pool.commit().unwrap(); + + assert!( + pool.prepare_retained(&identities(&[(3, 1), (1, 2), (2, 1)]), 2) + .unwrap() + ); + assert_eq!(slots(&pool), vec![2, 0, 1]); + assert_eq!(changed_slots(&pool), vec![0]); + } + + #[test] + fn retained_prepare_rejects_structural_changes_without_poisoning_complete_prepare() { + let mut pool = StableSlotPool::default(); + pool.prepare(&identities(&[(1, 1), (2, 1)]), 1).unwrap(); + pool.commit().unwrap(); + + assert!( + !pool + .prepare_retained(&identities(&[(1, 1), (3, 1)]), 2) + .unwrap() + ); + pool.prepare(&identities(&[(1, 1), (3, 1)]), 2).unwrap(); + assert_eq!(slots(&pool), vec![0, 2]); + } + + #[test] + fn abort_returns_reclaimed_allocations_without_committing_identity() { + let mut pool = StableSlotPool::default(); + pool.prepare(&identities(&[(1, 1), (2, 1)]), 1).unwrap(); + pool.commit().unwrap(); + pool.prepare(&identities(&[(1, 1)]), 2).unwrap(); + pool.commit().unwrap(); + pool.acknowledge(2).unwrap(); + + pool.prepare(&identities(&[(1, 1), (3, 1)]), 3).unwrap(); + assert_eq!(slots(&pool), vec![0, 1]); + pool.abort(); + pool.prepare(&identities(&[(1, 1), (4, 1)]), 4).unwrap(); + assert_eq!(slots(&pool), vec![0, 1]); + } + + #[test] + fn duplicate_desired_identity_fails_without_leaking_a_slot() { + let mut pool = StableSlotPool::default(); + assert_eq!( + pool.prepare(&identities(&[(1, 1), (1, 1)]), 1), + Err(StablePoolError::DuplicateIdentity) + ); + pool.prepare(&identities(&[(2, 1)]), 1).unwrap(); + assert_eq!(slots(&pool), vec![0]); + assert_eq!(pool.required_slots().unwrap(), 1); + assert_eq!(pool.live_count().unwrap(), 1); + } + + fn identities(values: &[(u32, u32)]) -> Vec { + values + .iter() + .map(|&(stable_id, content_revision)| SlotIdentity { + stable_id, + content_revision, + }) + .collect() + } + + fn slots(pool: &StableSlotPool) -> Vec { + pool.assignments() + .unwrap() + .iter() + .map(|assignment| assignment.slot) + .collect() + } + + fn changed_slots(pool: &StableSlotPool) -> Vec { + pool.assignments() + .unwrap() + .iter() + .filter(|assignment| assignment.changed) + .map(|assignment| assignment.slot) + .collect() + } +} diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs new file mode 100644 index 00000000..fea5fcbb --- /dev/null +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -0,0 +1,4574 @@ +use alloc::{collections::BTreeMap, vec::Vec}; + +use crate::{ + STATUS_RESULT_TOO_LARGE, ShapeRangeRef, ShapeRunRef, ShaperRegistry, + bidi::{BidiAnalysis, BidiError, DIRECTION_AUTO, analyze_into as analyze_bidi_into}, + unicode::{UnicodeAnalysis, UnicodeError}, +}; + +use super::{ + cluster_state::{ClusterArena, ClusterBuildInput}, + flow_composition::{EllipsisReplacement, FlowLayoutArena}, + flow_geometry::FlowGeometryArena, + font_binding::FontRenderBinding, + frame::{ + CommittedUpdate, OVERFLOW_CLIP, OVERFLOW_ELLIPSIS, OVERFLOW_VISIBLE, PreparedUpdate, + SessionRevision, UpdateRequest, + }, + identity_index::IdentityIndex, + policy::{ALLOCATION_ORDERED_DIRECT, CapabilitySetId, ValidatedPolicy}, + policy_gather::{ + DEFAULT_GATHER_RECORD_CAPACITY, GatherError, LayoutPlanInput, PolicyGatherWorkspace, + RetainedGather, + }, + positioning::PositionedGlyphArena, + render_plan::RenderPlanView, + render_plan_compiler::{RenderPlanCompiler, RenderPlanCompilerError}, + shaping_state::{BoundaryShape, BoundaryShapeArena, ShapeArena, ShapingRun, ShapingRunArena}, + style_state::{ + DEFAULT_STYLE_CAPACITY, MutationKey, ResolutionScope, ResolvedStyleArena, StyleArena, + StyleInvalidation, + }, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EngineError { + InvalidHandle, + HandleConflict, + PolicyMissing, + FontStackMissing, + SessionConflict, + SessionMissing, + RevisionConflict, + RevisionExhausted, + InvalidRequest, + ResultTooLarge, +} + +#[derive(Default)] +pub struct TextEngine { + policies: BTreeMap, + font_bindings: Vec, + font_stacks: Vec, + sessions: BTreeMap, + gather: PolicyGatherWorkspace, + gather_cache: Option, + prepared_gather_cache: Option, +} + +struct RegisteredFontBinding { + handle: u32, + shaping_handle: u32, + binding: FontRenderBinding, +} + +struct RegisteredFontStack { + handle: u32, + fonts: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct FallbackSpan { + source_run: u32, + text_start: u32, + text_end: u32, + font_index: u16, + binding_handle: u32, + font_handle: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ClusterRecord { + source_run: u32, + cluster: u32, + missing: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct TextEdit { + old_start: usize, + old_end: usize, + new_end: usize, +} + +#[derive(Clone, Copy)] +struct BoundaryCandidate { + source_run: usize, + cluster_start: usize, + source_binding_handle: u32, + source_font_handle: u32, + ellipsis_binding_handle: u32, + ellipsis_font_handle: u32, + source_advance: f64, + ellipsis_advance: f64, +} + +#[derive(Default)] +struct EngineSession { + revision: SessionRevision, + acknowledged_publication_generation: u32, + policy_binding: Option, + plan: RenderPlanCompiler, + semantic_records: Vec, + next_glyph_id: u32, + pending_next_glyph_id: u32, + next_content_revision: u32, + pending_next_content_revision: u32, + spare_paragraph: Option, + paragraphs: Vec, + ordered_paragraphs: Vec, + pending_ordered_paragraphs: Vec, + lifecycle_prepared: bool, + lifecycle_changed: bool, + compositing_independent: bool, + pending_compositing_independent: bool, +} + +struct RetainedParagraph { + id: u32, + order: u32, + pending_order: Option, + pending_remove: bool, + created: bool, + positioned_changed: bool, + state: ParagraphState, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ParagraphOrder { + order: u32, + id: u32, +} + +#[derive(Default)] +struct ParagraphState { + text: Vec, + pending_text: Vec, + text_unit_ids: Vec, + pending_text_unit_ids: Vec, + pending_text_mirrors_committed: bool, + next_text_unit_id: u32, + pending_next_text_unit_id: u32, + text_prepared: bool, + text_edit: Option, + styles: StyleArena, + pending_styles: StyleArena, + resolved_styles: ResolvedStyleArena, + pending_resolved_styles: ResolvedStyleArena, + unicode: UnicodeAnalysis, + pending_unicode: UnicodeAnalysis, + unicode_reused_for_text_edit: bool, + bidi: BidiAnalysis, + pending_bidi: BidiAnalysis, + shaping_runs: ShapingRunArena, + pending_shaping_runs: ShapingRunArena, + shape: ShapeArena, + pending_shape: ShapeArena, + incremental_shape_source_run: Option, + clusters: ClusterArena, + pending_clusters: ClusterArena, + glyph_identity_index: IdentityIndex, + geometry: FlowGeometryArena, + pending_geometry: FlowGeometryArena, + flow_layout: FlowLayoutArena, + pending_flow_layout: FlowLayoutArena, + intrinsic_geometry_scratch: FlowGeometryArena, + intrinsic_flow_layout_scratch: FlowLayoutArena, + intrinsic_flow_slot_scratch: super::flow_geometry::InlineSlotArena, + intrinsic_positioned_scratch: PositionedGlyphArena, + intrinsic_identity_scratch: IdentityIndex, + boundary_shape: BoundaryShapeArena, + pending_boundary_shape: BoundaryShapeArena, + boundary_shape_scratch: ShapeArena, + ellipsis_shape_scratch: ShapeArena, + ellipsis_text_scratch: Vec, + positioned: PositionedGlyphArena, + pending_positioned: PositionedGlyphArena, + flow_slot_scratch: super::flow_geometry::InlineSlotArena, + fallback_spans: Vec, + pending_fallback_spans: Vec, + fallback_span_scratch: Vec, + fallback_cluster_scratch: Vec, + style_mutation_scratch: Vec, + style_order_scratch: Vec, + style_nesting_scratch: Vec, + style_resolution_scratch: Vec, + styles_prepared: bool, + style_invalidation: StyleInvalidation, + unicode_prepared: bool, + bidi_prepared: bool, + shaping_runs_prepared: bool, + shape_prepared: bool, + clusters_prepared: bool, + geometry_fingerprint: u64, + pending_geometry_fingerprint: u64, + geometry_prepared: bool, + flow_layout_prepared: bool, + positioned_prepared: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct PolicyBinding { + handle: u32, + fingerprint: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct GatherCacheKey { + session_id: u32, + revision: SessionRevision, + policy_handle: u32, + policy_fingerprint: u64, + capability_set: u32, +} + +impl TextEngine { + fn invalidate_gather_cache(&mut self) { + self.gather_cache = None; + self.prepared_gather_cache = None; + } + + pub fn initialize(&mut self) -> Result<(), EngineError> { + self.gather + .reserve_records(DEFAULT_GATHER_RECORD_CAPACITY) + .map_err(gather_error) + } + + pub fn register_font_binding( + &mut self, + handle: u32, + shaping_handle: u32, + shaping_glyph_count: u32, + binding: FontRenderBinding, + ) -> Result<(), EngineError> { + if handle == 0 || shaping_handle == 0 || binding.glyph_count() != shaping_glyph_count { + return Err(EngineError::InvalidRequest); + } + if let Some(existing) = self + .font_bindings + .iter() + .find(|registered| registered.handle == handle) + { + return if existing.shaping_handle == shaping_handle && existing.binding == binding { + Ok(()) + } else { + Err(EngineError::HandleConflict) + }; + } + self.font_bindings + .try_reserve(1) + .map_err(|_| EngineError::ResultTooLarge)?; + self.font_bindings.push(RegisteredFontBinding { + handle, + shaping_handle, + binding, + }); + self.invalidate_gather_cache(); + Ok(()) + } + + pub fn dispose_font_binding(&mut self, handle: u32) { + if let Some(index) = self + .font_bindings + .iter() + .position(|binding| binding.handle == handle) + { + self.font_bindings.swap_remove(index); + self.invalidate_gather_cache(); + } + } + + pub fn font_binding(&self, handle: u32) -> Option<&FontRenderBinding> { + self.font_bindings + .iter() + .find(|binding| binding.handle == handle) + .map(|binding| &binding.binding) + } + + fn registered_font_binding(&self, handle: u32) -> Option<&RegisteredFontBinding> { + self.font_bindings + .iter() + .find(|binding| binding.handle == handle) + } + + pub fn shaping_handle_for_binding(&self, handle: u32) -> Option { + self.registered_font_binding(handle) + .map(|binding| binding.shaping_handle) + } + + pub fn dispose_bindings_for_shaping_font(&mut self, shaping_handle: u32) { + let previous_len = self.font_bindings.len(); + self.font_bindings + .retain(|binding| binding.shaping_handle != shaping_handle); + if self.font_bindings.len() != previous_len { + self.invalidate_gather_cache(); + } + } + + pub fn font_binding_count(&self) -> u32 { + self.font_bindings.len().try_into().unwrap_or(u32::MAX) + } + + pub fn register_font_stack(&mut self, handle: u32, fonts: &[u32]) -> Result<(), EngineError> { + if handle == 0 + || fonts.is_empty() + || fonts.len() > usize::from(u16::MAX) + || fonts.contains(&0) + || fonts + .iter() + .enumerate() + .any(|(index, font)| fonts[..index].contains(font)) + { + return Err(EngineError::InvalidRequest); + } + let insertion = match self + .font_stacks + .binary_search_by_key(&handle, |stack| stack.handle) + { + Ok(index) => { + return if self.font_stacks[index].fonts == fonts { + Ok(()) + } else { + Err(EngineError::HandleConflict) + }; + } + Err(index) => index, + }; + let mut retained = Vec::new(); + retained + .try_reserve_exact(fonts.len()) + .map_err(|_| EngineError::ResultTooLarge)?; + retained.extend_from_slice(fonts); + self.font_stacks + .try_reserve(1) + .map_err(|_| EngineError::ResultTooLarge)?; + self.font_stacks.insert( + insertion, + RegisteredFontStack { + handle, + fonts: retained, + }, + ); + Ok(()) + } + + pub fn dispose_font_stack(&mut self, handle: u32) -> Result<(), EngineError> { + let index = self + .font_stacks + .binary_search_by_key(&handle, |stack| stack.handle) + .map_err(|_| EngineError::FontStackMissing)?; + self.font_stacks.remove(index); + Ok(()) + } + + pub fn font_stack(&self, handle: u32) -> Result<&[u32], EngineError> { + self.font_stacks + .binary_search_by_key(&handle, |stack| stack.handle) + .ok() + .map(|index| self.font_stacks[index].fonts.as_slice()) + .ok_or(EngineError::FontStackMissing) + } + + pub fn font_stack_count(&self) -> u32 { + self.font_stacks.len().try_into().unwrap_or(u32::MAX) + } + + pub fn references_binding(&self, handle: u32) -> bool { + self.font_stacks + .iter() + .any(|stack| stack.fonts.contains(&handle)) + } + + pub fn references_shaping_font(&self, shaping_handle: u32) -> bool { + self.font_stacks.iter().any(|stack| { + stack + .fonts + .iter() + .any(|handle| self.shaping_handle_for_binding(*handle) == Some(shaping_handle)) + }) + } + + pub fn register_policy( + &mut self, + handle: u32, + policy: ValidatedPolicy, + ) -> Result<(), EngineError> { + if handle == 0 { + return Err(EngineError::InvalidHandle); + } + if let Some(existing) = self.policies.get(&handle) { + return if existing == &policy { + Ok(()) + } else { + Err(EngineError::HandleConflict) + }; + } + self.gather + .reserve_policy(&policy, DEFAULT_GATHER_RECORD_CAPACITY) + .map_err(|_| EngineError::ResultTooLarge)?; + self.policies.insert(handle, policy); + self.invalidate_gather_cache(); + Ok(()) + } + + pub fn dispose_policy(&mut self, handle: u32) -> Result<(), EngineError> { + self.policies + .remove(&handle) + .ok_or(EngineError::PolicyMissing)?; + self.invalidate_gather_cache(); + Ok(()) + } + + pub fn policy(&self, handle: u32) -> Result<&ValidatedPolicy, EngineError> { + self.policies.get(&handle).ok_or(EngineError::PolicyMissing) + } + + pub fn policy_count(&self) -> u32 { + self.policies.len().try_into().unwrap_or(u32::MAX) + } + + pub fn create_session(&mut self, handle: u32) -> Result<(), EngineError> { + if handle == 0 { + return Err(EngineError::InvalidHandle); + } + if self.sessions.contains_key(&handle) { + return Err(EngineError::SessionConflict); + } + let mut session = EngineSession::default(); + let mut spare = ParagraphState::default(); + spare.initialize()?; + session.spare_paragraph = Some(spare); + self.sessions.insert(handle, session); + Ok(()) + } + + pub fn dispose_session(&mut self, handle: u32) -> Result<(), EngineError> { + self.sessions + .remove(&handle) + .ok_or(EngineError::SessionMissing)?; + if self + .gather_cache + .is_some_and(|cache| cache.session_id == handle) + || self + .prepared_gather_cache + .is_some_and(|cache| cache.session_id == handle) + { + self.invalidate_gather_cache(); + } + Ok(()) + } + + pub fn reserve_session_text(&mut self, handle: u32, capacity: u32) -> Result<(), EngineError> { + let capacity = usize::try_from(capacity).map_err(|_| EngineError::ResultTooLarge)?; + let session = self + .sessions + .get_mut(&handle) + .ok_or(EngineError::SessionMissing)?; + if let Some(paragraph) = session.spare_paragraph.as_mut() { + paragraph.reserve_text(capacity)?; + } + Ok(()) + } + + pub(crate) fn session_revision(&self, handle: u32) -> Result { + self.sessions + .get(&handle) + .map(|session| session.revision) + .ok_or(EngineError::SessionMissing) + } + + #[cfg(test)] + pub(crate) fn session_text(&self, handle: u32) -> Result<&[u16], EngineError> { + self.sessions + .get(&handle) + .and_then(EngineSession::first_paragraph_state) + .map(|paragraph| paragraph.text.as_slice()) + .ok_or(EngineError::SessionMissing) + } + + #[cfg(test)] + pub(crate) fn session_style_count(&self, handle: u32) -> Result { + self.sessions + .get(&handle) + .and_then(EngineSession::first_paragraph_state) + .map(|paragraph| paragraph.styles.len()) + .ok_or(EngineError::SessionMissing) + } + + #[cfg(test)] + pub(crate) fn session_style_segment_count(&self, handle: u32) -> Result { + self.sessions + .get(&handle) + .and_then(EngineSession::first_paragraph_state) + .map(|paragraph| paragraph.resolved_styles.segments().len()) + .ok_or(EngineError::SessionMissing) + } + + #[cfg(test)] + pub(crate) fn session_shaping_run_count(&self, handle: u32) -> Result { + self.sessions + .get(&handle) + .and_then(EngineSession::first_paragraph_state) + .map(|paragraph| paragraph.shaping_runs.runs().len()) + .ok_or(EngineError::SessionMissing) + } + + pub fn session_count(&self) -> u32 { + self.sessions.len().try_into().unwrap_or(u32::MAX) + } + + #[cfg(test)] + pub(crate) fn prepare_update( + &mut self, + request: UpdateRequest<'_>, + publication_generation: u32, + ) -> Result { + self.prepare_update_inner(None, request, publication_generation) + } + + pub(crate) fn prepare_update_with_shaper( + &mut self, + shaper: &mut ShaperRegistry, + request: UpdateRequest<'_>, + publication_generation: u32, + ) -> Result { + self.prepare_update_inner(Some(shaper), request, publication_generation) + } + + fn prepare_update_inner( + &mut self, + mut shaper: Option<&mut ShaperRegistry>, + request: UpdateRequest<'_>, + publication_generation: u32, + ) -> Result { + if !request.limits.all_nonzero() { + return Err(EngineError::InvalidRequest); + } + let policy = self + .policies + .get(&request.policy_handle) + .ok_or(EngineError::PolicyMissing)?; + if policy + .capability_set(CapabilitySetId(request.capability_set)) + .is_none() + { + return Err(EngineError::InvalidRequest); + } + let policy_fingerprint = policy.fingerprint(); + let cached_gather = self.gather_cache; + let font_bindings = &self.font_bindings; + let font_stacks = &self.font_stacks; + let gather = &mut self.gather; + let gather_cache = &mut self.gather_cache; + let prepared_gather_cache = &mut self.prepared_gather_cache; + let session = self + .sessions + .get_mut(&request.session_id) + .ok_or(EngineError::SessionMissing)?; + if session.policy_binding.is_some_and(|binding| { + binding.handle != request.policy_handle || binding.fingerprint != policy_fingerprint + }) { + return Err(EngineError::InvalidRequest); + } + if request.expected_engine_revision != session.revision.engine + || request.consumed_plan_revision > session.revision.plan + || publication_generation == 0 + || request.acknowledged_publication_generation + < session.acknowledged_publication_generation + || request.acknowledged_publication_generation >= publication_generation + { + return Err(EngineError::RevisionConflict); + } + let next = SessionRevision { + engine: session + .revision + .engine + .checked_add(1) + .ok_or(EngineError::RevisionExhausted)?, + plan: session + .revision + .plan + .checked_add(1) + .ok_or(EngineError::RevisionExhausted)?, + }; + let current_gather_key = GatherCacheKey { + session_id: request.session_id, + revision: session.revision, + policy_handle: request.policy_handle, + policy_fingerprint, + capability_set: request.capability_set, + }; + let next_gather_key = GatherCacheKey { + revision: next, + ..current_gather_key + }; + let checkpoint = + session.revision.plan == 0 || request.consumed_plan_revision != session.revision.plan; + // A completed renderer fence is external monotonic state. It remains accepted even if + // plan preparation or publication later aborts. + session.acknowledged_publication_generation = request.acknowledged_publication_generation; + let mut next_glyph_id = session.next_glyph_id.max(1); + let mut next_content_revision = session.next_content_revision.max(1); + let implicit_paragraph = + if request.paragraph_mutations.len() == 0 && session.paragraphs.is_empty() { + request_semantic_paragraph_id(request)? + } else { + None + }; + let mut gather_output_matches_next = false; + let preparation = (|| { + session.semantic_records.clear(); + session.prepare_lifecycle( + request.paragraph_mutations, + implicit_paragraph, + request.limits.max_paragraphs, + )?; + let (mut text_cursor, mut style_cursor) = (0, 0); + let (mut constraint_cursor, mut inline_object_cursor) = (0, 0); + for order_index in 0..session.active_order().len() { + let paragraph_id = session.active_order()[order_index].id; + let text = request + .text_mutations + .take_paragraph(paragraph_id, &mut text_cursor) + .map_err(|_| EngineError::InvalidRequest)?; + let styles = request + .style_mutations + .take_paragraph(paragraph_id, &mut style_cursor) + .map_err(|_| EngineError::InvalidRequest)?; + let geometry = request + .geometry + .take_paragraph( + paragraph_id, + &mut constraint_cursor, + &mut inline_object_cursor, + ) + .map_err(|_| EngineError::InvalidRequest)?; + let paragraph = session + .paragraph_mut(paragraph_id) + .ok_or(EngineError::InvalidRequest)?; + paragraph.positioned_changed = paragraph.state.prepare( + shaper.as_deref_mut(), + font_stacks, + font_bindings, + text, + styles, + geometry, + request.limits, + &mut next_glyph_id, + &mut next_content_revision, + )?; + } + if text_cursor != request.text_mutations.len() + || style_cursor != request.style_mutations.len() + || constraint_cursor != request.geometry.constraint_count() + || inline_object_cursor != request.geometry.inline_object_count() + { + return Err(EngineError::InvalidRequest); + } + let positioned_changed = session.lifecycle_changed + || session + .paragraphs + .iter() + .any(|paragraph| paragraph.positioned_changed); + let reuse_ordered_plan = !checkpoint + && !positioned_changed + && request.compositing_independent == session.compositing_independent + && policy + .programs() + .iter() + .all(|program| program.allocation_strategy == ALLOCATION_ORDERED_DIRECT); + if reuse_ordered_plan { + session.plan.prepare_reuse().map_err(plan_error)?; + gather_output_matches_next = cached_gather == Some(current_gather_key); + } else { + let record_count = + session + .active_order() + .iter() + .try_fold(0usize, |total, ordered| { + let paragraph = session + .paragraph(ordered.id) + .ok_or(EngineError::InvalidRequest)?; + let positioned = if paragraph.state.positioned_prepared { + ¶graph.state.pending_positioned + } else { + ¶graph.state.positioned + }; + total + .checked_add(positioned.glyphs().len()) + .ok_or(EngineError::ResultTooLarge) + })?; + *gather_cache = None; + *prepared_gather_cache = None; + let capability_set = CapabilitySetId(request.capability_set); + let attempted_retained = cached_gather == Some(current_gather_key); + let retained = attempted_retained + && gather + .begin_retained(policy, record_count) + .map_err(gather_error)?; + if retained { + append_session_gather( + gather, + session, + policy, + capability_set, + font_bindings, + true, + checkpoint, + )?; + } + if !retained { + gather.begin(policy, record_count).map_err(gather_error)?; + append_session_gather( + gather, + session, + policy, + capability_set, + font_bindings, + false, + checkpoint, + )?; + } + let gathered = gather.view(); + let mut plan_input = gathered.plan_input(); + plan_input.order_independent = request.compositing_independent; + session + .plan + .prepare( + policy, + CapabilitySetId(request.capability_set), + plan_input, + checkpoint, + publication_generation, + request.acknowledged_publication_generation, + ) + .map_err(plan_error)?; + gather_output_matches_next = true; + } + let include_layout_inspection = + request.semantic_view_mask & super::frame::SEMANTIC_VIEW_LAYOUT_INSPECTION != 0; + if request.semantic_view_mask + & (super::frame::SEMANTIC_VIEW_MEASUREMENT + | super::frame::SEMANTIC_VIEW_LAYOUT_INSPECTION) + != 0 + { + let mut records = core::mem::take(&mut session.semantic_records); + let query = (|| { + for order_index in 0..session.active_order().len() { + let paragraph_id = session.active_order()[order_index].id; + let paragraph = session + .paragraph_mut(paragraph_id) + .ok_or(EngineError::InvalidRequest)?; + let state = &mut paragraph.state; + let visible_extents = { + let text = if state.text_prepared { + &state.pending_text + } else { + &state.text + }; + let clusters = if state.clusters_prepared { + &state.pending_clusters + } else { + &state.clusters + }; + let geometry = if state.geometry_prepared { + &state.pending_geometry + } else { + &state.geometry + }; + let flow = if state.flow_layout_prepared { + &state.pending_flow_layout + } else { + &state.flow_layout + }; + let flow_thread_id = geometry + .constraints + .first() + .ok_or(EngineError::InvalidRequest)? + .flow_thread_id; + super::layout_query::flow_extents(flow_thread_id, flow, text, clusters)? + }; + let active_flow = if state.flow_layout_prepared { + &state.pending_flow_layout + } else { + &state.flow_layout + }; + let active_line_count = active_flow.lines.len(); + let has_ellipsis = !active_flow.ellipsis_threads().is_empty(); + let cluster_count = if state.clusters_prepared { + state.pending_clusters.starts.len() + } else { + state.clusters.starts.len() + }; + let constraint = if state.geometry_prepared { + state.pending_geometry.constraints.first() + } else { + state.geometry.constraints.first() + } + .copied() + .ok_or(EngineError::InvalidRequest)?; + let needs_intrinsic = + visible_extents.consumed_clusters < cluster_count || has_ellipsis; + if needs_intrinsic { + state.prepare_intrinsic_flow_layout( + shaper.as_deref().ok_or(EngineError::InvalidRequest)?, + font_stacks, + font_bindings, + request.limits.max_lines, + request.limits.max_slots_per_band, + )?; + } + let max_lines_truncated = constraint.max_lines != 0 + && active_line_count + >= usize::try_from(constraint.max_lines) + .map_err(|_| EngineError::ResultTooLarge)?; + let inspect_full_clipped_layout = needs_intrinsic + && constraint.overflow == OVERFLOW_CLIP + && !max_lines_truncated; + if inspect_full_clipped_layout { + state.prepare_intrinsic_positioned( + shaper.as_deref().ok_or(EngineError::InvalidRequest)?, + )?; + } + let text = if state.text_prepared { + &state.pending_text + } else { + &state.text + }; + let clusters = if state.clusters_prepared { + &state.pending_clusters + } else { + &state.clusters + }; + let geometry = if state.geometry_prepared { + &state.pending_geometry + } else { + &state.geometry + }; + let active_flow = if state.flow_layout_prepared { + &state.pending_flow_layout + } else { + &state.flow_layout + }; + let active_positioned = if state.positioned_prepared { + &state.pending_positioned + } else { + &state.positioned + }; + let flow = if inspect_full_clipped_layout { + &state.intrinsic_flow_layout_scratch + } else { + active_flow + }; + let positioned = if inspect_full_clipped_layout { + &state.intrinsic_positioned_scratch + } else { + active_positioned + }; + let intrinsic_extents = if needs_intrinsic { + let flow_thread_id = geometry + .constraints + .first() + .ok_or(EngineError::InvalidRequest)? + .flow_thread_id; + Some(super::layout_query::flow_extents( + flow_thread_id, + &state.intrinsic_flow_layout_scratch, + text, + clusters, + )?) + } else { + None + }; + let (line_glyph_starts, line_glyph_counts) = + positioned.semantic_line_glyph_spans(); + super::layout_query::append_measurement( + &mut records, + paragraph_id, + text.len(), + clusters.starts.len(), + geometry, + flow, + positioned.semantic_glyphs(), + line_glyph_starts, + line_glyph_counts, + Some(positioned.semantic_line_inline_extents()), + text, + clusters, + intrinsic_extents, + include_layout_inspection, + )?; + } + Ok(()) + })(); + session.semantic_records = records; + query?; + } + session.pending_next_glyph_id = next_glyph_id; + session.pending_next_content_revision = next_content_revision; + session.pending_compositing_independent = request.compositing_independent; + Ok(()) + })(); + if let Err(error) = preparation { + session.abort_pending(); + return Err(error); + } + if gather_output_matches_next { + *prepared_gather_cache = Some(next_gather_key); + } + Ok(PreparedUpdate { + session_id: request.session_id, + previous: session.revision, + next, + required_base_revision: if checkpoint { 0 } else { session.revision.plan }, + checkpoint, + policy_handle: request.policy_handle, + capability_set: request.capability_set, + policy_fingerprint, + }) + } + + pub(crate) fn prepared_plan( + &self, + prepared: PreparedUpdate, + ) -> Result, EngineError> { + let session = self + .sessions + .get(&prepared.session_id) + .ok_or(EngineError::SessionMissing)?; + if session.revision != prepared.previous { + return Err(EngineError::RevisionConflict); + } + session + .plan + .plan_view( + prepared.policy_handle, + CapabilitySetId(prepared.capability_set), + prepared.policy_fingerprint, + ) + .map_err(plan_error) + } + + pub(crate) fn prepared_semantic_views( + &self, + prepared: PreparedUpdate, + ) -> Result<&[super::semantic_view::SemanticRecord], EngineError> { + let session = self + .sessions + .get(&prepared.session_id) + .ok_or(EngineError::SessionMissing)?; + if session.revision != prepared.previous { + return Err(EngineError::RevisionConflict); + } + Ok(&session.semantic_records) + } + + pub(crate) fn abort_update(&mut self, prepared: PreparedUpdate) -> Result<(), EngineError> { + let next_gather_key = prepared_gather_key(prepared, prepared.next); + let session = self + .sessions + .get_mut(&prepared.session_id) + .ok_or(EngineError::SessionMissing)?; + if session.revision != prepared.previous { + return Err(EngineError::RevisionConflict); + } + session.abort_pending(); + if self.prepared_gather_cache == Some(next_gather_key) { + self.prepared_gather_cache = None; + } + Ok(()) + } + + pub(crate) fn commit_update( + &mut self, + prepared: PreparedUpdate, + ) -> Result { + let previous_gather_key = prepared_gather_key(prepared, prepared.previous); + let next_gather_key = prepared_gather_key(prepared, prepared.next); + let session = self + .sessions + .get_mut(&prepared.session_id) + .ok_or(EngineError::SessionMissing)?; + if session.revision != prepared.previous { + return Err(EngineError::RevisionConflict); + } + session.plan.commit().map_err(plan_error)?; + session.commit_paragraphs(); + session.next_glyph_id = session.pending_next_glyph_id; + session.next_content_revision = session.pending_next_content_revision; + session.pending_next_glyph_id = 0; + session.pending_next_content_revision = 0; + session.compositing_independent = session.pending_compositing_independent; + session.policy_binding = Some(PolicyBinding { + handle: prepared.policy_handle, + fingerprint: prepared.policy_fingerprint, + }); + session.revision = prepared.next; + if self.prepared_gather_cache == Some(next_gather_key) { + self.gather_cache = Some(next_gather_key); + self.prepared_gather_cache = None; + } else if self.gather_cache == Some(previous_gather_key) { + self.gather_cache = Some(next_gather_key); + } + Ok(CommittedUpdate { + session_id: prepared.session_id, + revision: prepared.next, + required_base_revision: prepared.required_base_revision, + checkpoint: prepared.checkpoint, + }) + } +} + +fn prepared_gather_key(prepared: PreparedUpdate, revision: SessionRevision) -> GatherCacheKey { + GatherCacheKey { + session_id: prepared.session_id, + revision, + policy_handle: prepared.policy_handle, + policy_fingerprint: prepared.policy_fingerprint, + capability_set: prepared.capability_set, + } +} + +fn append_session_gather( + gather: &mut PolicyGatherWorkspace, + session: &EngineSession, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + font_bindings: &[RegisteredFontBinding], + retained: bool, + checkpoint: bool, +) -> Result<(), EngineError> { + let incremental = retained; + let mut retaining = retained; + for ordered in session.active_order() { + let paragraph = session + .paragraph(ordered.id) + .ok_or(EngineError::InvalidRequest)?; + let positioned = if paragraph.state.positioned_prepared { + ¶graph.state.pending_positioned + } else { + ¶graph.state.positioned + }; + let semantic_f32 = positioned.semantic_f32(); + let semantic_u32 = positioned.semantic_u32(); + let semantic_change_masks = if retaining && !paragraph.positioned_changed { + &[][..] + } else { + positioned.semantic_change_masks() + }; + let input = LayoutPlanInput { + transform_id: ordered.id, + glyphs: positioned.glyphs(), + semantic_glyphs: positioned.semantic_glyphs(), + semantic_change_masks, + semantic_f32: &semantic_f32, + semantic_u32: &semantic_u32, + }; + let binding_for_font = |handle| { + font_bindings + .iter() + .find(|binding| binding.handle == handle) + .map(|binding| &binding.binding) + }; + if retaining { + match gather + .append_retained(policy, capability_set, input, binding_for_font) + .map_err(gather_error)? + { + RetainedGather::Complete => {} + RetainedGather::RebuildFrom(source_start) => { + gather.truncate_to_retained_prefix(); + gather + .append_from( + policy, + capability_set, + input, + source_start, + true, + binding_for_font, + ) + .map_err(gather_error)?; + retaining = false; + } + } + } else { + gather + .append( + policy, + capability_set, + input, + incremental || checkpoint || !paragraph.positioned_changed, + binding_for_font, + ) + .map_err(gather_error)?; + } + } + if retaining && !gather.finish_retained() { + gather.truncate_to_retained_prefix(); + } + Ok(()) +} + +impl EngineSession { + #[cfg(test)] + fn first_paragraph_state(&self) -> Option<&ParagraphState> { + self.ordered_paragraphs + .first() + .and_then(|ordered| self.paragraph(ordered.id)) + .map(|paragraph| ¶graph.state) + .or_else(|| self.paragraphs.first().map(|paragraph| ¶graph.state)) + .or(self.spare_paragraph.as_ref()) + } + + fn paragraph(&self, id: u32) -> Option<&RetainedParagraph> { + self.paragraphs + .binary_search_by_key(&id, |paragraph| paragraph.id) + .ok() + .map(|index| &self.paragraphs[index]) + } + + fn paragraph_mut(&mut self, id: u32) -> Option<&mut RetainedParagraph> { + self.paragraphs + .binary_search_by_key(&id, |paragraph| paragraph.id) + .ok() + .map(|index| &mut self.paragraphs[index]) + } + + fn prepare_lifecycle( + &mut self, + mutations: super::semantic_wire::ParagraphMutationBatch<'_>, + implicit_paragraph: Option, + max_paragraphs: u32, + ) -> Result<(), EngineError> { + if self.lifecycle_prepared { + return Err(EngineError::InvalidRequest); + } + if mutations.len() == 0 && implicit_paragraph.is_none() { + return Ok(()); + } + self.lifecycle_prepared = true; + let result = (|| { + let mut creates = + usize::from(implicit_paragraph.is_some_and(|id| self.paragraph(id).is_none())); + let mut removals = 0usize; + for index in 0..mutations.len() { + match mutations.get(index).ok_or(EngineError::InvalidRequest)? { + super::semantic_wire::ParagraphMutation::Upsert { paragraph_id, .. } => { + creates += usize::from(self.paragraph(paragraph_id).is_none()); + } + super::semantic_wire::ParagraphMutation::Remove { paragraph_id } => { + if self.paragraph(paragraph_id).is_none() { + return Err(EngineError::InvalidRequest); + } + removals += 1; + } + } + } + let final_count = self + .paragraphs + .len() + .checked_add(creates) + .and_then(|count| count.checked_sub(removals)) + .ok_or(EngineError::InvalidRequest)?; + if final_count + > usize::try_from(max_paragraphs).map_err(|_| EngineError::InvalidRequest)? + { + return Err(EngineError::InvalidRequest); + } + self.paragraphs + .try_reserve(creates) + .map_err(|_| EngineError::ResultTooLarge)?; + self.pending_ordered_paragraphs + .try_reserve(final_count) + .map_err(|_| EngineError::ResultTooLarge)?; + + for index in 0..mutations.len() { + match mutations.get(index).ok_or(EngineError::InvalidRequest)? { + super::semantic_wire::ParagraphMutation::Upsert { + paragraph_id, + order, + } => self.prepare_upsert(paragraph_id, order)?, + super::semantic_wire::ParagraphMutation::Remove { paragraph_id } => { + self.paragraph_mut(paragraph_id) + .ok_or(EngineError::InvalidRequest)? + .pending_remove = true; + } + } + } + if let Some(paragraph_id) = implicit_paragraph + && self.paragraph(paragraph_id).is_none() + { + self.prepare_upsert(paragraph_id, 0)?; + } + + self.pending_ordered_paragraphs.clear(); + for paragraph in &self.paragraphs { + if paragraph.pending_remove { + continue; + } + self.pending_ordered_paragraphs.push(ParagraphOrder { + order: paragraph.pending_order.unwrap_or(paragraph.order), + id: paragraph.id, + }); + } + self.pending_ordered_paragraphs + .sort_unstable_by_key(|paragraph| (paragraph.order, paragraph.id)); + if self + .pending_ordered_paragraphs + .windows(2) + .any(|pair| pair[0].order == pair[1].order) + { + return Err(EngineError::InvalidRequest); + } + self.lifecycle_changed = self.pending_ordered_paragraphs != self.ordered_paragraphs; + Ok(()) + })(); + if result.is_err() { + self.abort_lifecycle(); + } + result + } + + fn prepare_upsert(&mut self, id: u32, order: u32) -> Result<(), EngineError> { + match self + .paragraphs + .binary_search_by_key(&id, |paragraph| paragraph.id) + { + Ok(index) => { + self.paragraphs[index].pending_order = Some(order); + Ok(()) + } + Err(index) => { + let state = if let Some(spare) = self.spare_paragraph.take() { + let mut spare = spare; + spare.reset_for_reuse(); + spare + } else { + let mut state = ParagraphState::default(); + state.initialize()?; + state + }; + self.paragraphs.insert( + index, + RetainedParagraph { + id, + order, + pending_order: Some(order), + pending_remove: false, + created: true, + positioned_changed: false, + state, + }, + ); + Ok(()) + } + } + } + + fn active_order(&self) -> &[ParagraphOrder] { + if self.lifecycle_prepared { + &self.pending_ordered_paragraphs + } else { + &self.ordered_paragraphs + } + } + + fn abort_pending(&mut self) { + self.plan.abort(); + self.semantic_records.clear(); + for paragraph in &mut self.paragraphs { + paragraph.state.abort_all(); + paragraph.positioned_changed = false; + } + self.abort_lifecycle(); + self.pending_next_glyph_id = 0; + self.pending_next_content_revision = 0; + self.pending_compositing_independent = self.compositing_independent; + } + + fn abort_lifecycle(&mut self) { + let mut index = 0; + while index < self.paragraphs.len() { + if self.paragraphs[index].created { + let mut paragraph = self.paragraphs.remove(index); + paragraph.state.abort_all(); + if self.spare_paragraph.is_none() { + self.spare_paragraph = Some(paragraph.state); + } + } else { + let paragraph = &mut self.paragraphs[index]; + paragraph.pending_order = None; + paragraph.pending_remove = false; + paragraph.positioned_changed = false; + index += 1; + } + } + self.pending_ordered_paragraphs.clear(); + self.lifecycle_prepared = false; + self.lifecycle_changed = false; + } + + fn commit_paragraphs(&mut self) { + for paragraph in &mut self.paragraphs { + paragraph.state.commit_all(); + paragraph.positioned_changed = false; + } + if !self.lifecycle_prepared { + return; + } + let mut index = 0; + while index < self.paragraphs.len() { + if self.paragraphs[index].pending_remove { + let paragraph = self.paragraphs.remove(index); + if self.spare_paragraph.is_none() { + self.spare_paragraph = Some(paragraph.state); + } + } else { + let paragraph = &mut self.paragraphs[index]; + if let Some(order) = paragraph.pending_order.take() { + paragraph.order = order; + } + paragraph.created = false; + index += 1; + } + } + core::mem::swap( + &mut self.ordered_paragraphs, + &mut self.pending_ordered_paragraphs, + ); + self.pending_ordered_paragraphs.clear(); + self.lifecycle_prepared = false; + self.lifecycle_changed = false; + } +} + +impl ParagraphState { + /// Clears paragraph identity and committed/pending semantics while retaining every allocation. + #[inline(never)] + fn reset_for_reuse(&mut self) { + self.text.clear(); + self.pending_text.clear(); + self.text_unit_ids.clear(); + self.pending_text_unit_ids.clear(); + self.pending_text_mirrors_committed = true; + self.next_text_unit_id = 0; + self.pending_next_text_unit_id = 0; + self.text_prepared = false; + self.text_edit = None; + self.styles.clear(); + self.pending_styles.clear(); + self.resolved_styles.clear(); + self.pending_resolved_styles.clear(); + self.unicode.clear(); + self.pending_unicode.clear(); + self.unicode_reused_for_text_edit = false; + self.bidi.clear(); + self.pending_bidi.clear(); + self.shaping_runs.clear(); + self.pending_shaping_runs.clear(); + self.shape.clear(); + self.pending_shape.clear(); + self.incremental_shape_source_run = None; + self.clusters.clear(); + self.pending_clusters.clear(); + self.geometry.clear(); + self.pending_geometry.clear(); + self.flow_layout.clear(); + self.pending_flow_layout.clear(); + self.intrinsic_geometry_scratch.clear(); + self.intrinsic_flow_layout_scratch.clear(); + self.intrinsic_positioned_scratch.clear(); + self.boundary_shape.clear(); + self.pending_boundary_shape.clear(); + self.boundary_shape_scratch.clear(); + self.ellipsis_shape_scratch.clear(); + self.ellipsis_text_scratch.clear(); + self.positioned.clear(); + self.pending_positioned.clear(); + self.fallback_spans.clear(); + self.pending_fallback_spans.clear(); + self.fallback_span_scratch.clear(); + self.fallback_cluster_scratch.clear(); + self.style_mutation_scratch.clear(); + self.style_order_scratch.clear(); + self.style_nesting_scratch.clear(); + self.style_resolution_scratch.clear(); + self.styles_prepared = false; + self.style_invalidation = StyleInvalidation::default(); + self.unicode_prepared = false; + self.bidi_prepared = false; + self.shaping_runs_prepared = false; + self.shape_prepared = false; + self.clusters_prepared = false; + self.geometry_fingerprint = 0; + self.pending_geometry_fingerprint = 0; + self.geometry_prepared = false; + self.flow_layout_prepared = false; + self.positioned_prepared = false; + } + + #[allow(clippy::too_many_arguments)] + fn prepare( + &mut self, + mut shaper: Option<&mut ShaperRegistry>, + font_stacks: &[RegisteredFontStack], + font_bindings: &[RegisteredFontBinding], + text_mutations: super::semantic_wire::TextMutationBatch<'_>, + style_mutations: super::semantic_wire::StyleMutationBatch<'_>, + geometry: super::semantic_wire::GeometryBatch<'_>, + limits: super::frame::UpdateLimits, + next_glyph_id: &mut u32, + next_content_revision: &mut u32, + ) -> Result { + self.prepare_text(text_mutations)?; + self.prepare_styles(style_mutations, |handle| { + font_stacks + .binary_search_by_key(&handle, |stack| stack.handle) + .is_ok() + })?; + self.prepare_unicode()?; + self.prepare_bidi()?; + self.prepare_shaping_runs()?; + if let Some(shaper) = shaper.as_deref_mut() { + self.prepare_shape(shaper, font_stacks, font_bindings)?; + self.prepare_clusters(shaper, next_glyph_id)?; + } + self.prepare_geometry(geometry)?; + let flow_changed = + self.clusters_prepared || self.geometry_prepared || self.style_invalidation.metrics; + let positioned_changed = flow_changed || self.style_invalidation.positioning; + if let Some(shaper) = shaper { + if flow_changed { + self.prepare_flow_layout( + shaper, + font_stacks, + font_bindings, + limits.max_lines, + limits.max_slots_per_band, + next_glyph_id, + )?; + } + if positioned_changed { + self.prepare_positioned(shaper, next_content_revision)?; + } + } + Ok(positioned_changed) + } + + fn abort_all(&mut self) { + self.abort_text(); + self.abort_styles(); + self.abort_unicode(); + self.abort_bidi(); + self.abort_shaping_runs(); + self.abort_shape(); + self.abort_clusters(); + self.abort_geometry(); + self.abort_flow_layout(); + self.abort_positioned(); + } + + fn commit_all(&mut self) { + self.commit_text(); + self.commit_styles(); + self.commit_unicode(); + self.commit_bidi(); + self.commit_shaping_runs(); + self.commit_shape(); + self.commit_clusters(); + self.commit_geometry(); + self.commit_flow_layout(); + self.commit_positioned(); + } + + fn initialize(&mut self) -> Result<(), EngineError> { + self.styles.reserve_default()?; + self.pending_styles.reserve_default()?; + self.resolved_styles.reserve_default()?; + self.pending_resolved_styles.reserve_default()?; + reserve_vec(&mut self.style_mutation_scratch, DEFAULT_STYLE_CAPACITY)?; + reserve_vec(&mut self.style_order_scratch, DEFAULT_STYLE_CAPACITY)?; + reserve_vec(&mut self.style_nesting_scratch, DEFAULT_STYLE_CAPACITY)?; + reserve_vec(&mut self.style_resolution_scratch, DEFAULT_STYLE_CAPACITY) + } + + fn reserve_text(&mut self, capacity: usize) -> Result<(), EngineError> { + reserve_text_buffer(&mut self.text, capacity)?; + reserve_text_buffer(&mut self.pending_text, capacity)?; + reserve_vec(&mut self.text_unit_ids, capacity)?; + reserve_vec(&mut self.pending_text_unit_ids, capacity)?; + self.unicode.reserve(capacity).map_err(unicode_error)?; + self.pending_unicode + .reserve(capacity) + .map_err(unicode_error)?; + self.bidi.reserve(capacity).map_err(bidi_error)?; + self.pending_bidi.reserve(capacity).map_err(bidi_error)?; + self.shaping_runs.reserve(capacity)?; + self.pending_shaping_runs.reserve(capacity)?; + let glyph_capacity = capacity.saturating_mul(2); + self.shape.reserve(glyph_capacity)?; + self.pending_shape.reserve(glyph_capacity)?; + self.clusters.reserve(capacity)?; + self.pending_clusters.reserve(capacity)?; + self.flow_layout.reserve(capacity, 1)?; + self.pending_flow_layout.reserve(capacity, 1)?; + self.intrinsic_flow_layout_scratch.reserve(capacity, 1)?; + self.boundary_shape.reserve(capacity.min(64))?; + self.pending_boundary_shape.reserve(capacity.min(64))?; + self.boundary_shape_scratch.reserve(glyph_capacity)?; + self.ellipsis_shape_scratch.reserve(4)?; + if self.ellipsis_text_scratch.capacity() == 0 { + self.ellipsis_text_scratch + .try_reserve_exact(1) + .map_err(|_| EngineError::ResultTooLarge)?; + } + self.positioned.reserve(glyph_capacity)?; + self.pending_positioned.reserve(glyph_capacity)?; + self.intrinsic_positioned_scratch.reserve(glyph_capacity)?; + self.glyph_identity_index + .prepare(glyph_capacity) + .map_err(|_| EngineError::ResultTooLarge)?; + reserve_vec(&mut self.fallback_spans, capacity)?; + reserve_vec(&mut self.pending_fallback_spans, capacity)?; + reserve_vec(&mut self.fallback_span_scratch, capacity)?; + reserve_vec(&mut self.fallback_cluster_scratch, glyph_capacity) + } + + fn prepare_text( + &mut self, + mutations: super::semantic_wire::TextMutationBatch<'_>, + ) -> Result<(), EngineError> { + self.abort_text(); + if mutations.len() == 0 { + return Ok(()); + } + if !self.pending_text_mirrors_committed { + if self.pending_text.try_reserve(self.text.len()).is_err() { + return Err(EngineError::ResultTooLarge); + } + if self + .pending_text_unit_ids + .try_reserve(self.text_unit_ids.len()) + .is_err() + { + return Err(EngineError::ResultTooLarge); + } + self.pending_text.clear(); + self.pending_text.extend_from_slice(&self.text); + self.pending_text_unit_ids.clear(); + self.pending_text_unit_ids + .extend_from_slice(&self.text_unit_ids); + self.pending_text_mirrors_committed = true; + } + self.pending_next_text_unit_id = self.next_text_unit_id.max(1); + self.text_prepared = true; + self.pending_text_mirrors_committed = false; + for index in 0..mutations.len() { + let Some(mutation) = mutations.get(index) else { + self.abort_text(); + return Err(EngineError::InvalidRequest); + }; + if let Err(error) = apply_text_mutation(&mut self.pending_text, mutation) { + self.abort_text(); + return Err(match error { + TextMutationError::Invalid => EngineError::InvalidRequest, + TextMutationError::Allocation => EngineError::ResultTooLarge, + }); + } + if let Err(error) = apply_text_identity_mutation( + &mut self.pending_text_unit_ids, + &mut self.pending_next_text_unit_id, + mutation, + ) { + self.abort_text(); + return Err(error); + } + } + if self.pending_text.len() != self.pending_text_unit_ids.len() { + self.abort_text(); + return Err(EngineError::InvalidRequest); + } + self.text_edit = changed_identity_range(&self.text_unit_ids, &self.pending_text_unit_ids); + Ok(()) + } + + fn abort_text(&mut self) { + if self.text_prepared { + self.pending_text.clear(); + self.pending_text.extend_from_slice(&self.text); + self.pending_text_unit_ids.clear(); + self.pending_text_unit_ids + .extend_from_slice(&self.text_unit_ids); + self.pending_text_mirrors_committed = true; + } + self.clear_text_preparation(); + } + + fn clear_text_preparation(&mut self) { + self.pending_next_text_unit_id = 0; + self.text_prepared = false; + self.text_edit = None; + } + + fn prepare_styles( + &mut self, + mutations: super::semantic_wire::StyleMutationBatch<'_>, + font_stack_exists: impl FnMut(u32) -> bool, + ) -> Result<(), EngineError> { + self.abort_styles(); + if mutations.len() == 0 { + if !self.text_prepared || self.styles.len() == 0 { + return Ok(()); + } + return self.styles.validate( + self.pending_text.as_slice(), + font_stack_exists, + &mut self.style_order_scratch, + &mut self.style_nesting_scratch, + ); + } + self.pending_styles.prepare_from( + &self.styles, + mutations, + &mut self.style_mutation_scratch, + )?; + if self.styles.len() != 0 && self.pending_styles.len() == 0 { + self.abort_styles(); + return Err(EngineError::InvalidRequest); + } + let text = if self.text_prepared { + self.pending_text.as_slice() + } else { + self.text.as_slice() + }; + if let Err(error) = self.pending_styles.validate( + text, + font_stack_exists, + &mut self.style_order_scratch, + &mut self.style_nesting_scratch, + ) { + self.abort_styles(); + return Err(error); + } + if let Err(error) = self.pending_styles.resolve( + &self.style_order_scratch, + &mut self.pending_resolved_styles, + &mut self.style_resolution_scratch, + ) { + self.abort_styles(); + return Err(error); + } + self.style_invalidation = self.resolved_styles.invalidation_against( + &self.styles, + &self.pending_resolved_styles, + &self.pending_styles, + ); + self.styles_prepared = true; + Ok(()) + } + + fn abort_styles(&mut self) { + self.pending_styles.clear(); + self.pending_resolved_styles.clear(); + self.style_mutation_scratch.clear(); + self.style_order_scratch.clear(); + self.style_nesting_scratch.clear(); + self.style_resolution_scratch.clear(); + self.styles_prepared = false; + self.style_invalidation = StyleInvalidation::default(); + } + + fn commit_styles(&mut self) { + if self.styles_prepared { + core::mem::swap(&mut self.styles, &mut self.pending_styles); + core::mem::swap(&mut self.resolved_styles, &mut self.pending_resolved_styles); + } + self.abort_styles(); + } + + fn commit_text(&mut self) { + if self.text_prepared { + let retains_mirror = self.pending_text.len() == self.text.len(); + let edit = self.text_edit; + core::mem::swap(&mut self.text, &mut self.pending_text); + core::mem::swap(&mut self.text_unit_ids, &mut self.pending_text_unit_ids); + self.next_text_unit_id = self.pending_next_text_unit_id; + if retains_mirror { + if let Some(edit) = edit { + self.pending_text[edit.old_start..edit.new_end] + .copy_from_slice(&self.text[edit.old_start..edit.new_end]); + self.pending_text_unit_ids[edit.old_start..edit.new_end] + .copy_from_slice(&self.text_unit_ids[edit.old_start..edit.new_end]); + } + self.pending_text_mirrors_committed = true; + } else { + self.pending_text.clear(); + self.pending_text_unit_ids.clear(); + self.pending_text_mirrors_committed = false; + } + } + self.clear_text_preparation(); + } + + fn prepare_unicode(&mut self) -> Result<(), EngineError> { + self.abort_unicode(); + if !self.text_prepared { + return Ok(()); + } + if let Some(edit) = self.text_edit + && self.unicode.reusable_for_ascii_letter_edit( + &self.text, + &self.pending_text, + edit.old_start, + edit.old_end, + edit.new_end, + ) + { + self.unicode_reused_for_text_edit = true; + return Ok(()); + } + self.pending_unicode + .analyze(&self.pending_text) + .map_err(unicode_error)?; + self.unicode_prepared = true; + Ok(()) + } + + fn abort_unicode(&mut self) { + self.unicode_prepared = false; + self.unicode_reused_for_text_edit = false; + } + + fn commit_unicode(&mut self) { + if self.unicode_prepared { + core::mem::swap(&mut self.unicode, &mut self.pending_unicode); + } + self.abort_unicode(); + } + + fn prepare_bidi(&mut self) -> Result<(), EngineError> { + self.abort_bidi(); + if self.unicode_reused_for_text_edit && !self.style_invalidation.bidi { + return Ok(()); + } + if !self.text_prepared && !self.style_invalidation.bidi { + return Ok(()); + } + let text = if self.text_prepared { + self.pending_text.as_slice() + } else { + self.text.as_slice() + }; + let styles = if self.styles_prepared { + &self.pending_resolved_styles + } else { + &self.resolved_styles + }; + let direction = styles + .segments() + .first() + .map_or(DIRECTION_AUTO, |segment| segment.style.direction); + analyze_bidi_into(text, direction, &mut self.pending_bidi).map_err(bidi_error)?; + self.bidi_prepared = true; + Ok(()) + } + + fn abort_bidi(&mut self) { + self.bidi_prepared = false; + } + + fn commit_bidi(&mut self) { + if self.bidi_prepared { + core::mem::swap(&mut self.bidi, &mut self.pending_bidi); + } + self.abort_bidi(); + } + + fn prepare_shaping_runs(&mut self) -> Result<(), EngineError> { + self.abort_shaping_runs(); + if !self.text_prepared && !self.style_invalidation.shaping && !self.bidi_prepared { + return Ok(()); + } + let text = if self.text_prepared { + self.pending_text.as_slice() + } else { + self.text.as_slice() + }; + let styles = if self.styles_prepared { + self.pending_resolved_styles.segments() + } else { + self.resolved_styles.segments() + }; + let style_storage = if self.styles_prepared { + &self.pending_styles + } else { + &self.styles + }; + let unicode = if self.unicode_prepared { + &self.pending_unicode + } else { + &self.unicode + }; + let bidi = if self.bidi_prepared { + &self.pending_bidi + } else { + &self.bidi + }; + self.pending_shaping_runs + .build(text, styles, style_storage, unicode, bidi)?; + self.shaping_runs_prepared = true; + Ok(()) + } + + fn abort_shaping_runs(&mut self) { + self.pending_shaping_runs.clear(); + self.shaping_runs_prepared = false; + } + + fn commit_shaping_runs(&mut self) { + if self.shaping_runs_prepared { + core::mem::swap(&mut self.shaping_runs, &mut self.pending_shaping_runs); + } + self.abort_shaping_runs(); + } + + fn prepare_shape( + &mut self, + shaper: &mut ShaperRegistry, + font_stacks: &[RegisteredFontStack], + font_bindings: &[RegisteredFontBinding], + ) -> Result<(), EngineError> { + self.abort_shape(); + if !self.shaping_runs_prepared { + return Ok(()); + } + if self.try_prepare_incremental_shape(shaper)? { + self.shape_prepared = true; + return Ok(()); + } + let text = if self.text_prepared { + self.pending_text.as_slice() + } else { + self.text.as_slice() + }; + let styles = if self.styles_prepared { + &self.pending_styles + } else { + &self.styles + }; + let runs = self.pending_shaping_runs.runs(); + let mut max_stack_depth = 0usize; + for (index, run) in runs.iter().copied().enumerate() { + let stack = find_font_stack(font_stacks, run.style.font_stack_handle)?; + let binding_handle = *stack.fonts.first().ok_or(EngineError::FontStackMissing)?; + let font_handle = find_font_binding(font_bindings, binding_handle)?.shaping_handle; + max_stack_depth = max_stack_depth.max(stack.fonts.len()); + push_fallback_span( + &mut self.pending_fallback_spans, + FallbackSpan { + source_run: u32::try_from(index).map_err(|_| EngineError::ResultTooLarge)?, + text_start: run.text_start, + text_end: run.text_end, + font_index: 0, + binding_handle, + font_handle, + }, + )?; + } + for _ in 0..max_stack_depth.max(1) { + self.pending_shape.clear(); + for span in self.pending_fallback_spans.iter().copied() { + let source_index = + usize::try_from(span.source_run).map_err(|_| EngineError::InvalidRequest)?; + let run = *runs.get(source_index).ok_or(EngineError::InvalidRequest)?; + let output = &mut self.pending_shape; + shaper + .with_shaped_run( + span.font_handle, + text, + ShapeRunRef { + text_start: span.text_start, + text_end: span.text_end, + script: run.script, + language: styles.resolved_language(run.style), + features: styles.resolved_features(run.style), + direction: run.direction, + cluster_level: 0, + flags: 0x40, + }, + |shaped| { + output.append( + source_index, + span.font_handle, + span.binding_handle, + span.text_start, + span.text_end, + shaped, + ) + }, + ) + .map_err(shaper_error)?; + } + collect_cluster_records(&self.pending_shape, &mut self.fallback_cluster_scratch)?; + self.fallback_span_scratch.clear(); + let mut changed = false; + let mut cluster_index = 0usize; + for span in self.pending_fallback_spans.iter().copied() { + while self + .fallback_cluster_scratch + .get(cluster_index) + .is_some_and(|record| { + record.source_run < span.source_run + || (record.source_run == span.source_run + && record.cluster < span.text_start) + }) + { + cluster_index += 1; + } + let stack_handle = runs + .get(span.source_run as usize) + .ok_or(EngineError::InvalidRequest)? + .style + .font_stack_handle; + let stack = find_font_stack(font_stacks, stack_handle)?; + let next_font_index = span.font_index.checked_add(1); + let next_binding = + next_font_index.and_then(|index| stack.fonts.get(usize::from(index)).copied()); + let mut cursor = span.text_start; + let mut record_index = cluster_index; + while let Some(record) = self.fallback_cluster_scratch.get(record_index).copied() { + if record.source_run != span.source_run || record.cluster >= span.text_end { + break; + } + if record.missing + && let (Some(font_index), Some(binding_handle)) = + (next_font_index, next_binding) + { + let font_handle = + find_font_binding(font_bindings, binding_handle)?.shaping_handle; + let cluster_start = record.cluster.max(cursor); + let cluster_end = self + .fallback_cluster_scratch + .get(record_index + 1) + .filter(|next| next.source_run == span.source_run) + .map_or_else( + || { + runs.get(span.source_run as usize) + .map_or(span.text_end, |run| run.text_end) + }, + |next| next.cluster, + ) + .min(span.text_end); + if cursor < cluster_start { + push_fallback_span( + &mut self.fallback_span_scratch, + FallbackSpan { + text_start: cursor, + text_end: cluster_start, + ..span + }, + )?; + } + if cluster_start < cluster_end { + push_fallback_span( + &mut self.fallback_span_scratch, + FallbackSpan { + text_start: cluster_start, + text_end: cluster_end, + font_index, + binding_handle, + font_handle, + ..span + }, + )?; + cursor = cluster_end; + changed = true; + } + } + record_index += 1; + } + if cursor < span.text_end || span.text_start == span.text_end { + push_fallback_span( + &mut self.fallback_span_scratch, + FallbackSpan { + text_start: cursor, + ..span + }, + )?; + } + } + if !changed { + self.shape_prepared = true; + return Ok(()); + } + core::mem::swap( + &mut self.pending_fallback_spans, + &mut self.fallback_span_scratch, + ); + } + Err(EngineError::InvalidRequest) + } + + fn try_prepare_incremental_shape( + &mut self, + shaper: &mut ShaperRegistry, + ) -> Result { + let Some(edit) = self.text_edit else { + return Ok(false); + }; + let old_runs = self.shaping_runs.runs(); + let new_runs = self.pending_shaping_runs.runs(); + if old_runs.len() != new_runs.len() || old_runs.is_empty() { + return Ok(false); + } + let Some(old_run_index) = containing_run(old_runs, edit.old_start, edit.old_end) else { + return Ok(false); + }; + let Some(new_run_index) = containing_run(new_runs, edit.old_start, edit.new_end) else { + return Ok(false); + }; + if old_run_index != new_run_index + || !same_edit_run_topology(old_runs, new_runs, edit, old_run_index)? + { + return Ok(false); + } + let old_run = old_runs[old_run_index]; + let new_run = new_runs[new_run_index]; + let affected_source_run = + u32::try_from(old_run_index).map_err(|_| EngineError::ResultTooLarge)?; + let mut affected_fallbacks = self + .fallback_spans + .iter() + .copied() + .filter(|span| span.source_run == affected_source_run); + let Some(fallback) = affected_fallbacks.next() else { + return Ok(false); + }; + if affected_fallbacks.next().is_some() + || fallback.font_index != 0 + || fallback.text_start != old_run.text_start + || fallback.text_end != old_run.text_end + { + return Ok(false); + } + if self + .shape + .runs + .iter() + .filter(|run| run.source_run == affected_source_run) + .count() + != 1 + { + return Ok(false); + } + let delta = edit_delta(edit)?; + let styles = if self.styles_prepared { + &self.pending_styles + } else { + &self.styles + }; + self.boundary_shape_scratch.clear(); + let scratch = &mut self.boundary_shape_scratch; + shaper + .with_shaped_run( + fallback.font_handle, + &self.pending_text, + ShapeRunRef { + text_start: new_run.text_start, + text_end: new_run.text_end, + script: new_run.script, + language: styles.resolved_language(new_run.style), + features: styles.resolved_features(new_run.style), + direction: new_run.direction, + cluster_level: 0, + flags: 0x40, + }, + |shaped| { + scratch.append( + old_run_index, + fallback.font_handle, + fallback.binding_handle, + new_run.text_start, + new_run.text_end, + shaped, + ) + }, + ) + .map_err(shaper_error)?; + if self.boundary_shape_scratch.glyph_ids.contains(&0) { + self.boundary_shape_scratch.clear(); + return Ok(false); + } + for (shape_run_index, shaped_run) in self.shape.runs.iter().copied().enumerate() { + if shaped_run.source_run == affected_source_run { + self.pending_shape.append_text_range_from( + &self.boundary_shape_scratch, + 0, + affected_source_run, + new_run.text_start, + new_run.text_end, + 0, + )?; + continue; + } + let run_delta = if shaped_run.text_start >= old_run.text_end { + delta + } else { + 0 + }; + self.pending_shape.append_text_range_from( + &self.shape, + shape_run_index, + shaped_run.source_run, + shaped_run.text_start, + shaped_run.text_end, + run_delta, + )?; + } + for span in self.fallback_spans.iter().copied() { + let (text_start, text_end) = if span.source_run == affected_source_run { + (new_run.text_start, new_run.text_end) + } else { + ( + map_old_offset(span.text_start, edit)?, + map_old_offset(span.text_end, edit)?, + ) + }; + self.pending_fallback_spans.push(FallbackSpan { + text_start, + text_end, + ..span + }); + } + self.boundary_shape_scratch.clear(); + self.incremental_shape_source_run = Some(affected_source_run); + Ok(true) + } + + fn abort_shape(&mut self) { + self.pending_shape.clear(); + self.pending_fallback_spans.clear(); + self.fallback_span_scratch.clear(); + self.fallback_cluster_scratch.clear(); + self.incremental_shape_source_run = None; + self.shape_prepared = false; + } + + fn commit_shape(&mut self) { + if self.shape_prepared { + core::mem::swap(&mut self.shape, &mut self.pending_shape); + core::mem::swap(&mut self.fallback_spans, &mut self.pending_fallback_spans); + } + self.abort_shape(); + } + + fn prepare_clusters( + &mut self, + shaper: &ShaperRegistry, + next_glyph_id: &mut u32, + ) -> Result<(), EngineError> { + self.abort_clusters(); + if !self.shape_prepared && !self.style_invalidation.metrics { + return Ok(()); + } + let text = if self.text_prepared { + self.pending_text.as_slice() + } else { + self.text.as_slice() + }; + let text_unit_ids = if self.text_prepared { + self.pending_text_unit_ids.as_slice() + } else { + self.text_unit_ids.as_slice() + }; + let unicode = if self.unicode_prepared { + &self.pending_unicode + } else { + &self.unicode + }; + let styles = if self.styles_prepared { + self.pending_resolved_styles.segments() + } else { + self.resolved_styles.segments() + }; + let runs = if self.shaping_runs_prepared { + self.pending_shaping_runs.runs() + } else { + self.shaping_runs.runs() + }; + if runs.is_empty() { + self.pending_clusters.clear(); + self.clusters_prepared = true; + return Ok(()); + } + let shape = if self.shape_prepared { + &self.pending_shape + } else { + &self.shape + }; + let build_input = || ClusterBuildInput { + text, + text_unit_ids, + unicode, + styles, + runs, + shape, + }; + if let Some(source_run) = self.incremental_shape_source_run + && let Some((cluster_start, cluster_end)) = self + .pending_clusters + .rebuild_source_run_if_topology_is_stable( + &self.clusters, + build_input(), + source_run, + |handle| shaper.font_metrics(handle), + )? + { + if let Err(error) = self.pending_clusters.assign_stable_glyph_ids_in_range( + &self.clusters, + cluster_start, + cluster_end, + &mut self.glyph_identity_index, + next_glyph_id, + ) { + self.abort_clusters(); + return Err(error); + } + self.clusters_prepared = true; + return Ok(()); + } + self.pending_clusters + .build(build_input(), |handle| shaper.font_metrics(handle))?; + if let Err(error) = self.pending_clusters.assign_stable_glyph_ids( + &self.clusters, + &mut self.glyph_identity_index, + next_glyph_id, + ) { + self.abort_clusters(); + return Err(error); + } + self.clusters_prepared = true; + Ok(()) + } + + fn abort_clusters(&mut self) { + self.pending_clusters.clear(); + self.clusters_prepared = false; + } + + fn commit_clusters(&mut self) { + if self.clusters_prepared { + core::mem::swap(&mut self.clusters, &mut self.pending_clusters); + } + self.abort_clusters(); + } + + fn prepare_geometry( + &mut self, + geometry: super::semantic_wire::GeometryBatch<'_>, + ) -> Result<(), EngineError> { + self.abort_geometry(); + if geometry.is_empty() { + return Ok(()); + } + let text_length = if self.text_prepared { + self.pending_text.len() + } else { + self.text.len() + }; + geometry + .validate_text_length(text_length) + .map_err(|_| EngineError::InvalidRequest)?; + self.pending_geometry.build(geometry)?; + self.pending_geometry_fingerprint = geometry.fingerprint(); + if geometry.inline_object_count() == 0 && self.pending_geometry == self.geometry { + self.pending_geometry.clear(); + self.pending_geometry_fingerprint = 0; + return Ok(()); + } + self.geometry_prepared = true; + Ok(()) + } + + fn abort_geometry(&mut self) { + self.pending_geometry.clear(); + self.pending_geometry_fingerprint = 0; + self.geometry_prepared = false; + } + + fn commit_geometry(&mut self) { + if self.geometry_prepared { + self.geometry_fingerprint = self.pending_geometry_fingerprint; + core::mem::swap(&mut self.geometry, &mut self.pending_geometry); + } + self.abort_geometry(); + } + + fn prepare_flow_layout( + &mut self, + shaper: &mut ShaperRegistry, + font_stacks: &[RegisteredFontStack], + font_bindings: &[RegisteredFontBinding], + max_lines: u32, + max_slots_per_band: u32, + next_glyph_id: &mut u32, + ) -> Result<(), EngineError> { + self.abort_flow_layout(); + self.pending_boundary_shape.clear(); + let clusters = if self.clusters_prepared { + &self.pending_clusters + } else { + &self.clusters + }; + let styles = if self.styles_prepared { + self.pending_resolved_styles.segments() + } else { + self.resolved_styles.segments() + }; + let style_storage = if self.styles_prepared { + &self.pending_styles + } else { + &self.styles + }; + let runs = if self.shaping_runs_prepared { + self.pending_shaping_runs.runs() + } else { + self.shaping_runs.runs() + }; + let text = if self.text_prepared { + self.pending_text.as_slice() + } else { + self.text.as_slice() + }; + let geometry = if self.geometry_prepared { + &self.pending_geometry + } else { + &self.geometry + }; + let max_slots_per_band = + usize::try_from(max_slots_per_band).map_err(|_| EngineError::ResultTooLarge)?; + let max_lines = usize::try_from(max_lines).map_err(|_| EngineError::ResultTooLarge)?; + if !self.geometry_prepared + && !self.style_invalidation.metrics + && self.boundary_shape.records.is_empty() + && geometry + .constraints + .iter() + .all(|constraint| constraint.overflow != OVERFLOW_ELLIPSIS) + && let Some(edit) = self.text_edit + && edit.old_end.saturating_sub(edit.old_start) + == edit.new_end.saturating_sub(edit.old_start) + && self.pending_flow_layout.rebuild_until_state_converges( + &self.flow_layout, + geometry, + &self.clusters, + clusters, + styles, + &mut self.flow_slot_scratch, + u32::try_from(edit.old_start).map_err(|_| EngineError::ResultTooLarge)?, + max_lines, + max_slots_per_band, + |handle| shaper.font_metrics(handle), + |stack_handle| { + font_stacks + .binary_search_by_key(&stack_handle, |stack| stack.handle) + .ok() + .and_then(|index| font_stacks[index].fonts.first().copied()) + .and_then(|handle| { + font_bindings + .iter() + .find(|binding| binding.handle == handle) + .map(|binding| binding.shaping_handle) + }) + }, + )? + { + self.flow_layout_prepared = true; + return Ok(()); + } + self.pending_flow_layout.build( + geometry, + clusters, + styles, + &mut self.flow_slot_scratch, + max_lines, + max_slots_per_band, + |handle| shaper.font_metrics(handle), + |stack_handle| { + font_stacks + .binary_search_by_key(&stack_handle, |stack| stack.handle) + .ok() + .and_then(|index| font_stacks[index].fonts.first().copied()) + .and_then(|handle| { + font_bindings + .iter() + .find(|binding| binding.handle == handle) + .map(|binding| binding.shaping_handle) + }) + }, + )?; + let mut ellipsis_index = 0usize; + while ellipsis_index < self.pending_flow_layout.ellipsis_threads().len() { + let flow_thread_id = self.pending_flow_layout.ellipsis_threads()[ellipsis_index]; + let line = self + .pending_flow_layout + .lines + .iter() + .rev() + .find(|line| line.flow_thread_id == flow_thread_id) + .copied() + .ok_or(EngineError::InvalidRequest)?; + let fragment_index = usize::try_from(line.fragment_start) + .map_err(|_| EngineError::InvalidRequest)? + .checked_add(usize::from(line.fragment_count)) + .and_then(|end| end.checked_sub(1)) + .ok_or(EngineError::InvalidRequest)?; + let fragment = *self + .pending_flow_layout + .fragments + .get(fragment_index) + .ok_or(EngineError::InvalidRequest)?; + let line_cluster_start = usize::try_from(fragment.line.cluster_start) + .map_err(|_| EngineError::InvalidRequest)?; + let line_text_start = fragment.line.text_start; + let source_shape = &mut self.boundary_shape_scratch; + let ellipsis_shape = &mut self.ellipsis_shape_scratch; + let ellipsis_text = &mut self.ellipsis_text_scratch; + let mut final_candidate = None; + let target = self + .pending_flow_layout + .truncate_for_ellipsis(flow_thread_id, clusters, |cluster_end, text_end| { + let candidate = prepare_boundary_candidate( + shaper, + text, + ellipsis_text, + source_shape, + ellipsis_shape, + clusters, + runs, + style_storage, + font_stacks, + font_bindings, + line_cluster_start, + line_text_start, + cluster_end, + text_end, + )?; + let retained_advance = clusters.advances[candidate.cluster_start..cluster_end] + .iter() + .copied() + .sum::(); + final_candidate = Some(candidate); + Ok(EllipsisReplacement { + cluster_start: candidate.cluster_start, + advance_adjustment: candidate.source_advance + candidate.ellipsis_advance + - retained_advance, + }) + })? + .ok_or(EngineError::InvalidRequest)?; + let candidate = final_candidate.ok_or(EngineError::InvalidRequest)?; + if usize::try_from(target.boundary_cluster_start).ok() != Some(candidate.cluster_start) + { + return Err(EngineError::InvalidRequest); + } + let source_span = if source_shape.runs.is_empty() { + ( + u32::try_from(self.pending_boundary_shape.shape.glyph_ids.len()) + .map_err(|_| EngineError::ResultTooLarge)?, + 0, + ) + } else { + self.pending_boundary_shape + .shape + .append_from(source_shape, 0)? + }; + let ellipsis_span = self + .pending_boundary_shape + .shape + .append_from(ellipsis_shape, 0)?; + append_boundary_source_ids( + &mut self.pending_boundary_shape.stable_ids, + source_shape, + clusters, + next_glyph_id, + )?; + let previous = self + .boundary_shape + .records + .iter() + .find(|record| record.flow_thread_id == flow_thread_id) + .copied(); + let previous_ellipsis_ids = previous + .and_then(|record| { + let start = usize::try_from(record.ellipsis_glyph_start).ok()?; + let end = + start.checked_add(usize::try_from(record.ellipsis_glyph_count).ok()?)?; + self.boundary_shape.stable_ids.get(start..end) + }) + .unwrap_or(&[]); + let ellipsis_count = + usize::try_from(ellipsis_span.1).map_err(|_| EngineError::InvalidRequest)?; + for ordinal in 0..ellipsis_count { + let stable_id = previous_ellipsis_ids + .get(ordinal) + .copied() + .filter(|id| *id != 0) + .map_or_else(|| allocate_glyph_id(next_glyph_id), Ok)?; + self.pending_boundary_shape.stable_ids.push(stable_id); + } + let boundary_index = u32::try_from(self.pending_boundary_shape.records.len()) + .map_err(|_| EngineError::ResultTooLarge)?; + self.pending_boundary_shape.records.push(BoundaryShape { + flow_thread_id, + source_run: u32::try_from(candidate.source_run) + .map_err(|_| EngineError::ResultTooLarge)?, + cluster_start: target.boundary_cluster_start, + cluster_end: target.cluster_end, + text_end: target.text_end, + source_binding_handle: candidate.source_binding_handle, + source_font_handle: candidate.source_font_handle, + ellipsis_binding_handle: candidate.ellipsis_binding_handle, + ellipsis_font_handle: candidate.ellipsis_font_handle, + source_glyph_start: source_span.0, + source_glyph_count: source_span.1, + ellipsis_glyph_start: ellipsis_span.0, + ellipsis_glyph_count: ellipsis_span.1, + }); + self.pending_flow_layout.fragments[fragment_index].boundary_index = boundary_index; + ellipsis_index += 1; + } + self.flow_layout_prepared = true; + Ok(()) + } + + fn prepare_intrinsic_flow_layout( + &mut self, + shaper: &ShaperRegistry, + font_stacks: &[RegisteredFontStack], + font_bindings: &[RegisteredFontBinding], + max_lines: u32, + max_slots_per_band: u32, + ) -> Result<(), EngineError> { + let source_geometry = if self.geometry_prepared { + &self.pending_geometry + } else { + &self.geometry + }; + self.intrinsic_geometry_scratch.clone_from(source_geometry); + let constraint = self + .intrinsic_geometry_scratch + .constraints + .first_mut() + .ok_or(EngineError::InvalidRequest)?; + let region_start = + usize::try_from(constraint.region_start).map_err(|_| EngineError::InvalidRequest)?; + let final_region = region_start + .checked_add(usize::from(constraint.region_count)) + .and_then(|end| end.checked_sub(1)) + .ok_or(EngineError::InvalidRequest)?; + let region = self + .intrinsic_geometry_scratch + .regions + .get_mut(final_region) + .ok_or(EngineError::InvalidRequest)?; + const INTRINSIC_BLOCK_END: f32 = 16_777_216.0; + constraint.max_lines = 0; + constraint.viewport_block_end = INTRINSIC_BLOCK_END; + constraint.overflow = OVERFLOW_VISIBLE; + region.record.block_end = INTRINSIC_BLOCK_END; + + let clusters = if self.clusters_prepared { + &self.pending_clusters + } else { + &self.clusters + }; + let styles = if self.styles_prepared { + self.pending_resolved_styles.segments() + } else { + self.resolved_styles.segments() + }; + self.intrinsic_flow_layout_scratch.build( + &self.intrinsic_geometry_scratch, + clusters, + styles, + &mut self.intrinsic_flow_slot_scratch, + usize::try_from(max_lines).map_err(|_| EngineError::ResultTooLarge)?, + usize::try_from(max_slots_per_band).map_err(|_| EngineError::ResultTooLarge)?, + |handle| shaper.font_metrics(handle), + |stack_handle| { + font_stacks + .binary_search_by_key(&stack_handle, |stack| stack.handle) + .ok() + .and_then(|index| font_stacks[index].fonts.first().copied()) + .and_then(|handle| { + font_bindings + .iter() + .find(|binding| binding.handle == handle) + .map(|binding| binding.shaping_handle) + }) + }, + ) + } + + fn prepare_intrinsic_positioned(&mut self, shaper: &ShaperRegistry) -> Result<(), EngineError> { + let text = if self.text_prepared { + self.pending_text.as_slice() + } else { + self.text.as_slice() + }; + let clusters = if self.clusters_prepared { + &self.pending_clusters + } else { + &self.clusters + }; + let runs = if self.shaping_runs_prepared { + self.pending_shaping_runs.runs() + } else { + self.shaping_runs.runs() + }; + let shape = if self.shape_prepared { + &self.pending_shape + } else { + &self.shape + }; + let styles = if self.styles_prepared { + self.pending_resolved_styles.segments() + } else { + self.resolved_styles.segments() + }; + let bidi = if self.bidi_prepared { + &self.pending_bidi + } else { + &self.bidi + }; + let previous = if self.positioned_prepared { + &self.pending_positioned + } else { + &self.positioned + }; + let mut next_content_revision = 1; + self.intrinsic_positioned_scratch.build( + previous, + &self.intrinsic_flow_layout_scratch, + text, + clusters, + runs, + shape, + &BoundaryShapeArena::default(), + styles, + bidi, + &mut self.intrinsic_identity_scratch, + &mut next_content_revision, + |handle| shaper.font_metrics(handle), + |handle, glyph| shaper.font_glyph_extents(handle, glyph), + ) + } + + fn abort_flow_layout(&mut self) { + self.pending_flow_layout.clear(); + self.pending_boundary_shape.clear(); + self.flow_layout_prepared = false; + } + + fn commit_flow_layout(&mut self) { + if self.flow_layout_prepared { + core::mem::swap(&mut self.flow_layout, &mut self.pending_flow_layout); + core::mem::swap(&mut self.boundary_shape, &mut self.pending_boundary_shape); + } + self.abort_flow_layout(); + } + + fn prepare_positioned( + &mut self, + shaper: &ShaperRegistry, + next_content_revision: &mut u32, + ) -> Result<(), EngineError> { + self.abort_positioned(); + let text = if self.text_prepared { + self.pending_text.as_slice() + } else { + self.text.as_slice() + }; + let clusters = if self.clusters_prepared { + &self.pending_clusters + } else { + &self.clusters + }; + let runs = if self.shaping_runs_prepared { + self.pending_shaping_runs.runs() + } else { + self.shaping_runs.runs() + }; + let shape = if self.shape_prepared { + &self.pending_shape + } else { + &self.shape + }; + let styles = if self.styles_prepared { + self.pending_resolved_styles.segments() + } else { + self.resolved_styles.segments() + }; + let bidi = if self.bidi_prepared { + &self.pending_bidi + } else { + &self.bidi + }; + let flow = if self.flow_layout_prepared { + &self.pending_flow_layout + } else { + &self.flow_layout + }; + let boundary_shape = if self.flow_layout_prepared { + &self.pending_boundary_shape + } else { + &self.boundary_shape + }; + self.pending_positioned.build( + &self.positioned, + flow, + text, + clusters, + runs, + shape, + boundary_shape, + styles, + bidi, + &mut self.glyph_identity_index, + next_content_revision, + |handle| shaper.font_metrics(handle), + |handle, glyph| shaper.font_glyph_extents(handle, glyph), + )?; + self.positioned_prepared = true; + Ok(()) + } + + fn abort_positioned(&mut self) { + self.pending_positioned.clear(); + self.positioned_prepared = false; + } + + fn commit_positioned(&mut self) { + if self.positioned_prepared { + core::mem::swap(&mut self.positioned, &mut self.pending_positioned); + } + self.abort_positioned(); + } +} + +fn apply_text_mutation( + text: &mut Vec, + mutation: super::semantic_wire::TextMutation<'_>, +) -> Result<(), TextMutationError> { + let start = usize::try_from(mutation.text_start).map_err(|_| TextMutationError::Invalid)?; + let delete_count = + usize::try_from(mutation.delete_count).map_err(|_| TextMutationError::Invalid)?; + let delete_end = start + .checked_add(delete_count) + .ok_or(TextMutationError::Invalid)?; + if delete_end > text.len() || !mutation.insert_utf16_le.len().is_multiple_of(2) { + return Err(TextMutationError::Invalid); + } + let insert_count = mutation.insert_utf16_le.len() / 2; + let old_len = text.len(); + let new_len = old_len + .checked_sub(delete_count) + .and_then(|length| length.checked_add(insert_count)) + .ok_or(TextMutationError::Invalid)?; + if u32::try_from(new_len).is_err() { + return Err(TextMutationError::Invalid); + } + if new_len > old_len { + text.try_reserve(new_len - old_len) + .map_err(|_| TextMutationError::Allocation)?; + text.resize(new_len, 0); + } + text.copy_within(delete_end..old_len, start + insert_count); + if new_len < old_len { + text.truncate(new_len); + } + for (unit, bytes) in text[start..start + insert_count] + .iter_mut() + .zip(mutation.insert_utf16_le.chunks_exact(2)) + { + *unit = u16::from_le_bytes([bytes[0], bytes[1]]); + } + Ok(()) +} + +fn changed_identity_range(previous: &[u32], next: &[u32]) -> Option { + let shared = previous.len().min(next.len()); + let mut start = 0usize; + while start < shared && previous[start] == next[start] { + start += 1; + } + if start == previous.len() && start == next.len() { + return None; + } + let mut previous_end = previous.len(); + let mut next_end = next.len(); + while previous_end > start + && next_end > start + && previous[previous_end - 1] == next[next_end - 1] + { + previous_end -= 1; + next_end -= 1; + } + Some(TextEdit { + old_start: start, + old_end: previous_end, + new_end: next_end, + }) +} + +fn apply_text_identity_mutation( + identities: &mut Vec, + next_identity: &mut u32, + mutation: super::semantic_wire::TextMutation<'_>, +) -> Result<(), EngineError> { + let start = usize::try_from(mutation.text_start).map_err(|_| EngineError::InvalidRequest)?; + let delete_count = + usize::try_from(mutation.delete_count).map_err(|_| EngineError::InvalidRequest)?; + let delete_end = start + .checked_add(delete_count) + .ok_or(EngineError::InvalidRequest)?; + let insert_count = mutation.insert_utf16_le.len() / 2; + let old_len = identities.len(); + let new_len = old_len + .checked_sub(delete_count) + .and_then(|length| length.checked_add(insert_count)) + .ok_or(EngineError::InvalidRequest)?; + if delete_end > old_len { + return Err(EngineError::InvalidRequest); + } + if new_len > old_len { + identities + .try_reserve(new_len - old_len) + .map_err(|_| EngineError::ResultTooLarge)?; + identities.resize(new_len, 0); + } + identities.copy_within(delete_end..old_len, start + insert_count); + if new_len < old_len { + identities.truncate(new_len); + } + for identity in &mut identities[start..start + insert_count] { + if *next_identity == 0 { + return Err(EngineError::RevisionExhausted); + } + *identity = *next_identity; + *next_identity = next_identity + .checked_add(1) + .ok_or(EngineError::RevisionExhausted)?; + } + Ok(()) +} + +fn reserve_text_buffer(text: &mut Vec, capacity: usize) -> Result<(), EngineError> { + if text.capacity() < capacity { + text.try_reserve_exact(capacity.saturating_sub(text.len())) + .map_err(|_| EngineError::ResultTooLarge)?; + } + Ok(()) +} + +fn unicode_error(error: UnicodeError) -> EngineError { + match error { + UnicodeError::InvalidUtf16 => EngineError::InvalidRequest, + UnicodeError::ResultTooLarge => EngineError::ResultTooLarge, + } +} + +fn same_shaping_properties(left: ShapingRun, right: ShapingRun) -> bool { + left.script == right.script + && left.direction == right.direction + && left.bidi_level == right.bidi_level + && left.style.same_layout_sources(right.style) +} + +fn containing_run(runs: &[ShapingRun], start: usize, end: usize) -> Option { + let start = u32::try_from(start).ok()?; + let end = u32::try_from(end).ok()?; + runs.iter().position(|run| { + run.text_start <= start + && end <= run.text_end + && (start < end || (run.text_start < start && start < run.text_end)) + }) +} + +fn edit_delta(edit: TextEdit) -> Result { + i64::try_from(edit.new_end) + .and_then(|new_end| i64::try_from(edit.old_end).map(|old_end| new_end - old_end)) + .map_err(|_| EngineError::ResultTooLarge) +} + +fn map_old_offset(offset: u32, edit: TextEdit) -> Result { + let old_start = u32::try_from(edit.old_start).map_err(|_| EngineError::ResultTooLarge)?; + let old_end = u32::try_from(edit.old_end).map_err(|_| EngineError::ResultTooLarge)?; + if offset <= old_start { + Ok(offset) + } else if offset >= old_end { + shifted_text_offset(offset, edit_delta(edit)?) + } else { + Err(EngineError::InvalidRequest) + } +} + +fn same_edit_run_topology( + old_runs: &[ShapingRun], + new_runs: &[ShapingRun], + edit: TextEdit, + affected: usize, +) -> Result { + for (index, (&old, &new)) in old_runs.iter().zip(new_runs).enumerate() { + if !same_shaping_properties(old, new) { + return Ok(false); + } + let expected_start = map_old_offset(old.text_start, edit); + let expected_end = map_old_offset(old.text_end, edit); + if index == affected { + let delta = edit_delta(edit)?; + let old_start = + u32::try_from(edit.old_start).map_err(|_| EngineError::ResultTooLarge)?; + let old_end = u32::try_from(edit.old_end).map_err(|_| EngineError::ResultTooLarge)?; + let start = if old.text_start <= old_start { + old.text_start + } else { + shifted_text_offset(old.text_start, delta)? + }; + let end = if old.text_end >= old_end { + shifted_text_offset(old.text_end, delta)? + } else { + old.text_end + }; + if new.text_start != start || new.text_end != end { + return Ok(false); + } + } else if expected_start.ok() != Some(new.text_start) + || expected_end.ok() != Some(new.text_end) + { + return Ok(false); + } + } + Ok(true) +} + +fn shifted_text_offset(value: u32, delta: i64) -> Result { + let shifted = i64::from(value) + .checked_add(delta) + .ok_or(EngineError::ResultTooLarge)?; + u32::try_from(shifted).map_err(|_| EngineError::ResultTooLarge) +} + +fn bidi_error(error: BidiError) -> EngineError { + match error { + BidiError::InvalidDirection => EngineError::InvalidRequest, + BidiError::ResultTooLarge => EngineError::ResultTooLarge, + } +} + +fn shaper_error(status: u32) -> EngineError { + if status == STATUS_RESULT_TOO_LARGE { + EngineError::ResultTooLarge + } else { + EngineError::InvalidRequest + } +} + +fn find_font_stack( + font_stacks: &[RegisteredFontStack], + handle: u32, +) -> Result<&RegisteredFontStack, EngineError> { + font_stacks + .binary_search_by_key(&handle, |stack| stack.handle) + .ok() + .and_then(|index| font_stacks.get(index)) + .ok_or(EngineError::FontStackMissing) +} + +fn find_font_binding( + font_bindings: &[RegisteredFontBinding], + handle: u32, +) -> Result<&RegisteredFontBinding, EngineError> { + font_bindings + .iter() + .find(|binding| binding.handle == handle) + .ok_or(EngineError::FontStackMissing) +} + +#[allow(clippy::too_many_arguments)] +fn prepare_boundary_candidate( + shaper: &mut ShaperRegistry, + text: &[u16], + ellipsis_text: &mut Vec, + source_shape: &mut ShapeArena, + ellipsis_shape: &mut ShapeArena, + clusters: &ClusterArena, + runs: &[ShapingRun], + styles: &StyleArena, + font_stacks: &[RegisteredFontStack], + font_bindings: &[RegisteredFontBinding], + line_cluster_start: usize, + line_text_start: u32, + cluster_end: usize, + text_end: u32, +) -> Result { + let anchor = cluster_end + .checked_sub(1) + .filter(|index| *index >= line_cluster_start) + .or_else(|| (cluster_end < clusters.starts.len()).then_some(cluster_end)) + .ok_or(EngineError::InvalidRequest)?; + let source_run = usize::try_from( + *clusters + .source_runs + .get(anchor) + .ok_or(EngineError::InvalidRequest)?, + ) + .map_err(|_| EngineError::InvalidRequest)?; + let run = *runs.get(source_run).ok_or(EngineError::InvalidRequest)?; + let source_binding_handle = *clusters + .binding_handles + .get(anchor) + .ok_or(EngineError::InvalidRequest)?; + let source_font_handle = *clusters + .font_handles + .get(anchor) + .ok_or(EngineError::InvalidRequest)?; + if source_binding_handle == 0 || source_font_handle == 0 { + return Err(EngineError::InvalidRequest); + } + let mut cluster_start = cluster_end; + while cluster_start > line_cluster_start { + let previous = cluster_start - 1; + if clusters.source_runs.get(previous) != Some(&(source_run as u32)) + || clusters.binding_handles.get(previous) != Some(&source_binding_handle) + || clusters.font_handles.get(previous) != Some(&source_font_handle) + { + break; + } + cluster_start = previous; + } + + source_shape.clear(); + let mut source_advance = 0.0; + if cluster_start < cluster_end { + let item_start = *clusters + .starts + .get(cluster_start) + .ok_or(EngineError::InvalidRequest)?; + shaper + .with_shaped_range( + source_font_handle, + text, + ShapeRunRef { + text_start: run.text_start, + text_end: run.text_end, + script: run.script, + language: styles.resolved_language(run.style), + features: styles.resolved_features(run.style), + direction: run.direction, + cluster_level: 0, + flags: 0x40, + }, + ShapeRangeRef { + item_start, + item_end: text_end, + context_start: line_text_start.max(run.text_start).min(item_start), + context_end: text_end, + flags: 0x40 | 0x02 | u32::from(item_start == line_text_start), + }, + |shaped| { + source_shape.append( + source_run, + source_font_handle, + source_binding_handle, + item_start, + text_end, + shaped, + ) + }, + ) + .map_err(shaper_error)?; + let metrics = shaper + .font_metrics(source_font_handle) + .ok_or(EngineError::InvalidRequest)?; + if metrics.units_per_em == 0 { + return Err(EngineError::InvalidRequest); + } + let scale = f64::from(run.style.font_size) / f64::from(metrics.units_per_em); + source_advance = source_shape + .x_advances + .iter() + .try_fold(0.0, |advance, value| { + let next = advance + f64::from(value.unsigned_abs()) * scale; + next.is_finite() + .then_some(next) + .ok_or(EngineError::InvalidRequest) + })?; + for cluster in cluster_start..cluster_end { + source_advance += f64::from(run.style.letter_spacing); + if clusters + .starts + .get(cluster) + .and_then(|start| usize::try_from(*start).ok()) + .and_then(|start| text.get(start)) + == Some(&0x20) + { + source_advance += f64::from(run.style.word_spacing); + } + } + } + + ellipsis_text.clear(); + ellipsis_text.push(0x2026); + let stack = find_font_stack(font_stacks, run.style.font_stack_handle)?; + let mut selected = None; + for (font_index, binding_handle) in stack.fonts.iter().copied().enumerate() { + let font_handle = find_font_binding(font_bindings, binding_handle)?.shaping_handle; + ellipsis_shape.clear(); + let missing = shaper + .with_shaped_run( + font_handle, + ellipsis_text, + ShapeRunRef { + text_start: 0, + text_end: 1, + script: run.script, + language: styles.resolved_language(run.style), + features: &[], + direction: run.direction, + cluster_level: 0, + flags: 0x40, + }, + |shaped| { + let missing = shaped.glyph_infos().iter().any(|info| info.glyph_id == 0); + ellipsis_shape.append(source_run, font_handle, binding_handle, 0, 1, shaped)?; + Ok(missing) + }, + ) + .map_err(shaper_error)?; + if !missing || font_index + 1 == stack.fonts.len() { + selected = Some((binding_handle, font_handle)); + break; + } + } + let (ellipsis_binding_handle, ellipsis_font_handle) = + selected.ok_or(EngineError::FontStackMissing)?; + let metrics = shaper + .font_metrics(ellipsis_font_handle) + .ok_or(EngineError::InvalidRequest)?; + if metrics.units_per_em == 0 { + return Err(EngineError::InvalidRequest); + } + let scale = f64::from(run.style.font_size) / f64::from(metrics.units_per_em); + let ellipsis_advance = ellipsis_shape + .x_advances + .iter() + .try_fold(0.0, |advance, value| { + let next = advance + f64::from(value.unsigned_abs()) * scale; + next.is_finite() + .then_some(next) + .ok_or(EngineError::InvalidRequest) + })?; + Ok(BoundaryCandidate { + source_run, + cluster_start, + source_binding_handle, + source_font_handle, + ellipsis_binding_handle, + ellipsis_font_handle, + source_advance, + ellipsis_advance, + }) +} + +fn append_boundary_source_ids( + output: &mut Vec, + source: &ShapeArena, + clusters: &ClusterArena, + next_glyph_id: &mut u32, +) -> Result<(), EngineError> { + let mut previous_cluster = None; + let mut ordinal = 0usize; + for &text_cluster in &source.clusters { + if previous_cluster == Some(text_cluster) { + ordinal += 1; + } else { + previous_cluster = Some(text_cluster); + ordinal = 0; + } + let stable_id = clusters + .starts + .binary_search(&text_cluster) + .ok() + .and_then(|cluster| { + let start = usize::try_from(*clusters.glyph_starts.get(cluster)?).ok()?; + let count = usize::try_from(*clusters.glyph_counts.get(cluster)?).ok()?; + (ordinal < count) + .then(|| clusters.glyph_stable_ids.get(start + ordinal).copied()) + .flatten() + }) + .filter(|id| *id != 0) + .map_or_else(|| allocate_glyph_id(next_glyph_id), Ok)?; + output.push(stable_id); + } + Ok(()) +} + +fn allocate_glyph_id(next_glyph_id: &mut u32) -> Result { + let stable_id = (*next_glyph_id).max(1); + *next_glyph_id = stable_id + .checked_add(1) + .ok_or(EngineError::ResultTooLarge)?; + Ok(stable_id) +} + +fn push_fallback_span( + spans: &mut Vec, + span: FallbackSpan, +) -> Result<(), EngineError> { + if let Some(previous) = spans.last_mut() + && previous.source_run == span.source_run + && previous.text_end == span.text_start + && previous.font_index == span.font_index + && previous.binding_handle == span.binding_handle + && previous.font_handle == span.font_handle + { + previous.text_end = span.text_end; + return Ok(()); + } + spans + .try_reserve(1) + .map_err(|_| EngineError::ResultTooLarge)?; + spans.push(span); + Ok(()) +} + +fn collect_cluster_records( + shape: &ShapeArena, + records: &mut Vec, +) -> Result<(), EngineError> { + records.clear(); + reserve_vec(records, shape.glyph_ids.len())?; + for run in &shape.runs { + let start = usize::try_from(run.glyph_start).map_err(|_| EngineError::InvalidRequest)?; + let end = start + .checked_add(usize::try_from(run.glyph_count).map_err(|_| EngineError::InvalidRequest)?) + .ok_or(EngineError::InvalidRequest)?; + let clusters = shape + .clusters + .get(start..end) + .ok_or(EngineError::InvalidRequest)?; + let glyph_ids = shape + .glyph_ids + .get(start..end) + .ok_or(EngineError::InvalidRequest)?; + for (&cluster, &glyph_id) in clusters.iter().zip(glyph_ids) { + records.push(ClusterRecord { + source_run: run.source_run, + cluster, + missing: glyph_id == 0, + }); + } + } + records.sort_unstable_by_key(|record| (record.source_run, record.cluster)); + let mut write_index = 0usize; + for read_index in 0..records.len() { + let record = records[read_index]; + if write_index > 0 + && records[write_index - 1].source_run == record.source_run + && records[write_index - 1].cluster == record.cluster + { + records[write_index - 1].missing |= record.missing; + } else { + records[write_index] = record; + write_index += 1; + } + } + records.truncate(write_index); + Ok(()) +} + +fn reserve_vec(values: &mut Vec, capacity: usize) -> Result<(), EngineError> { + if values.capacity() < capacity { + values + .try_reserve_exact(capacity.saturating_sub(values.len())) + .map_err(|_| EngineError::ResultTooLarge)?; + } + Ok(()) +} + +fn request_semantic_paragraph_id(request: UpdateRequest<'_>) -> Result, EngineError> { + let mut paragraph_id = None; + for index in 0..request.text_mutations.len() { + merge_paragraph_id( + &mut paragraph_id, + request.text_mutations.paragraph_id(index), + )?; + } + for index in 0..request.style_mutations.len() { + merge_paragraph_id( + &mut paragraph_id, + request.style_mutations.paragraph_id(index), + )?; + } + let geometry_count = request + .geometry + .constraint_count() + .checked_add(request.geometry.inline_object_count()) + .ok_or(EngineError::InvalidRequest)?; + for index in 0..geometry_count { + merge_paragraph_id(&mut paragraph_id, request.geometry.paragraph_id(index))?; + } + Ok(paragraph_id) +} + +fn merge_paragraph_id( + current: &mut Option, + candidate: Option, +) -> Result<(), EngineError> { + let candidate = candidate.ok_or(EngineError::InvalidRequest)?; + if current.is_some_and(|value| value != candidate) { + return Err(EngineError::InvalidRequest); + } + *current = Some(candidate); + Ok(()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TextMutationError { + Invalid, + Allocation, +} + +fn plan_error(error: RenderPlanCompilerError) -> EngineError { + if error.is_result_too_large() { + EngineError::ResultTooLarge + } else { + EngineError::InvalidRequest + } +} + +fn gather_error(error: GatherError) -> EngineError { + match error { + GatherError::AllocationFailed => EngineError::ResultTooLarge, + GatherError::InvalidSemanticShape + | GatherError::FontBindingMissing + | GatherError::GlyphBindingMissing + | GatherError::ResourceBindingMissing + | GatherError::ProgramMissing + | GatherError::SourceFieldMissing => EngineError::InvalidRequest, + } +} + +#[cfg(test)] +mod tests { + use crate::engine::style_state::ResolvedStyle; + + use super::*; + + #[test] + fn retained_edit_range_and_run_topology_track_insertions_without_crossing_run_boundaries() { + let edit = changed_identity_range(&[1, 2, 3, 4], &[1, 5, 6, 2, 3, 4]).unwrap(); + assert_eq!( + edit, + TextEdit { + old_start: 1, + old_end: 1, + new_end: 3, + } + ); + let style = ResolvedStyle::test_typography(16.0, 0.0, 0.0); + let old = [ + ShapingRun { + text_start: 0, + text_end: 4, + script: 1, + direction: 0, + bidi_level: 0, + style, + }, + ShapingRun { + text_start: 5, + text_end: 9, + script: 1, + direction: 0, + bidi_level: 0, + style, + }, + ]; + let new = [ + ShapingRun { + text_end: 6, + ..old[0] + }, + ShapingRun { + text_start: 7, + text_end: 11, + ..old[1] + }, + ]; + assert_eq!(containing_run(&old, edit.old_start, edit.old_end), Some(0)); + assert_eq!(containing_run(&new, edit.old_start, edit.new_end), Some(0)); + assert!(same_edit_run_topology(&old, &new, edit, 0).unwrap()); + } + use crate::{ + abi_contract::{ + self as abi, ENGINE_TEXT_MUTATION_DELETE_COUNT, ENGINE_TEXT_MUTATION_ENCODING, + ENGINE_TEXT_MUTATION_INSERT_COUNT, ENGINE_TEXT_MUTATION_INSERT_OFFSET, + ENGINE_TEXT_MUTATION_OPCODE, ENGINE_TEXT_MUTATION_PARAGRAPH_ID, + ENGINE_TEXT_MUTATION_RECORD_SIZE, ENGINE_TEXT_MUTATION_TEXT_START, + ENGINE_UPDATE_REQUEST_HEADER_SIZE, + }, + bidi::DIRECTION_RTL, + engine::{ + font_binding::{ + FieldTable, FontRenderBinding, FontResource, FontStrike, MISSING_RESOURCE_INDEX, + }, + frame::{ + PARAGRAPH_MUTATION_REMOVE, PARAGRAPH_MUTATION_UPSERT, STYLE_FIELD_DIRECTION, + STYLE_FIELD_FONT_SIZE, STYLE_FIELD_FONT_STACK, STYLE_FIELD_LINE_HEIGHT, + STYLE_FIELD_RASTER_PIXEL_RATIO, STYLE_FLAG_ROOT, STYLE_MUTATION_REMOVE, + STYLE_MUTATION_UPSERT, TEXT_ENCODING_UTF16_LE, TEXT_MUTATION_REPLACE_UTF16, + }, + policy::{ + ALLOCATION_ORDERED_DIRECT, BATCH_ORDER, BATCH_PROGRAM, BATCH_RESOURCE, + BATCH_TECHNIQUE, BUFFER_USAGE_COPY_DST, BUFFER_USAGE_STORAGE, BufferId, + BufferSchema, CAP_ORDERED_DIRECT, CapabilitySet, Operation, PolicyDescriptor, + ProgramCapabilities, ProgramDescriptor, ProgramId, ScalarType, TechniqueId, + }, + semantic_wire::{ + parse_paragraph_mutations, parse_style_mutations, parse_text_mutations, + }, + }, + wire::write_u32, + }; + use alloc::vec; + + #[test] + fn registration_is_idempotent_but_rejects_handle_conflicts() { + let first = validated_policy(TechniqueId(1)); + let mut engine = TextEngine::default(); + assert_eq!(engine.register_policy(1, first.clone()), Ok(())); + assert_eq!(engine.register_policy(1, first), Ok(())); + assert_eq!(engine.policy_count(), 1); + assert_eq!( + engine.register_policy(1, validated_policy(TechniqueId(2))), + Err(EngineError::HandleConflict) + ); + assert_eq!( + engine.policy(1).unwrap().programs()[0].technique, + TechniqueId(1) + ); + } + + #[test] + fn font_stacks_retain_exact_order_and_reject_ambiguous_identity() { + let mut engine = TextEngine::default(); + assert_eq!( + engine.register_font_stack(0, &[1]), + Err(EngineError::InvalidRequest) + ); + assert_eq!( + engine.register_font_stack(1, &[]), + Err(EngineError::InvalidRequest) + ); + assert_eq!( + engine.register_font_stack(1, &[1, 1]), + Err(EngineError::InvalidRequest) + ); + assert_eq!(engine.register_font_stack(7, &[9, 4, 12]), Ok(())); + assert_eq!(engine.register_font_stack(7, &[9, 4, 12]), Ok(())); + assert_eq!(engine.font_stack(7), Ok(&[9, 4, 12][..])); + assert!(engine.references_binding(4)); + assert_eq!(engine.font_stack_count(), 1); + assert_eq!( + engine.register_font_stack(7, &[9, 12]), + Err(EngineError::HandleConflict) + ); + assert_eq!(engine.dispose_font_stack(7), Ok(())); + assert!(!engine.references_binding(4)); + assert_eq!( + engine.dispose_font_stack(7), + Err(EngineError::FontStackMissing) + ); + } + + #[test] + fn fallback_clusters_restore_logical_order_and_merge_missing_glyphs() { + let shape = ShapeArena { + runs: vec![crate::engine::shaping_state::ShapedRun { + source_run: 7, + binding_handle: 11, + font_handle: 11, + text_start: 0, + text_end: 6, + glyph_start: 0, + glyph_count: 4, + }], + glyph_ids: vec![3, 0, 2, 0], + clusters: vec![4, 4, 2, 0], + x_advances: vec![], + y_advances: vec![], + x_offsets: vec![], + y_offsets: vec![], + glyph_flags: vec![], + }; + let mut records = Vec::new(); + collect_cluster_records(&shape, &mut records).unwrap(); + assert_eq!( + records, + vec![ + ClusterRecord { + source_run: 7, + cluster: 0, + missing: true, + }, + ClusterRecord { + source_run: 7, + cluster: 2, + missing: false, + }, + ClusterRecord { + source_run: 7, + cluster: 4, + missing: true, + }, + ] + ); + } + + #[test] + fn binding_identity_is_distinct_from_shared_shaping_font_identity() { + let mut engine = TextEngine::default(); + let binding = render_binding(3, 7); + assert_eq!( + engine.register_font_binding(11, 101, 4, binding.clone()), + Err(EngineError::InvalidRequest) + ); + assert_eq!( + engine.register_font_binding(11, 101, 3, binding.clone()), + Ok(()) + ); + assert_eq!(engine.register_font_binding(11, 101, 3, binding), Ok(())); + assert_eq!(engine.font_binding_count(), 1); + assert_eq!(engine.font_binding(11).unwrap().technique(), TechniqueId(7)); + assert_eq!( + engine.register_font_binding(12, 101, 3, render_binding(3, 8)), + Ok(()) + ); + assert_eq!(engine.font_binding_count(), 2); + assert_eq!( + engine + .registered_font_binding(12) + .map(|binding| binding.shaping_handle), + Some(101) + ); + assert_eq!( + engine.register_font_binding(11, 101, 3, render_binding(3, 8)), + Err(EngineError::HandleConflict) + ); + assert_eq!( + engine.register_font_binding(11, 102, 3, render_binding(3, 7)), + Err(EngineError::HandleConflict) + ); + engine.dispose_font_binding(11); + assert_eq!(engine.font_binding_count(), 1); + } + + #[test] + fn disposal_is_exact_and_missing_handles_are_observable() { + let mut engine = TextEngine::default(); + assert_eq!( + engine.register_policy(0, validated_policy(TechniqueId(1))), + Err(EngineError::InvalidHandle) + ); + assert_eq!(engine.dispose_policy(1), Err(EngineError::PolicyMissing)); + engine + .register_policy(1, validated_policy(TechniqueId(1))) + .unwrap(); + assert_eq!(engine.dispose_policy(1), Ok(())); + assert_eq!(engine.dispose_policy(1), Err(EngineError::PolicyMissing)); + } + + #[test] + fn update_preparation_is_revisioned_and_commit_is_explicit() { + let mut engine = TextEngine::default(); + engine + .register_policy(9, validated_policy(TechniqueId(1))) + .unwrap(); + engine.create_session(4).unwrap(); + + let first = engine.prepare_update(update(0, 0, 0), 1).unwrap(); + let first_plan = engine.prepared_plan(first).unwrap(); + assert_eq!(first_plan.policy_handle, 9); + assert_eq!(first_plan.capability_set, 1); + assert_eq!( + engine.session_revision(4).unwrap(), + SessionRevision::default() + ); + let first = engine.commit_update(first).unwrap(); + assert!(first.checkpoint); + assert_eq!(first.required_base_revision, 0); + assert_eq!(first.revision, SessionRevision { engine: 1, plan: 1 }); + assert_eq!( + engine.gather_cache.map(|cache| cache.revision), + Some(first.revision) + ); + + let second = engine.prepare_update(update(1, 1, 1), 2).unwrap(); + assert_eq!( + engine.gather_cache.map(|cache| cache.revision), + Some(first.revision) + ); + assert_eq!( + engine.prepared_gather_cache.map(|cache| cache.revision), + Some(SessionRevision { engine: 2, plan: 2 }) + ); + let second = engine.commit_update(second).unwrap(); + assert!(!second.checkpoint); + assert_eq!(second.required_base_revision, 1); + assert_eq!( + engine.gather_cache.map(|cache| cache.revision), + Some(second.revision) + ); + + assert_eq!( + engine.prepare_update(update(1, 2, 1), 3), + Err(EngineError::RevisionConflict) + ); + assert_eq!(engine.session_count(), 1); + assert_eq!(engine.dispose_session(4), Ok(())); + assert_eq!(engine.dispose_session(4), Err(EngineError::SessionMissing)); + } + + #[test] + fn update_rejects_a_capability_set_outside_the_registered_policy() { + let mut engine = TextEngine::default(); + engine + .register_policy(9, validated_policy(TechniqueId(1))) + .unwrap(); + engine.create_session(4).unwrap(); + let mut request = update(0, 0, 0); + request.capability_set = 3; + assert_eq!( + engine.prepare_update(request, 1), + Err(EngineError::InvalidRequest) + ); + assert_eq!( + engine.session_revision(4).unwrap(), + SessionRevision::default() + ); + } + + #[test] + fn a_committed_session_accepts_another_capability_set_from_the_same_policy() { + let mut engine = TextEngine::default(); + engine + .register_policy(9, validated_policy(TechniqueId(1))) + .unwrap(); + engine.create_session(4).unwrap(); + let first = engine.prepare_update(update(0, 0, 0), 1).unwrap(); + engine.commit_update(first).unwrap(); + + let mut request = update(1, 1, 1); + request.capability_set = 2; + let second = engine.prepare_update(request, 2).unwrap(); + assert_eq!(engine.prepared_plan(second).unwrap().capability_set, 2); + engine.commit_update(second).unwrap(); + } + + #[test] + fn renderer_fence_acknowledgment_is_monotonic_and_cannot_name_the_pending_publication() { + let mut engine = TextEngine::default(); + engine + .register_policy(9, validated_policy(TechniqueId(1))) + .unwrap(); + engine.create_session(4).unwrap(); + let first = engine.prepare_update(update(0, 0, 0), 1).unwrap(); + engine.commit_update(first).unwrap(); + let second = engine.prepare_update(update(1, 1, 1), 2).unwrap(); + engine.commit_update(second).unwrap(); + + assert_eq!( + engine.prepare_update(update(2, 2, 3), 3), + Err(EngineError::RevisionConflict) + ); + assert_eq!( + engine.prepare_update(update(2, 2, 0), 3), + Err(EngineError::RevisionConflict) + ); + } + + #[test] + fn aborting_a_prepared_plan_preserves_revisions_and_allows_retry() { + let mut engine = TextEngine::default(); + engine + .register_policy(9, validated_policy(TechniqueId(1))) + .unwrap(); + engine.create_session(4).unwrap(); + let prepared = engine.prepare_update(update(0, 0, 0), 1).unwrap(); + assert!(engine.prepared_gather_cache.is_some()); + engine.abort_update(prepared).unwrap(); + assert!(engine.gather_cache.is_none()); + assert!(engine.prepared_gather_cache.is_none()); + assert_eq!( + engine.session_revision(4).unwrap(), + SessionRevision::default() + ); + let retry = engine.prepare_update(update(0, 0, 0), 1).unwrap(); + engine.commit_update(retry).unwrap(); + } + + #[test] + fn ordered_utf16_replacements_commit_and_abort_with_the_session_transaction() { + let mut engine = TextEngine::default(); + engine + .register_policy(9, validated_policy(TechniqueId(1))) + .unwrap(); + engine.create_session(4).unwrap(); + engine.reserve_session_text(4, 8).unwrap(); + + let initial_bytes = text_mutation_bytes(&[(0, 0, &[0x61, 0x62, 0x63, 0x64])]); + let initial_batch = + parse_text_mutations(&initial_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1).unwrap(); + let mut initial = update(0, 0, 0); + initial.text_mutations = initial_batch; + let prepared = engine.prepare_update(initial, 1).unwrap(); + assert!(engine.session_text(4).unwrap().is_empty()); + engine.commit_update(prepared).unwrap(); + assert_eq!(engine.session_text(4).unwrap(), &[0x61, 0x62, 0x63, 0x64]); + assert_eq!( + engine + .sessions + .get(&4) + .unwrap() + .first_paragraph_state() + .unwrap() + .text_unit_ids, + [1, 2, 3, 4] + ); + assert_eq!( + engine + .sessions + .get(&4) + .unwrap() + .first_paragraph_state() + .unwrap() + .unicode + .grapheme_boundaries(), + &[0, 1, 2, 3, 4] + ); + + let edit_bytes = text_mutation_bytes(&[(1, 1, &[0x58, 0x59]), (5, 0, &[0x21])]); + let edit_batch = + parse_text_mutations(&edit_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 2).unwrap(); + let mut edit = update(1, 1, 1); + edit.text_mutations = edit_batch; + let prepared = engine.prepare_update(edit, 2).unwrap(); + engine.abort_update(prepared).unwrap(); + assert_eq!(engine.session_text(4).unwrap(), &[0x61, 0x62, 0x63, 0x64]); + let session = engine.sessions.get(&4).unwrap(); + let paragraph = session.first_paragraph_state().unwrap(); + assert_eq!(paragraph.text_unit_ids, [1, 2, 3, 4]); + assert_eq!(paragraph.next_text_unit_id, 5); + + let retry = engine.prepare_update(edit, 2).unwrap(); + engine.commit_update(retry).unwrap(); + assert_eq!( + engine.session_text(4).unwrap(), + &[0x61, 0x58, 0x59, 0x63, 0x64, 0x21] + ); + assert_eq!( + engine + .sessions + .get(&4) + .unwrap() + .first_paragraph_state() + .unwrap() + .text_unit_ids, + [1, 5, 6, 3, 4, 7] + ); + + let settled_capacities = { + let session = engine.sessions.get(&4).unwrap(); + let paragraph = session.first_paragraph_state().unwrap(); + [paragraph.text.capacity(), paragraph.pending_text.capacity()] + }; + let warm_bytes = text_mutation_bytes(&[(0, 1, &[0x7a])]); + let warm_batch = + parse_text_mutations(&warm_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1).unwrap(); + let mut warm = update(2, 2, 2); + warm.text_mutations = warm_batch; + let prepared = engine.prepare_update(warm, 3).unwrap(); + engine.commit_update(prepared).unwrap(); + let session = engine.sessions.get(&4).unwrap(); + let paragraph = session.first_paragraph_state().unwrap(); + assert_eq!(paragraph.text_unit_ids, [8, 5, 6, 3, 4, 7]); + assert!(paragraph.pending_text_mirrors_committed); + assert_eq!(paragraph.pending_text, paragraph.text); + assert_eq!(paragraph.pending_text_unit_ids, paragraph.text_unit_ids); + assert_eq!( + [paragraph.pending_text.capacity(), paragraph.text.capacity(),], + settled_capacities + ); + } + + #[test] + fn invalid_utf16_aborts_text_and_unicode_analysis_together() { + let mut engine = TextEngine::default(); + engine + .register_policy(9, validated_policy(TechniqueId(1))) + .unwrap(); + engine.create_session(4).unwrap(); + + let invalid_bytes = text_mutation_bytes(&[(0, 0, &[0xd800])]); + let mut invalid = update(0, 0, 0); + invalid.text_mutations = + parse_text_mutations(&invalid_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1).unwrap(); + assert_eq!( + engine.prepare_update(invalid, 1), + Err(EngineError::InvalidRequest) + ); + let session = engine.sessions.get(&4).unwrap(); + let paragraph = session.first_paragraph_state().unwrap(); + assert!(paragraph.text.is_empty()); + assert!(paragraph.unicode.grapheme_boundaries().is_empty()); + assert!(paragraph.bidi.levels.is_empty()); + } + + #[test] + fn ascii_text_reuse_does_not_suppress_an_independent_bidi_invalidation() { + let mut paragraph = ParagraphState::default(); + paragraph.text = "abc".encode_utf16().collect(); + paragraph.pending_text = "axc".encode_utf16().collect(); + paragraph.text_prepared = true; + paragraph.text_edit = Some(TextEdit { + old_start: 1, + old_end: 2, + new_end: 2, + }); + paragraph.style_invalidation.bidi = true; + paragraph.unicode.analyze(¶graph.text).unwrap(); + + paragraph.prepare_unicode().unwrap(); + assert!(paragraph.unicode_reused_for_text_edit); + paragraph.prepare_bidi().unwrap(); + assert!(paragraph.bidi_prepared); + assert_eq!(paragraph.pending_bidi.paragraph_levels, [0]); + } + + #[test] + fn root_direction_reanalyzes_bidi_without_a_text_mutation() { + let mut engine = TextEngine::default(); + engine + .register_policy(9, validated_policy(TechniqueId(1))) + .unwrap(); + engine.register_font_stack(7, &[42]).unwrap(); + engine.create_session(4).unwrap(); + + let text_bytes = text_mutation_bytes(&[(0, 0, &[0x61, 0x62, 0x63, 0x64])]); + let mut text = update(0, 0, 0); + text.text_mutations = + parse_text_mutations(&text_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1).unwrap(); + let prepared = engine.prepare_update(text, 1).unwrap(); + engine.commit_update(prepared).unwrap(); + assert_eq!( + engine + .sessions + .get(&4) + .unwrap() + .first_paragraph_state() + .unwrap() + .bidi + .paragraph_levels, + &[0] + ); + + let root_bytes = root_style_bytes_with_direction(7, DIRECTION_RTL); + let mut root = update(1, 1, 1); + root.style_mutations = + parse_style_mutations(&root_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1).unwrap(); + let prepared = engine.prepare_update(root, 2).unwrap(); + assert_eq!( + engine + .sessions + .get(&4) + .unwrap() + .first_paragraph_state() + .unwrap() + .bidi + .paragraph_levels, + &[0] + ); + engine.commit_update(prepared).unwrap(); + assert_eq!( + engine + .sessions + .get(&4) + .unwrap() + .first_paragraph_state() + .unwrap() + .bidi + .paragraph_levels, + &[1] + ); + } + + #[test] + fn retained_style_upserts_commit_and_root_removal_aborts_transactionally() { + let mut engine = TextEngine::default(); + engine + .register_policy(9, validated_policy(TechniqueId(1))) + .unwrap(); + engine.register_font_stack(7, &[42]).unwrap(); + engine.create_session(4).unwrap(); + + let initial_bytes = text_mutation_bytes(&[(0, 0, &[0x61, 0x62, 0x63, 0x64])]); + let mut initial = update(0, 0, 0); + initial.text_mutations = + parse_text_mutations(&initial_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1).unwrap(); + let prepared = engine.prepare_update(initial, 1).unwrap(); + engine.commit_update(prepared).unwrap(); + + let root_bytes = root_style_bytes(7); + let mut root = update(1, 1, 1); + root.style_mutations = + parse_style_mutations(&root_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1).unwrap(); + let prepared = engine.prepare_update(root, 2).unwrap(); + assert_eq!(engine.session_style_count(4), Ok(0)); + assert_eq!(engine.session_style_segment_count(4), Ok(0)); + engine.commit_update(prepared).unwrap(); + assert_eq!(engine.session_style_count(4), Ok(1)); + assert_eq!(engine.session_style_segment_count(4), Ok(1)); + assert_eq!(engine.session_shaping_run_count(4), Ok(1)); + + let remove_bytes = remove_style_bytes(1); + let mut remove = update(2, 2, 2); + remove.style_mutations = + parse_style_mutations(&remove_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1).unwrap(); + assert_eq!( + engine.prepare_update(remove, 3), + Err(EngineError::InvalidRequest) + ); + assert_eq!(engine.session_style_count(4), Ok(1)); + + let missing_stack_bytes = root_style_bytes(99); + let mut missing_stack = update(2, 2, 2); + missing_stack.style_mutations = + parse_style_mutations(&missing_stack_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1) + .unwrap(); + assert_eq!( + engine.prepare_update(missing_stack, 3), + Err(EngineError::InvalidRequest) + ); + assert_eq!(engine.session_style_count(4), Ok(1)); + } + + #[test] + fn an_invalid_later_replacement_cannot_partially_mutate_committed_text() { + let mut engine = TextEngine::default(); + engine + .register_policy(9, validated_policy(TechniqueId(1))) + .unwrap(); + engine.create_session(4).unwrap(); + let bytes = text_mutation_bytes(&[(0, 0, &[0x61]), (9, 0, &[0x62])]); + let batch = parse_text_mutations(&bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 2).unwrap(); + let mut request = update(0, 0, 0); + request.text_mutations = batch; + assert_eq!( + engine.prepare_update(request, 1), + Err(EngineError::InvalidRequest) + ); + assert!(engine.session_text(4).unwrap().is_empty()); + } + + #[test] + fn ordered_paragraphs_commit_reorder_and_remove_as_one_session() { + let mut engine = TextEngine::default(); + engine + .register_policy(9, validated_policy(TechniqueId(1))) + .unwrap(); + engine.create_session(4).unwrap(); + engine.reserve_session_text(4, 8).unwrap(); + + let lifecycle_bytes = paragraph_mutation_bytes(&[ + (PARAGRAPH_MUTATION_UPSERT, 2, 1), + (PARAGRAPH_MUTATION_UPSERT, 1, 0), + ]); + let text_bytes = + paragraph_text_mutation_bytes(&[(1, 0, 0, &[0x61, 0x62]), (2, 0, 0, &[0x63, 0x64])]); + let mut initial = update(0, 0, 0); + initial.limits.max_paragraphs = 2; + initial.paragraph_mutations = + parse_paragraph_mutations(&lifecycle_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 2) + .unwrap(); + initial.text_mutations = + parse_text_mutations(&text_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 2).unwrap(); + let prepared = engine.prepare_update(initial, 1).unwrap(); + engine.commit_update(prepared).unwrap(); + + let session = engine.sessions.get(&4).unwrap(); + assert_eq!( + session + .ordered_paragraphs + .iter() + .map(|entry| entry.id) + .collect::>(), + [1, 2] + ); + assert_eq!(session.paragraph(1).unwrap().state.text, [0x61, 0x62]); + assert_eq!(session.paragraph(2).unwrap().state.text, [0x63, 0x64]); + + let reorder_bytes = paragraph_mutation_bytes(&[ + (PARAGRAPH_MUTATION_UPSERT, 1, 1), + (PARAGRAPH_MUTATION_UPSERT, 2, 0), + ]); + let mut reorder = update(1, 1, 1); + reorder.limits.max_paragraphs = 2; + reorder.paragraph_mutations = + parse_paragraph_mutations(&reorder_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 2) + .unwrap(); + let prepared = engine.prepare_update(reorder, 2).unwrap(); + engine.commit_update(prepared).unwrap(); + let session = engine.sessions.get(&4).unwrap(); + assert_eq!( + session + .ordered_paragraphs + .iter() + .map(|entry| entry.id) + .collect::>(), + [2, 1] + ); + assert_eq!(session.paragraph(1).unwrap().state.text, [0x61, 0x62]); + assert_eq!(session.paragraph(2).unwrap().state.text, [0x63, 0x64]); + + let remove_bytes = paragraph_mutation_bytes(&[(PARAGRAPH_MUTATION_REMOVE, 1, 0)]); + let mut remove = update(2, 2, 2); + remove.limits.max_paragraphs = 2; + remove.paragraph_mutations = + parse_paragraph_mutations(&remove_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1).unwrap(); + let prepared = engine.prepare_update(remove, 3).unwrap(); + engine.commit_update(prepared).unwrap(); + let session = engine.sessions.get(&4).unwrap(); + assert_eq!( + session.ordered_paragraphs, + [ParagraphOrder { order: 0, id: 2 }] + ); + assert!(session.paragraph(1).is_none()); + assert_eq!(session.paragraph(2).unwrap().state.text, [0x63, 0x64]); + assert!(session.spare_paragraph.is_some()); + + let spare_text_capacity = session.spare_paragraph.as_ref().unwrap().text.capacity(); + let replacement_lifecycle = paragraph_mutation_bytes(&[(PARAGRAPH_MUTATION_UPSERT, 3, 1)]); + let replacement_text = paragraph_text_mutation_bytes(&[(3, 0, 0, &[0x7a])]); + let mut replacement = update(3, 3, 3); + replacement.limits.max_paragraphs = 2; + replacement.paragraph_mutations = + parse_paragraph_mutations(&replacement_lifecycle, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1) + .unwrap(); + replacement.text_mutations = + parse_text_mutations(&replacement_text, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1).unwrap(); + let prepared = engine.prepare_update(replacement, 4).unwrap(); + engine.commit_update(prepared).unwrap(); + let recycled = &engine.sessions.get(&4).unwrap().paragraph(3).unwrap().state; + assert_eq!( + recycled.text, + [0x7a], + "a recycled paragraph must begin semantically empty" + ); + assert_eq!( + recycled.text.capacity(), + spare_text_capacity, + "paragraph recycling must retain its reserved text allocation" + ); + } + + #[test] + fn a_later_paragraph_failure_rolls_back_every_child_and_lifecycle_change() { + let mut engine = TextEngine::default(); + engine + .register_policy(9, validated_policy(TechniqueId(1))) + .unwrap(); + engine.create_session(4).unwrap(); + let lifecycle_bytes = paragraph_mutation_bytes(&[ + (PARAGRAPH_MUTATION_UPSERT, 1, 0), + (PARAGRAPH_MUTATION_UPSERT, 2, 1), + ]); + let initial_text = paragraph_text_mutation_bytes(&[(1, 0, 0, &[0x61]), (2, 0, 0, &[0x62])]); + let mut initial = update(0, 0, 0); + initial.limits.max_paragraphs = 2; + initial.paragraph_mutations = + parse_paragraph_mutations(&lifecycle_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 2) + .unwrap(); + initial.text_mutations = + parse_text_mutations(&initial_text, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 2).unwrap(); + let prepared = engine.prepare_update(initial, 1).unwrap(); + engine.commit_update(prepared).unwrap(); + + let reorder_bytes = paragraph_mutation_bytes(&[ + (PARAGRAPH_MUTATION_UPSERT, 1, 1), + (PARAGRAPH_MUTATION_UPSERT, 2, 0), + ]); + let invalid_text = paragraph_text_mutation_bytes(&[(1, 0, 1, &[0x78]), (2, 9, 0, &[0x79])]); + let mut invalid = update(1, 1, 1); + invalid.limits.max_paragraphs = 2; + invalid.paragraph_mutations = + parse_paragraph_mutations(&reorder_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 2) + .unwrap(); + invalid.text_mutations = + parse_text_mutations(&invalid_text, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 2).unwrap(); + assert_eq!( + engine.prepare_update(invalid, 2), + Err(EngineError::InvalidRequest) + ); + + let session = engine.sessions.get(&4).unwrap(); + assert_eq!(session.revision, SessionRevision { engine: 1, plan: 1 }); + assert_eq!( + session + .ordered_paragraphs + .iter() + .map(|entry| entry.id) + .collect::>(), + [1, 2] + ); + assert_eq!(session.paragraph(1).unwrap().state.text, [0x61]); + assert_eq!(session.paragraph(2).unwrap().state.text, [0x62]); + assert!(!session.lifecycle_prepared); + } + + #[test] + fn paragraph_limits_unknown_semantics_and_order_collisions_are_atomic() { + let mut engine = TextEngine::default(); + engine + .register_policy(9, validated_policy(TechniqueId(1))) + .unwrap(); + engine.create_session(4).unwrap(); + let lifecycle_bytes = paragraph_mutation_bytes(&[ + (PARAGRAPH_MUTATION_UPSERT, 1, 0), + (PARAGRAPH_MUTATION_UPSERT, 2, 1), + ]); + let mut too_many = update(0, 0, 0); + too_many.paragraph_mutations = + parse_paragraph_mutations(&lifecycle_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 2) + .unwrap(); + assert_eq!( + engine.prepare_update(too_many, 1), + Err(EngineError::InvalidRequest) + ); + assert!(engine.sessions.get(&4).unwrap().paragraphs.is_empty()); + + let mut initial = update(0, 0, 0); + initial.limits.max_paragraphs = 2; + initial.paragraph_mutations = + parse_paragraph_mutations(&lifecycle_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 2) + .unwrap(); + let prepared = engine.prepare_update(initial, 1).unwrap(); + engine.commit_update(prepared).unwrap(); + + let collision_bytes = paragraph_mutation_bytes(&[(PARAGRAPH_MUTATION_UPSERT, 1, 1)]); + let mut collision = update(1, 1, 1); + collision.limits.max_paragraphs = 2; + collision.paragraph_mutations = + parse_paragraph_mutations(&collision_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1) + .unwrap(); + assert_eq!( + engine.prepare_update(collision, 2), + Err(EngineError::InvalidRequest) + ); + + let unknown_text = paragraph_text_mutation_bytes(&[(3, 0, 0, &[0x61])]); + let mut unknown = update(1, 1, 1); + unknown.limits.max_paragraphs = 2; + unknown.text_mutations = + parse_text_mutations(&unknown_text, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1).unwrap(); + assert_eq!( + engine.prepare_update(unknown, 2), + Err(EngineError::InvalidRequest) + ); + let session = engine.sessions.get(&4).unwrap(); + assert_eq!( + session + .ordered_paragraphs + .iter() + .map(|entry| entry.id) + .collect::>(), + [1, 2] + ); + } + + #[test] + fn single_paragraph_session_rejects_mixed_and_rebound_paragraph_ids() { + let mut engine = TextEngine::default(); + engine + .register_policy(9, validated_policy(TechniqueId(1))) + .unwrap(); + engine.create_session(4).unwrap(); + + let mut mixed_bytes = text_mutation_bytes(&[(0, 0, &[0x61]), (1, 0, &[0x62])]); + let second = + ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize + ENGINE_TEXT_MUTATION_RECORD_SIZE as usize; + write_u32( + &mut mixed_bytes, + second + ENGINE_TEXT_MUTATION_PARAGRAPH_ID, + 2, + ); + let mixed = + parse_text_mutations(&mixed_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 2).unwrap(); + let mut request = update(0, 0, 0); + request.text_mutations = mixed; + assert_eq!( + engine.prepare_update(request, 1), + Err(EngineError::InvalidRequest) + ); + + let initial_bytes = text_mutation_bytes(&[(0, 0, &[0x61])]); + let mut initial = update(0, 0, 0); + initial.text_mutations = + parse_text_mutations(&initial_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1).unwrap(); + let prepared = engine.prepare_update(initial, 1).unwrap(); + engine.commit_update(prepared).unwrap(); + + let mut rebound_bytes = text_mutation_bytes(&[(1, 0, &[0x62])]); + write_u32( + &mut rebound_bytes, + ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize + ENGINE_TEXT_MUTATION_PARAGRAPH_ID, + 2, + ); + let mut rebound = update(1, 1, 1); + rebound.text_mutations = + parse_text_mutations(&rebound_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1).unwrap(); + assert_eq!( + engine.prepare_update(rebound, 2), + Err(EngineError::InvalidRequest) + ); + assert_eq!(engine.session_text(4).unwrap(), &[0x61]); + } + + #[test] + fn a_committed_session_rejects_rebinding_its_policy_identity() { + let mut engine = TextEngine::default(); + engine + .register_policy(9, validated_policy(TechniqueId(1))) + .unwrap(); + engine.create_session(4).unwrap(); + let first = engine.prepare_update(update(0, 0, 0), 1).unwrap(); + engine.commit_update(first).unwrap(); + + engine.dispose_policy(9).unwrap(); + engine + .register_policy(9, validated_policy(TechniqueId(2))) + .unwrap(); + assert_eq!( + engine.prepare_update(update(1, 1, 1), 2), + Err(EngineError::InvalidRequest) + ); + } + + fn validated_policy(technique: TechniqueId) -> ValidatedPolicy { + ValidatedPolicy::new(PolicyDescriptor { + capability_sets: vec![ + CapabilitySet { + id: CapabilitySetId(1), + flags: CAP_ORDERED_DIRECT, + max_buffer_bytes: 1024, + update_alignment: 4, + coalesce_gap_bytes: 0, + range_call_penalty_bytes: 0, + max_buffers_per_draw: 1, + max_resources_per_draw: 1, + max_indirect_draws: 0, + fragmentation_budget: 1, + whole_buffer_threshold_basis_points: 10_000, + }, + CapabilitySet { + id: CapabilitySetId(2), + flags: CAP_ORDERED_DIRECT, + max_buffer_bytes: 1024, + update_alignment: 4, + coalesce_gap_bytes: 0, + range_call_penalty_bytes: 0, + max_buffers_per_draw: 1, + max_resources_per_draw: 1, + max_indirect_draws: 0, + fragmentation_budget: 1, + whole_buffer_threshold_basis_points: 10_000, + }, + ], + programs: vec![ProgramDescriptor { + technique, + variant: 0, + id: ProgramId(1), + capability_set: CapabilitySetId(0), + resource_kind_mask: 1, + semantic_view_mask: 0, + storage_key_mask: BATCH_TECHNIQUE | BATCH_PROGRAM | BATCH_RESOURCE, + draw_key_mask: BATCH_TECHNIQUE + | BATCH_PROGRAM + | BATCH_RESOURCE + | BATCH_ORDER + | crate::engine::policy::BATCH_TRANSFORM, + allocation_strategy: ALLOCATION_ORDERED_DIRECT, + f32_input_count: 1, + u32_input_count: 0, + inputs: vec![crate::engine::policy::InputSource::semantic(0)], + capabilities: ProgramCapabilities::default(), + buffers: vec![BufferSchema::packed( + BufferId(1), + ScalarType::F32, + 1, + BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST, + 1, + )], + operations: vec![ + Operation::LoadF32 { + target: 0, + field: 0, + }, + Operation::StoreF32 { + source: 0, + buffer: BufferId(1), + lane: 0, + }, + ], + }], + }) + .unwrap() + } + + fn render_binding(glyph_count: u32, technique: u32) -> FontRenderBinding { + FontRenderBinding::new( + TechniqueId(technique), + 0, + glyph_count, + vec![FontStrike { ppem: 0 }], + vec![FontResource { + id: 1, + generation: 1, + kind: 1, + reference: 0, + }], + (0..glyph_count) + .map(|glyph| { + if glyph == 0 { + MISSING_RESOURCE_INDEX + } else { + 0 + } + }) + .collect(), + FieldTable::new(glyph_count, 0, vec![]).unwrap(), + FieldTable::new(glyph_count, 0, vec![]).unwrap(), + FieldTable::new(glyph_count, 0, vec![]).unwrap(), + FieldTable::new(glyph_count, 0, vec![]).unwrap(), + FieldTable::new(1, 0, vec![]).unwrap(), + FieldTable::new(1, 0, vec![]).unwrap(), + ) + .unwrap() + } + + fn update( + expected_engine_revision: u32, + consumed_plan_revision: u32, + acknowledged_publication_generation: u32, + ) -> UpdateRequest<'static> { + UpdateRequest { + session_id: 4, + expected_engine_revision, + consumed_plan_revision, + acknowledged_publication_generation, + policy_handle: 9, + capability_set: 1, + semantic_view_mask: 0, + compositing_independent: false, + limits: super::super::frame::UpdateLimits { + max_paragraphs: 1, + max_clusters: 1, + max_lines: 1, + max_regions: 1, + max_exclusions: 1, + max_inline_objects: 1, + max_slots_per_band: 1, + max_output_bytes: 128, + }, + paragraph_mutations: super::super::semantic_wire::ParagraphMutationBatch::empty(), + text_mutations: super::super::semantic_wire::TextMutationBatch::empty(), + style_mutations: super::super::semantic_wire::StyleMutationBatch::empty(), + geometry: super::super::semantic_wire::GeometryBatch::empty(), + } + } + + fn root_style_bytes(font_stack_handle: u32) -> Vec { + root_style_bytes_inner(font_stack_handle, None) + } + + fn root_style_bytes_with_direction(font_stack_handle: u32, direction: u8) -> Vec { + root_style_bytes_inner(font_stack_handle, Some(direction)) + } + + fn root_style_bytes_inner(font_stack_handle: u32, direction: Option) -> Vec { + let record = ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize; + let mut bytes = vec![0; record + abi::ENGINE_STYLE_MUTATION_RECORD_SIZE as usize]; + bytes[record + abi::ENGINE_STYLE_MUTATION_OPCODE] = STYLE_MUTATION_UPSERT; + bytes[record + abi::ENGINE_STYLE_MUTATION_FLAGS] = STYLE_FLAG_ROOT; + write_u32(&mut bytes, record + abi::ENGINE_STYLE_MUTATION_STYLE_ID, 1); + write_u32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_PARAGRAPH_ID, + 1, + ); + write_u32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_FIELD_MASK, + STYLE_FIELD_FONT_STACK + | STYLE_FIELD_FONT_SIZE + | STYLE_FIELD_LINE_HEIGHT + | STYLE_FIELD_RASTER_PIXEL_RATIO + | direction.map_or(0, |_| STYLE_FIELD_DIRECTION), + ); + write_u32(&mut bytes, record + abi::ENGINE_STYLE_MUTATION_TEXT_END, 4); + write_u32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_FONT_STACK_HANDLE, + font_stack_handle, + ); + write_f32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_FONT_SIZE, + 16.0, + ); + write_f32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_LINE_HEIGHT, + 1.2, + ); + write_f32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_RASTER_PIXEL_RATIO, + 1.0, + ); + if let Some(direction) = direction { + bytes[record + abi::ENGINE_STYLE_MUTATION_DIRECTION] = direction; + } + bytes + } + + fn write_f32(bytes: &mut [u8], offset: usize, value: f32) { + write_u32(bytes, offset, value.to_bits()); + } + + fn remove_style_bytes(style_id: u32) -> Vec { + let record = ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize; + let mut bytes = vec![0; record + abi::ENGINE_STYLE_MUTATION_RECORD_SIZE as usize]; + bytes[record + abi::ENGINE_STYLE_MUTATION_OPCODE] = STYLE_MUTATION_REMOVE; + write_u32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_STYLE_ID, + style_id, + ); + write_u32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_PARAGRAPH_ID, + 1, + ); + bytes + } + + fn paragraph_mutation_bytes(records: &[(u8, u32, u32)]) -> Vec { + let record_offset = ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize; + let mut bytes = + vec![ + 0; + record_offset + records.len() * abi::ENGINE_PARAGRAPH_MUTATION_RECORD_SIZE as usize + ]; + for (index, &(opcode, paragraph_id, order)) in records.iter().enumerate() { + let start = record_offset + index * abi::ENGINE_PARAGRAPH_MUTATION_RECORD_SIZE as usize; + let record = + &mut bytes[start..start + abi::ENGINE_PARAGRAPH_MUTATION_RECORD_SIZE as usize]; + record[abi::ENGINE_PARAGRAPH_MUTATION_OPCODE] = opcode; + write_u32( + record, + abi::ENGINE_PARAGRAPH_MUTATION_PARAGRAPH_ID, + paragraph_id, + ); + write_u32(record, abi::ENGINE_PARAGRAPH_MUTATION_ORDER, order); + } + bytes + } + + fn paragraph_text_mutation_bytes(records: &[(u32, u32, u32, &[u16])]) -> Vec { + let record_offset = ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize; + let records_length = records.len() * ENGINE_TEXT_MUTATION_RECORD_SIZE as usize; + let payload_length = records + .iter() + .map(|(_, _, _, insert)| insert.len() * 2) + .sum::(); + let mut bytes = vec![0; record_offset + records_length + payload_length]; + let mut payload_offset = record_offset + records_length; + for (index, &(paragraph_id, text_start, delete_count, insert)) in records.iter().enumerate() + { + let start = record_offset + index * ENGINE_TEXT_MUTATION_RECORD_SIZE as usize; + let record = &mut bytes[start..start + ENGINE_TEXT_MUTATION_RECORD_SIZE as usize]; + record[ENGINE_TEXT_MUTATION_OPCODE] = TEXT_MUTATION_REPLACE_UTF16; + record[ENGINE_TEXT_MUTATION_ENCODING] = TEXT_ENCODING_UTF16_LE; + write_u32(record, ENGINE_TEXT_MUTATION_PARAGRAPH_ID, paragraph_id); + write_u32(record, ENGINE_TEXT_MUTATION_TEXT_START, text_start); + write_u32(record, ENGINE_TEXT_MUTATION_DELETE_COUNT, delete_count); + if !insert.is_empty() { + write_u32( + record, + ENGINE_TEXT_MUTATION_INSERT_OFFSET, + u32::try_from(payload_offset).unwrap(), + ); + write_u32( + record, + ENGINE_TEXT_MUTATION_INSERT_COUNT, + u32::try_from(insert.len()).unwrap(), + ); + for &unit in insert { + bytes[payload_offset..payload_offset + 2].copy_from_slice(&unit.to_le_bytes()); + payload_offset += 2; + } + } + } + bytes + } + + fn text_mutation_bytes(records: &[(u32, u32, &[u16])]) -> Vec { + let record_offset = ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize; + let records_length = records.len() * ENGINE_TEXT_MUTATION_RECORD_SIZE as usize; + let payload_length = records + .iter() + .map(|(_, _, insert)| insert.len() * 2) + .sum::(); + let mut bytes = vec![0; record_offset + records_length + payload_length]; + let mut payload_offset = record_offset + records_length; + for (index, &(text_start, delete_count, insert)) in records.iter().enumerate() { + let start = record_offset + index * ENGINE_TEXT_MUTATION_RECORD_SIZE as usize; + let end = start + ENGINE_TEXT_MUTATION_RECORD_SIZE as usize; + let record = &mut bytes[start..end]; + record[ENGINE_TEXT_MUTATION_OPCODE] = TEXT_MUTATION_REPLACE_UTF16; + record[ENGINE_TEXT_MUTATION_ENCODING] = TEXT_ENCODING_UTF16_LE; + write_u32(record, ENGINE_TEXT_MUTATION_PARAGRAPH_ID, 1); + write_u32(record, ENGINE_TEXT_MUTATION_TEXT_START, text_start); + write_u32(record, ENGINE_TEXT_MUTATION_DELETE_COUNT, delete_count); + if !insert.is_empty() { + write_u32( + record, + ENGINE_TEXT_MUTATION_INSERT_OFFSET, + u32::try_from(payload_offset).unwrap(), + ); + write_u32( + record, + ENGINE_TEXT_MUTATION_INSERT_COUNT, + u32::try_from(insert.len()).unwrap(), + ); + for &unit in insert { + bytes[payload_offset..payload_offset + 2].copy_from_slice(&unit.to_le_bytes()); + payload_offset += 2; + } + } + } + bytes + } +} diff --git a/packages/text/rust/shaper/src/engine/style_state.rs b/packages/text/rust/shaper/src/engine/style_state.rs new file mode 100644 index 00000000..b96fd304 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/style_state.rs @@ -0,0 +1,933 @@ +//! Flat retained style storage and transactional mutation merge. + +use alloc::vec::Vec; + +use crate::{ + FeatureRecord, + engine::{ + frame::{ + DECORATION_NONE, STYLE_FIELD_BASELINE_SHIFT, STYLE_FIELD_DECORATION, + STYLE_FIELD_DIRECTION, STYLE_FIELD_FEATURES, STYLE_FIELD_FONT_SIZE, + STYLE_FIELD_FONT_STACK, STYLE_FIELD_FOREGROUND, STYLE_FIELD_LANGUAGE, + STYLE_FIELD_LETTER_SPACING, STYLE_FIELD_LINE_HEIGHT, STYLE_FIELD_MATERIAL, + STYLE_FIELD_RASTER_PIXEL_RATIO, STYLE_FIELD_WORD_SPACING, + }, + semantic_wire::{StyleMutation, StyleMutationBatch, StyleValue}, + }, + valid_utf16_boundary, +}; + +use super::EngineError; + +const ROOT_REQUIRED_FIELDS: u32 = + STYLE_FIELD_FONT_STACK | STYLE_FIELD_FONT_SIZE | STYLE_FIELD_RASTER_PIXEL_RATIO; +pub(crate) const DEFAULT_STYLE_CAPACITY: usize = 64; +const DEFAULT_LANGUAGE_CAPACITY: usize = 512; +const DEFAULT_FEATURE_CAPACITY: usize = 128; +const NO_STYLE_SOURCE: u32 = u32::MAX; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct RetainedStyle { + pub style_id: u32, + pub cascade_order: u32, + pub field_mask: u32, + pub text_start: u32, + pub text_end: u32, + pub font_stack_handle: u32, + pub material_id: u32, + pub language_start: u32, + pub language_length: u16, + pub feature_start: u32, + pub feature_count: u16, + pub font_size: f32, + pub line_height: f32, + pub letter_spacing: f32, + pub word_spacing: f32, + pub baseline_shift: f32, + pub raster_pixel_ratio: f32, + pub direction: u8, + pub foreground_rgba: u32, + pub decoration_rgba: u32, + pub decoration_flags: u32, + pub decoration_style: u8, + pub decoration_thickness: f32, + pub decoration_offset: f32, + pub root: bool, +} + +#[derive(Default)] +pub(crate) struct StyleArena { + records: Vec, + languages: Vec, + features: Vec, +} + +#[derive(Clone, Copy)] +pub(crate) struct MutationKey { + style_id: u32, + request_index: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct ResolvedStyle { + pub font_stack_handle: u32, + pub material_id: u32, + pub font_size: f32, + pub line_height: f32, + pub letter_spacing: f32, + pub word_spacing: f32, + pub baseline_shift: f32, + pub raster_pixel_ratio: f32, + pub direction: u8, + pub bidi_override: bool, + pub foreground_rgba: u32, + pub decoration_rgba: u32, + pub decoration_flags: u32, + pub decoration_style: u8, + pub decoration_thickness: f32, + pub decoration_offset: f32, + language_source: u32, + features_source: u32, + pub has_line_height: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct StyleSegment { + pub text_start: u32, + pub text_end: u32, + pub style: ResolvedStyle, +} + +#[derive(Clone, Copy)] +pub(crate) struct ResolutionScope { + end: u32, + style: ResolvedStyle, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct StyleInvalidation { + pub bidi: bool, + pub shaping: bool, + pub metrics: bool, + pub positioning: bool, +} + +#[derive(Default)] +pub(crate) struct ResolvedStyleArena { + segments: Vec, +} + +impl Default for ResolvedStyle { + fn default() -> Self { + Self { + font_stack_handle: 0, + material_id: 0, + font_size: 16.0, + line_height: 0.0, + letter_spacing: 0.0, + word_spacing: 0.0, + baseline_shift: 0.0, + raster_pixel_ratio: 1.0, + direction: 0, + bidi_override: false, + foreground_rgba: u32::MAX, + decoration_rgba: 0, + decoration_flags: 0, + decoration_style: DECORATION_NONE, + decoration_thickness: 0.0, + decoration_offset: 0.0, + language_source: NO_STYLE_SOURCE, + features_source: NO_STYLE_SOURCE, + has_line_height: false, + } + } +} + +impl ResolvedStyle { + fn same_layout_scalars(self, other: Self) -> bool { + self.font_stack_handle == other.font_stack_handle + && self.font_size.to_bits() == other.font_size.to_bits() + && self.line_height.to_bits() == other.line_height.to_bits() + && self.has_line_height == other.has_line_height + && self.letter_spacing.to_bits() == other.letter_spacing.to_bits() + && self.word_spacing.to_bits() == other.word_spacing.to_bits() + && self.baseline_shift.to_bits() == other.baseline_shift.to_bits() + && self.direction == other.direction + && self.bidi_override == other.bidi_override + } + + pub(crate) fn same_layout_sources(self, other: Self) -> bool { + self.same_layout_scalars(other) + && self.language_source == other.language_source + && self.features_source == other.features_source + } +} + +#[cfg(test)] +impl ResolvedStyle { + pub(crate) fn test_typography(font_size: f32, letter_spacing: f32, word_spacing: f32) -> Self { + Self { + font_size, + letter_spacing, + word_spacing, + ..Self::default() + } + } +} + +impl ResolvedStyleArena { + pub(crate) fn reserve_default(&mut self) -> Result<(), EngineError> { + self.segments + .try_reserve_exact(DEFAULT_STYLE_CAPACITY.saturating_mul(2)) + .map_err(|_| EngineError::ResultTooLarge) + } + + pub(crate) fn clear(&mut self) { + self.segments.clear(); + } + + pub(crate) fn segments(&self) -> &[StyleSegment] { + &self.segments + } + + pub(crate) fn invalidation_against( + &self, + previous_storage: &StyleArena, + next: &Self, + next_storage: &StyleArena, + ) -> StyleInvalidation { + if self.segments.len() != next.segments.len() { + return StyleInvalidation { + bidi: true, + shaping: true, + metrics: true, + positioning: true, + }; + } + let mut invalidation = StyleInvalidation::default(); + for (previous, next) in self.segments.iter().zip(&next.segments) { + if previous.text_start != next.text_start || previous.text_end != next.text_end { + return StyleInvalidation { + bidi: true, + shaping: true, + metrics: true, + positioning: true, + }; + } + let old = previous.style; + let new = next.style; + invalidation.bidi |= + old.direction != new.direction || old.bidi_override != new.bidi_override; + invalidation.shaping |= invalidation.bidi + || old.font_stack_handle != new.font_stack_handle + || previous_storage.resolved_language(old) != next_storage.resolved_language(new) + || previous_storage.resolved_features(old) != next_storage.resolved_features(new); + invalidation.metrics |= invalidation.shaping + || old.font_size.to_bits() != new.font_size.to_bits() + || old.line_height.to_bits() != new.line_height.to_bits() + || old.has_line_height != new.has_line_height + || old.letter_spacing.to_bits() != new.letter_spacing.to_bits() + || old.word_spacing.to_bits() != new.word_spacing.to_bits() + || old.baseline_shift.to_bits() != new.baseline_shift.to_bits(); + invalidation.positioning |= invalidation.metrics + || old.material_id != new.material_id + || old.raster_pixel_ratio.to_bits() != new.raster_pixel_ratio.to_bits() + || old.foreground_rgba != new.foreground_rgba + || old.decoration_rgba != new.decoration_rgba + || old.decoration_flags != new.decoration_flags + || old.decoration_style != new.decoration_style + || old.decoration_thickness.to_bits() != new.decoration_thickness.to_bits() + || old.decoration_offset.to_bits() != new.decoration_offset.to_bits(); + } + invalidation + } +} + +impl StyleArena { + pub(crate) fn len(&self) -> usize { + self.records.len() + } + + pub(crate) fn reserve_default(&mut self) -> Result<(), EngineError> { + self.records + .try_reserve_exact(DEFAULT_STYLE_CAPACITY) + .map_err(|_| EngineError::ResultTooLarge)?; + self.languages + .try_reserve_exact(DEFAULT_LANGUAGE_CAPACITY) + .map_err(|_| EngineError::ResultTooLarge)?; + self.features + .try_reserve_exact(DEFAULT_FEATURE_CAPACITY) + .map_err(|_| EngineError::ResultTooLarge) + } + + pub(crate) fn clear(&mut self) { + self.records.clear(); + self.languages.clear(); + self.features.clear(); + } + + pub(crate) fn prepare_from( + &mut self, + committed: &Self, + mutations: StyleMutationBatch<'_>, + scratch: &mut Vec, + ) -> Result<(), EngineError> { + self.clear(); + scratch.clear(); + scratch + .try_reserve(mutations.len()) + .map_err(|_| EngineError::ResultTooLarge)?; + for request_index in 0..mutations.len() { + let mutation = mutations + .get(request_index) + .ok_or(EngineError::InvalidRequest)?; + let style_id = match mutation { + StyleMutation::Remove { style_id, .. } => style_id, + StyleMutation::Upsert(value) => value.style_id, + }; + scratch.push(MutationKey { + style_id, + request_index, + }); + } + scratch.sort_unstable_by_key(|key| (key.style_id, key.request_index)); + collapse_to_last_mutation(scratch); + + self.records + .try_reserve(committed.records.len().saturating_add(scratch.len())) + .map_err(|_| EngineError::ResultTooLarge)?; + self.languages + .try_reserve( + committed + .languages + .len() + .saturating_add(total_language_bytes(mutations)?), + ) + .map_err(|_| EngineError::ResultTooLarge)?; + self.features + .try_reserve( + committed + .features + .len() + .saturating_add(total_feature_count(mutations)?), + ) + .map_err(|_| EngineError::ResultTooLarge)?; + + let mut committed_index = 0; + let mut mutation_index = 0; + while committed_index < committed.records.len() || mutation_index < scratch.len() { + let committed_id = committed + .records + .get(committed_index) + .map_or(u32::MAX, |style| style.style_id); + let mutation_id = scratch + .get(mutation_index) + .map_or(u32::MAX, |key| key.style_id); + if committed_id < mutation_id { + self.push_retained(committed, committed_index)?; + committed_index += 1; + } else if mutation_id < committed_id { + self.push_mutation(mutations, scratch[mutation_index].request_index)?; + mutation_index += 1; + } else { + self.push_mutation(mutations, scratch[mutation_index].request_index)?; + committed_index += 1; + mutation_index += 1; + } + } + Ok(()) + } + + pub(crate) fn validate( + &self, + text: &[u16], + mut font_stack_exists: impl FnMut(u32) -> bool, + order_scratch: &mut Vec, + nesting_scratch: &mut Vec, + ) -> Result<(), EngineError> { + if self.records.is_empty() { + return Ok(()); + } + let text_length = u32::try_from(text.len()).map_err(|_| EngineError::InvalidRequest)?; + let mut root_count = 0; + for style in &self.records { + if style.text_end > text_length + || !valid_utf16_boundary(text, style.text_start) + || !valid_utf16_boundary(text, style.text_end) + || style.field_mask & STYLE_FIELD_FONT_STACK != 0 + && !font_stack_exists(style.font_stack_handle) + { + return Err(EngineError::InvalidRequest); + } + if style.root { + root_count += 1; + if style.text_start != 0 + || style.text_end != text_length + || style.field_mask & ROOT_REQUIRED_FIELDS != ROOT_REQUIRED_FIELDS + { + return Err(EngineError::InvalidRequest); + } + } + for feature in self.features(*style) { + if !valid_utf16_boundary(text, feature.start) + || !valid_utf16_boundary(text, feature.end) + { + return Err(EngineError::InvalidRequest); + } + } + } + if root_count != 1 { + return Err(EngineError::InvalidRequest); + } + + order_scratch.clear(); + order_scratch + .try_reserve(self.records.len()) + .map_err(|_| EngineError::ResultTooLarge)?; + order_scratch.extend(0..self.records.len()); + order_scratch.sort_unstable_by_key(|index| { + let style = self.records[*index]; + ( + style.text_start, + core::cmp::Reverse(style.text_end), + !style.root, + style.cascade_order, + ) + }); + if order_scratch.windows(2).any(|pair| { + let left = self.records[pair[0]]; + let right = self.records[pair[1]]; + left.text_start == right.text_start + && left.text_end == right.text_end + && left.cascade_order == right.cascade_order + }) { + return Err(EngineError::InvalidRequest); + } + nesting_scratch.clear(); + nesting_scratch + .try_reserve(self.records.len()) + .map_err(|_| EngineError::ResultTooLarge)?; + for index in order_scratch.iter().copied() { + let style = self.records[index]; + while nesting_scratch + .last() + .is_some_and(|end| style.text_start >= *end) + { + nesting_scratch.pop(); + } + if nesting_scratch + .last() + .is_some_and(|end| style.text_end > *end) + { + return Err(EngineError::InvalidRequest); + } + nesting_scratch.push(style.text_end); + } + Ok(()) + } + + pub(crate) fn resolve( + &self, + containment_order: &[usize], + output: &mut ResolvedStyleArena, + scope_scratch: &mut Vec, + ) -> Result<(), EngineError> { + output.clear(); + scope_scratch.clear(); + output + .segments + .try_reserve(self.records.len().saturating_mul(2)) + .map_err(|_| EngineError::ResultTooLarge)?; + scope_scratch + .try_reserve(self.records.len()) + .map_err(|_| EngineError::ResultTooLarge)?; + let root = containment_order + .first() + .and_then(|index| self.records.get(*index)) + .filter(|style| style.root) + .ok_or(EngineError::InvalidRequest)?; + if root.text_end == 0 { + output.segments.push(StyleSegment { + text_start: 0, + text_end: 0, + style: apply_style(ResolvedStyle::default(), *root, containment_order[0]), + }); + return Ok(()); + } + + let mut current = 0; + let mut opening = 0; + while current < root.text_end { + while scope_scratch + .last() + .is_some_and(|scope| scope.end <= current) + { + scope_scratch.pop(); + } + while let Some(index) = containment_order.get(opening).copied() { + let style = self.records[index]; + if style.text_start != current { + break; + } + let inherited = scope_scratch + .last() + .map_or_else(ResolvedStyle::default, |scope| scope.style); + scope_scratch.push(ResolutionScope { + end: style.text_end, + style: apply_style(inherited, style, index), + }); + opening += 1; + } + let scope = scope_scratch + .last() + .copied() + .ok_or(EngineError::InvalidRequest)?; + let next_open = containment_order + .get(opening) + .map_or(root.text_end, |index| self.records[*index].text_start); + let next = scope.end.min(next_open); + if next <= current { + return Err(EngineError::InvalidRequest); + } + if output.segments.last().is_some_and(|previous| { + previous.text_end == current && self.same_resolved(previous.style, scope.style) + }) { + output + .segments + .last_mut() + .ok_or(EngineError::InvalidRequest)? + .text_end = next; + } else { + output.segments.push(StyleSegment { + text_start: current, + text_end: next, + style: scope.style, + }); + } + current = next; + } + if opening != containment_order.len() + || !scope_scratch.iter().all(|scope| scope.end == root.text_end) + { + return Err(EngineError::InvalidRequest); + } + Ok(()) + } + + pub(crate) fn resolved_language(&self, style: ResolvedStyle) -> Option<&[u8]> { + let index = usize::try_from(style.language_source).ok()?; + self.records.get(index).map(|source| self.language(*source)) + } + + pub(crate) fn resolved_features(&self, style: ResolvedStyle) -> &[FeatureRecord] { + let Ok(index) = usize::try_from(style.features_source) else { + return &[]; + }; + self.records + .get(index) + .map_or(&[], |source| self.features(*source)) + } + + pub(crate) fn same_layout_style(&self, left: ResolvedStyle, right: ResolvedStyle) -> bool { + left.same_layout_scalars(right) + && self.resolved_language(left) == self.resolved_language(right) + && self.resolved_features(left) == self.resolved_features(right) + } + + fn same_resolved(&self, left: ResolvedStyle, right: ResolvedStyle) -> bool { + let same_scalars = ResolvedStyle { + language_source: NO_STYLE_SOURCE, + features_source: NO_STYLE_SOURCE, + ..left + } == ResolvedStyle { + language_source: NO_STYLE_SOURCE, + features_source: NO_STYLE_SOURCE, + ..right + }; + same_scalars + && self.resolved_language(left) == self.resolved_language(right) + && self.resolved_features(left) == self.resolved_features(right) + } + + fn push_retained(&mut self, source: &Self, index: usize) -> Result<(), EngineError> { + let mut style = source.records[index]; + let language = source.language(style); + let features = source.features(style); + set_payload_ranges(self, &mut style, language, features)?; + self.records.push(style); + Ok(()) + } + + fn push_mutation( + &mut self, + mutations: StyleMutationBatch<'_>, + request_index: usize, + ) -> Result<(), EngineError> { + let mutation = mutations + .get(request_index) + .ok_or(EngineError::InvalidRequest)?; + let StyleMutation::Upsert(value) = mutation else { + return Ok(()); + }; + let mut style = retained(value); + style.language_start = + u32::try_from(self.languages.len()).map_err(|_| EngineError::ResultTooLarge)?; + style.language_length = + u16::try_from(value.language.len()).map_err(|_| EngineError::ResultTooLarge)?; + self.languages.extend_from_slice(value.language); + style.feature_start = + u32::try_from(self.features.len()).map_err(|_| EngineError::ResultTooLarge)?; + style.feature_count = + u16::try_from(value.features.len() / 16).map_err(|_| EngineError::ResultTooLarge)?; + for index in 0..value.features.len() / 16 { + self.features.push( + StyleMutationBatch::feature(value, index).ok_or(EngineError::InvalidRequest)?, + ); + } + self.records.push(style); + Ok(()) + } + + fn language(&self, style: RetainedStyle) -> &[u8] { + let start = style.language_start as usize; + &self.languages[start..start + usize::from(style.language_length)] + } + + fn features(&self, style: RetainedStyle) -> &[FeatureRecord] { + let start = style.feature_start as usize; + &self.features[start..start + usize::from(style.feature_count)] + } +} + +fn apply_style(mut resolved: ResolvedStyle, style: RetainedStyle, source: usize) -> ResolvedStyle { + let fields = style.field_mask; + if fields & STYLE_FIELD_FONT_STACK != 0 { + resolved.font_stack_handle = style.font_stack_handle; + } + if fields & STYLE_FIELD_MATERIAL != 0 { + resolved.material_id = style.material_id; + } + if fields & STYLE_FIELD_LANGUAGE != 0 { + resolved.language_source = u32::try_from(source).unwrap_or(NO_STYLE_SOURCE); + } + if fields & STYLE_FIELD_FEATURES != 0 { + resolved.features_source = u32::try_from(source).unwrap_or(NO_STYLE_SOURCE); + } + if fields & STYLE_FIELD_FONT_SIZE != 0 { + resolved.font_size = style.font_size; + } + if fields & STYLE_FIELD_LINE_HEIGHT != 0 { + resolved.line_height = style.line_height; + resolved.has_line_height = true; + } + if fields & STYLE_FIELD_LETTER_SPACING != 0 { + resolved.letter_spacing = style.letter_spacing; + } + if fields & STYLE_FIELD_WORD_SPACING != 0 { + resolved.word_spacing = style.word_spacing; + } + if fields & STYLE_FIELD_BASELINE_SHIFT != 0 { + resolved.baseline_shift = style.baseline_shift; + } + if fields & STYLE_FIELD_RASTER_PIXEL_RATIO != 0 { + resolved.raster_pixel_ratio = style.raster_pixel_ratio; + } + if fields & STYLE_FIELD_DIRECTION != 0 { + resolved.direction = style.direction; + resolved.bidi_override = !style.root && style.direction != 0; + } + if fields & STYLE_FIELD_FOREGROUND != 0 { + resolved.foreground_rgba = style.foreground_rgba; + } + if fields & STYLE_FIELD_DECORATION != 0 { + resolved.decoration_rgba = style.decoration_rgba; + resolved.decoration_flags = style.decoration_flags; + resolved.decoration_style = style.decoration_style; + resolved.decoration_thickness = style.decoration_thickness; + resolved.decoration_offset = style.decoration_offset; + } + resolved +} + +fn collapse_to_last_mutation(scratch: &mut Vec) { + let mut read = 0; + let mut write = 0; + while read < scratch.len() { + let mut next = read + 1; + while next < scratch.len() && scratch[next].style_id == scratch[read].style_id { + next += 1; + } + scratch[write] = scratch[next - 1]; + write += 1; + read = next; + } + scratch.truncate(write); +} + +fn total_language_bytes(mutations: StyleMutationBatch<'_>) -> Result { + let mut total = 0usize; + for index in 0..mutations.len() { + if let StyleMutation::Upsert(value) = + mutations.get(index).ok_or(EngineError::InvalidRequest)? + { + total = total + .checked_add(value.language.len()) + .ok_or(EngineError::ResultTooLarge)?; + } + } + Ok(total) +} + +fn total_feature_count(mutations: StyleMutationBatch<'_>) -> Result { + let mut total = 0usize; + for index in 0..mutations.len() { + if let StyleMutation::Upsert(value) = + mutations.get(index).ok_or(EngineError::InvalidRequest)? + { + total = total + .checked_add(value.features.len() / 16) + .ok_or(EngineError::ResultTooLarge)?; + } + } + Ok(total) +} + +fn set_payload_ranges( + arena: &mut StyleArena, + style: &mut RetainedStyle, + language: &[u8], + features: &[FeatureRecord], +) -> Result<(), EngineError> { + style.language_start = + u32::try_from(arena.languages.len()).map_err(|_| EngineError::ResultTooLarge)?; + style.language_length = + u16::try_from(language.len()).map_err(|_| EngineError::ResultTooLarge)?; + arena.languages.extend_from_slice(language); + style.feature_start = + u32::try_from(arena.features.len()).map_err(|_| EngineError::ResultTooLarge)?; + style.feature_count = u16::try_from(features.len()).map_err(|_| EngineError::ResultTooLarge)?; + arena.features.extend_from_slice(features); + Ok(()) +} + +fn retained(value: StyleValue<'_>) -> RetainedStyle { + RetainedStyle { + style_id: value.style_id, + cascade_order: value.cascade_order, + field_mask: value.field_mask, + text_start: value.text_start, + text_end: value.text_end, + font_stack_handle: value.font_stack_handle, + material_id: value.material_id, + language_start: 0, + language_length: 0, + feature_start: 0, + feature_count: 0, + font_size: value.font_size, + line_height: value.line_height, + letter_spacing: value.letter_spacing, + word_spacing: value.word_spacing, + baseline_shift: value.baseline_shift, + raster_pixel_ratio: value.raster_pixel_ratio, + direction: value.direction, + foreground_rgba: value.foreground_rgba, + decoration_rgba: value.decoration_rgba, + decoration_flags: value.decoration_flags, + decoration_style: value.decoration_style, + decoration_thickness: value.decoration_thickness, + decoration_offset: value.decoration_offset, + root: value.root, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::frame::{ + STYLE_FIELD_FEATURES, STYLE_FIELD_FOREGROUND, STYLE_FIELD_LANGUAGE, + STYLE_FIELD_LETTER_SPACING, STYLE_FIELD_MATERIAL, STYLE_FIELD_WORD_SPACING, + }; + use alloc::vec; + + #[test] + fn resolves_nested_and_equal_range_styles_once_per_boundary() { + let root_fields = ROOT_REQUIRED_FIELDS + | STYLE_FIELD_LANGUAGE + | STYLE_FIELD_FEATURES + | STYLE_FIELD_FOREGROUND; + let arena = StyleArena { + records: vec![ + style(10, 0, 0, 8, root_fields, true), + RetainedStyle { + font_size: 20.0, + foreground_rgba: 0xff00_00ff, + ..style( + 20, + 1, + 2, + 6, + STYLE_FIELD_FONT_SIZE | STYLE_FIELD_FOREGROUND, + false, + ) + }, + RetainedStyle { + letter_spacing: 1.5, + word_spacing: 2.0, + ..style( + 30, + 2, + 3, + 5, + STYLE_FIELD_LETTER_SPACING | STYLE_FIELD_WORD_SPACING, + false, + ) + }, + RetainedStyle { + material_id: 9, + ..style(40, 3, 3, 5, STYLE_FIELD_MATERIAL, false) + }, + ], + languages: b"en".to_vec(), + features: vec![FeatureRecord { + tag: u32::from_be_bytes(*b"kern"), + value: 1, + start: 0, + end: 8, + }], + }; + let mut order = Vec::new(); + let mut nesting = Vec::new(); + arena + .validate(&[0x61; 8], |handle| handle == 7, &mut order, &mut nesting) + .unwrap(); + let mut resolved = ResolvedStyleArena::default(); + let mut scopes = Vec::new(); + arena.resolve(&order, &mut resolved, &mut scopes).unwrap(); + + assert_eq!( + resolved + .segments() + .iter() + .map(|segment| (segment.text_start, segment.text_end)) + .collect::>(), + [(0, 2), (2, 3), (3, 5), (5, 6), (6, 8)], + ); + let deepest = resolved.segments()[2].style; + assert_eq!(deepest.font_stack_handle, 7); + assert_eq!(deepest.font_size, 20.0); + assert_eq!(deepest.letter_spacing, 1.5); + assert_eq!(deepest.word_spacing, 2.0); + assert_eq!(deepest.material_id, 9); + assert_eq!(deepest.foreground_rgba, 0xff00_00ff); + assert_eq!(arena.resolved_language(deepest), Some(&b"en"[..])); + assert_eq!(arena.resolved_features(deepest), arena.features.as_slice()); + assert!(!deepest.has_line_height); + assert_eq!(resolved.segments()[3].style.material_id, 0); + } + + #[test] + fn invalidation_stops_at_the_first_affected_retained_stage() { + let storage = StyleArena::default(); + let previous = resolved(ResolvedStyle::default()); + + assert_eq!( + previous.invalidation_against(&storage, &previous, &storage), + StyleInvalidation::default(), + ); + + let mut font_size = ResolvedStyle::default(); + font_size.font_size = 24.0; + assert_eq!( + previous.invalidation_against(&storage, &resolved(font_size), &storage), + StyleInvalidation { + bidi: false, + shaping: false, + metrics: true, + positioning: true, + }, + ); + + let mut foreground = ResolvedStyle::default(); + foreground.foreground_rgba = 0xff00_00ff; + assert_eq!( + previous.invalidation_against(&storage, &resolved(foreground), &storage), + StyleInvalidation { + bidi: false, + shaping: false, + metrics: false, + positioning: true, + }, + ); + + let mut direction = ResolvedStyle::default(); + direction.direction = 2; + assert_eq!( + previous.invalidation_against(&storage, &resolved(direction), &storage), + StyleInvalidation { + bidi: true, + shaping: true, + metrics: true, + positioning: true, + }, + ); + } + + fn resolved(style: ResolvedStyle) -> ResolvedStyleArena { + ResolvedStyleArena { + segments: vec![StyleSegment { + text_start: 0, + text_end: 8, + style, + }], + } + } + + fn style( + style_id: u32, + cascade_order: u32, + text_start: u32, + text_end: u32, + field_mask: u32, + root: bool, + ) -> RetainedStyle { + RetainedStyle { + style_id, + cascade_order, + field_mask, + text_start, + text_end, + font_stack_handle: if field_mask & STYLE_FIELD_FONT_STACK != 0 { + 7 + } else { + 0 + }, + material_id: 0, + language_start: 0, + language_length: if field_mask & STYLE_FIELD_LANGUAGE != 0 { + 2 + } else { + 0 + }, + feature_start: 0, + feature_count: if field_mask & STYLE_FIELD_FEATURES != 0 { + 1 + } else { + 0 + }, + font_size: 16.0, + line_height: 1.2, + letter_spacing: 0.0, + word_spacing: 0.0, + baseline_shift: 0.0, + raster_pixel_ratio: 1.0, + direction: 0, + foreground_rgba: u32::MAX, + decoration_rgba: 0, + decoration_flags: 0, + decoration_style: DECORATION_NONE, + decoration_thickness: 0.0, + decoration_offset: 0.0, + root, + } + } +} diff --git a/packages/text/rust/shaper/src/engine/transport.rs b/packages/text/rust/shaper/src/engine/transport.rs new file mode 100644 index 00000000..429c2c2d --- /dev/null +++ b/packages/text/rust/shaper/src/engine/transport.rs @@ -0,0 +1,529 @@ +use alloc::vec::Vec; +use core::slice; + +use crate::{ + STATUS_INVALID_REQUEST, STATUS_RESULT_TOO_LARGE, + abi_contract::{ + ABI_VERSION, ENGINE_RESULT_ABI_VERSION, ENGINE_RESULT_BUFFER_COUNT, + ENGINE_RESULT_BUFFERS_OFFSET, ENGINE_RESULT_BYTE_LENGTH, ENGINE_RESULT_CAPABILITY_SET, + ENGINE_RESULT_DIAGNOSTIC_COUNT, ENGINE_RESULT_DIAGNOSTICS_OFFSET, ENGINE_RESULT_DRAW_COUNT, + ENGINE_RESULT_DRAWS_OFFSET, ENGINE_RESULT_ENGINE_REVISION, ENGINE_RESULT_FLAGS, + ENGINE_RESULT_HEADER_ALIGNMENT, ENGINE_RESULT_HEADER_SIZE, ENGINE_RESULT_OUTPUT_SLOT, + ENGINE_RESULT_PATCH_COUNT, ENGINE_RESULT_PATCHES_OFFSET, ENGINE_RESULT_PLAN_REVISION, + ENGINE_RESULT_POLICY_FINGERPRINT_HIGH, ENGINE_RESULT_POLICY_FINGERPRINT_LOW, + ENGINE_RESULT_POLICY_HANDLE, ENGINE_RESULT_PRIMITIVE_COUNT, + ENGINE_RESULT_PRIMITIVES_OFFSET, ENGINE_RESULT_PUBLICATION_GENERATION, + ENGINE_RESULT_REQUEST_CAPACITY, ENGINE_RESULT_REQUIRED_BASE_REVISION, + ENGINE_RESULT_REQUIRED_REQUEST_CAPACITY, ENGINE_RESULT_REQUIRED_RESULT_CAPACITY, + ENGINE_RESULT_RESOURCE_COUNT, ENGINE_RESULT_RESOURCES_OFFSET, + ENGINE_RESULT_RESULT_CAPACITY, ENGINE_RESULT_RETIREMENT_COUNT, + ENGINE_RESULT_RETIREMENTS_OFFSET, ENGINE_RESULT_SEMANTICS_COUNT, + ENGINE_RESULT_SEMANTICS_OFFSET, ENGINE_RESULT_SESSION_ID, ENGINE_RESULT_STATUS, + ENGINE_UPDATE_REQUEST_HEADER_SIZE, + }, + engine::{ + frame::{CommittedUpdate, RESULT_FLAG_CHECKPOINT, SessionRevision}, + render_plan::RenderPlanView, + render_plan_wire::{EncodedPlanLayout, encode_publication}, + semantic_view::SemanticRecord, + }, + wire::write_u32, +}; + +const ARENA_ALIGNMENT: usize = 16; +const MAX_ARENA_BYTES: u32 = 64 * 1024 * 1024; + +#[repr(C, align(16))] +#[derive(Clone, Copy)] +struct ArenaBlock([u8; ARENA_ALIGNMENT]); + +pub(crate) struct FrameTransport { + request: AlignedArena, + outputs: [AlignedArena; 2], + active_slot: Option, + publication_generation: u32, +} + +impl FrameTransport { + pub fn new(request_capacity: u32, result_capacity: u32) -> Result { + if request_capacity < ENGINE_UPDATE_REQUEST_HEADER_SIZE + || result_capacity < ENGINE_RESULT_HEADER_SIZE + { + return Err(STATUS_INVALID_REQUEST); + } + Ok(Self { + request: AlignedArena::new(request_capacity)?, + outputs: [ + AlignedArena::new(result_capacity)?, + AlignedArena::new(result_capacity)?, + ], + active_slot: None, + publication_generation: 0, + }) + } + + pub fn reserve(&mut self, request_capacity: u32, result_capacity: u32) -> Result<(), u32> { + if request_capacity < ENGINE_UPDATE_REQUEST_HEADER_SIZE + || result_capacity < ENGINE_RESULT_HEADER_SIZE + { + return Err(STATUS_INVALID_REQUEST); + } + self.request.reserve(request_capacity)?; + self.outputs[0].reserve(result_capacity)?; + self.outputs[1].reserve(result_capacity) + } + + pub fn request_pointer(&self) -> usize { + self.request.pointer() + } + + pub fn request_capacity(&self) -> u32 { + self.request.capacity() + } + + pub fn result_capacity(&self) -> u32 { + self.outputs[0].capacity().min(self.outputs[1].capacity()) + } + + pub fn request_at(&self, pointer: usize, length: u32) -> Result<&[u8], u32> { + if pointer != self.request.pointer() || length > self.request.capacity() { + return Err(STATUS_INVALID_REQUEST); + } + self.request + .bytes() + .get(..usize::try_from(length).map_err(|_| STATUS_INVALID_REQUEST)?) + .ok_or(STATUS_INVALID_REQUEST) + } + + pub fn ensure_publish_capacity(&self, byte_length: u32) -> Result<(), u32> { + if byte_length <= self.result_capacity() { + Ok(()) + } else { + Err(STATUS_RESULT_TOO_LARGE) + } + } + + pub fn next_publication_generation(&self) -> Result { + self.publication_generation + .checked_add(1) + .ok_or(STATUS_RESULT_TOO_LARGE) + } + + #[cfg(test)] + pub fn stage_plan(&mut self, plan: RenderPlanView<'_>) -> Result { + self.stage_publication(plan, &[]) + } + + pub fn stage_publication( + &mut self, + plan: RenderPlanView<'_>, + semantic_views: &[SemanticRecord], + ) -> Result { + let slot = self.inactive_slot(); + let layout = encode_publication(plan, semantic_views, self.outputs[slot].bytes_mut())?; + Ok(StagedPlan { + slot, + policy_handle: plan.policy_handle, + capability_set: plan.capability_set, + policy_fingerprint: plan.policy_fingerprint, + layout, + }) + } + + pub fn publish_success(&mut self, commit: CommittedUpdate, staged: StagedPlan) -> usize { + debug_assert_eq!(staged.slot, self.inactive_slot()); + let generation = self.publication_generation + 1; + self.write_header( + staged.slot, + HeaderValues { + status: 0, + flags: if commit.checkpoint { + RESULT_FLAG_CHECKPOINT + } else { + 0 + }, + session_id: commit.session_id, + revision: commit.revision, + required_base_revision: commit.required_base_revision, + publication_generation: generation, + required_request_capacity: 0, + required_result_capacity: 0, + policy_handle: staged.policy_handle, + capability_set: staged.capability_set, + policy_fingerprint: staged.policy_fingerprint, + layout: staged.layout, + }, + ); + self.active_slot = Some(staged.slot); + self.publication_generation = generation; + self.outputs[staged.slot].pointer() + } + + pub fn publish_failure( + &mut self, + session_id: u32, + revision: SessionRevision, + status: u32, + required_request_capacity: u32, + required_result_capacity: u32, + ) -> usize { + let slot = self.inactive_slot(); + self.write_header( + slot, + HeaderValues { + status, + flags: 0, + session_id, + revision, + required_base_revision: revision.plan, + publication_generation: self.publication_generation, + required_request_capacity, + required_result_capacity, + policy_handle: 0, + capability_set: 0, + policy_fingerprint: 0, + layout: EncodedPlanLayout { + byte_length: ENGINE_RESULT_HEADER_SIZE, + ..EncodedPlanLayout::default() + }, + }, + ); + self.outputs[slot].pointer() + } + + fn inactive_slot(&self) -> usize { + self.active_slot.map_or(0, |slot| slot ^ 1) + } + + fn write_header(&mut self, slot: usize, values: HeaderValues) { + let result_capacity = self.outputs[slot].capacity(); + let request_capacity = self.request.capacity(); + let bytes = self.outputs[slot].bytes_mut(); + bytes[..ENGINE_RESULT_HEADER_SIZE as usize].fill(0); + write_u32(bytes, ENGINE_RESULT_ABI_VERSION, ABI_VERSION); + write_u32(bytes, ENGINE_RESULT_BYTE_LENGTH, values.layout.byte_length); + write_u32(bytes, ENGINE_RESULT_STATUS, values.status); + write_u32(bytes, ENGINE_RESULT_FLAGS, values.flags); + write_u32(bytes, ENGINE_RESULT_SESSION_ID, values.session_id); + write_u32(bytes, ENGINE_RESULT_ENGINE_REVISION, values.revision.engine); + write_u32(bytes, ENGINE_RESULT_PLAN_REVISION, values.revision.plan); + write_u32( + bytes, + ENGINE_RESULT_REQUIRED_BASE_REVISION, + values.required_base_revision, + ); + write_u32( + bytes, + ENGINE_RESULT_PUBLICATION_GENERATION, + values.publication_generation, + ); + write_u32(bytes, ENGINE_RESULT_OUTPUT_SLOT, [0, 1][slot]); + write_u32(bytes, ENGINE_RESULT_REQUEST_CAPACITY, request_capacity); + write_u32( + bytes, + ENGINE_RESULT_REQUIRED_REQUEST_CAPACITY, + values.required_request_capacity, + ); + write_u32(bytes, ENGINE_RESULT_RESULT_CAPACITY, result_capacity); + write_u32( + bytes, + ENGINE_RESULT_REQUIRED_RESULT_CAPACITY, + values.required_result_capacity, + ); + write_u32(bytes, ENGINE_RESULT_POLICY_HANDLE, values.policy_handle); + write_u32(bytes, ENGINE_RESULT_CAPABILITY_SET, values.capability_set); + write_u32( + bytes, + ENGINE_RESULT_POLICY_FINGERPRINT_LOW, + values.policy_fingerprint as u32, + ); + write_u32( + bytes, + ENGINE_RESULT_POLICY_FINGERPRINT_HIGH, + (values.policy_fingerprint >> 32) as u32, + ); + write_span( + bytes, + ENGINE_RESULT_SEMANTICS_OFFSET, + ENGINE_RESULT_SEMANTICS_COUNT, + values.layout.semantic_views, + ); + write_span( + bytes, + ENGINE_RESULT_RESOURCES_OFFSET, + ENGINE_RESULT_RESOURCE_COUNT, + values.layout.resources, + ); + write_span( + bytes, + ENGINE_RESULT_BUFFERS_OFFSET, + ENGINE_RESULT_BUFFER_COUNT, + values.layout.buffers, + ); + write_span( + bytes, + ENGINE_RESULT_PATCHES_OFFSET, + ENGINE_RESULT_PATCH_COUNT, + values.layout.patches, + ); + write_span( + bytes, + ENGINE_RESULT_PRIMITIVES_OFFSET, + ENGINE_RESULT_PRIMITIVE_COUNT, + values.layout.primitives, + ); + write_span( + bytes, + ENGINE_RESULT_DRAWS_OFFSET, + ENGINE_RESULT_DRAW_COUNT, + values.layout.draws, + ); + write_span( + bytes, + ENGINE_RESULT_RETIREMENTS_OFFSET, + ENGINE_RESULT_RETIREMENT_COUNT, + values.layout.retirements, + ); + write_span( + bytes, + ENGINE_RESULT_DIAGNOSTICS_OFFSET, + ENGINE_RESULT_DIAGNOSTIC_COUNT, + values.layout.diagnostics, + ); + } +} + +struct HeaderValues { + status: u32, + flags: u32, + session_id: u32, + revision: SessionRevision, + required_base_revision: u32, + publication_generation: u32, + required_request_capacity: u32, + required_result_capacity: u32, + policy_handle: u32, + capability_set: u32, + policy_fingerprint: u64, + layout: EncodedPlanLayout, +} + +pub(crate) struct StagedPlan { + slot: usize, + policy_handle: u32, + capability_set: u32, + policy_fingerprint: u64, + layout: EncodedPlanLayout, +} + +fn write_span( + bytes: &mut [u8], + offset_field: usize, + count_field: usize, + span: super::render_plan_wire::TableSpan, +) { + write_u32(bytes, offset_field, span.offset); + write_u32(bytes, count_field, span.count); +} + +struct AlignedArena { + blocks: Vec, +} + +impl AlignedArena { + fn new(required: u32) -> Result { + let mut arena = Self { blocks: Vec::new() }; + arena.reserve(required)?; + Ok(arena) + } + + fn reserve(&mut self, required: u32) -> Result<(), u32> { + let required = aligned_capacity(required)?; + let current = self.capacity(); + if required <= current { + return Ok(()); + } + let doubled = current.checked_mul(2).unwrap_or(MAX_ARENA_BYTES); + let target = required.max(doubled).min(MAX_ARENA_BYTES); + if target < required { + return Err(STATUS_RESULT_TOO_LARGE); + } + let target_blocks = + usize::try_from(target).map_err(|_| STATUS_RESULT_TOO_LARGE)? / ARENA_ALIGNMENT; + self.blocks + .try_reserve_exact(target_blocks - self.blocks.len()) + .map_err(|_| STATUS_RESULT_TOO_LARGE)?; + self.blocks + .resize(target_blocks, ArenaBlock([0; ARENA_ALIGNMENT])); + Ok(()) + } + + fn capacity(&self) -> u32 { + u32::try_from(self.blocks.len() * ARENA_ALIGNMENT).unwrap_or(MAX_ARENA_BYTES) + } + + fn pointer(&self) -> usize { + self.blocks.as_ptr() as usize + } + + fn bytes(&self) -> &[u8] { + // SAFETY: `ArenaBlock` is exactly 16 initialized bytes with no padding, and the returned + // slice shares the lifetime and immutability of the source block slice. + unsafe { + slice::from_raw_parts( + self.blocks.as_ptr().cast::(), + self.blocks.len() * ARENA_ALIGNMENT, + ) + } + } + + fn bytes_mut(&mut self) -> &mut [u8] { + // SAFETY: `ArenaBlock` is exactly 16 initialized bytes with no padding, and the returned + // slice is the sole mutable borrow of the source block slice. + unsafe { + slice::from_raw_parts_mut( + self.blocks.as_mut_ptr().cast::(), + self.blocks.len() * ARENA_ALIGNMENT, + ) + } + } +} + +fn aligned_capacity(required: u32) -> Result { + if required > MAX_ARENA_BYTES { + return Err(STATUS_RESULT_TOO_LARGE); + } + required + .checked_add((ARENA_ALIGNMENT - 1) as u32) + .map(|value| value & !((ARENA_ALIGNMENT - 1) as u32)) + .filter(|value| *value <= MAX_ARENA_BYTES) + .ok_or(STATUS_RESULT_TOO_LARGE) +} + +const _: () = assert!(core::mem::size_of::() == ARENA_ALIGNMENT); +const _: () = assert!(core::mem::align_of::() == ARENA_ALIGNMENT); +const _: () = assert!(ENGINE_RESULT_HEADER_ALIGNMENT as usize == ARENA_ALIGNMENT); + +#[cfg(test)] +mod tests { + use super::*; + use crate::wire::read_u32; + use crate::{ + abi_contract::{ENGINE_RESULT_POLICY_HANDLE, PATCH_PAYLOAD_OFFSET}, + engine::render_plan::{BUFFER_ORDERED_DIRECT, BufferRecord, PATCH_WRITE, PatchRecord}, + }; + + #[test] + fn arenas_are_aligned_and_double_without_losing_request_bytes() { + let mut transport = + FrameTransport::new(ENGINE_UPDATE_REQUEST_HEADER_SIZE, ENGINE_RESULT_HEADER_SIZE) + .unwrap(); + assert_eq!(transport.request_pointer() % ARENA_ALIGNMENT, 0); + transport.request.bytes_mut()[0] = 7; + let initial = transport.request_capacity(); + transport + .reserve(initial + 1, ENGINE_RESULT_HEADER_SIZE) + .unwrap(); + assert_eq!(transport.request_capacity(), initial * 2); + assert_eq!(transport.request.bytes()[0], 7); + } + + #[test] + fn successful_publications_alternate_and_failures_preserve_the_active_slot() { + let mut transport = FrameTransport::new(256, 256).unwrap(); + let first_plan = transport.stage_plan(plan()).unwrap(); + let first = transport.publish_success(commit(1), first_plan); + let first_bytes = transport.outputs[0].bytes(); + assert_eq!(read_u32(first_bytes, ENGINE_RESULT_OUTPUT_SLOT).unwrap(), 0); + assert_eq!( + read_u32(first_bytes, ENGINE_RESULT_PUBLICATION_GENERATION).unwrap(), + 1 + ); + + let failure = transport.publish_failure( + 3, + SessionRevision { engine: 1, plan: 1 }, + STATUS_INVALID_REQUEST, + 512, + 0, + ); + assert_ne!(failure, first); + assert_eq!(transport.active_slot, Some(0)); + assert_eq!(transport.publication_generation, 1); + + let second_plan = transport.stage_plan(plan()).unwrap(); + let second = transport.publish_success(commit(2), second_plan); + assert_eq!(second, failure); + let second_bytes = transport.outputs[1].bytes(); + assert_eq!( + read_u32(second_bytes, ENGINE_RESULT_OUTPUT_SLOT).unwrap(), + 1 + ); + assert_eq!( + read_u32(second_bytes, ENGINE_RESULT_PUBLICATION_GENERATION).unwrap(), + 2 + ); + } + + #[test] + fn publication_header_addresses_the_exact_plan_tables_and_payload() { + let buffers = [BufferRecord { + id: 1, + generation: 2, + program_id: 3, + policy_buffer_id: 4, + scalar_type: 1, + vector_width: 4, + strategy: BUFFER_ORDERED_DIRECT, + live_records: 1, + capacity_records: 8, + byte_length: 128, + ..BufferRecord::default() + }]; + let patches = [PatchRecord { + opcode: PATCH_WRITE, + buffer_id: 1, + buffer_generation: 2, + byte_length: 4, + ..PatchRecord::default() + }]; + let plan = RenderPlanView { + policy_handle: 9, + capability_set: 10, + policy_fingerprint: 0x1122_3344_5566_7788, + buffers: &buffers, + patches: &patches, + payload: &[1, 2, 3, 4], + ..RenderPlanView::default() + }; + let mut transport = FrameTransport::new(256, 1024).unwrap(); + let staged = transport.stage_plan(plan).unwrap(); + transport.publish_success(commit(1), staged); + let bytes = transport.outputs[0].bytes(); + let patch_offset = read_u32(bytes, ENGINE_RESULT_PATCHES_OFFSET).unwrap() as usize; + let payload_offset = read_u32(bytes, patch_offset + PATCH_PAYLOAD_OFFSET).unwrap() as usize; + assert_eq!(read_u32(bytes, ENGINE_RESULT_POLICY_HANDLE).unwrap(), 9); + assert_eq!(read_u32(bytes, ENGINE_RESULT_BUFFER_COUNT).unwrap(), 1); + assert_eq!(read_u32(bytes, ENGINE_RESULT_PATCH_COUNT).unwrap(), 1); + assert_eq!(&bytes[payload_offset..payload_offset + 4], &[1, 2, 3, 4]); + } + + fn commit(revision: u32) -> CommittedUpdate { + CommittedUpdate { + session_id: 3, + revision: SessionRevision { + engine: revision, + plan: revision, + }, + required_base_revision: revision - 1, + checkpoint: revision == 1, + } + } + + fn plan() -> RenderPlanView<'static> { + RenderPlanView { + policy_handle: 1, + ..RenderPlanView::default() + } + } +} diff --git a/packages/text/rust/shaper/src/engine/wire.rs b/packages/text/rust/shaper/src/engine/wire.rs new file mode 100644 index 00000000..9b1e95d5 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/wire.rs @@ -0,0 +1,669 @@ +//! Fixed-width policy wire decoding. +//! +//! Rust compiler-derived offsets in `abi_contract` are the only layout authority. This decoder +//! performs one bounded registration-time pass over direct Wasm memory and retains typed policy +//! data; frame updates never revisit these records. + +use alloc::vec::Vec; + +use crate::{ + STATUS_INVALID_REQUEST, + abi_contract::{ + POLICY_BUFFER_ALIGNMENT, POLICY_BUFFER_CAPACITY_CLASS, POLICY_BUFFER_COUNT, + POLICY_BUFFER_ID, POLICY_BUFFER_RECORD_ALIGNMENT, POLICY_BUFFER_RECORD_SIZE, + POLICY_BUFFER_RESERVED0, POLICY_BUFFER_SCALAR, POLICY_BUFFER_STRIDE, POLICY_BUFFER_USAGE, + POLICY_BUFFER_VECTOR_WIDTH, POLICY_BUFFERS_OFFSET, POLICY_BYTE_LENGTH, + POLICY_CAPABILITY_SET_COALESCE_GAP_BYTES, POLICY_CAPABILITY_SET_COUNT, + POLICY_CAPABILITY_SET_FLAGS, POLICY_CAPABILITY_SET_FRAGMENTATION_BUDGET, + POLICY_CAPABILITY_SET_ID, POLICY_CAPABILITY_SET_MAX_BUFFER_BYTES, + POLICY_CAPABILITY_SET_MAX_BUFFERS_PER_DRAW, POLICY_CAPABILITY_SET_MAX_INDIRECT_DRAWS, + POLICY_CAPABILITY_SET_MAX_RESOURCES_PER_DRAW, + POLICY_CAPABILITY_SET_RANGE_CALL_PENALTY_BYTES, POLICY_CAPABILITY_SET_RECORD_ALIGNMENT, + POLICY_CAPABILITY_SET_RECORD_SIZE, POLICY_CAPABILITY_SET_RESERVED, + POLICY_CAPABILITY_SET_UPDATE_ALIGNMENT, + POLICY_CAPABILITY_SET_WHOLE_BUFFER_THRESHOLD_BASIS_POINTS, POLICY_CAPABILITY_SETS_OFFSET, + POLICY_INPUT_COUNT, POLICY_INPUT_FIELD, POLICY_INPUT_RECORD_ALIGNMENT, + POLICY_INPUT_RECORD_SIZE, POLICY_INPUT_RESERVED, POLICY_INPUT_SCOPE, POLICY_INPUTS_OFFSET, + POLICY_OPERATION_COUNT, POLICY_OPERATION_IMMEDIATE0, POLICY_OPERATION_IMMEDIATE1, + POLICY_OPERATION_IMMEDIATE2, POLICY_OPERATION_OPCODE, POLICY_OPERATION_OPERAND0, + POLICY_OPERATION_OPERAND1, POLICY_OPERATION_RECORD_ALIGNMENT, POLICY_OPERATION_RECORD_SIZE, + POLICY_OPERATION_TARGET, POLICY_OPERATIONS_OFFSET, POLICY_PROGRAM_ALLOCATION_STRATEGY, + POLICY_PROGRAM_BUFFER_COUNT, POLICY_PROGRAM_BUFFER_START, POLICY_PROGRAM_CAPABILITY_SET_ID, + POLICY_PROGRAM_COMPOSITING_CAPABILITIES, POLICY_PROGRAM_COUNT, + POLICY_PROGRAM_DRAW_KEY_MASK, POLICY_PROGRAM_F32_INPUT_COUNT, POLICY_PROGRAM_ID, + POLICY_PROGRAM_INPUT_COUNT, POLICY_PROGRAM_INPUT_START, POLICY_PROGRAM_OPERATION_COUNT, + POLICY_PROGRAM_OPERATION_START, POLICY_PROGRAM_PAINT_CAPABILITIES, + POLICY_PROGRAM_RECORD_ALIGNMENT, POLICY_PROGRAM_RECORD_SIZE, POLICY_PROGRAM_RESERVED0, + POLICY_PROGRAM_RESERVED1, POLICY_PROGRAM_RESOURCE_KIND_MASK, + POLICY_PROGRAM_SEMANTIC_VIEW_MASK, POLICY_PROGRAM_STORAGE_KEY_MASK, + POLICY_PROGRAM_TECHNIQUE_ID, POLICY_PROGRAM_U32_INPUT_COUNT, POLICY_PROGRAM_VARIANT, + POLICY_PROGRAMS_OFFSET, POLICY_REQUEST_HEADER_SIZE, + }, + engine::policy::{ + BufferId, BufferSchema, CapabilitySet, CapabilitySetId, INPUT_GLYPH, INPUT_RESOURCE, + INPUT_SEMANTIC, INPUT_STRIKE, InputScope, InputSource, MAX_BUFFERS_PER_PROGRAM, + MAX_CAPABILITY_SETS, MAX_INPUT_FIELDS_PER_PROGRAM, MAX_OPERATIONS_PER_PROGRAM, + MAX_PROGRAMS, OP_ADD_F32, OP_CONSTANT_F32, OP_CONSTANT_U32, OP_CONVERT_U32_TO_F32, + OP_LESS_THAN_F32, OP_LOAD_F32, OP_LOAD_U32, OP_MULTIPLY_F32, OP_SELECT_F32, OP_STORE_F32, + OP_STORE_U16, OP_STORE_U32, OP_SUBTRACT_F32, Operation, PolicyDescriptor, + ProgramCapabilities, ProgramDescriptor, ProgramId, ScalarType, TechniqueId, + ValidatedPolicy, + }, + wire::{array, read_u16, read_u32}, +}; + +pub(crate) fn parse_policy(bytes: &[u8]) -> Result { + if bytes.len() < POLICY_REQUEST_HEADER_SIZE as usize + || read_u32(bytes, POLICY_BYTE_LENGTH)? + != u32::try_from(bytes.len()).map_err(|_| STATUS_INVALID_REQUEST)? + { + return Err(STATUS_INVALID_REQUEST); + } + + let capability_set_count = read_u32(bytes, POLICY_CAPABILITY_SET_COUNT)?; + let program_count = read_u32(bytes, POLICY_PROGRAM_COUNT)?; + let buffer_count = read_u32(bytes, POLICY_BUFFER_COUNT)?; + let operation_count = read_u32(bytes, POLICY_OPERATION_COUNT)?; + let input_count = read_u32(bytes, POLICY_INPUT_COUNT)?; + if usize::try_from(capability_set_count).map_err(|_| STATUS_INVALID_REQUEST)? + > MAX_CAPABILITY_SETS + || usize::try_from(program_count).map_err(|_| STATUS_INVALID_REQUEST)? > MAX_PROGRAMS + || usize::try_from(buffer_count).map_err(|_| STATUS_INVALID_REQUEST)? + > MAX_PROGRAMS * MAX_BUFFERS_PER_PROGRAM + || usize::try_from(operation_count).map_err(|_| STATUS_INVALID_REQUEST)? + > MAX_PROGRAMS * MAX_OPERATIONS_PER_PROGRAM + || usize::try_from(input_count).map_err(|_| STATUS_INVALID_REQUEST)? + > MAX_PROGRAMS * MAX_INPUT_FIELDS_PER_PROGRAM + { + return Err(STATUS_INVALID_REQUEST); + } + + let capability_sets = table( + bytes, + read_u32(bytes, POLICY_CAPABILITY_SETS_OFFSET)?, + capability_set_count, + POLICY_CAPABILITY_SET_RECORD_SIZE, + POLICY_CAPABILITY_SET_RECORD_ALIGNMENT, + )?; + let programs = table( + bytes, + read_u32(bytes, POLICY_PROGRAMS_OFFSET)?, + program_count, + POLICY_PROGRAM_RECORD_SIZE, + POLICY_PROGRAM_RECORD_ALIGNMENT, + )?; + let buffers_offset = read_u32(bytes, POLICY_BUFFERS_OFFSET)?; + let buffers = table( + bytes, + buffers_offset, + buffer_count, + POLICY_BUFFER_RECORD_SIZE, + POLICY_BUFFER_RECORD_ALIGNMENT, + )?; + let operations_offset = read_u32(bytes, POLICY_OPERATIONS_OFFSET)?; + let operations = table( + bytes, + operations_offset, + operation_count, + POLICY_OPERATION_RECORD_SIZE, + POLICY_OPERATION_RECORD_ALIGNMENT, + )?; + let inputs = table( + bytes, + read_u32(bytes, POLICY_INPUTS_OFFSET)?, + input_count, + POLICY_INPUT_RECORD_SIZE, + POLICY_INPUT_RECORD_ALIGNMENT, + )?; + reject_overlaps( + bytes, + capability_sets, + programs, + buffers, + operations, + inputs, + )?; + + let capability_sets = decode_capability_sets(capability_sets)?; + + let mut decoded = Vec::new(); + decoded + .try_reserve_exact(usize::try_from(program_count).map_err(|_| STATUS_INVALID_REQUEST)?) + .map_err(|_| STATUS_INVALID_REQUEST)?; + for record in programs.chunks_exact(POLICY_PROGRAM_RECORD_SIZE as usize) { + if read_u16(record, POLICY_PROGRAM_RESERVED0)? != 0 + || read_u16(record, POLICY_PROGRAM_RESERVED1)? != 0 + { + return Err(STATUS_INVALID_REQUEST); + } + let selected_buffers = indexed_records( + buffers, + read_u32(record, POLICY_PROGRAM_BUFFER_START)?, + read_u16(record, POLICY_PROGRAM_BUFFER_COUNT)?, + POLICY_BUFFER_RECORD_SIZE, + )?; + let selected_operations = indexed_records( + operations, + read_u32(record, POLICY_PROGRAM_OPERATION_START)?, + read_u16(record, POLICY_PROGRAM_OPERATION_COUNT)?, + POLICY_OPERATION_RECORD_SIZE, + )?; + let selected_inputs = indexed_records( + inputs, + read_u32(record, POLICY_PROGRAM_INPUT_START)?, + read_u16(record, POLICY_PROGRAM_INPUT_COUNT)?, + POLICY_INPUT_RECORD_SIZE, + )?; + decoded.push(ProgramDescriptor { + technique: TechniqueId(read_u32(record, POLICY_PROGRAM_TECHNIQUE_ID)?), + variant: read_u16(record, POLICY_PROGRAM_VARIANT)?, + id: ProgramId(read_u32(record, POLICY_PROGRAM_ID)?), + capability_set: CapabilitySetId(read_u32(record, POLICY_PROGRAM_CAPABILITY_SET_ID)?), + resource_kind_mask: read_u32(record, POLICY_PROGRAM_RESOURCE_KIND_MASK)?, + semantic_view_mask: read_u32(record, POLICY_PROGRAM_SEMANTIC_VIEW_MASK)?, + storage_key_mask: read_u32(record, POLICY_PROGRAM_STORAGE_KEY_MASK)?, + draw_key_mask: read_u32(record, POLICY_PROGRAM_DRAW_KEY_MASK)?, + allocation_strategy: read_u16(record, POLICY_PROGRAM_ALLOCATION_STRATEGY)?, + f32_input_count: byte(record, POLICY_PROGRAM_F32_INPUT_COUNT)?, + u32_input_count: byte(record, POLICY_PROGRAM_U32_INPUT_COUNT)?, + inputs: decode_inputs(selected_inputs)?, + capabilities: ProgramCapabilities { + paint: read_u32(record, POLICY_PROGRAM_PAINT_CAPABILITIES)?, + compositing: read_u32(record, POLICY_PROGRAM_COMPOSITING_CAPABILITIES)?, + }, + buffers: decode_buffers(selected_buffers)?, + operations: decode_operations(selected_operations)?, + }); + } + ValidatedPolicy::new(PolicyDescriptor { + capability_sets, + programs: decoded, + }) + .map_err(|_| STATUS_INVALID_REQUEST) +} + +fn table(bytes: &[u8], offset: u32, count: u32, stride: u32, alignment: u32) -> Result<&[u8], u32> { + if count == 0 { + return if offset == 0 { + Ok(&bytes[..0]) + } else { + Err(STATUS_INVALID_REQUEST) + }; + } + if offset < POLICY_REQUEST_HEADER_SIZE { + return Err(STATUS_INVALID_REQUEST); + } + array(bytes, offset, count, stride, alignment) +} + +fn reject_overlaps( + bytes: &[u8], + capability_sets: &[u8], + programs: &[u8], + buffers: &[u8], + operations: &[u8], + inputs: &[u8], +) -> Result<(), u32> { + let ranges = [ + relative_range(bytes, capability_sets)?, + relative_range(bytes, programs)?, + relative_range(bytes, buffers)?, + relative_range(bytes, operations)?, + relative_range(bytes, inputs)?, + ]; + for index in 0..ranges.len() { + for previous in &ranges[..index] { + let current = ranges[index]; + if current.start < previous.end && previous.start < current.end { + return Err(STATUS_INVALID_REQUEST); + } + } + } + Ok(()) +} + +fn decode_inputs(records: &[u8]) -> Result, u32> { + let mut inputs = Vec::new(); + inputs + .try_reserve_exact(records.len() / POLICY_INPUT_RECORD_SIZE as usize) + .map_err(|_| STATUS_INVALID_REQUEST)?; + for record in records.chunks_exact(POLICY_INPUT_RECORD_SIZE as usize) { + if read_u16(record, POLICY_INPUT_RESERVED)? != 0 { + return Err(STATUS_INVALID_REQUEST); + } + let scope = match byte(record, POLICY_INPUT_SCOPE)? { + INPUT_SEMANTIC => InputScope::Semantic, + INPUT_GLYPH => InputScope::Glyph, + INPUT_RESOURCE => InputScope::Resource, + INPUT_STRIKE => InputScope::Strike, + _ => return Err(STATUS_INVALID_REQUEST), + }; + inputs.push(InputSource { + scope, + field: byte(record, POLICY_INPUT_FIELD)?, + }); + } + Ok(inputs) +} + +fn decode_capability_sets(records: &[u8]) -> Result, u32> { + let mut capability_sets = Vec::new(); + capability_sets + .try_reserve_exact(records.len() / POLICY_CAPABILITY_SET_RECORD_SIZE as usize) + .map_err(|_| STATUS_INVALID_REQUEST)?; + for record in records.chunks_exact(POLICY_CAPABILITY_SET_RECORD_SIZE as usize) { + if read_u16(record, POLICY_CAPABILITY_SET_RESERVED)? != 0 + || read_u16(record, POLICY_CAPABILITY_SET_RESERVED + 2)? != 0 + || read_u16(record, POLICY_CAPABILITY_SET_RESERVED + 4)? != 0 + { + return Err(STATUS_INVALID_REQUEST); + } + capability_sets.push(CapabilitySet { + id: CapabilitySetId(read_u32(record, POLICY_CAPABILITY_SET_ID)?), + flags: read_u32(record, POLICY_CAPABILITY_SET_FLAGS)?, + max_buffer_bytes: read_u32(record, POLICY_CAPABILITY_SET_MAX_BUFFER_BYTES)?, + update_alignment: read_u32(record, POLICY_CAPABILITY_SET_UPDATE_ALIGNMENT)?, + coalesce_gap_bytes: read_u32(record, POLICY_CAPABILITY_SET_COALESCE_GAP_BYTES)?, + range_call_penalty_bytes: read_u32( + record, + POLICY_CAPABILITY_SET_RANGE_CALL_PENALTY_BYTES, + )?, + max_buffers_per_draw: read_u16(record, POLICY_CAPABILITY_SET_MAX_BUFFERS_PER_DRAW)?, + max_resources_per_draw: read_u16(record, POLICY_CAPABILITY_SET_MAX_RESOURCES_PER_DRAW)?, + max_indirect_draws: read_u16(record, POLICY_CAPABILITY_SET_MAX_INDIRECT_DRAWS)?, + fragmentation_budget: read_u16(record, POLICY_CAPABILITY_SET_FRAGMENTATION_BUDGET)?, + whole_buffer_threshold_basis_points: read_u16( + record, + POLICY_CAPABILITY_SET_WHOLE_BUFFER_THRESHOLD_BASIS_POINTS, + )?, + }); + } + Ok(capability_sets) +} + +#[derive(Clone, Copy)] +struct ByteRange { + start: usize, + end: usize, +} + +fn relative_range(container: &[u8], selected: &[u8]) -> Result { + let start = (selected.as_ptr() as usize) + .checked_sub(container.as_ptr() as usize) + .ok_or(STATUS_INVALID_REQUEST)?; + Ok(ByteRange { + start, + end: start + .checked_add(selected.len()) + .ok_or(STATUS_INVALID_REQUEST)?, + }) +} + +fn indexed_records(records: &[u8], start: u32, count: u16, stride: u32) -> Result<&[u8], u32> { + let offset = start.checked_mul(stride).ok_or(STATUS_INVALID_REQUEST)?; + array(records, offset, u32::from(count), stride, 1) +} + +fn decode_buffers(records: &[u8]) -> Result, u32> { + let mut buffers = Vec::new(); + buffers + .try_reserve_exact(records.len() / POLICY_BUFFER_RECORD_SIZE as usize) + .map_err(|_| STATUS_INVALID_REQUEST)?; + for record in records.chunks_exact(POLICY_BUFFER_RECORD_SIZE as usize) { + if read_u16(record, POLICY_BUFFER_RESERVED0)? != 0 { + return Err(STATUS_INVALID_REQUEST); + } + let scalar = match byte(record, POLICY_BUFFER_SCALAR)? { + value if value == ScalarType::F32 as u8 => ScalarType::F32, + value if value == ScalarType::U32 as u8 => ScalarType::U32, + value if value == ScalarType::U16 as u8 => ScalarType::U16, + _ => return Err(STATUS_INVALID_REQUEST), + }; + buffers.push(BufferSchema { + id: BufferId(read_u16(record, POLICY_BUFFER_ID)?), + scalar, + vector_width: byte(record, POLICY_BUFFER_VECTOR_WIDTH)?, + alignment: read_u16(record, POLICY_BUFFER_ALIGNMENT)?, + stride: read_u16(record, POLICY_BUFFER_STRIDE)?, + usage: read_u32(record, POLICY_BUFFER_USAGE)?, + capacity_class: read_u16(record, POLICY_BUFFER_CAPACITY_CLASS)?, + }); + } + Ok(buffers) +} + +fn decode_operations(records: &[u8]) -> Result, u32> { + let mut operations = Vec::new(); + operations + .try_reserve_exact(records.len() / POLICY_OPERATION_RECORD_SIZE as usize) + .map_err(|_| STATUS_INVALID_REQUEST)?; + for record in records.chunks_exact(POLICY_OPERATION_RECORD_SIZE as usize) { + operations.push(decode_operation(record)?); + } + Ok(operations) +} + +fn decode_operation(record: &[u8]) -> Result { + let opcode = byte(record, POLICY_OPERATION_OPCODE)?; + let target = byte(record, POLICY_OPERATION_TARGET)?; + let operand0 = byte(record, POLICY_OPERATION_OPERAND0)?; + let operand1 = byte(record, POLICY_OPERATION_OPERAND1)?; + let immediate0 = read_u32(record, POLICY_OPERATION_IMMEDIATE0)?; + let immediate1 = read_u32(record, POLICY_OPERATION_IMMEDIATE1)?; + let immediate2 = read_u32(record, POLICY_OPERATION_IMMEDIATE2)?; + let no_immediates = || { + (immediate0 == 0 && immediate1 == 0 && immediate2 == 0) + .then_some(()) + .ok_or(STATUS_INVALID_REQUEST) + }; + match opcode { + OP_LOAD_F32 | OP_LOAD_U32 => { + if operand1 != 0 { + return Err(STATUS_INVALID_REQUEST); + } + no_immediates()?; + Ok(if opcode == OP_LOAD_F32 { + Operation::LoadF32 { + target, + field: operand0, + } + } else { + Operation::LoadU32 { + target, + field: operand0, + } + }) + } + OP_CONSTANT_F32 | OP_CONSTANT_U32 => { + if operand0 != 0 || operand1 != 0 || immediate1 != 0 || immediate2 != 0 { + return Err(STATUS_INVALID_REQUEST); + } + Ok(if opcode == OP_CONSTANT_F32 { + Operation::ConstantF32 { + target, + bits: immediate0, + } + } else { + Operation::ConstantU32 { + target, + value: immediate0, + } + }) + } + OP_ADD_F32 | OP_SUBTRACT_F32 | OP_MULTIPLY_F32 | OP_LESS_THAN_F32 => { + no_immediates()?; + Ok(match opcode { + OP_ADD_F32 => Operation::AddF32 { + target, + left: operand0, + right: operand1, + }, + OP_SUBTRACT_F32 => Operation::SubtractF32 { + target, + left: operand0, + right: operand1, + }, + OP_MULTIPLY_F32 => Operation::MultiplyF32 { + target, + left: operand0, + right: operand1, + }, + _ => Operation::LessThanF32 { + target, + left: operand0, + right: operand1, + }, + }) + } + OP_SELECT_F32 => { + if immediate0 > u8::MAX.into() || immediate1 != 0 || immediate2 != 0 { + return Err(STATUS_INVALID_REQUEST); + } + Ok(Operation::SelectF32 { + target, + condition: operand0, + when_true: operand1, + when_false: immediate0 as u8, + }) + } + OP_CONVERT_U32_TO_F32 => { + if operand1 != 0 { + return Err(STATUS_INVALID_REQUEST); + } + no_immediates()?; + Ok(Operation::ConvertU32ToF32 { + target, + source: operand0, + }) + } + OP_STORE_F32 | OP_STORE_U32 | OP_STORE_U16 => { + if target != 0 || immediate0 > u16::MAX.into() || immediate1 != 0 || immediate2 != 0 { + return Err(STATUS_INVALID_REQUEST); + } + let buffer = BufferId(immediate0 as u16); + Ok(match opcode { + OP_STORE_F32 => Operation::StoreF32 { + source: operand0, + buffer, + lane: operand1, + }, + OP_STORE_U32 => Operation::StoreU32 { + source: operand0, + buffer, + lane: operand1, + }, + _ => Operation::StoreU16 { + source: operand0, + buffer, + lane: operand1, + }, + }) + } + _ => Err(STATUS_INVALID_REQUEST), + } +} + +fn byte(bytes: &[u8], offset: usize) -> Result { + bytes.get(offset).copied().ok_or(STATUS_INVALID_REQUEST) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + + const CAPABILITY_SETS_OFFSET: usize = POLICY_REQUEST_HEADER_SIZE as usize; + const PROGRAMS_OFFSET: usize = + CAPABILITY_SETS_OFFSET + POLICY_CAPABILITY_SET_RECORD_SIZE as usize; + const BUFFERS_OFFSET: usize = PROGRAMS_OFFSET + POLICY_PROGRAM_RECORD_SIZE as usize; + const OPERATIONS_OFFSET: usize = BUFFERS_OFFSET + POLICY_BUFFER_RECORD_SIZE as usize; + const OPERATION_COUNT: usize = 4; + const INPUTS_OFFSET: usize = + OPERATIONS_OFFSET + OPERATION_COUNT * POLICY_OPERATION_RECORD_SIZE as usize; + const INPUT_COUNT: usize = 2; + const BYTE_LENGTH: usize = INPUTS_OFFSET + INPUT_COUNT * POLICY_INPUT_RECORD_SIZE as usize; + + #[test] + fn decodes_compiler_mapped_policy_records() { + let bytes = valid_policy_bytes(); + let policy = parse_policy(&bytes).unwrap(); + let program = policy + .program(CapabilitySetId(1), TechniqueId(1), 0) + .unwrap(); + assert_eq!(program.id, ProgramId(2)); + assert_eq!(program.buffers[0].stride(), 8); + assert_eq!(program.operations.len(), OPERATION_COUNT); + assert_eq!( + program.inputs, + vec![ + InputSource::semantic(0), + InputSource { + scope: InputScope::Glyph, + field: 1, + }, + ] + ); + } + + #[test] + fn rejects_forged_lengths_overlaps_and_reserved_data() { + let mut forged_length = valid_policy_bytes(); + put_u32(&mut forged_length, POLICY_BYTE_LENGTH, u32::MAX); + assert_eq!(parse_policy(&forged_length), Err(STATUS_INVALID_REQUEST)); + + let mut overlap = valid_policy_bytes(); + put_u32( + &mut overlap, + POLICY_BUFFERS_OFFSET, + u32::try_from(PROGRAMS_OFFSET).unwrap(), + ); + assert_eq!(parse_policy(&overlap), Err(STATUS_INVALID_REQUEST)); + + let mut reserved = valid_policy_bytes(); + put_u16( + &mut reserved[PROGRAMS_OFFSET..], + POLICY_PROGRAM_RESERVED0, + 1, + ); + assert_eq!(parse_policy(&reserved), Err(STATUS_INVALID_REQUEST)); + + let mut unknown_input = valid_policy_bytes(); + unknown_input[INPUTS_OFFSET + POLICY_INPUT_SCOPE] = u8::MAX; + assert_eq!(parse_policy(&unknown_input), Err(STATUS_INVALID_REQUEST)); + } + + #[test] + fn rejects_unknown_and_noncanonical_operations() { + let mut unknown = valid_policy_bytes(); + unknown[OPERATIONS_OFFSET + POLICY_OPERATION_OPCODE] = u8::MAX; + assert_eq!(parse_policy(&unknown), Err(STATUS_INVALID_REQUEST)); + + let mut noncanonical = valid_policy_bytes(); + noncanonical[OPERATIONS_OFFSET + POLICY_OPERATION_OPERAND1] = 1; + assert_eq!(parse_policy(&noncanonical), Err(STATUS_INVALID_REQUEST)); + } + + fn valid_policy_bytes() -> Vec { + let mut bytes = vec![0; BYTE_LENGTH]; + put_u32(&mut bytes, POLICY_BYTE_LENGTH, BYTE_LENGTH as u32); + put_u32( + &mut bytes, + POLICY_CAPABILITY_SETS_OFFSET, + CAPABILITY_SETS_OFFSET as u32, + ); + put_u32(&mut bytes, POLICY_CAPABILITY_SET_COUNT, 1); + put_u32(&mut bytes, POLICY_PROGRAMS_OFFSET, PROGRAMS_OFFSET as u32); + put_u32(&mut bytes, POLICY_PROGRAM_COUNT, 1); + put_u32(&mut bytes, POLICY_BUFFERS_OFFSET, BUFFERS_OFFSET as u32); + put_u32(&mut bytes, POLICY_BUFFER_COUNT, 1); + put_u32( + &mut bytes, + POLICY_OPERATIONS_OFFSET, + OPERATIONS_OFFSET as u32, + ); + put_u32(&mut bytes, POLICY_OPERATION_COUNT, OPERATION_COUNT as u32); + put_u32(&mut bytes, POLICY_INPUTS_OFFSET, INPUTS_OFFSET as u32); + put_u32(&mut bytes, POLICY_INPUT_COUNT, INPUT_COUNT as u32); + + let capability = &mut bytes[CAPABILITY_SETS_OFFSET..PROGRAMS_OFFSET]; + put_u32(capability, POLICY_CAPABILITY_SET_ID, 1); + put_u32( + capability, + POLICY_CAPABILITY_SET_FLAGS, + crate::engine::policy::CAP_ORDERED_DIRECT, + ); + put_u32(capability, POLICY_CAPABILITY_SET_MAX_BUFFER_BYTES, 1024); + put_u32(capability, POLICY_CAPABILITY_SET_UPDATE_ALIGNMENT, 4); + put_u16(capability, POLICY_CAPABILITY_SET_MAX_BUFFERS_PER_DRAW, 1); + put_u16(capability, POLICY_CAPABILITY_SET_MAX_RESOURCES_PER_DRAW, 1); + put_u16(capability, POLICY_CAPABILITY_SET_FRAGMENTATION_BUDGET, 1); + put_u16( + capability, + POLICY_CAPABILITY_SET_WHOLE_BUFFER_THRESHOLD_BASIS_POINTS, + 10_000, + ); + + let program = &mut bytes[PROGRAMS_OFFSET..BUFFERS_OFFSET]; + put_u32(program, POLICY_PROGRAM_TECHNIQUE_ID, 1); + put_u32(program, POLICY_PROGRAM_ID, 2); + put_u32(program, POLICY_PROGRAM_RESOURCE_KIND_MASK, 1); + put_u32( + program, + POLICY_PROGRAM_STORAGE_KEY_MASK, + crate::engine::policy::BATCH_TECHNIQUE + | crate::engine::policy::BATCH_PROGRAM + | crate::engine::policy::BATCH_RESOURCE, + ); + put_u32( + program, + POLICY_PROGRAM_DRAW_KEY_MASK, + crate::engine::policy::BATCH_TECHNIQUE + | crate::engine::policy::BATCH_PROGRAM + | crate::engine::policy::BATCH_RESOURCE + | crate::engine::policy::BATCH_ORDER + | crate::engine::policy::BATCH_TRANSFORM, + ); + put_u16( + program, + POLICY_PROGRAM_ALLOCATION_STRATEGY, + crate::engine::policy::ALLOCATION_ORDERED_DIRECT, + ); + program[POLICY_PROGRAM_F32_INPUT_COUNT] = 2; + put_u16(program, POLICY_PROGRAM_BUFFER_COUNT, 1); + put_u16( + program, + POLICY_PROGRAM_OPERATION_COUNT, + OPERATION_COUNT as u16, + ); + put_u16(program, POLICY_PROGRAM_INPUT_COUNT, INPUT_COUNT as u16); + + let buffer = &mut bytes[BUFFERS_OFFSET..OPERATIONS_OFFSET]; + put_u16(buffer, POLICY_BUFFER_ID, 1); + buffer[POLICY_BUFFER_SCALAR] = ScalarType::F32 as u8; + buffer[POLICY_BUFFER_VECTOR_WIDTH] = 2; + put_u16(buffer, POLICY_BUFFER_ALIGNMENT, 4); + put_u16(buffer, POLICY_BUFFER_STRIDE, 8); + put_u32( + buffer, + POLICY_BUFFER_USAGE, + crate::engine::policy::BUFFER_USAGE_COPY_DST, + ); + put_u16(buffer, POLICY_BUFFER_CAPACITY_CLASS, 1); + + write_operation(&mut bytes, 0, OP_LOAD_F32, 0, 0, 0, 0); + write_operation(&mut bytes, 1, OP_LOAD_F32, 1, 1, 0, 0); + write_operation(&mut bytes, 2, OP_STORE_F32, 0, 0, 0, 1); + write_operation(&mut bytes, 3, OP_STORE_F32, 0, 1, 1, 1); + bytes[INPUTS_OFFSET + POLICY_INPUT_SCOPE] = INPUT_SEMANTIC; + bytes[INPUTS_OFFSET + POLICY_INPUT_FIELD] = 0; + bytes[INPUTS_OFFSET + POLICY_INPUT_RECORD_SIZE as usize + POLICY_INPUT_SCOPE] = INPUT_GLYPH; + bytes[INPUTS_OFFSET + POLICY_INPUT_RECORD_SIZE as usize + POLICY_INPUT_FIELD] = 1; + bytes + } + + fn write_operation( + bytes: &mut [u8], + index: usize, + opcode: u8, + target: u8, + operand0: u8, + operand1: u8, + immediate0: u32, + ) { + let start = OPERATIONS_OFFSET + index * POLICY_OPERATION_RECORD_SIZE as usize; + let record = &mut bytes[start..start + POLICY_OPERATION_RECORD_SIZE as usize]; + record[POLICY_OPERATION_OPCODE] = opcode; + record[POLICY_OPERATION_TARGET] = target; + record[POLICY_OPERATION_OPERAND0] = operand0; + record[POLICY_OPERATION_OPERAND1] = operand1; + put_u32(record, POLICY_OPERATION_IMMEDIATE0, immediate0); + } + + fn put_u16(bytes: &mut [u8], offset: usize, value: u16) { + bytes[offset..offset + 2].copy_from_slice(&value.to_le_bytes()); + } + + fn put_u32(bytes: &mut [u8], offset: usize, value: u32) { + bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } +} diff --git a/packages/text/rust/shaper/src/generated/line_break_data.rs b/packages/text/rust/shaper/src/generated/line_break_data.rs new file mode 100644 index 00000000..3b1ead22 --- /dev/null +++ b/packages/text/rust/shaper/src/generated/line_break_data.rs @@ -0,0 +1,297 @@ +// Generated by scripts/generate-unicode-line-break-data.mjs from +// @cto.af/linebreak@4.0.3 and @unicode/unicode-17.0.0@1.6.17. Do not edit by hand. + +pub const UNICODE_VERSION: &str = "17.0.0"; +pub const INITIAL_PUNCTUATION: u32 = 1 << 8; +pub const FINAL_PUNCTUATION: u32 = 1 << 9; +pub const EAST_ASIAN: u32 = 1 << 10; +pub const UNASSIGNED_EXTENDED_PICTOGRAPHIC: u32 = 1 << 11; + +pub const XX: u8 = 0; +pub const ER: u8 = 1; +pub const CM: u8 = 2; +pub const BA: u8 = 3; +pub const LF: u8 = 4; +pub const BK: u8 = 5; +pub const CR: u8 = 6; +pub const SP: u8 = 7; +pub const EX: u8 = 8; +pub const QU: u8 = 9; +pub const AL: u8 = 10; +pub const PR: u8 = 11; +pub const PO: u8 = 12; +pub const OP: u8 = 13; +pub const CP: u8 = 14; +pub const IS: u8 = 15; +pub const HY: u8 = 16; +pub const SY: u8 = 17; +pub const NU: u8 = 18; +pub const CL: u8 = 19; +pub const NL: u8 = 20; +pub const GL: u8 = 21; +pub const AI: u8 = 22; +pub const BB: u8 = 23; +pub const HH: u8 = 24; +pub const HL: u8 = 25; +pub const SA: u8 = 26; +pub const JL: u8 = 27; +pub const JV: u8 = 28; +pub const JT: u8 = 29; +pub const NS: u8 = 30; +pub const AK: u8 = 31; +pub const VI: u8 = 32; +pub const AS: u8 = 33; +pub const ID: u8 = 34; +pub const VF: u8 = 35; +pub const ZW: u8 = 36; +pub const ZWJ: u8 = 37; +pub const B2: u8 = 38; +pub const IN: u8 = 39; +pub const WJ: u8 = 40; +pub const EB: u8 = 41; +pub const CJ: u8 = 42; +pub const H2: u8 = 43; +pub const H3: u8 = 44; +pub const SG: u8 = 45; +pub const CB: u8 = 46; +pub const AP: u8 = 47; +pub const RI: u8 = 48; +pub const EM: u8 = 49; + +pub const LINE_BREAK_END_VALUES: &[u32] = &[ + 9, 2, 10, 3, 11, 4, 13, 5, 14, 6, 32, 2, 33, 7, 34, 8, 35, 9, 36, 10, 37, 11, 38, 12, + 39, 10, 40, 9, 41, 13, 42, 14, 43, 10, 44, 11, 45, 15, 46, 16, 47, 15, 48, 17, 58, 18, 60, 15, + 63, 10, 64, 8, 91, 10, 92, 13, 93, 11, 94, 14, 123, 10, 124, 13, 125, 3, 126, 19, 127, 10, 133, 2, + 134, 20, 160, 2, 161, 21, 162, 13, 163, 12, 166, 11, 171, 10, 172, 265, 173, 10, 174, 3, 176, 10, 177, 12, + 178, 11, 180, 10, 181, 23, 187, 10, 188, 521, 191, 10, 192, 13, 712, 10, 713, 23, 716, 10, 717, 23, 735, 10, + 736, 23, 768, 10, 860, 2, 867, 21, 880, 2, 894, 10, 895, 15, 1155, 10, 1162, 2, 1417, 10, 1418, 15, 1419, 24, + 1423, 10, 1424, 11, 1425, 10, 1470, 2, 1471, 24, 1472, 2, 1473, 10, 1475, 2, 1476, 10, 1478, 2, 1479, 8, 1480, 2, + 1488, 10, 1515, 25, 1519, 10, 1523, 25, 1536, 10, 1542, 18, 1545, 10, 1548, 12, 1550, 15, 1552, 10, 1563, 2, 1564, 8, + 1565, 2, 1568, 8, 1611, 10, 1632, 2, 1642, 18, 1643, 12, 1645, 18, 1648, 10, 1649, 2, 1748, 10, 1749, 8, 1750, 10, + 1757, 2, 1758, 18, 1759, 10, 1765, 2, 1767, 10, 1769, 2, 1770, 10, 1774, 2, 1776, 10, 1786, 18, 1809, 10, 1810, 2, + 1840, 10, 1867, 2, 1958, 10, 1969, 2, 1984, 10, 1994, 18, 2027, 10, 2036, 2, 2040, 10, 2041, 15, 2042, 8, 2045, 10, + 2046, 2, 2048, 11, 2070, 10, 2074, 2, 2075, 10, 2084, 2, 2085, 10, 2088, 2, 2089, 10, 2094, 2, 2137, 10, 2140, 2, + 2192, 10, 2194, 18, 2199, 10, 2208, 2, 2250, 10, 2274, 2, 2275, 18, 2308, 2, 2362, 10, 2365, 2, 2366, 10, 2384, 2, + 2385, 10, 2392, 2, 2402, 10, 2404, 2, 2406, 3, 2416, 18, 2433, 10, 2436, 2, 2492, 10, 2493, 2, 2494, 10, 2501, 2, + 2503, 10, 2505, 2, 2507, 10, 2510, 2, 2519, 10, 2520, 2, 2530, 10, 2532, 2, 2534, 10, 2544, 18, 2546, 10, 2548, 12, + 2553, 10, 2554, 12, 2555, 10, 2556, 11, 2558, 10, 2559, 2, 2561, 10, 2564, 2, 2620, 10, 2621, 2, 2622, 10, 2627, 2, + 2631, 10, 2633, 2, 2635, 10, 2638, 2, 2641, 10, 2642, 2, 2662, 10, 2672, 18, 2674, 2, 2677, 10, 2678, 2, 2689, 10, + 2692, 2, 2748, 10, 2749, 2, 2750, 10, 2758, 2, 2759, 10, 2762, 2, 2763, 10, 2766, 2, 2786, 10, 2788, 2, 2790, 10, + 2800, 18, 2801, 10, 2802, 11, 2810, 10, 2816, 2, 2817, 10, 2820, 2, 2876, 10, 2877, 2, 2878, 10, 2885, 2, 2887, 10, + 2889, 2, 2891, 10, 2894, 2, 2901, 10, 2904, 2, 2914, 10, 2916, 2, 2918, 10, 2928, 18, 2946, 10, 2947, 2, 3006, 10, + 3011, 2, 3014, 10, 3017, 2, 3018, 10, 3022, 2, 3031, 10, 3032, 2, 3046, 10, 3056, 18, 3065, 10, 3066, 11, 3072, 10, + 3077, 2, 3132, 10, 3133, 2, 3134, 10, 3141, 2, 3142, 10, 3145, 2, 3146, 10, 3150, 2, 3157, 10, 3159, 2, 3170, 10, + 3172, 2, 3174, 10, 3184, 18, 3191, 10, 3192, 23, 3201, 10, 3204, 2, 3205, 23, 3260, 10, 3261, 2, 3262, 10, 3269, 2, + 3270, 10, 3273, 2, 3274, 10, 3278, 2, 3285, 10, 3287, 2, 3298, 10, 3300, 2, 3302, 10, 3312, 18, 3315, 10, 3316, 2, + 3328, 10, 3332, 2, 3387, 10, 3389, 2, 3390, 10, 3397, 2, 3398, 10, 3401, 2, 3402, 10, 3406, 2, 3415, 10, 3416, 2, + 3426, 10, 3428, 2, 3430, 10, 3440, 18, 3449, 10, 3450, 12, 3457, 10, 3460, 2, 3530, 10, 3531, 2, 3535, 10, 3541, 2, + 3542, 10, 3543, 2, 3544, 10, 3552, 2, 3558, 10, 3568, 18, 3570, 10, 3572, 2, 3633, 10, 3634, 2, 3636, 10, 3643, 2, + 3647, 10, 3648, 11, 3655, 10, 3663, 2, 3664, 10, 3674, 18, 3676, 3, 3761, 10, 3762, 2, 3764, 10, 3773, 2, 3784, 10, + 3791, 2, 3792, 10, 3802, 18, 3841, 10, 3845, 23, 3846, 10, 3848, 23, 3849, 21, 3851, 23, 3852, 3, 3853, 21, 3858, 8, + 3859, 21, 3860, 10, 3861, 8, 3864, 10, 3866, 2, 3872, 10, 3882, 18, 3892, 10, 3893, 3, 3894, 2, 3895, 10, 3896, 2, + 3897, 10, 3898, 2, 3899, 13, 3900, 19, 3901, 13, 3902, 19, 3904, 2, 3953, 10, 3967, 2, 3968, 3, 3973, 2, 3974, 3, + 3976, 2, 3981, 10, 3992, 2, 3993, 10, 4029, 2, 4030, 10, 4032, 3, 4038, 10, 4039, 2, 4048, 10, 4050, 23, 4051, 3, + 4052, 23, 4057, 10, 4059, 21, 4139, 10, 4159, 2, 4160, 10, 4170, 18, 4172, 3, 4182, 10, 4186, 2, 4190, 10, 4193, 2, + 4194, 10, 4197, 2, 4199, 10, 4206, 2, 4209, 10, 4213, 2, 4226, 10, 4238, 2, 4239, 10, 4240, 2, 4250, 18, 4254, 2, + 4352, 10, 4448, 1051, 4520, 28, 4608, 29, 4957, 10, 4960, 2, 4961, 10, 4962, 3, 5120, 10, 5121, 24, 5760, 10, 5761, 3, + 5787, 10, 5788, 13, 5789, 19, 5867, 10, 5870, 3, 5906, 10, 5910, 2, 5938, 10, 5941, 2, 5943, 3, 5970, 10, 5972, 2, + 6002, 10, 6004, 2, 6068, 10, 6100, 2, 6102, 3, 6103, 30, 6104, 10, 6105, 3, 6106, 10, 6107, 3, 6108, 11, 6109, 10, + 6110, 2, 6112, 10, 6122, 18, 6146, 10, 6148, 8, 6150, 3, 6151, 23, 6152, 10, 6154, 8, 6155, 10, 6158, 2, 6159, 21, + 6160, 2, 6170, 18, 6277, 10, 6279, 2, 6313, 10, 6314, 2, 6432, 10, 6444, 2, 6448, 10, 6460, 2, 6468, 10, 6470, 8, + 6480, 18, 6608, 10, 6619, 18, 6679, 10, 6684, 2, 6741, 10, 6751, 2, 6752, 10, 6781, 2, 6783, 10, 6784, 2, 6794, 18, + 6800, 10, 6810, 18, 6832, 10, 6878, 2, 6880, 10, 6891, 2, 6892, 21, 6912, 10, 6917, 2, 6964, 31, 6980, 2, 6981, 32, + 6989, 31, 6990, 10, 6992, 3, 7002, 33, 7004, 3, 7005, 34, 7009, 3, 7019, 34, 7028, 2, 7037, 34, 7040, 3, 7043, 2, + 7073, 10, 7086, 2, 7088, 10, 7098, 18, 7104, 10, 7142, 33, 7154, 2, 7156, 35, 7204, 10, 7224, 2, 7227, 10, 7232, 3, + 7242, 18, 7248, 10, 7258, 18, 7294, 10, 7296, 3, 7376, 10, 7379, 2, 7380, 10, 7401, 2, 7405, 10, 7406, 2, 7412, 10, + 7413, 2, 7415, 10, 7418, 2, 7616, 10, 7629, 2, 7630, 21, 7676, 2, 7677, 21, 7680, 2, 8189, 10, 8190, 23, 8192, 10, + 8199, 3, 8200, 21, 8203, 3, 8204, 36, 8205, 2, 8206, 37, 8208, 2, 8209, 24, 8210, 21, 8212, 24, 8213, 38, 8216, 10, + 8217, 265, 8218, 521, 8219, 13, 8221, 265, 8222, 521, 8223, 13, 8224, 265, 8228, 10, 8231, 39, 8232, 3, 8234, 5, 8239, 2, + 8240, 21, 8248, 12, 8249, 10, 8250, 265, 8251, 521, 8252, 10, 8254, 30, 8260, 10, 8261, 15, 8262, 13, 8263, 19, 8266, 30, + 8278, 10, 8279, 3, 8280, 12, 8284, 3, 8285, 10, 8288, 3, 8289, 40, 8294, 10, 8304, 2, 8317, 10, 8318, 13, 8319, 19, + 8333, 10, 8334, 13, 8335, 19, 8352, 10, 8359, 11, 8360, 12, 8361, 11, 8362, 1035, 8374, 11, 8375, 12, 8379, 11, 8380, 12, + 8382, 11, 8383, 12, 8384, 11, 8385, 12, 8400, 11, 8433, 2, 8451, 10, 8452, 12, 8457, 10, 8458, 12, 8470, 10, 8471, 11, + 8722, 10, 8724, 11, 8943, 10, 8944, 39, 8968, 10, 8969, 13, 8970, 19, 8971, 13, 8972, 19, 8986, 10, 8988, 1058, 9001, 10, + 9002, 1037, 9003, 1043, 9193, 10, 9197, 1034, 9200, 10, 9201, 1058, 9203, 34, 9204, 1058, 9725, 10, 9727, 1034, 9728, 10, 9732, 34, + 9748, 10, 9750, 1058, 9752, 10, 9753, 34, 9754, 10, 9757, 34, 9758, 41, 9760, 34, 9776, 10, 9784, 1034, 9785, 10, 9788, 34, + 9800, 10, 9812, 1034, 9832, 10, 9833, 34, 9855, 10, 9856, 1058, 9866, 10, 9872, 1034, 9875, 10, 9876, 1034, 9889, 10, 9890, 1034, + 9898, 10, 9900, 1034, 9917, 10, 9919, 1058, 9924, 34, 9926, 1058, 9929, 34, 9933, 10, 9934, 34, 9935, 1034, 9938, 34, 9939, 10, + 9940, 34, 9941, 1058, 9944, 10, 9946, 34, 9948, 10, 9949, 34, 9951, 10, 9954, 34, 9962, 10, 9963, 1058, 9969, 10, 9970, 34, + 9972, 1058, 9973, 34, 9974, 1058, 9975, 10, 9977, 34, 9978, 41, 9979, 1058, 9981, 10, 9982, 1058, 9989, 34, 9990, 1034, 9992, 10, + 9994, 34, 9996, 1065, 9998, 41, 10024, 10, 10025, 1034, 10060, 10, 10061, 1034, 10062, 10, 10063, 1034, 10067, 10, 10070, 1034, 10071, 10, + 10072, 1034, 10075, 10, 10081, 9, 10082, 10, 10084, 8, 10085, 34, 10088, 10, 10089, 13, 10090, 19, 10091, 13, 10092, 19, 10093, 13, + 10094, 19, 10095, 13, 10096, 19, 10097, 13, 10098, 19, 10099, 13, 10100, 19, 10101, 13, 10102, 19, 10133, 10, 10136, 1034, 10160, 10, + 10161, 1034, 10175, 10, 10176, 1034, 10181, 10, 10182, 13, 10183, 19, 10214, 10, 10215, 13, 10216, 19, 10217, 13, 10218, 19, 10219, 13, + 10220, 19, 10221, 13, 10222, 19, 10223, 13, 10224, 19, 10240, 10, 10241, 3, 10627, 10, 10628, 13, 10629, 19, 10630, 13, 10631, 19, + 10632, 13, 10633, 19, 10634, 13, 10635, 19, 10636, 13, 10637, 19, 10638, 13, 10639, 19, 10640, 13, 10641, 19, 10642, 13, 10643, 19, + 10644, 13, 10645, 19, 10646, 13, 10647, 19, 10648, 13, 10649, 19, 10712, 10, 10713, 13, 10714, 19, 10715, 13, 10716, 19, 10748, 10, + 10749, 13, 10750, 19, 11035, 10, 11037, 1034, 11088, 10, 11089, 1034, 11093, 10, 11094, 1034, 11503, 10, 11506, 2, 11513, 10, 11514, 8, + 11517, 3, 11518, 10, 11519, 8, 11520, 3, 11632, 10, 11633, 3, 11647, 10, 11648, 2, 11744, 10, 11776, 2, 11778, 9, 11779, 265, + 11780, 521, 11781, 265, 11782, 521, 11785, 9, 11786, 265, 11787, 521, 11788, 9, 11789, 265, 11790, 521, 11798, 3, 11799, 10, 11800, 24, + 11801, 13, 11802, 3, 11804, 10, 11805, 265, 11806, 521, 11808, 10, 11809, 265, 11810, 521, 11811, 13, 11812, 19, 11813, 13, 11814, 19, + 11815, 13, 11816, 19, 11817, 13, 11818, 19, 11822, 3, 11823, 8, 11824, 10, 11826, 3, 11827, 10, 11829, 3, 11834, 10, 11836, 38, + 11839, 3, 11840, 10, 11841, 24, 11842, 3, 11843, 13, 11851, 3, 11852, 10, 11853, 3, 11854, 10, 11856, 3, 11859, 10, 11861, 8, + 11862, 13, 11863, 14, 11864, 13, 11865, 14, 11866, 13, 11867, 14, 11868, 13, 11869, 14, 11870, 24, 11904, 10, 11930, 1058, 11931, 10, + 12020, 1058, 12032, 10, 12246, 1058, 12272, 10, 12288, 1058, 12289, 1027, 12291, 1043, 12293, 1058, 12294, 1054, 12296, 1058, 12297, 1037, 12298, 1043, + 12299, 1037, 12300, 1043, 12301, 1037, 12302, 1043, 12303, 1037, 12304, 1043, 12305, 1037, 12306, 1043, 12308, 1058, 12309, 1037, 12310, 1043, 12311, 1037, + 12312, 1043, 12313, 1037, 12314, 1043, 12315, 1037, 12316, 1043, 12317, 1054, 12318, 1037, 12320, 1043, 12330, 1058, 12336, 1026, 12341, 1058, 12342, 1026, + 12347, 1058, 12349, 1054, 12351, 1058, 12352, 34, 12353, 10, 12354, 1054, 12355, 1058, 12356, 1054, 12357, 1058, 12358, 1054, 12359, 1058, 12360, 1054, + 12361, 1058, 12362, 1054, 12387, 1058, 12388, 1054, 12419, 1058, 12420, 1054, 12421, 1058, 12422, 1054, 12423, 1058, 12424, 1054, 12430, 1058, 12431, 1054, + 12437, 1058, 12439, 1054, 12441, 10, 12443, 1026, 12447, 1054, 12448, 1058, 12450, 1054, 12451, 1058, 12452, 1054, 12453, 1058, 12454, 1054, 12455, 1058, + 12456, 1054, 12457, 1058, 12458, 1054, 12483, 1058, 12484, 1054, 12515, 1058, 12516, 1054, 12517, 1058, 12518, 1054, 12519, 1058, 12520, 1054, 12526, 1058, + 12527, 1054, 12533, 1058, 12535, 1054, 12539, 1058, 12543, 1054, 12544, 1058, 12549, 10, 12592, 1058, 12593, 10, 12687, 1058, 12688, 10, 12774, 1058, + 12783, 10, 12784, 1058, 12800, 1054, 12831, 1058, 12832, 10, 12872, 1058, 12880, 10, 19904, 1058, 19968, 1034, 40981, 1058, 40982, 1054, 42125, 1058, + 42128, 10, 42183, 1058, 42238, 10, 42240, 3, 42509, 10, 42510, 3, 42511, 8, 42512, 3, 42528, 10, 42538, 18, 42607, 10, 42611, 2, + 42612, 10, 42622, 2, 42654, 10, 42656, 2, 42736, 10, 42738, 2, 42739, 10, 42744, 3, 43010, 10, 43011, 2, 43014, 10, 43015, 2, + 43019, 10, 43020, 2, 43043, 10, 43048, 2, 43052, 10, 43053, 2, 43064, 10, 43065, 12, 43124, 10, 43126, 23, 43128, 8, 43136, 10, + 43138, 2, 43188, 10, 43206, 2, 43214, 10, 43216, 3, 43226, 18, 43232, 10, 43250, 2, 43260, 10, 43261, 23, 43263, 10, 43264, 2, + 43274, 18, 43302, 10, 43310, 2, 43312, 3, 43335, 10, 43348, 2, 43360, 10, 43389, 1051, 43392, 10, 43396, 2, 43443, 31, 43456, 2, + 43457, 32, 43463, 34, 43466, 3, 43470, 34, 43471, 10, 43472, 3, 43482, 33, 43486, 10, 43488, 34, 43493, 10, 43494, 2, 43504, 10, + 43514, 18, 43520, 10, 43561, 33, 43575, 2, 43584, 10, 43587, 3, 43588, 2, 43596, 3, 43598, 2, 43600, 10, 43610, 33, 43612, 10, + 43613, 34, 43616, 3, 43643, 10, 43646, 2, 43696, 10, 43697, 2, 43698, 10, 43701, 2, 43703, 10, 43705, 2, 43710, 10, 43712, 2, + 43713, 10, 43714, 2, 43755, 10, 43760, 2, 43762, 3, 43765, 10, 43767, 2, 44003, 10, 44011, 2, 44012, 3, 44014, 2, 44016, 10, + 44026, 18, 44032, 10, 44033, 1067, 44060, 1068, 44061, 1067, 44088, 1068, 44089, 1067, 44116, 1068, 44117, 1067, 44144, 1068, 44145, 1067, 44172, 1068, + 44173, 1067, 44200, 1068, 44201, 1067, 44228, 1068, 44229, 1067, 44256, 1068, 44257, 1067, 44284, 1068, 44285, 1067, 44312, 1068, 44313, 1067, 44340, 1068, + 44341, 1067, 44368, 1068, 44369, 1067, 44396, 1068, 44397, 1067, 44424, 1068, 44425, 1067, 44452, 1068, 44453, 1067, 44480, 1068, 44481, 1067, 44508, 1068, + 44509, 1067, 44536, 1068, 44537, 1067, 44564, 1068, 44565, 1067, 44592, 1068, 44593, 1067, 44620, 1068, 44621, 1067, 44648, 1068, 44649, 1067, 44676, 1068, + 44677, 1067, 44704, 1068, 44705, 1067, 44732, 1068, 44733, 1067, 44760, 1068, 44761, 1067, 44788, 1068, 44789, 1067, 44816, 1068, 44817, 1067, 44844, 1068, + 44845, 1067, 44872, 1068, 44873, 1067, 44900, 1068, 44901, 1067, 44928, 1068, 44929, 1067, 44956, 1068, 44957, 1067, 44984, 1068, 44985, 1067, 45012, 1068, + 45013, 1067, 45040, 1068, 45041, 1067, 45068, 1068, 45069, 1067, 45096, 1068, 45097, 1067, 45124, 1068, 45125, 1067, 45152, 1068, 45153, 1067, 45180, 1068, + 45181, 1067, 45208, 1068, 45209, 1067, 45236, 1068, 45237, 1067, 45264, 1068, 45265, 1067, 45292, 1068, 45293, 1067, 45320, 1068, 45321, 1067, 45348, 1068, + 45349, 1067, 45376, 1068, 45377, 1067, 45404, 1068, 45405, 1067, 45432, 1068, 45433, 1067, 45460, 1068, 45461, 1067, 45488, 1068, 45489, 1067, 45516, 1068, + 45517, 1067, 45544, 1068, 45545, 1067, 45572, 1068, 45573, 1067, 45600, 1068, 45601, 1067, 45628, 1068, 45629, 1067, 45656, 1068, 45657, 1067, 45684, 1068, + 45685, 1067, 45712, 1068, 45713, 1067, 45740, 1068, 45741, 1067, 45768, 1068, 45769, 1067, 45796, 1068, 45797, 1067, 45824, 1068, 45825, 1067, 45852, 1068, + 45853, 1067, 45880, 1068, 45881, 1067, 45908, 1068, 45909, 1067, 45936, 1068, 45937, 1067, 45964, 1068, 45965, 1067, 45992, 1068, 45993, 1067, 46020, 1068, + 46021, 1067, 46048, 1068, 46049, 1067, 46076, 1068, 46077, 1067, 46104, 1068, 46105, 1067, 46132, 1068, 46133, 1067, 46160, 1068, 46161, 1067, 46188, 1068, + 46189, 1067, 46216, 1068, 46217, 1067, 46244, 1068, 46245, 1067, 46272, 1068, 46273, 1067, 46300, 1068, 46301, 1067, 46328, 1068, 46329, 1067, 46356, 1068, + 46357, 1067, 46384, 1068, 46385, 1067, 46412, 1068, 46413, 1067, 46440, 1068, 46441, 1067, 46468, 1068, 46469, 1067, 46496, 1068, 46497, 1067, 46524, 1068, + 46525, 1067, 46552, 1068, 46553, 1067, 46580, 1068, 46581, 1067, 46608, 1068, 46609, 1067, 46636, 1068, 46637, 1067, 46664, 1068, 46665, 1067, 46692, 1068, + 46693, 1067, 46720, 1068, 46721, 1067, 46748, 1068, 46749, 1067, 46776, 1068, 46777, 1067, 46804, 1068, 46805, 1067, 46832, 1068, 46833, 1067, 46860, 1068, + 46861, 1067, 46888, 1068, 46889, 1067, 46916, 1068, 46917, 1067, 46944, 1068, 46945, 1067, 46972, 1068, 46973, 1067, 47000, 1068, 47001, 1067, 47028, 1068, + 47029, 1067, 47056, 1068, 47057, 1067, 47084, 1068, 47085, 1067, 47112, 1068, 47113, 1067, 47140, 1068, 47141, 1067, 47168, 1068, 47169, 1067, 47196, 1068, + 47197, 1067, 47224, 1068, 47225, 1067, 47252, 1068, 47253, 1067, 47280, 1068, 47281, 1067, 47308, 1068, 47309, 1067, 47336, 1068, 47337, 1067, 47364, 1068, + 47365, 1067, 47392, 1068, 47393, 1067, 47420, 1068, 47421, 1067, 47448, 1068, 47449, 1067, 47476, 1068, 47477, 1067, 47504, 1068, 47505, 1067, 47532, 1068, + 47533, 1067, 47560, 1068, 47561, 1067, 47588, 1068, 47589, 1067, 47616, 1068, 47617, 1067, 47644, 1068, 47645, 1067, 47672, 1068, 47673, 1067, 47700, 1068, + 47701, 1067, 47728, 1068, 47729, 1067, 47756, 1068, 47757, 1067, 47784, 1068, 47785, 1067, 47812, 1068, 47813, 1067, 47840, 1068, 47841, 1067, 47868, 1068, + 47869, 1067, 47896, 1068, 47897, 1067, 47924, 1068, 47925, 1067, 47952, 1068, 47953, 1067, 47980, 1068, 47981, 1067, 48008, 1068, 48009, 1067, 48036, 1068, + 48037, 1067, 48064, 1068, 48065, 1067, 48092, 1068, 48093, 1067, 48120, 1068, 48121, 1067, 48148, 1068, 48149, 1067, 48176, 1068, 48177, 1067, 48204, 1068, + 48205, 1067, 48232, 1068, 48233, 1067, 48260, 1068, 48261, 1067, 48288, 1068, 48289, 1067, 48316, 1068, 48317, 1067, 48344, 1068, 48345, 1067, 48372, 1068, + 48373, 1067, 48400, 1068, 48401, 1067, 48428, 1068, 48429, 1067, 48456, 1068, 48457, 1067, 48484, 1068, 48485, 1067, 48512, 1068, 48513, 1067, 48540, 1068, + 48541, 1067, 48568, 1068, 48569, 1067, 48596, 1068, 48597, 1067, 48624, 1068, 48625, 1067, 48652, 1068, 48653, 1067, 48680, 1068, 48681, 1067, 48708, 1068, + 48709, 1067, 48736, 1068, 48737, 1067, 48764, 1068, 48765, 1067, 48792, 1068, 48793, 1067, 48820, 1068, 48821, 1067, 48848, 1068, 48849, 1067, 48876, 1068, + 48877, 1067, 48904, 1068, 48905, 1067, 48932, 1068, 48933, 1067, 48960, 1068, 48961, 1067, 48988, 1068, 48989, 1067, 49016, 1068, 49017, 1067, 49044, 1068, + 49045, 1067, 49072, 1068, 49073, 1067, 49100, 1068, 49101, 1067, 49128, 1068, 49129, 1067, 49156, 1068, 49157, 1067, 49184, 1068, 49185, 1067, 49212, 1068, + 49213, 1067, 49240, 1068, 49241, 1067, 49268, 1068, 49269, 1067, 49296, 1068, 49297, 1067, 49324, 1068, 49325, 1067, 49352, 1068, 49353, 1067, 49380, 1068, + 49381, 1067, 49408, 1068, 49409, 1067, 49436, 1068, 49437, 1067, 49464, 1068, 49465, 1067, 49492, 1068, 49493, 1067, 49520, 1068, 49521, 1067, 49548, 1068, + 49549, 1067, 49576, 1068, 49577, 1067, 49604, 1068, 49605, 1067, 49632, 1068, 49633, 1067, 49660, 1068, 49661, 1067, 49688, 1068, 49689, 1067, 49716, 1068, + 49717, 1067, 49744, 1068, 49745, 1067, 49772, 1068, 49773, 1067, 49800, 1068, 49801, 1067, 49828, 1068, 49829, 1067, 49856, 1068, 49857, 1067, 49884, 1068, + 49885, 1067, 49912, 1068, 49913, 1067, 49940, 1068, 49941, 1067, 49968, 1068, 49969, 1067, 49996, 1068, 49997, 1067, 50024, 1068, 50025, 1067, 50052, 1068, + 50053, 1067, 50080, 1068, 50081, 1067, 50108, 1068, 50109, 1067, 50136, 1068, 50137, 1067, 50164, 1068, 50165, 1067, 50192, 1068, 50193, 1067, 50220, 1068, + 50221, 1067, 50248, 1068, 50249, 1067, 50276, 1068, 50277, 1067, 50304, 1068, 50305, 1067, 50332, 1068, 50333, 1067, 50360, 1068, 50361, 1067, 50388, 1068, + 50389, 1067, 50416, 1068, 50417, 1067, 50444, 1068, 50445, 1067, 50472, 1068, 50473, 1067, 50500, 1068, 50501, 1067, 50528, 1068, 50529, 1067, 50556, 1068, + 50557, 1067, 50584, 1068, 50585, 1067, 50612, 1068, 50613, 1067, 50640, 1068, 50641, 1067, 50668, 1068, 50669, 1067, 50696, 1068, 50697, 1067, 50724, 1068, + 50725, 1067, 50752, 1068, 50753, 1067, 50780, 1068, 50781, 1067, 50808, 1068, 50809, 1067, 50836, 1068, 50837, 1067, 50864, 1068, 50865, 1067, 50892, 1068, + 50893, 1067, 50920, 1068, 50921, 1067, 50948, 1068, 50949, 1067, 50976, 1068, 50977, 1067, 51004, 1068, 51005, 1067, 51032, 1068, 51033, 1067, 51060, 1068, + 51061, 1067, 51088, 1068, 51089, 1067, 51116, 1068, 51117, 1067, 51144, 1068, 51145, 1067, 51172, 1068, 51173, 1067, 51200, 1068, 51201, 1067, 51228, 1068, + 51229, 1067, 51256, 1068, 51257, 1067, 51284, 1068, 51285, 1067, 51312, 1068, 51313, 1067, 51340, 1068, 51341, 1067, 51368, 1068, 51369, 1067, 51396, 1068, + 51397, 1067, 51424, 1068, 51425, 1067, 51452, 1068, 51453, 1067, 51480, 1068, 51481, 1067, 51508, 1068, 51509, 1067, 51536, 1068, 51537, 1067, 51564, 1068, + 51565, 1067, 51592, 1068, 51593, 1067, 51620, 1068, 51621, 1067, 51648, 1068, 51649, 1067, 51676, 1068, 51677, 1067, 51704, 1068, 51705, 1067, 51732, 1068, + 51733, 1067, 51760, 1068, 51761, 1067, 51788, 1068, 51789, 1067, 51816, 1068, 51817, 1067, 51844, 1068, 51845, 1067, 51872, 1068, 51873, 1067, 51900, 1068, + 51901, 1067, 51928, 1068, 51929, 1067, 51956, 1068, 51957, 1067, 51984, 1068, 51985, 1067, 52012, 1068, 52013, 1067, 52040, 1068, 52041, 1067, 52068, 1068, + 52069, 1067, 52096, 1068, 52097, 1067, 52124, 1068, 52125, 1067, 52152, 1068, 52153, 1067, 52180, 1068, 52181, 1067, 52208, 1068, 52209, 1067, 52236, 1068, + 52237, 1067, 52264, 1068, 52265, 1067, 52292, 1068, 52293, 1067, 52320, 1068, 52321, 1067, 52348, 1068, 52349, 1067, 52376, 1068, 52377, 1067, 52404, 1068, + 52405, 1067, 52432, 1068, 52433, 1067, 52460, 1068, 52461, 1067, 52488, 1068, 52489, 1067, 52516, 1068, 52517, 1067, 52544, 1068, 52545, 1067, 52572, 1068, + 52573, 1067, 52600, 1068, 52601, 1067, 52628, 1068, 52629, 1067, 52656, 1068, 52657, 1067, 52684, 1068, 52685, 1067, 52712, 1068, 52713, 1067, 52740, 1068, + 52741, 1067, 52768, 1068, 52769, 1067, 52796, 1068, 52797, 1067, 52824, 1068, 52825, 1067, 52852, 1068, 52853, 1067, 52880, 1068, 52881, 1067, 52908, 1068, + 52909, 1067, 52936, 1068, 52937, 1067, 52964, 1068, 52965, 1067, 52992, 1068, 52993, 1067, 53020, 1068, 53021, 1067, 53048, 1068, 53049, 1067, 53076, 1068, + 53077, 1067, 53104, 1068, 53105, 1067, 53132, 1068, 53133, 1067, 53160, 1068, 53161, 1067, 53188, 1068, 53189, 1067, 53216, 1068, 53217, 1067, 53244, 1068, + 53245, 1067, 53272, 1068, 53273, 1067, 53300, 1068, 53301, 1067, 53328, 1068, 53329, 1067, 53356, 1068, 53357, 1067, 53384, 1068, 53385, 1067, 53412, 1068, + 53413, 1067, 53440, 1068, 53441, 1067, 53468, 1068, 53469, 1067, 53496, 1068, 53497, 1067, 53524, 1068, 53525, 1067, 53552, 1068, 53553, 1067, 53580, 1068, + 53581, 1067, 53608, 1068, 53609, 1067, 53636, 1068, 53637, 1067, 53664, 1068, 53665, 1067, 53692, 1068, 53693, 1067, 53720, 1068, 53721, 1067, 53748, 1068, + 53749, 1067, 53776, 1068, 53777, 1067, 53804, 1068, 53805, 1067, 53832, 1068, 53833, 1067, 53860, 1068, 53861, 1067, 53888, 1068, 53889, 1067, 53916, 1068, + 53917, 1067, 53944, 1068, 53945, 1067, 53972, 1068, 53973, 1067, 54000, 1068, 54001, 1067, 54028, 1068, 54029, 1067, 54056, 1068, 54057, 1067, 54084, 1068, + 54085, 1067, 54112, 1068, 54113, 1067, 54140, 1068, 54141, 1067, 54168, 1068, 54169, 1067, 54196, 1068, 54197, 1067, 54224, 1068, 54225, 1067, 54252, 1068, + 54253, 1067, 54280, 1068, 54281, 1067, 54308, 1068, 54309, 1067, 54336, 1068, 54337, 1067, 54364, 1068, 54365, 1067, 54392, 1068, 54393, 1067, 54420, 1068, + 54421, 1067, 54448, 1068, 54449, 1067, 54476, 1068, 54477, 1067, 54504, 1068, 54505, 1067, 54532, 1068, 54533, 1067, 54560, 1068, 54561, 1067, 54588, 1068, + 54589, 1067, 54616, 1068, 54617, 1067, 54644, 1068, 54645, 1067, 54672, 1068, 54673, 1067, 54700, 1068, 54701, 1067, 54728, 1068, 54729, 1067, 54756, 1068, + 54757, 1067, 54784, 1068, 54785, 1067, 54812, 1068, 54813, 1067, 54840, 1068, 54841, 1067, 54868, 1068, 54869, 1067, 54896, 1068, 54897, 1067, 54924, 1068, + 54925, 1067, 54952, 1068, 54953, 1067, 54980, 1068, 54981, 1067, 55008, 1068, 55009, 1067, 55036, 1068, 55037, 1067, 55064, 1068, 55065, 1067, 55092, 1068, + 55093, 1067, 55120, 1068, 55121, 1067, 55148, 1068, 55149, 1067, 55176, 1068, 55177, 1067, 55204, 1068, 55216, 10, 55239, 28, 55243, 10, 55292, 29, + 63744, 10, 64256, 1058, 64285, 10, 64286, 25, 64287, 2, 64297, 25, 64298, 10, 64311, 25, 64312, 10, 64317, 25, 64318, 10, 64319, 25, + 64320, 10, 64322, 25, 64323, 10, 64325, 25, 64326, 10, 64336, 25, 64830, 10, 64831, 19, 64832, 13, 65020, 10, 65021, 12, 65024, 10, + 65040, 2, 65043, 1043, 65045, 1054, 65047, 1032, 65048, 1037, 65049, 1043, 65050, 1063, 65056, 10, 65057, 21, 65058, 2, 65059, 21, 65060, 2, + 65061, 21, 65062, 2, 65064, 21, 65065, 2, 65066, 21, 65067, 2, 65068, 21, 65069, 2, 65071, 21, 65072, 2, 65077, 1058, 65078, 1037, + 65079, 1043, 65080, 1037, 65081, 1043, 65082, 1037, 65083, 1043, 65084, 1037, 65085, 1043, 65086, 1037, 65087, 1043, 65088, 1037, 65089, 1043, 65090, 1037, + 65091, 1043, 65092, 1037, 65093, 1043, 65095, 1058, 65096, 1037, 65097, 1043, 65104, 1058, 65105, 1043, 65106, 1058, 65107, 1043, 65108, 10, 65110, 1054, + 65112, 1032, 65113, 1058, 65114, 1037, 65115, 1043, 65116, 1037, 65117, 1043, 65118, 1037, 65119, 1043, 65127, 1058, 65128, 10, 65129, 1058, 65130, 1035, + 65131, 1036, 65132, 1058, 65279, 10, 65280, 40, 65281, 10, 65282, 1032, 65284, 1058, 65285, 1035, 65286, 1036, 65288, 1058, 65289, 1037, 65290, 1043, + 65292, 1058, 65293, 1043, 65294, 1058, 65295, 1043, 65306, 1058, 65308, 1054, 65311, 1058, 65312, 1032, 65339, 1058, 65340, 1037, 65341, 1058, 65342, 1043, + 65371, 1058, 65372, 1037, 65373, 1058, 65374, 1043, 65375, 1058, 65376, 1037, 65378, 1043, 65379, 1037, 65381, 1043, 65382, 1054, 65383, 1058, 65393, 1054, + 65438, 1058, 65440, 1054, 65471, 1058, 65474, 10, 65480, 1058, 65482, 10, 65488, 1058, 65490, 10, 65496, 1058, 65498, 10, 65501, 1058, 65504, 10, + 65505, 1036, 65506, 1035, 65509, 1058, 65511, 1035, 65512, 10, 65519, 1034, 65529, 10, 65532, 2, 65533, 46, 65792, 10, 65795, 3, 66045, 10, + 66046, 2, 66272, 10, 66273, 2, 66422, 10, 66427, 2, 66463, 10, 66464, 3, 66512, 10, 66513, 3, 66720, 10, 66730, 18, 67671, 10, + 67672, 3, 67871, 10, 67872, 3, 68097, 10, 68100, 2, 68101, 10, 68103, 2, 68108, 10, 68112, 2, 68152, 10, 68155, 2, 68159, 10, + 68160, 2, 68176, 10, 68184, 3, 68325, 10, 68327, 2, 68336, 10, 68342, 3, 68343, 39, 68409, 10, 68416, 3, 68900, 10, 68904, 2, + 68912, 10, 68922, 18, 68928, 10, 68938, 18, 68969, 10, 68974, 2, 68975, 24, 69291, 10, 69293, 2, 69294, 24, 69328, 10, 69329, 3, + 69370, 10, 69376, 2, 69446, 10, 69457, 2, 69506, 10, 69510, 2, 69632, 10, 69635, 2, 69637, 47, 69688, 31, 69702, 2, 69703, 32, + 69705, 3, 69710, 34, 69714, 10, 69734, 34, 69744, 33, 69745, 2, 69747, 31, 69749, 2, 69750, 31, 69759, 10, 69760, 21, 69763, 2, + 69808, 10, 69819, 2, 69821, 10, 69822, 18, 69826, 3, 69827, 2, 69837, 10, 69838, 18, 69872, 10, 69882, 18, 69888, 10, 69891, 2, + 69927, 10, 69941, 2, 69942, 10, 69952, 18, 69956, 3, 69957, 10, 69959, 2, 70003, 10, 70004, 2, 70005, 10, 70006, 23, 70016, 10, + 70019, 2, 70067, 10, 70081, 2, 70085, 10, 70087, 3, 70088, 10, 70089, 3, 70093, 2, 70094, 10, 70096, 2, 70106, 18, 70107, 10, + 70108, 23, 70109, 10, 70112, 3, 70188, 10, 70200, 2, 70202, 3, 70203, 10, 70205, 3, 70206, 10, 70207, 2, 70209, 10, 70210, 2, + 70313, 10, 70314, 3, 70367, 10, 70379, 2, 70384, 10, 70394, 18, 70400, 10, 70404, 2, 70405, 10, 70413, 31, 70415, 10, 70417, 31, + 70419, 10, 70441, 31, 70442, 10, 70449, 31, 70450, 10, 70452, 31, 70453, 10, 70458, 31, 70459, 10, 70461, 2, 70462, 3, 70469, 2, + 70471, 10, 70473, 2, 70475, 10, 70477, 2, 70478, 32, 70480, 10, 70481, 33, 70487, 10, 70488, 2, 70493, 10, 70494, 3, 70496, 33, + 70498, 31, 70500, 2, 70502, 10, 70509, 2, 70512, 10, 70517, 2, 70528, 10, 70538, 33, 70539, 10, 70540, 33, 70542, 10, 70543, 33, + 70544, 10, 70546, 33, 70582, 31, 70583, 10, 70584, 34, 70593, 2, 70594, 10, 70595, 2, 70597, 10, 70598, 2, 70599, 10, 70603, 2, + 70604, 10, 70608, 2, 70609, 32, 70610, 47, 70611, 2, 70614, 34, 70615, 10, 70617, 34, 70625, 10, 70627, 2, 70709, 10, 70727, 2, + 70731, 10, 70735, 3, 70736, 10, 70746, 18, 70748, 3, 70750, 10, 70751, 2, 70832, 10, 70852, 2, 70864, 10, 70874, 18, 71087, 10, + 71094, 2, 71096, 10, 71105, 2, 71106, 23, 71108, 3, 71110, 8, 71113, 10, 71128, 3, 71132, 10, 71134, 2, 71216, 10, 71233, 2, + 71235, 3, 71248, 10, 71258, 18, 71264, 10, 71277, 23, 71339, 10, 71352, 2, 71360, 10, 71370, 18, 71376, 10, 71396, 18, 71453, 10, + 71468, 2, 71472, 10, 71482, 18, 71484, 10, 71487, 3, 71724, 10, 71739, 2, 71904, 10, 71914, 18, 71936, 10, 71943, 31, 71945, 10, + 71946, 31, 71948, 10, 71956, 31, 71957, 10, 71959, 31, 71960, 10, 71984, 31, 71990, 2, 71991, 10, 71993, 2, 71995, 10, 71998, 2, + 71999, 32, 72000, 47, 72001, 2, 72002, 47, 72004, 2, 72007, 3, 72016, 10, 72026, 33, 72145, 10, 72152, 2, 72154, 10, 72161, 2, + 72162, 10, 72163, 23, 72164, 10, 72165, 2, 72193, 10, 72203, 2, 72243, 10, 72250, 2, 72251, 10, 72255, 2, 72256, 23, 72257, 10, + 72261, 3, 72262, 23, 72263, 10, 72264, 2, 72273, 10, 72284, 2, 72330, 10, 72346, 2, 72349, 3, 72350, 10, 72353, 23, 72355, 3, + 72448, 10, 72458, 23, 72544, 10, 72552, 2, 72688, 10, 72698, 18, 72751, 10, 72759, 2, 72760, 10, 72768, 2, 72769, 10, 72774, 3, + 72784, 10, 72794, 18, 72816, 10, 72817, 23, 72818, 8, 72850, 10, 72872, 2, 72873, 10, 72887, 2, 73009, 10, 73015, 2, 73018, 10, + 73019, 2, 73020, 10, 73022, 2, 73023, 10, 73030, 2, 73031, 10, 73032, 2, 73040, 10, 73050, 18, 73098, 10, 73103, 2, 73104, 10, + 73106, 2, 73107, 10, 73112, 2, 73120, 10, 73130, 18, 73184, 10, 73194, 18, 73440, 10, 73458, 33, 73459, 3, 73463, 2, 73465, 3, + 73472, 10, 73474, 2, 73475, 47, 73476, 2, 73489, 31, 73490, 10, 73524, 31, 73531, 2, 73534, 10, 73538, 2, 73539, 32, 73541, 3, + 73552, 34, 73562, 33, 73563, 2, 73693, 10, 73697, 12, 73727, 10, 73728, 3, 74864, 10, 74869, 3, 78424, 10, 78427, 13, 78430, 19, + 78466, 10, 78467, 19, 78470, 10, 78471, 13, 78472, 19, 78473, 13, 78474, 19, 78713, 10, 78714, 13, 78716, 19, 78895, 10, 78896, 13, + 78903, 21, 78904, 13, 78905, 19, 78908, 21, 78909, 13, 78910, 19, 78911, 13, 78912, 19, 78913, 2, 78919, 10, 78934, 2, 83406, 10, + 83407, 13, 83408, 19, 90368, 10, 90398, 33, 90416, 2, 90426, 33, 92768, 10, 92778, 18, 92782, 10, 92784, 3, 92864, 10, 92874, 18, + 92912, 10, 92917, 2, 92918, 3, 92976, 10, 92983, 2, 92986, 3, 92996, 10, 92997, 3, 93008, 10, 93018, 18, 93550, 10, 93552, 3, + 93562, 18, 93847, 10, 93849, 3, 94031, 10, 94032, 2, 94033, 10, 94088, 2, 94095, 10, 94099, 2, 94176, 10, 94180, 1054, 94181, 1045, + 94192, 10, 94194, 1026, 94196, 1054, 94199, 1058, 94208, 10, 101120, 1058, 101590, 1034, 101631, 10, 101632, 1034, 101663, 1058, 101760, 10, 101875, 1058, + 110576, 10, 110580, 1034, 110581, 10, 110588, 1034, 110589, 10, 110591, 1034, 110592, 10, 110883, 1058, 110898, 10, 110899, 1054, 110928, 10, 110931, 1054, + 110933, 10, 110934, 1054, 110948, 10, 110952, 1054, 110960, 10, 111356, 1058, 113821, 10, 113823, 2, 113824, 3, 113828, 2, 118000, 10, 118010, 18, + 118528, 10, 118574, 2, 118576, 10, 118599, 2, 119141, 10, 119146, 2, 119149, 10, 119171, 2, 119173, 10, 119180, 2, 119210, 10, 119214, 2, + 119362, 10, 119365, 2, 119552, 10, 119639, 1034, 119648, 10, 119671, 1034, 120782, 10, 120832, 18, 121344, 10, 121399, 2, 121403, 10, 121453, 2, + 121461, 10, 121462, 2, 121476, 10, 121477, 2, 121479, 10, 121483, 3, 121499, 10, 121504, 2, 121505, 10, 121520, 2, 122880, 10, 122887, 2, + 122888, 10, 122905, 2, 122907, 10, 122914, 2, 122915, 10, 122917, 2, 122918, 10, 122923, 2, 123023, 10, 123024, 2, 123184, 10, 123191, 2, + 123200, 10, 123210, 18, 123566, 10, 123567, 2, 123628, 10, 123632, 2, 123642, 18, 123647, 10, 123648, 11, 124140, 10, 124144, 2, 124154, 18, + 124398, 10, 124400, 2, 124401, 10, 124411, 18, 124643, 10, 124644, 2, 124646, 10, 124647, 2, 124654, 10, 124656, 2, 124661, 10, 124662, 2, + 125136, 10, 125143, 2, 125252, 10, 125259, 2, 125264, 10, 125274, 18, 125278, 10, 125280, 13, 126124, 10, 126125, 12, 126128, 10, 126129, 12, + 126976, 10, 126980, 34, 126981, 1058, 127020, 34, 127024, 2082, 127124, 34, 127136, 2082, 127151, 34, 127153, 2082, 127168, 34, 127169, 2082, 127183, 34, + 127184, 1058, 127185, 2082, 127222, 34, 127232, 2082, 127374, 10, 127375, 1034, 127377, 10, 127387, 1034, 127406, 10, 127462, 2082, 127488, 48, 127491, 1058, + 127504, 2082, 127548, 1058, 127552, 2082, 127561, 1058, 127568, 2082, 127570, 1058, 127584, 2082, 127590, 1058, 127744, 2082, 127777, 1058, 127789, 34, 127798, 1058, + 127799, 34, 127869, 1058, 127870, 34, 127877, 1058, 127878, 1065, 127892, 1058, 127900, 34, 127902, 10, 127904, 34, 127925, 1058, 127927, 1034, 127932, 1058, + 127933, 1034, 127938, 1058, 127941, 1065, 127943, 1058, 127944, 1065, 127946, 1058, 127947, 1065, 127949, 41, 127951, 34, 127956, 1058, 127968, 34, 127985, 1058, + 127988, 34, 127989, 1058, 127992, 34, 127995, 1058, 128000, 1073, 128063, 1058, 128064, 34, 128065, 1058, 128066, 34, 128068, 1065, 128070, 1058, 128081, 1065, + 128102, 1058, 128121, 1065, 128124, 1058, 128125, 1065, 128129, 1058, 128132, 1065, 128133, 1058, 128136, 1065, 128143, 1058, 128144, 1065, 128145, 1058, 128146, 1065, + 128160, 1058, 128161, 1034, 128162, 1058, 128163, 1034, 128164, 1058, 128165, 1034, 128170, 1058, 128171, 1065, 128175, 1058, 128176, 1034, 128177, 1058, 128179, 1034, + 128253, 1058, 128255, 34, 128256, 1058, 128263, 1034, 128279, 1058, 128293, 1034, 128306, 1058, 128318, 1034, 128330, 10, 128331, 34, 128335, 1058, 128336, 34, + 128360, 1058, 128372, 34, 128374, 41, 128378, 34, 128379, 1065, 128400, 34, 128401, 41, 128405, 34, 128407, 1065, 128420, 34, 128421, 1058, 128468, 34, + 128476, 10, 128500, 34, 128506, 10, 128507, 34, 128581, 1058, 128584, 1065, 128587, 1058, 128592, 1065, 128630, 10, 128633, 9, 128636, 30, 128640, 10, + 128675, 1058, 128676, 1065, 128692, 1058, 128695, 1065, 128704, 1058, 128705, 1065, 128710, 1058, 128716, 34, 128717, 1065, 128720, 34, 128723, 1058, 128725, 34, + 128729, 1058, 128732, 2082, 128736, 1058, 128747, 34, 128749, 1058, 128752, 2082, 128756, 34, 128765, 1058, 128768, 2082, 128884, 10, 128887, 34, 128891, 10, + 128896, 34, 128981, 10, 128986, 34, 128992, 2082, 129004, 1058, 129008, 2082, 129009, 1058, 129024, 2082, 129036, 10, 129040, 2058, 129096, 10, 129104, 2058, + 129114, 10, 129120, 2058, 129160, 10, 129168, 2058, 129198, 10, 129200, 2058, 129212, 10, 129216, 2058, 129218, 10, 129232, 2058, 129241, 10, 129280, 2058, + 129292, 10, 129293, 1065, 129295, 1058, 129296, 1065, 129304, 1058, 129312, 1065, 129318, 1058, 129319, 1065, 129328, 1058, 129338, 1065, 129339, 1058, 129340, 34, + 129343, 1065, 129350, 1058, 129351, 34, 129399, 1058, 129400, 1065, 129461, 1058, 129463, 1065, 129464, 1058, 129466, 1065, 129467, 1058, 129468, 1065, 129485, 1058, + 129488, 1065, 129489, 1058, 129502, 1065, 129536, 1058, 129624, 10, 129632, 2082, 129646, 34, 129648, 2082, 129661, 1058, 129664, 2082, 129675, 1058, 129678, 2082, + 129731, 1058, 129734, 1065, 129735, 1058, 129736, 2082, 129737, 1058, 129741, 2082, 129757, 1058, 129759, 2082, 129771, 1058, 129775, 2082, 129776, 1058, 129785, 1065, + 129792, 2082, 130032, 10, 130042, 18, 130048, 10, 131070, 2082, 131072, 10, 196606, 1058, 196608, 10, 262142, 1058, 917505, 10, 917506, 2, 917536, 10, + 917632, 2, 917760, 10, 918000, 2, 1114112, 10, +]; diff --git a/packages/text/rust/shaper/src/generated/script_data.rs b/packages/text/rust/shaper/src/generated/script_data.rs new file mode 100644 index 00000000..711773f4 --- /dev/null +++ b/packages/text/rust/shaper/src/generated/script_data.rs @@ -0,0 +1,360 @@ +// Generated by scripts/generate-unicode-script-data.mjs from +// @unicode/unicode-17.0.0@1.6.17 and unicode-property-value-aliases@3.9.0. +// Do not edit by hand. + +pub const UNICODE_VERSION: &str = "17.0.0"; +pub const COMMON_SCRIPT: u32 = 1517910393; +pub const INHERITED_SCRIPT: u32 = 1516858984; +pub const UNKNOWN_SCRIPT: u32 = 1517976186; + +pub static SCRIPT_END_VALUES: &[u32] = &[ + 65, 1517910393, 91, 1281455214, 97, 1517910393, 123, 1281455214, 170, 1517910393, 171, 1281455214, 186, 1517910393, 187, 1281455214, 192, 1517910393, 215, 1281455214, 216, 1517910393, 247, 1281455214, + 248, 1517910393, 697, 1281455214, 736, 1517910393, 741, 1281455214, 746, 1517910393, 748, 1114599535, 768, 1517910393, 880, 1516858984, 884, 1198679403, 885, 1517910393, 888, 1198679403, 890, 1517976186, + 894, 1198679403, 895, 1517910393, 896, 1198679403, 900, 1517976186, 901, 1198679403, 902, 1517910393, 903, 1198679403, 904, 1517910393, 907, 1198679403, 908, 1517976186, 909, 1198679403, 910, 1517976186, + 930, 1198679403, 931, 1517976186, 994, 1198679403, 1008, 1131376756, 1024, 1198679403, 1157, 1132032620, 1159, 1516858984, 1328, 1132032620, 1329, 1517976186, 1367, 1098018158, 1369, 1517976186, 1419, 1098018158, + 1421, 1517976186, 1424, 1098018158, 1425, 1517976186, 1480, 1214603890, 1488, 1517976186, 1515, 1214603890, 1519, 1517976186, 1525, 1214603890, 1536, 1517976186, 1541, 1098015074, 1542, 1517910393, 1548, 1098015074, + 1549, 1517910393, 1563, 1098015074, 1564, 1517910393, 1567, 1098015074, 1568, 1517910393, 1600, 1098015074, 1601, 1517910393, 1611, 1098015074, 1622, 1516858984, 1648, 1098015074, 1649, 1516858984, 1757, 1098015074, + 1758, 1517910393, 1792, 1098015074, 1806, 1400468067, 1807, 1517976186, 1867, 1400468067, 1869, 1517976186, 1872, 1400468067, 1920, 1098015074, 1970, 1416126817, 1984, 1517976186, 2043, 1315663727, 2045, 1517976186, + 2048, 1315663727, 2094, 1398893938, 2096, 1517976186, 2111, 1398893938, 2112, 1517976186, 2140, 1298230884, 2142, 1517976186, 2143, 1298230884, 2144, 1517976186, 2155, 1400468067, 2160, 1517976186, 2194, 1098015074, + 2199, 1517976186, 2274, 1098015074, 2275, 1517910393, 2304, 1098015074, 2385, 1147500129, 2389, 1516858984, 2404, 1147500129, 2406, 1517910393, 2432, 1147500129, 2436, 1113943655, 2437, 1517976186, 2445, 1113943655, + 2447, 1517976186, 2449, 1113943655, 2451, 1517976186, 2473, 1113943655, 2474, 1517976186, 2481, 1113943655, 2482, 1517976186, 2483, 1113943655, 2486, 1517976186, 2490, 1113943655, 2492, 1517976186, 2501, 1113943655, + 2503, 1517976186, 2505, 1113943655, 2507, 1517976186, 2511, 1113943655, 2519, 1517976186, 2520, 1113943655, 2524, 1517976186, 2526, 1113943655, 2527, 1517976186, 2532, 1113943655, 2534, 1517976186, 2559, 1113943655, + 2561, 1517976186, 2564, 1198879349, 2565, 1517976186, 2571, 1198879349, 2575, 1517976186, 2577, 1198879349, 2579, 1517976186, 2601, 1198879349, 2602, 1517976186, 2609, 1198879349, 2610, 1517976186, 2612, 1198879349, + 2613, 1517976186, 2615, 1198879349, 2616, 1517976186, 2618, 1198879349, 2620, 1517976186, 2621, 1198879349, 2622, 1517976186, 2627, 1198879349, 2631, 1517976186, 2633, 1198879349, 2635, 1517976186, 2638, 1198879349, + 2641, 1517976186, 2642, 1198879349, 2649, 1517976186, 2653, 1198879349, 2654, 1517976186, 2655, 1198879349, 2662, 1517976186, 2679, 1198879349, 2689, 1517976186, 2692, 1198877298, 2693, 1517976186, 2702, 1198877298, + 2703, 1517976186, 2706, 1198877298, 2707, 1517976186, 2729, 1198877298, 2730, 1517976186, 2737, 1198877298, 2738, 1517976186, 2740, 1198877298, 2741, 1517976186, 2746, 1198877298, 2748, 1517976186, 2758, 1198877298, + 2759, 1517976186, 2762, 1198877298, 2763, 1517976186, 2766, 1198877298, 2768, 1517976186, 2769, 1198877298, 2784, 1517976186, 2788, 1198877298, 2790, 1517976186, 2802, 1198877298, 2809, 1517976186, 2816, 1198877298, + 2817, 1517976186, 2820, 1332902241, 2821, 1517976186, 2829, 1332902241, 2831, 1517976186, 2833, 1332902241, 2835, 1517976186, 2857, 1332902241, 2858, 1517976186, 2865, 1332902241, 2866, 1517976186, 2868, 1332902241, + 2869, 1517976186, 2874, 1332902241, 2876, 1517976186, 2885, 1332902241, 2887, 1517976186, 2889, 1332902241, 2891, 1517976186, 2894, 1332902241, 2901, 1517976186, 2904, 1332902241, 2908, 1517976186, 2910, 1332902241, + 2911, 1517976186, 2916, 1332902241, 2918, 1517976186, 2936, 1332902241, 2946, 1517976186, 2948, 1415671148, 2949, 1517976186, 2955, 1415671148, 2958, 1517976186, 2961, 1415671148, 2962, 1517976186, 2966, 1415671148, + 2969, 1517976186, 2971, 1415671148, 2972, 1517976186, 2973, 1415671148, 2974, 1517976186, 2976, 1415671148, 2979, 1517976186, 2981, 1415671148, 2984, 1517976186, 2987, 1415671148, 2990, 1517976186, 3002, 1415671148, + 3006, 1517976186, 3011, 1415671148, 3014, 1517976186, 3017, 1415671148, 3018, 1517976186, 3022, 1415671148, 3024, 1517976186, 3025, 1415671148, 3031, 1517976186, 3032, 1415671148, 3046, 1517976186, 3067, 1415671148, + 3072, 1517976186, 3085, 1415933045, 3086, 1517976186, 3089, 1415933045, 3090, 1517976186, 3113, 1415933045, 3114, 1517976186, 3130, 1415933045, 3132, 1517976186, 3141, 1415933045, 3142, 1517976186, 3145, 1415933045, + 3146, 1517976186, 3150, 1415933045, 3157, 1517976186, 3159, 1415933045, 3160, 1517976186, 3163, 1415933045, 3164, 1517976186, 3166, 1415933045, 3168, 1517976186, 3172, 1415933045, 3174, 1517976186, 3184, 1415933045, + 3191, 1517976186, 3200, 1415933045, 3213, 1265525857, 3214, 1517976186, 3217, 1265525857, 3218, 1517976186, 3241, 1265525857, 3242, 1517976186, 3252, 1265525857, 3253, 1517976186, 3258, 1265525857, 3260, 1517976186, + 3269, 1265525857, 3270, 1517976186, 3273, 1265525857, 3274, 1517976186, 3278, 1265525857, 3285, 1517976186, 3287, 1265525857, 3292, 1517976186, 3295, 1265525857, 3296, 1517976186, 3300, 1265525857, 3302, 1517976186, + 3312, 1265525857, 3313, 1517976186, 3316, 1265525857, 3328, 1517976186, 3341, 1298954605, 3342, 1517976186, 3345, 1298954605, 3346, 1517976186, 3397, 1298954605, 3398, 1517976186, 3401, 1298954605, 3402, 1517976186, + 3408, 1298954605, 3412, 1517976186, 3428, 1298954605, 3430, 1517976186, 3456, 1298954605, 3457, 1517976186, 3460, 1399418472, 3461, 1517976186, 3479, 1399418472, 3482, 1517976186, 3506, 1399418472, 3507, 1517976186, + 3516, 1399418472, 3517, 1517976186, 3518, 1399418472, 3520, 1517976186, 3527, 1399418472, 3530, 1517976186, 3531, 1399418472, 3535, 1517976186, 3541, 1399418472, 3542, 1517976186, 3543, 1399418472, 3544, 1517976186, + 3552, 1399418472, 3558, 1517976186, 3568, 1399418472, 3570, 1517976186, 3573, 1399418472, 3585, 1517976186, 3643, 1416126825, 3647, 1517976186, 3648, 1517910393, 3676, 1416126825, 3713, 1517976186, 3715, 1281453935, + 3716, 1517976186, 3717, 1281453935, 3718, 1517976186, 3723, 1281453935, 3724, 1517976186, 3748, 1281453935, 3749, 1517976186, 3750, 1281453935, 3751, 1517976186, 3774, 1281453935, 3776, 1517976186, 3781, 1281453935, + 3782, 1517976186, 3783, 1281453935, 3784, 1517976186, 3791, 1281453935, 3792, 1517976186, 3802, 1281453935, 3804, 1517976186, 3808, 1281453935, 3840, 1517976186, 3912, 1416192628, 3913, 1517976186, 3949, 1416192628, + 3953, 1517976186, 3992, 1416192628, 3993, 1517976186, 4029, 1416192628, 4030, 1517976186, 4045, 1416192628, 4046, 1517976186, 4053, 1416192628, 4057, 1517910393, 4059, 1416192628, 4096, 1517976186, 4256, 1299803506, + 4294, 1197830002, 4295, 1517976186, 4296, 1197830002, 4301, 1517976186, 4302, 1197830002, 4304, 1517976186, 4347, 1197830002, 4348, 1517910393, 4352, 1197830002, 4608, 1214344807, 4681, 1165256809, 4682, 1517976186, + 4686, 1165256809, 4688, 1517976186, 4695, 1165256809, 4696, 1517976186, 4697, 1165256809, 4698, 1517976186, 4702, 1165256809, 4704, 1517976186, 4745, 1165256809, 4746, 1517976186, 4750, 1165256809, 4752, 1517976186, + 4785, 1165256809, 4786, 1517976186, 4790, 1165256809, 4792, 1517976186, 4799, 1165256809, 4800, 1517976186, 4801, 1165256809, 4802, 1517976186, 4806, 1165256809, 4808, 1517976186, 4823, 1165256809, 4824, 1517976186, + 4881, 1165256809, 4882, 1517976186, 4886, 1165256809, 4888, 1517976186, 4955, 1165256809, 4957, 1517976186, 4989, 1165256809, 4992, 1517976186, 5018, 1165256809, 5024, 1517976186, 5110, 1130915186, 5112, 1517976186, + 5118, 1130915186, 5120, 1517976186, 5760, 1130458739, 5789, 1332175213, 5792, 1517976186, 5867, 1383427698, 5870, 1517910393, 5881, 1383427698, 5888, 1517976186, 5910, 1416064103, 5919, 1517976186, 5920, 1416064103, + 5941, 1214344815, 5943, 1517910393, 5952, 1517976186, 5972, 1114990692, 5984, 1517976186, 5997, 1415669602, 5998, 1517976186, 6001, 1415669602, 6002, 1517976186, 6004, 1415669602, 6016, 1517976186, 6110, 1265134962, + 6112, 1517976186, 6122, 1265134962, 6128, 1517976186, 6138, 1265134962, 6144, 1517976186, 6146, 1299148391, 6148, 1517910393, 6149, 1299148391, 6150, 1517910393, 6170, 1299148391, 6176, 1517976186, 6265, 1299148391, + 6272, 1517976186, 6315, 1299148391, 6320, 1517976186, 6390, 1130458739, 6400, 1517976186, 6431, 1281977698, 6432, 1517976186, 6444, 1281977698, 6448, 1517976186, 6460, 1281977698, 6464, 1517976186, 6465, 1281977698, + 6468, 1517976186, 6480, 1281977698, 6510, 1415670885, 6512, 1517976186, 6517, 1415670885, 6528, 1517976186, 6572, 1415670901, 6576, 1517976186, 6602, 1415670901, 6608, 1517976186, 6619, 1415670901, 6622, 1517976186, + 6624, 1415670901, 6656, 1265134962, 6684, 1114990441, 6686, 1517976186, 6688, 1114990441, 6751, 1281453665, 6752, 1517976186, 6781, 1281453665, 6783, 1517976186, 6794, 1281453665, 6800, 1517976186, 6810, 1281453665, + 6816, 1517976186, 6830, 1281453665, 6832, 1517976186, 6878, 1516858984, 6880, 1517976186, 6892, 1516858984, 6912, 1517976186, 6989, 1113681001, 6990, 1517976186, 7040, 1113681001, 7104, 1400204900, 7156, 1113683051, + 7164, 1517976186, 7168, 1113683051, 7224, 1281716323, 7227, 1517976186, 7242, 1281716323, 7245, 1517976186, 7248, 1281716323, 7296, 1332503403, 7307, 1132032620, 7312, 1517976186, 7355, 1197830002, 7357, 1517976186, + 7360, 1197830002, 7368, 1400204900, 7376, 1517976186, 7379, 1516858984, 7380, 1517910393, 7393, 1516858984, 7394, 1517910393, 7401, 1516858984, 7405, 1517910393, 7406, 1516858984, 7412, 1517910393, 7413, 1516858984, + 7416, 1517910393, 7418, 1516858984, 7419, 1517910393, 7424, 1517976186, 7462, 1281455214, 7467, 1198679403, 7468, 1132032620, 7517, 1281455214, 7522, 1198679403, 7526, 1281455214, 7531, 1198679403, 7544, 1281455214, + 7545, 1132032620, 7615, 1281455214, 7616, 1198679403, 7680, 1516858984, 7936, 1281455214, 7958, 1198679403, 7960, 1517976186, 7966, 1198679403, 7968, 1517976186, 8006, 1198679403, 8008, 1517976186, 8014, 1198679403, + 8016, 1517976186, 8024, 1198679403, 8025, 1517976186, 8026, 1198679403, 8027, 1517976186, 8028, 1198679403, 8029, 1517976186, 8030, 1198679403, 8031, 1517976186, 8062, 1198679403, 8064, 1517976186, 8117, 1198679403, + 8118, 1517976186, 8133, 1198679403, 8134, 1517976186, 8148, 1198679403, 8150, 1517976186, 8156, 1198679403, 8157, 1517976186, 8176, 1198679403, 8178, 1517976186, 8181, 1198679403, 8182, 1517976186, 8191, 1198679403, + 8192, 1517976186, 8204, 1517910393, 8206, 1516858984, 8293, 1517910393, 8294, 1517976186, 8305, 1517910393, 8306, 1281455214, 8308, 1517976186, 8319, 1517910393, 8320, 1281455214, 8335, 1517910393, 8336, 1517976186, + 8349, 1281455214, 8352, 1517976186, 8386, 1517910393, 8400, 1517976186, 8433, 1516858984, 8448, 1517976186, 8486, 1517910393, 8487, 1198679403, 8490, 1517910393, 8492, 1281455214, 8498, 1517910393, 8499, 1281455214, + 8526, 1517910393, 8527, 1281455214, 8544, 1517910393, 8585, 1281455214, 8588, 1517910393, 8592, 1517976186, 9258, 1517910393, 9280, 1517976186, 9291, 1517910393, 9312, 1517976186, 10240, 1517910393, 10496, 1114792297, + 11124, 1517910393, 11126, 1517976186, 11264, 1517910393, 11360, 1198285159, 11392, 1281455214, 11508, 1131376756, 11513, 1517976186, 11520, 1131376756, 11558, 1197830002, 11559, 1517976186, 11560, 1197830002, 11565, 1517976186, + 11566, 1197830002, 11568, 1517976186, 11624, 1415999079, 11631, 1517976186, 11633, 1415999079, 11647, 1517976186, 11648, 1415999079, 11671, 1165256809, 11680, 1517976186, 11687, 1165256809, 11688, 1517976186, 11695, 1165256809, + 11696, 1517976186, 11703, 1165256809, 11704, 1517976186, 11711, 1165256809, 11712, 1517976186, 11719, 1165256809, 11720, 1517976186, 11727, 1165256809, 11728, 1517976186, 11735, 1165256809, 11736, 1517976186, 11743, 1165256809, + 11744, 1517976186, 11776, 1132032620, 11870, 1517910393, 11904, 1517976186, 11930, 1214344809, 11931, 1517976186, 12020, 1214344809, 12032, 1517976186, 12246, 1214344809, 12272, 1517976186, 12293, 1517910393, 12294, 1214344809, + 12295, 1517910393, 12296, 1214344809, 12321, 1517910393, 12330, 1214344809, 12334, 1516858984, 12336, 1214344807, 12344, 1517910393, 12348, 1214344809, 12352, 1517910393, 12353, 1517976186, 12439, 1214870113, 12441, 1517976186, + 12443, 1516858984, 12445, 1517910393, 12448, 1214870113, 12449, 1517910393, 12539, 1264676449, 12541, 1517910393, 12544, 1264676449, 12549, 1517976186, 12592, 1114599535, 12593, 1517976186, 12687, 1214344807, 12688, 1517976186, + 12704, 1517910393, 12736, 1114599535, 12774, 1517910393, 12783, 1517976186, 12784, 1517910393, 12800, 1264676449, 12831, 1214344807, 12832, 1517976186, 12896, 1517910393, 12927, 1214344807, 13008, 1517910393, 13055, 1264676449, + 13056, 1517910393, 13144, 1264676449, 13312, 1517910393, 19904, 1214344809, 19968, 1517910393, 40960, 1214344809, 42125, 1500080489, 42128, 1517976186, 42183, 1500080489, 42192, 1517976186, 42240, 1281979253, 42540, 1449224553, + 42560, 1517976186, 42656, 1132032620, 42744, 1113681269, 42752, 1517976186, 42786, 1517910393, 42888, 1281455214, 42891, 1517910393, 42973, 1281455214, 42993, 1517976186, 43008, 1281455214, 43053, 1400466543, 43056, 1517976186, + 43066, 1517910393, 43072, 1517976186, 43128, 1349017959, 43136, 1517976186, 43206, 1398895986, 43214, 1517976186, 43226, 1398895986, 43232, 1517976186, 43264, 1147500129, 43310, 1264675945, 43311, 1517910393, 43312, 1264675945, + 43348, 1382706791, 43359, 1517976186, 43360, 1382706791, 43389, 1214344807, 43392, 1517976186, 43470, 1247901281, 43471, 1517976186, 43472, 1517910393, 43482, 1247901281, 43486, 1517976186, 43488, 1247901281, 43519, 1299803506, + 43520, 1517976186, 43575, 1130914157, 43584, 1517976186, 43598, 1130914157, 43600, 1517976186, 43610, 1130914157, 43612, 1517976186, 43616, 1130914157, 43648, 1299803506, 43715, 1415673460, 43739, 1517976186, 43744, 1415673460, + 43767, 1299473769, 43777, 1517976186, 43783, 1165256809, 43785, 1517976186, 43791, 1165256809, 43793, 1517976186, 43799, 1165256809, 43808, 1517976186, 43815, 1165256809, 43816, 1517976186, 43823, 1165256809, 43824, 1517976186, + 43867, 1281455214, 43868, 1517910393, 43877, 1281455214, 43878, 1198679403, 43882, 1281455214, 43884, 1517910393, 43888, 1517976186, 43968, 1130915186, 44014, 1299473769, 44016, 1517976186, 44026, 1299473769, 44032, 1517976186, + 55204, 1214344807, 55216, 1517976186, 55239, 1214344807, 55243, 1517976186, 55292, 1214344807, 63744, 1517976186, 64110, 1214344809, 64112, 1517976186, 64218, 1214344809, 64256, 1517976186, 64263, 1281455214, 64275, 1517976186, + 64280, 1098018158, 64285, 1517976186, 64311, 1214603890, 64312, 1517976186, 64317, 1214603890, 64318, 1517976186, 64319, 1214603890, 64320, 1517976186, 64322, 1214603890, 64323, 1517976186, 64325, 1214603890, 64326, 1517976186, + 64336, 1214603890, 64830, 1098015074, 64832, 1517910393, 64976, 1098015074, 65008, 1517976186, 65024, 1098015074, 65040, 1516858984, 65050, 1517910393, 65056, 1517976186, 65070, 1516858984, 65072, 1132032620, 65107, 1517910393, + 65108, 1517976186, 65127, 1517910393, 65128, 1517976186, 65132, 1517910393, 65136, 1517976186, 65141, 1098015074, 65142, 1517976186, 65277, 1098015074, 65279, 1517976186, 65280, 1517910393, 65281, 1517976186, 65313, 1517910393, + 65339, 1281455214, 65345, 1517910393, 65371, 1281455214, 65382, 1517910393, 65392, 1264676449, 65393, 1517910393, 65438, 1264676449, 65440, 1517910393, 65471, 1214344807, 65474, 1517976186, 65480, 1214344807, 65482, 1517976186, + 65488, 1214344807, 65490, 1517976186, 65496, 1214344807, 65498, 1517976186, 65501, 1214344807, 65504, 1517976186, 65511, 1517910393, 65512, 1517976186, 65519, 1517910393, 65529, 1517976186, 65534, 1517910393, 65536, 1517976186, + 65548, 1281977954, 65549, 1517976186, 65575, 1281977954, 65576, 1517976186, 65595, 1281977954, 65596, 1517976186, 65598, 1281977954, 65599, 1517976186, 65614, 1281977954, 65616, 1517976186, 65630, 1281977954, 65664, 1517976186, + 65787, 1281977954, 65792, 1517976186, 65795, 1517910393, 65799, 1517976186, 65844, 1517910393, 65847, 1517976186, 65856, 1517910393, 65935, 1198679403, 65936, 1517976186, 65949, 1517910393, 65952, 1517976186, 65953, 1198679403, + 66000, 1517976186, 66045, 1517910393, 66046, 1516858984, 66176, 1517976186, 66205, 1283023721, 66208, 1517976186, 66257, 1130459753, 66272, 1517976186, 66273, 1516858984, 66300, 1517910393, 66304, 1517976186, 66340, 1232363884, + 66349, 1517976186, 66352, 1232363884, 66379, 1198486632, 66384, 1517976186, 66427, 1348825709, 66432, 1517976186, 66462, 1432838514, 66463, 1517976186, 66464, 1432838514, 66500, 1483761007, 66504, 1517976186, 66518, 1483761007, + 66560, 1517976186, 66640, 1148416628, 66688, 1399349623, 66718, 1332964705, 66720, 1517976186, 66730, 1332964705, 66736, 1517976186, 66772, 1332963173, 66776, 1517976186, 66812, 1332963173, 66816, 1517976186, 66856, 1164730977, + 66864, 1517976186, 66916, 1097295970, 66927, 1517976186, 66928, 1097295970, 66939, 1449751656, 66940, 1517976186, 66955, 1449751656, 66956, 1517976186, 66963, 1449751656, 66964, 1517976186, 66966, 1449751656, 66967, 1517976186, + 66978, 1449751656, 66979, 1517976186, 66994, 1449751656, 66995, 1517976186, 67002, 1449751656, 67003, 1517976186, 67005, 1449751656, 67008, 1517976186, 67060, 1416586354, 67072, 1517976186, 67383, 1281977953, 67392, 1517976186, + 67414, 1281977953, 67424, 1517976186, 67432, 1281977953, 67456, 1517976186, 67462, 1281455214, 67463, 1517976186, 67505, 1281455214, 67506, 1517976186, 67515, 1281455214, 67584, 1517976186, 67590, 1131442804, 67592, 1517976186, + 67593, 1131442804, 67594, 1517976186, 67638, 1131442804, 67639, 1517976186, 67641, 1131442804, 67644, 1517976186, 67645, 1131442804, 67647, 1517976186, 67648, 1131442804, 67670, 1098018153, 67671, 1517976186, 67680, 1098018153, + 67712, 1348562029, 67743, 1315070324, 67751, 1517976186, 67760, 1315070324, 67808, 1517976186, 67827, 1214346354, 67828, 1517976186, 67830, 1214346354, 67835, 1517976186, 67840, 1214346354, 67868, 1349021304, 67871, 1517976186, + 67872, 1349021304, 67898, 1283023977, 67903, 1517976186, 67904, 1283023977, 67930, 1399415924, 67968, 1517976186, 68000, 1298494063, 68024, 1298494051, 68028, 1517976186, 68048, 1298494051, 68050, 1517976186, 68096, 1298494051, + 68100, 1265131890, 68101, 1517976186, 68103, 1265131890, 68108, 1517976186, 68116, 1265131890, 68117, 1517976186, 68120, 1265131890, 68121, 1517976186, 68150, 1265131890, 68152, 1517976186, 68155, 1265131890, 68159, 1517976186, + 68169, 1265131890, 68176, 1517976186, 68185, 1265131890, 68192, 1517976186, 68224, 1398895202, 68256, 1315009122, 68288, 1517976186, 68327, 1298230889, 68331, 1517976186, 68343, 1298230889, 68352, 1517976186, 68406, 1098281844, + 68409, 1517976186, 68416, 1098281844, 68438, 1349678185, 68440, 1517976186, 68448, 1349678185, 68467, 1349020777, 68472, 1517976186, 68480, 1349020777, 68498, 1349020784, 68505, 1517976186, 68509, 1349020784, 68521, 1517976186, + 68528, 1349020784, 68608, 1517976186, 68681, 1332898664, 68736, 1517976186, 68787, 1215655527, 68800, 1517976186, 68851, 1215655527, 68858, 1517976186, 68864, 1215655527, 68904, 1383032935, 68912, 1517976186, 68922, 1383032935, + 68928, 1517976186, 68966, 1197568609, 68969, 1517976186, 68998, 1197568609, 69006, 1517976186, 69008, 1197568609, 69216, 1517976186, 69247, 1098015074, 69248, 1517976186, 69290, 1499822697, 69291, 1517976186, 69294, 1499822697, + 69296, 1517976186, 69298, 1499822697, 69314, 1517976186, 69320, 1098015074, 69328, 1517976186, 69337, 1098015074, 69370, 1517976186, 69376, 1098015074, 69416, 1399809903, 69424, 1517976186, 69466, 1399809892, 69488, 1517976186, + 69514, 1333094258, 69552, 1517976186, 69580, 1130918515, 69600, 1517976186, 69623, 1164736877, 69632, 1517976186, 69710, 1114792296, 69714, 1517976186, 69750, 1114792296, 69759, 1517976186, 69760, 1114792296, 69827, 1265920105, + 69837, 1517976186, 69838, 1265920105, 69840, 1517976186, 69865, 1399812705, 69872, 1517976186, 69882, 1399812705, 69888, 1517976186, 69941, 1130457965, 69942, 1517976186, 69960, 1130457965, 69968, 1517976186, 70007, 1298229354, + 70016, 1517976186, 70112, 1399353956, 70113, 1517976186, 70133, 1399418472, 70144, 1517976186, 70162, 1265135466, 70163, 1517976186, 70210, 1265135466, 70272, 1517976186, 70279, 1299541108, 70280, 1517976186, 70281, 1299541108, + 70282, 1517976186, 70286, 1299541108, 70287, 1517976186, 70302, 1299541108, 70303, 1517976186, 70314, 1299541108, 70320, 1517976186, 70379, 1399418468, 70384, 1517976186, 70394, 1399418468, 70400, 1517976186, 70404, 1198678382, + 70405, 1517976186, 70413, 1198678382, 70415, 1517976186, 70417, 1198678382, 70419, 1517976186, 70441, 1198678382, 70442, 1517976186, 70449, 1198678382, 70450, 1517976186, 70452, 1198678382, 70453, 1517976186, 70458, 1198678382, + 70459, 1517976186, 70460, 1516858984, 70469, 1198678382, 70471, 1517976186, 70473, 1198678382, 70475, 1517976186, 70478, 1198678382, 70480, 1517976186, 70481, 1198678382, 70487, 1517976186, 70488, 1198678382, 70493, 1517976186, + 70500, 1198678382, 70502, 1517976186, 70509, 1198678382, 70512, 1517976186, 70517, 1198678382, 70528, 1517976186, 70538, 1416983655, 70539, 1517976186, 70540, 1416983655, 70542, 1517976186, 70543, 1416983655, 70544, 1517976186, + 70582, 1416983655, 70583, 1517976186, 70593, 1416983655, 70594, 1517976186, 70595, 1416983655, 70597, 1517976186, 70598, 1416983655, 70599, 1517976186, 70603, 1416983655, 70604, 1517976186, 70614, 1416983655, 70615, 1517976186, + 70617, 1416983655, 70625, 1517976186, 70627, 1416983655, 70656, 1517976186, 70748, 1315272545, 70749, 1517976186, 70754, 1315272545, 70784, 1517976186, 70856, 1416196712, 70864, 1517976186, 70874, 1416196712, 71040, 1517976186, + 71094, 1399415908, 71096, 1517976186, 71134, 1399415908, 71168, 1517976186, 71237, 1299145833, 71248, 1517976186, 71258, 1299145833, 71264, 1517976186, 71277, 1299148391, 71296, 1517976186, 71354, 1415670642, 71360, 1517976186, + 71370, 1415670642, 71376, 1517976186, 71396, 1299803506, 71424, 1517976186, 71451, 1097363309, 71453, 1517976186, 71468, 1097363309, 71472, 1517976186, 71495, 1097363309, 71680, 1517976186, 71740, 1148151666, 71840, 1517976186, + 71923, 1466004065, 71935, 1517976186, 71936, 1466004065, 71943, 1147756907, 71945, 1517976186, 71946, 1147756907, 71948, 1517976186, 71956, 1147756907, 71957, 1517976186, 71959, 1147756907, 71960, 1517976186, 71990, 1147756907, + 71991, 1517976186, 71993, 1147756907, 71995, 1517976186, 72007, 1147756907, 72016, 1517976186, 72026, 1147756907, 72096, 1517976186, 72104, 1315008100, 72106, 1517976186, 72152, 1315008100, 72154, 1517976186, 72165, 1315008100, + 72192, 1517976186, 72264, 1516334690, 72272, 1517976186, 72355, 1399814511, 72368, 1517976186, 72384, 1130458739, 72441, 1348564323, 72448, 1517976186, 72458, 1147500129, 72544, 1517976186, 72552, 1399353956, 72640, 1517976186, + 72674, 1400204917, 72688, 1517976186, 72698, 1400204917, 72704, 1517976186, 72713, 1114139507, 72714, 1517976186, 72759, 1114139507, 72760, 1517976186, 72774, 1114139507, 72784, 1517976186, 72813, 1114139507, 72816, 1517976186, + 72848, 1298231907, 72850, 1517976186, 72872, 1298231907, 72873, 1517976186, 72887, 1298231907, 72960, 1517976186, 72967, 1198485101, 72968, 1517976186, 72970, 1198485101, 72971, 1517976186, 73015, 1198485101, 73018, 1517976186, + 73019, 1198485101, 73020, 1517976186, 73022, 1198485101, 73023, 1517976186, 73032, 1198485101, 73040, 1517976186, 73050, 1198485101, 73056, 1517976186, 73062, 1198485095, 73063, 1517976186, 73065, 1198485095, 73066, 1517976186, + 73103, 1198485095, 73104, 1517976186, 73106, 1198485095, 73107, 1517976186, 73113, 1198485095, 73120, 1517976186, 73130, 1198485095, 73136, 1517976186, 73180, 1416588403, 73184, 1517976186, 73194, 1416588403, 73440, 1517976186, + 73465, 1298230113, 73472, 1517976186, 73489, 1264678761, 73490, 1517976186, 73531, 1264678761, 73534, 1517976186, 73563, 1264678761, 73648, 1517976186, 73649, 1281979253, 73664, 1517976186, 73714, 1415671148, 73727, 1517976186, + 73728, 1415671148, 74650, 1483961720, 74752, 1517976186, 74863, 1483961720, 74864, 1517976186, 74869, 1483961720, 74880, 1517976186, 75076, 1483961720, 77712, 1517976186, 77811, 1131441518, 77824, 1517976186, 78934, 1164409200, + 78944, 1517976186, 82939, 1164409200, 82944, 1517976186, 83527, 1215067511, 90368, 1517976186, 90426, 1198877544, 92160, 1517976186, 92729, 1113681269, 92736, 1517976186, 92767, 1299345263, 92768, 1517976186, 92778, 1299345263, + 92782, 1517976186, 92784, 1299345263, 92863, 1416524641, 92864, 1517976186, 92874, 1416524641, 92880, 1517976186, 92910, 1113682803, 92912, 1517976186, 92918, 1113682803, 92928, 1517976186, 92998, 1215131239, 93008, 1517976186, + 93018, 1215131239, 93019, 1517976186, 93026, 1215131239, 93027, 1517976186, 93048, 1215131239, 93053, 1517976186, 93072, 1215131239, 93504, 1517976186, 93562, 1265787241, 93760, 1517976186, 93851, 1298490470, 93856, 1517976186, + 93881, 1113944678, 93883, 1517976186, 93908, 1113944678, 93952, 1517976186, 94027, 1349284452, 94031, 1517976186, 94088, 1349284452, 94095, 1517976186, 94112, 1349284452, 94176, 1517976186, 94177, 1415671399, 94178, 1316186229, + 94180, 1214344809, 94181, 1265202291, 94192, 1517976186, 94199, 1214344809, 94208, 1517976186, 101120, 1415671399, 101590, 1265202291, 101631, 1517976186, 101632, 1265202291, 101663, 1415671399, 101760, 1517976186, 101875, 1415671399, + 110576, 1517976186, 110580, 1264676449, 110581, 1517976186, 110588, 1264676449, 110589, 1517976186, 110591, 1264676449, 110592, 1517976186, 110593, 1264676449, 110880, 1214870113, 110883, 1264676449, 110898, 1517976186, 110899, 1214870113, + 110928, 1517976186, 110931, 1214870113, 110933, 1517976186, 110934, 1264676449, 110948, 1517976186, 110952, 1264676449, 110960, 1517976186, 111356, 1316186229, 113664, 1517976186, 113771, 1148547180, 113776, 1517976186, 113789, 1148547180, + 113792, 1517976186, 113801, 1148547180, 113808, 1517976186, 113818, 1148547180, 113820, 1517976186, 113824, 1148547180, 113828, 1517910393, 117760, 1517976186, 118013, 1517910393, 118016, 1517976186, 118452, 1517910393, 118458, 1517976186, + 118481, 1517910393, 118496, 1517976186, 118513, 1517910393, 118528, 1517976186, 118574, 1516858984, 118576, 1517976186, 118599, 1516858984, 118608, 1517976186, 118724, 1517910393, 118784, 1517976186, 119030, 1517910393, 119040, 1517976186, + 119079, 1517910393, 119081, 1517976186, 119143, 1517910393, 119146, 1516858984, 119163, 1517910393, 119171, 1516858984, 119173, 1517910393, 119180, 1516858984, 119210, 1517910393, 119214, 1516858984, 119275, 1517910393, 119296, 1517976186, + 119366, 1198679403, 119488, 1517976186, 119508, 1517910393, 119520, 1517976186, 119540, 1517910393, 119552, 1517976186, 119639, 1517910393, 119648, 1517976186, 119673, 1517910393, 119808, 1517976186, 119893, 1517910393, 119894, 1517976186, + 119965, 1517910393, 119966, 1517976186, 119968, 1517910393, 119970, 1517976186, 119971, 1517910393, 119973, 1517976186, 119975, 1517910393, 119977, 1517976186, 119981, 1517910393, 119982, 1517976186, 119994, 1517910393, 119995, 1517976186, + 119996, 1517910393, 119997, 1517976186, 120004, 1517910393, 120005, 1517976186, 120070, 1517910393, 120071, 1517976186, 120075, 1517910393, 120077, 1517976186, 120085, 1517910393, 120086, 1517976186, 120093, 1517910393, 120094, 1517976186, + 120122, 1517910393, 120123, 1517976186, 120127, 1517910393, 120128, 1517976186, 120133, 1517910393, 120134, 1517976186, 120135, 1517910393, 120138, 1517976186, 120145, 1517910393, 120146, 1517976186, 120486, 1517910393, 120488, 1517976186, + 120780, 1517910393, 120782, 1517976186, 120832, 1517910393, 121484, 1399287415, 121499, 1517976186, 121504, 1399287415, 121505, 1517976186, 121520, 1399287415, 122624, 1517976186, 122655, 1281455214, 122661, 1517976186, 122667, 1281455214, + 122880, 1517976186, 122887, 1198285159, 122888, 1517976186, 122905, 1198285159, 122907, 1517976186, 122914, 1198285159, 122915, 1517976186, 122917, 1198285159, 122918, 1517976186, 122923, 1198285159, 122928, 1517976186, 122990, 1132032620, + 123023, 1517976186, 123024, 1132032620, 123136, 1517976186, 123181, 1215131248, 123184, 1517976186, 123198, 1215131248, 123200, 1517976186, 123210, 1215131248, 123214, 1517976186, 123216, 1215131248, 123536, 1517976186, 123567, 1416590447, + 123584, 1517976186, 123642, 1466132591, 123647, 1517976186, 123648, 1466132591, 124112, 1517976186, 124154, 1315006317, 124368, 1517976186, 124411, 1332633967, 124415, 1517976186, 124416, 1332633967, 124608, 1517976186, 124639, 1415674223, + 124640, 1517976186, 124662, 1415674223, 124670, 1517976186, 124672, 1415674223, 124896, 1517976186, 124903, 1165256809, 124904, 1517976186, 124908, 1165256809, 124909, 1517976186, 124911, 1165256809, 124912, 1517976186, 124927, 1165256809, + 124928, 1517976186, 125125, 1298493028, 125127, 1517976186, 125143, 1298493028, 125184, 1517976186, 125260, 1097100397, 125264, 1517976186, 125274, 1097100397, 125278, 1517976186, 125280, 1097100397, 126065, 1517976186, 126133, 1517910393, + 126209, 1517976186, 126270, 1517910393, 126464, 1517976186, 126468, 1098015074, 126469, 1517976186, 126496, 1098015074, 126497, 1517976186, 126499, 1098015074, 126500, 1517976186, 126501, 1098015074, 126503, 1517976186, 126504, 1098015074, + 126505, 1517976186, 126515, 1098015074, 126516, 1517976186, 126520, 1098015074, 126521, 1517976186, 126522, 1098015074, 126523, 1517976186, 126524, 1098015074, 126530, 1517976186, 126531, 1098015074, 126535, 1517976186, 126536, 1098015074, + 126537, 1517976186, 126538, 1098015074, 126539, 1517976186, 126540, 1098015074, 126541, 1517976186, 126544, 1098015074, 126545, 1517976186, 126547, 1098015074, 126548, 1517976186, 126549, 1098015074, 126551, 1517976186, 126552, 1098015074, + 126553, 1517976186, 126554, 1098015074, 126555, 1517976186, 126556, 1098015074, 126557, 1517976186, 126558, 1098015074, 126559, 1517976186, 126560, 1098015074, 126561, 1517976186, 126563, 1098015074, 126564, 1517976186, 126565, 1098015074, + 126567, 1517976186, 126571, 1098015074, 126572, 1517976186, 126579, 1098015074, 126580, 1517976186, 126584, 1098015074, 126585, 1517976186, 126589, 1098015074, 126590, 1517976186, 126591, 1098015074, 126592, 1517976186, 126602, 1098015074, + 126603, 1517976186, 126620, 1098015074, 126625, 1517976186, 126628, 1098015074, 126629, 1517976186, 126634, 1098015074, 126635, 1517976186, 126652, 1098015074, 126704, 1517976186, 126706, 1098015074, 126976, 1517976186, 127020, 1517910393, + 127024, 1517976186, 127124, 1517910393, 127136, 1517976186, 127151, 1517910393, 127153, 1517976186, 127168, 1517910393, 127169, 1517976186, 127184, 1517910393, 127185, 1517976186, 127222, 1517910393, 127232, 1517976186, 127406, 1517910393, + 127462, 1517976186, 127488, 1517910393, 127489, 1214870113, 127491, 1517910393, 127504, 1517976186, 127548, 1517910393, 127552, 1517976186, 127561, 1517910393, 127568, 1517976186, 127570, 1517910393, 127584, 1517976186, 127590, 1517910393, + 127744, 1517976186, 128729, 1517910393, 128732, 1517976186, 128749, 1517910393, 128752, 1517976186, 128765, 1517910393, 128768, 1517976186, 128986, 1517910393, 128992, 1517976186, 129004, 1517910393, 129008, 1517976186, 129009, 1517910393, + 129024, 1517976186, 129036, 1517910393, 129040, 1517976186, 129096, 1517910393, 129104, 1517976186, 129114, 1517910393, 129120, 1517976186, 129160, 1517910393, 129168, 1517976186, 129198, 1517910393, 129200, 1517976186, 129212, 1517910393, + 129216, 1517976186, 129218, 1517910393, 129232, 1517976186, 129241, 1517910393, 129280, 1517976186, 129624, 1517910393, 129632, 1517976186, 129646, 1517910393, 129648, 1517976186, 129661, 1517910393, 129664, 1517976186, 129675, 1517910393, + 129678, 1517976186, 129735, 1517910393, 129736, 1517976186, 129737, 1517910393, 129741, 1517976186, 129757, 1517910393, 129759, 1517976186, 129771, 1517910393, 129775, 1517976186, 129785, 1517910393, 129792, 1517976186, 129939, 1517910393, + 129940, 1517976186, 130043, 1517910393, 131072, 1517976186, 173792, 1214344809, 173824, 1517976186, 178206, 1214344809, 178208, 1517976186, 183982, 1214344809, 183984, 1517976186, 191457, 1214344809, 191472, 1517976186, 192094, 1214344809, + 194560, 1517976186, 195102, 1214344809, 196608, 1517976186, 201547, 1214344809, 201552, 1517976186, 210042, 1214344809, 917505, 1517976186, 917506, 1517910393, 917536, 1517976186, 917632, 1517910393, 917760, 1517976186, 918000, 1516858984, + 1114112, 1517976186 +]; +pub static SCRIPT_EXTENSION_END_VALUES: &[u32] = &[ + 65, 0, 91, 1, 97, 0, 123, 1, 170, 0, 171, 1, 183, 0, 184, 2, 186, 0, 187, 1, 192, 0, 215, 1, + 216, 0, 247, 1, 248, 0, 697, 1, 700, 0, 701, 3, 711, 0, 712, 4, 713, 0, 716, 4, 717, 0, 718, 5, + 727, 0, 728, 6, 729, 0, 730, 4, 736, 0, 741, 1, 746, 0, 748, 7, 768, 0, 769, 8, 770, 9, 771, 10, + 772, 11, 773, 12, 774, 13, 775, 14, 776, 15, 777, 16, 778, 17, 779, 18, 780, 19, 781, 20, 782, 21, 783, 22, + 784, 23, 785, 21, 786, 24, 787, 23, 788, 25, 803, 23, 804, 26, 805, 27, 806, 28, 813, 23, 814, 29, 815, 28, + 816, 23, 817, 30, 818, 31, 834, 23, 835, 32, 837, 23, 838, 32, 856, 23, 857, 33, 862, 23, 863, 34, 867, 23, + 880, 1, 884, 32, 886, 35, 888, 32, 890, 36, 894, 32, 895, 0, 896, 32, 900, 36, 901, 32, 902, 0, 903, 32, + 904, 0, 907, 32, 908, 36, 909, 32, 910, 36, 930, 32, 931, 36, 994, 32, 1008, 37, 1024, 32, 1155, 38, 1156, 39, + 1157, 40, 1159, 41, 1160, 40, 1328, 38, 1329, 36, 1367, 42, 1369, 36, 1417, 42, 1418, 43, 1419, 42, 1421, 36, 1424, 42, + 1425, 36, 1480, 44, 1488, 36, 1515, 44, 1519, 36, 1525, 44, 1536, 36, 1541, 45, 1542, 0, 1548, 45, 1549, 46, 1563, 45, + 1564, 46, 1565, 47, 1567, 45, 1568, 48, 1600, 45, 1601, 49, 1611, 45, 1622, 50, 1632, 45, 1642, 51, 1648, 45, 1649, 50, + 1748, 45, 1749, 52, 1757, 45, 1758, 0, 1792, 45, 1806, 53, 1807, 36, 1867, 53, 1869, 36, 1872, 53, 1920, 45, 1970, 54, + 1984, 36, 2043, 55, 2045, 36, 2048, 55, 2094, 56, 2096, 36, 2111, 56, 2112, 36, 2140, 57, 2142, 36, 2143, 57, 2144, 36, + 2155, 53, 2160, 36, 2194, 45, 2199, 36, 2274, 45, 2275, 0, 2304, 45, 2385, 58, 2386, 59, 2387, 60, 2389, 23, 2404, 58, + 2405, 61, 2406, 62, 2416, 63, 2432, 58, 2436, 64, 2437, 36, 2445, 64, 2447, 36, 2449, 64, 2451, 36, 2473, 64, 2474, 36, + 2481, 64, 2482, 36, 2483, 64, 2486, 36, 2490, 64, 2492, 36, 2501, 64, 2503, 36, 2505, 64, 2507, 36, 2511, 64, 2519, 36, + 2520, 64, 2524, 36, 2526, 64, 2527, 36, 2532, 64, 2534, 36, 2544, 65, 2559, 64, 2561, 36, 2564, 66, 2565, 36, 2571, 66, + 2575, 36, 2577, 66, 2579, 36, 2601, 66, 2602, 36, 2609, 66, 2610, 36, 2612, 66, 2613, 36, 2615, 66, 2616, 36, 2618, 66, + 2620, 36, 2621, 66, 2622, 36, 2627, 66, 2631, 36, 2633, 66, 2635, 36, 2638, 66, 2641, 36, 2642, 66, 2649, 36, 2653, 66, + 2654, 36, 2655, 66, 2662, 36, 2672, 67, 2679, 66, 2689, 36, 2692, 68, 2693, 36, 2702, 68, 2703, 36, 2706, 68, 2707, 36, + 2729, 68, 2730, 36, 2737, 68, 2738, 36, 2740, 68, 2741, 36, 2746, 68, 2748, 36, 2758, 68, 2759, 36, 2762, 68, 2763, 36, + 2766, 68, 2768, 36, 2769, 68, 2784, 36, 2788, 68, 2790, 36, 2800, 69, 2802, 68, 2809, 36, 2816, 68, 2817, 36, 2820, 70, + 2821, 36, 2829, 70, 2831, 36, 2833, 70, 2835, 36, 2857, 70, 2858, 36, 2865, 70, 2866, 36, 2868, 70, 2869, 36, 2874, 70, + 2876, 36, 2885, 70, 2887, 36, 2889, 70, 2891, 36, 2894, 70, 2901, 36, 2904, 70, 2908, 36, 2910, 70, 2911, 36, 2916, 70, + 2918, 36, 2936, 70, 2946, 36, 2948, 71, 2949, 36, 2955, 71, 2958, 36, 2961, 71, 2962, 36, 2966, 71, 2969, 36, 2971, 71, + 2972, 36, 2973, 71, 2974, 36, 2976, 71, 2979, 36, 2981, 71, 2984, 36, 2987, 71, 2990, 36, 3002, 71, 3006, 36, 3011, 71, + 3014, 36, 3017, 71, 3018, 36, 3022, 71, 3024, 36, 3025, 71, 3031, 36, 3032, 71, 3046, 36, 3060, 72, 3067, 71, 3072, 36, + 3085, 73, 3086, 36, 3089, 73, 3090, 36, 3113, 73, 3114, 36, 3130, 73, 3132, 36, 3141, 73, 3142, 36, 3145, 73, 3146, 36, + 3150, 73, 3157, 36, 3159, 73, 3160, 36, 3163, 73, 3164, 36, 3166, 73, 3168, 36, 3172, 73, 3174, 36, 3184, 73, 3191, 36, + 3200, 73, 3213, 74, 3214, 36, 3217, 74, 3218, 36, 3241, 74, 3242, 36, 3252, 74, 3253, 36, 3258, 74, 3260, 36, 3269, 74, + 3270, 36, 3273, 74, 3274, 36, 3278, 74, 3285, 36, 3287, 74, 3292, 36, 3295, 74, 3296, 36, 3300, 74, 3302, 36, 3312, 75, + 3313, 36, 3316, 74, 3328, 36, 3341, 76, 3342, 36, 3345, 76, 3346, 36, 3397, 76, 3398, 36, 3401, 76, 3402, 36, 3408, 76, + 3412, 36, 3428, 76, 3430, 36, 3456, 76, 3457, 36, 3460, 77, 3461, 36, 3479, 77, 3482, 36, 3506, 77, 3507, 36, 3516, 77, + 3517, 36, 3518, 77, 3520, 36, 3527, 77, 3530, 36, 3531, 77, 3535, 36, 3541, 77, 3542, 36, 3543, 77, 3544, 36, 3552, 77, + 3558, 36, 3568, 77, 3570, 36, 3573, 77, 3585, 36, 3643, 78, 3647, 36, 3648, 0, 3676, 78, 3713, 36, 3715, 79, 3716, 36, + 3717, 79, 3718, 36, 3723, 79, 3724, 36, 3748, 79, 3749, 36, 3750, 79, 3751, 36, 3774, 79, 3776, 36, 3781, 79, 3782, 36, + 3783, 79, 3784, 36, 3791, 79, 3792, 36, 3802, 79, 3804, 36, 3808, 79, 3840, 36, 3912, 80, 3913, 36, 3949, 80, 3953, 36, + 3992, 80, 3993, 36, 4029, 80, 4030, 36, 4045, 80, 4046, 36, 4053, 80, 4057, 0, 4059, 80, 4096, 36, 4160, 81, 4170, 82, + 4256, 81, 4294, 83, 4295, 36, 4296, 83, 4301, 36, 4302, 83, 4304, 36, 4347, 83, 4348, 84, 4352, 83, 4608, 85, 4681, 86, + 4682, 36, 4686, 86, 4688, 36, 4695, 86, 4696, 36, 4697, 86, 4698, 36, 4702, 86, 4704, 36, 4745, 86, 4746, 36, 4750, 86, + 4752, 36, 4785, 86, 4786, 36, 4790, 86, 4792, 36, 4799, 86, 4800, 36, 4801, 86, 4802, 36, 4806, 86, 4808, 36, 4823, 86, + 4824, 36, 4881, 86, 4882, 36, 4886, 86, 4888, 36, 4955, 86, 4957, 36, 4989, 86, 4992, 36, 5018, 86, 5024, 36, 5110, 87, + 5112, 36, 5118, 87, 5120, 36, 5760, 88, 5789, 89, 5792, 36, 5881, 90, 5888, 36, 5910, 91, 5919, 36, 5920, 91, 5941, 92, + 5943, 93, 5952, 36, 5972, 94, 5984, 36, 5997, 95, 5998, 36, 6001, 95, 6002, 36, 6004, 95, 6016, 36, 6110, 96, 6112, 36, + 6122, 96, 6128, 36, 6138, 96, 6144, 36, 6146, 97, 6148, 98, 6149, 97, 6150, 98, 6170, 97, 6176, 36, 6265, 97, 6272, 36, + 6315, 97, 6320, 36, 6390, 88, 6400, 36, 6431, 99, 6432, 36, 6444, 99, 6448, 36, 6460, 99, 6464, 36, 6465, 99, 6468, 36, + 6480, 99, 6510, 100, 6512, 36, 6517, 100, 6528, 36, 6572, 101, 6576, 36, 6602, 101, 6608, 36, 6619, 101, 6622, 36, 6624, 101, + 6656, 96, 6684, 102, 6686, 36, 6688, 102, 6751, 103, 6752, 36, 6781, 103, 6783, 36, 6794, 103, 6800, 36, 6810, 103, 6816, 36, + 6830, 103, 6832, 36, 6878, 23, 6880, 36, 6892, 23, 6912, 36, 6989, 104, 6990, 36, 7040, 104, 7104, 105, 7156, 106, 7164, 36, + 7168, 106, 7224, 107, 7227, 36, 7242, 107, 7245, 36, 7248, 107, 7296, 108, 7307, 38, 7312, 36, 7355, 83, 7357, 36, 7360, 83, + 7368, 105, 7376, 36, 7377, 109, 7378, 58, 7379, 109, 7380, 110, 7381, 58, 7382, 111, 7383, 112, 7384, 113, 7385, 114, 7386, 115, + 7387, 116, 7388, 58, 7390, 115, 7392, 58, 7393, 115, 7394, 117, 7395, 118, 7401, 58, 7402, 119, 7403, 120, 7404, 121, 7405, 58, + 7406, 122, 7410, 58, 7411, 123, 7412, 124, 7413, 125, 7415, 117, 7416, 64, 7418, 124, 7419, 126, 7424, 36, 7462, 1, 7467, 32, + 7468, 38, 7517, 1, 7522, 32, 7526, 1, 7531, 32, 7544, 1, 7545, 38, 7615, 1, 7618, 32, 7672, 23, 7673, 127, 7674, 23, + 7675, 53, 7680, 23, 7936, 1, 7958, 32, 7960, 36, 7966, 32, 7968, 36, 8006, 32, 8008, 36, 8014, 32, 8016, 36, 8024, 32, + 8025, 36, 8026, 32, 8027, 36, 8028, 32, 8029, 36, 8030, 32, 8031, 36, 8062, 32, 8064, 36, 8117, 32, 8118, 36, 8133, 32, + 8134, 36, 8148, 32, 8150, 36, 8156, 32, 8157, 36, 8176, 32, 8178, 36, 8181, 32, 8182, 36, 8191, 32, 8192, 36, 8204, 0, + 8206, 23, 8239, 0, 8240, 128, 8271, 0, 8272, 129, 8282, 0, 8283, 130, 8285, 0, 8286, 131, 8293, 0, 8294, 36, 8305, 0, + 8306, 1, 8308, 36, 8319, 0, 8320, 1, 8335, 0, 8336, 36, 8349, 1, 8352, 36, 8386, 0, 8400, 36, 8432, 23, 8433, 132, + 8448, 36, 8486, 0, 8487, 32, 8490, 0, 8492, 1, 8498, 0, 8499, 1, 8526, 0, 8527, 1, 8544, 0, 8585, 1, 8588, 0, + 8592, 36, 9258, 0, 9280, 36, 9291, 0, 9312, 36, 10240, 0, 10496, 133, 11124, 0, 11126, 36, 11264, 0, 11360, 134, 11392, 1, + 11508, 37, 11513, 36, 11520, 37, 11558, 83, 11559, 36, 11560, 83, 11565, 36, 11566, 83, 11568, 36, 11624, 135, 11631, 36, 11633, 135, + 11647, 36, 11648, 135, 11671, 86, 11680, 36, 11687, 86, 11688, 36, 11695, 86, 11696, 36, 11703, 86, 11704, 36, 11711, 86, 11712, 36, + 11719, 86, 11720, 36, 11727, 86, 11728, 36, 11735, 86, 11736, 36, 11743, 86, 11744, 36, 11776, 38, 11799, 0, 11800, 136, 11824, 0, + 11825, 137, 11826, 138, 11836, 0, 11837, 139, 11841, 0, 11842, 140, 11843, 0, 11844, 40, 11870, 0, 11904, 36, 11930, 141, 11931, 36, + 12020, 141, 12032, 36, 12246, 141, 12272, 36, 12288, 142, 12289, 0, 12290, 143, 12291, 144, 12292, 145, 12293, 0, 12296, 141, 12298, 146, + 12300, 147, 12306, 148, 12307, 0, 12308, 145, 12316, 148, 12320, 145, 12321, 0, 12330, 141, 12334, 149, 12336, 85, 12337, 145, 12342, 150, + 12343, 0, 12344, 145, 12348, 141, 12350, 151, 12352, 141, 12353, 36, 12439, 152, 12441, 36, 12445, 150, 12448, 152, 12449, 150, 12539, 153, + 12540, 148, 12541, 150, 12544, 153, 12549, 36, 12592, 7, 12593, 36, 12687, 85, 12688, 36, 12704, 141, 12736, 7, 12774, 141, 12783, 36, + 12784, 142, 12800, 153, 12831, 85, 12832, 36, 12872, 141, 12896, 0, 12927, 85, 12928, 0, 12977, 141, 12992, 0, 13004, 141, 13008, 0, + 13055, 153, 13056, 141, 13144, 153, 13169, 141, 13179, 0, 13184, 141, 13280, 0, 13311, 141, 13312, 0, 19904, 141, 19968, 0, 40960, 141, + 42125, 154, 42128, 36, 42183, 154, 42192, 36, 42240, 155, 42540, 156, 42560, 36, 42607, 38, 42608, 40, 42656, 38, 42744, 157, 42752, 36, + 42760, 158, 42786, 0, 42888, 1, 42891, 0, 42973, 1, 42993, 36, 43008, 1, 43053, 159, 43056, 36, 43059, 160, 43062, 161, 43064, 162, + 43065, 163, 43066, 162, 43072, 36, 43128, 164, 43136, 36, 43206, 165, 43214, 36, 43226, 165, 43232, 36, 43249, 58, 43250, 166, 43251, 58, + 43252, 167, 43264, 58, 43310, 168, 43311, 169, 43312, 168, 43348, 170, 43359, 36, 43360, 170, 43389, 85, 43392, 36, 43470, 171, 43471, 36, + 43472, 172, 43482, 171, 43486, 36, 43488, 171, 43519, 81, 43520, 36, 43575, 173, 43584, 36, 43598, 173, 43600, 36, 43610, 173, 43612, 36, + 43616, 173, 43648, 81, 43715, 174, 43739, 36, 43744, 174, 43767, 175, 43777, 36, 43783, 86, 43785, 36, 43791, 86, 43793, 36, 43799, 86, + 43808, 36, 43815, 86, 43816, 36, 43823, 86, 43824, 36, 43867, 1, 43868, 0, 43877, 1, 43878, 32, 43882, 1, 43884, 0, 43888, 36, + 43968, 87, 44014, 175, 44016, 36, 44026, 175, 44032, 36, 55204, 85, 55216, 36, 55239, 85, 55243, 36, 55292, 85, 63744, 36, 64110, 141, + 64112, 36, 64218, 141, 64256, 36, 64263, 1, 64275, 36, 64280, 42, 64285, 36, 64311, 44, 64312, 36, 64317, 44, 64318, 36, 64319, 44, + 64320, 36, 64322, 44, 64323, 36, 64325, 44, 64326, 36, 64336, 44, 64830, 45, 64832, 176, 64976, 45, 65008, 36, 65010, 45, 65011, 177, + 65021, 45, 65022, 177, 65024, 45, 65040, 23, 65050, 0, 65056, 36, 65070, 23, 65072, 38, 65093, 0, 65095, 145, 65107, 0, 65108, 36, + 65127, 0, 65128, 36, 65132, 0, 65136, 36, 65141, 45, 65142, 36, 65277, 45, 65279, 36, 65280, 0, 65281, 36, 65313, 0, 65339, 1, + 65345, 0, 65371, 1, 65377, 0, 65382, 148, 65392, 153, 65393, 150, 65438, 153, 65440, 150, 65471, 85, 65474, 36, 65480, 85, 65482, 36, + 65488, 85, 65490, 36, 65496, 85, 65498, 36, 65501, 85, 65504, 36, 65511, 0, 65512, 36, 65519, 0, 65529, 36, 65534, 0, 65536, 36, + 65548, 178, 65549, 36, 65575, 178, 65576, 36, 65595, 178, 65596, 36, 65598, 178, 65599, 36, 65614, 178, 65616, 36, 65630, 178, 65664, 36, + 65787, 178, 65792, 36, 65794, 179, 65795, 180, 65799, 36, 65844, 181, 65847, 36, 65856, 180, 65935, 32, 65936, 36, 65949, 0, 65952, 36, + 65953, 32, 66000, 36, 66045, 0, 66046, 23, 66176, 36, 66205, 182, 66208, 36, 66257, 183, 66272, 36, 66300, 184, 66304, 36, 66340, 185, + 66349, 36, 66352, 185, 66379, 186, 66384, 36, 66427, 187, 66432, 36, 66462, 188, 66463, 36, 66464, 188, 66500, 189, 66504, 36, 66518, 189, + 66560, 36, 66640, 190, 66688, 191, 66718, 192, 66720, 36, 66730, 192, 66736, 36, 66772, 193, 66776, 36, 66812, 193, 66816, 36, 66856, 194, + 66864, 36, 66916, 195, 66927, 36, 66928, 195, 66939, 196, 66940, 36, 66955, 196, 66956, 36, 66963, 196, 66964, 36, 66966, 196, 66967, 36, + 66978, 196, 66979, 36, 66994, 196, 66995, 36, 67002, 196, 67003, 36, 67005, 196, 67008, 36, 67060, 197, 67072, 36, 67383, 198, 67392, 36, + 67414, 198, 67424, 36, 67432, 198, 67456, 36, 67462, 1, 67463, 36, 67505, 1, 67506, 36, 67515, 1, 67584, 36, 67590, 199, 67592, 36, + 67593, 199, 67594, 36, 67638, 199, 67639, 36, 67641, 199, 67644, 36, 67645, 199, 67647, 36, 67648, 199, 67670, 200, 67671, 36, 67680, 200, + 67712, 201, 67743, 202, 67751, 36, 67760, 202, 67808, 36, 67827, 203, 67828, 36, 67830, 203, 67835, 36, 67840, 203, 67868, 204, 67871, 36, + 67872, 204, 67898, 205, 67903, 36, 67904, 205, 67930, 206, 67968, 36, 68000, 207, 68024, 208, 68028, 36, 68048, 208, 68050, 36, 68096, 208, + 68100, 209, 68101, 36, 68103, 209, 68108, 36, 68116, 209, 68117, 36, 68120, 209, 68121, 36, 68150, 209, 68152, 36, 68155, 209, 68159, 36, + 68169, 209, 68176, 36, 68185, 209, 68192, 36, 68224, 210, 68256, 211, 68288, 36, 68327, 212, 68331, 36, 68338, 212, 68339, 213, 68343, 212, + 68352, 36, 68406, 214, 68409, 36, 68416, 214, 68438, 215, 68440, 36, 68448, 215, 68467, 216, 68472, 36, 68480, 216, 68498, 217, 68505, 36, + 68509, 217, 68521, 36, 68528, 217, 68608, 36, 68681, 218, 68736, 36, 68787, 219, 68800, 36, 68851, 219, 68858, 36, 68864, 219, 68904, 220, + 68912, 36, 68922, 220, 68928, 36, 68966, 221, 68969, 36, 68998, 221, 69006, 36, 69008, 221, 69216, 36, 69247, 45, 69248, 36, 69290, 222, + 69291, 36, 69294, 222, 69296, 36, 69298, 222, 69314, 36, 69320, 45, 69328, 36, 69337, 45, 69370, 36, 69376, 45, 69416, 223, 69424, 36, + 69466, 224, 69488, 36, 69514, 225, 69552, 36, 69580, 226, 69600, 36, 69623, 227, 69632, 36, 69710, 228, 69714, 36, 69750, 228, 69759, 36, + 69760, 228, 69827, 229, 69837, 36, 69838, 229, 69840, 36, 69865, 230, 69872, 36, 69882, 230, 69888, 36, 69941, 231, 69942, 36, 69960, 231, + 69968, 36, 70007, 232, 70016, 36, 70112, 233, 70113, 36, 70133, 77, 70144, 36, 70162, 234, 70163, 36, 70210, 234, 70272, 36, 70279, 235, + 70280, 36, 70281, 235, 70282, 36, 70286, 235, 70287, 36, 70302, 235, 70303, 36, 70314, 235, 70320, 36, 70379, 236, 70384, 36, 70394, 236, + 70400, 36, 70401, 237, 70402, 72, 70403, 237, 70404, 72, 70405, 36, 70413, 237, 70415, 36, 70417, 237, 70419, 36, 70441, 237, 70442, 36, + 70449, 237, 70450, 36, 70452, 237, 70453, 36, 70458, 237, 70459, 36, 70461, 72, 70469, 237, 70471, 36, 70473, 237, 70475, 36, 70478, 237, + 70480, 36, 70481, 237, 70487, 36, 70488, 237, 70493, 36, 70500, 237, 70502, 36, 70509, 237, 70512, 36, 70517, 237, 70528, 36, 70538, 238, + 70539, 36, 70540, 238, 70542, 36, 70543, 238, 70544, 36, 70582, 238, 70583, 36, 70593, 238, 70594, 36, 70595, 238, 70597, 36, 70598, 238, + 70599, 36, 70603, 238, 70604, 36, 70614, 238, 70615, 36, 70617, 238, 70625, 36, 70627, 238, 70656, 36, 70748, 239, 70749, 36, 70754, 239, + 70784, 36, 70856, 240, 70864, 36, 70874, 240, 71040, 36, 71094, 241, 71096, 36, 71134, 241, 71168, 36, 71237, 242, 71248, 36, 71258, 242, + 71264, 36, 71277, 97, 71296, 36, 71354, 243, 71360, 36, 71370, 243, 71376, 36, 71396, 81, 71424, 36, 71451, 244, 71453, 36, 71468, 244, + 71472, 36, 71495, 244, 71680, 36, 71740, 245, 71840, 36, 71923, 246, 71935, 36, 71936, 246, 71943, 247, 71945, 36, 71946, 247, 71948, 36, + 71956, 247, 71957, 36, 71959, 247, 71960, 36, 71990, 247, 71991, 36, 71993, 247, 71995, 36, 72007, 247, 72016, 36, 72026, 247, 72096, 36, + 72104, 126, 72106, 36, 72152, 126, 72154, 36, 72165, 126, 72192, 36, 72264, 248, 72272, 36, 72355, 249, 72368, 36, 72384, 88, 72441, 250, + 72448, 36, 72458, 58, 72544, 36, 72552, 233, 72640, 36, 72674, 251, 72688, 36, 72698, 251, 72704, 36, 72713, 252, 72714, 36, 72759, 252, + 72760, 36, 72774, 252, 72784, 36, 72813, 252, 72816, 36, 72848, 253, 72850, 36, 72872, 253, 72873, 36, 72887, 253, 72960, 36, 72967, 254, + 72968, 36, 72970, 254, 72971, 36, 73015, 254, 73018, 36, 73019, 254, 73020, 36, 73022, 254, 73023, 36, 73032, 254, 73040, 36, 73050, 254, + 73056, 36, 73062, 255, 73063, 36, 73065, 255, 73066, 36, 73103, 255, 73104, 36, 73106, 255, 73107, 36, 73113, 255, 73120, 36, 73130, 255, + 73136, 36, 73180, 256, 73184, 36, 73194, 256, 73440, 36, 73465, 257, 73472, 36, 73489, 258, 73490, 36, 73531, 258, 73534, 36, 73563, 258, + 73648, 36, 73649, 155, 73664, 36, 73680, 71, 73682, 72, 73683, 71, 73684, 72, 73714, 71, 73727, 36, 73728, 71, 74650, 259, 74752, 36, + 74863, 259, 74864, 36, 74869, 259, 74880, 36, 75076, 259, 77712, 36, 77811, 260, 77824, 36, 78934, 261, 78944, 36, 82939, 261, 82944, 36, + 83527, 262, 90368, 36, 90426, 263, 92160, 36, 92729, 157, 92736, 36, 92767, 264, 92768, 36, 92778, 264, 92782, 36, 92784, 264, 92863, 265, + 92864, 36, 92874, 265, 92880, 36, 92910, 266, 92912, 36, 92918, 266, 92928, 36, 92998, 267, 93008, 36, 93018, 267, 93019, 36, 93026, 267, + 93027, 36, 93048, 267, 93053, 36, 93072, 267, 93504, 36, 93562, 268, 93760, 36, 93851, 269, 93856, 36, 93881, 270, 93883, 36, 93908, 270, + 93952, 36, 94027, 271, 94031, 36, 94088, 271, 94095, 36, 94112, 271, 94176, 36, 94177, 272, 94178, 273, 94180, 141, 94181, 274, 94192, 36, + 94199, 141, 94208, 36, 101120, 272, 101590, 274, 101631, 36, 101632, 274, 101663, 272, 101760, 36, 101875, 272, 110576, 36, 110580, 153, 110581, 36, + 110588, 153, 110589, 36, 110591, 153, 110592, 36, 110593, 153, 110880, 152, 110883, 153, 110898, 36, 110899, 152, 110928, 36, 110931, 152, 110933, 36, + 110934, 153, 110948, 36, 110952, 153, 110960, 36, 111356, 273, 113664, 36, 113771, 139, 113776, 36, 113789, 139, 113792, 36, 113801, 139, 113808, 36, + 113818, 139, 113820, 36, 113828, 139, 117760, 36, 118013, 0, 118016, 36, 118452, 0, 118458, 36, 118481, 0, 118496, 36, 118513, 0, 118528, 36, + 118574, 23, 118576, 36, 118599, 23, 118608, 36, 118724, 0, 118784, 36, 119030, 0, 119040, 36, 119079, 0, 119081, 36, 119143, 0, 119146, 23, + 119163, 0, 119171, 23, 119173, 0, 119180, 23, 119210, 0, 119214, 23, 119275, 0, 119296, 36, 119366, 32, 119488, 36, 119508, 0, 119520, 36, + 119540, 0, 119552, 36, 119639, 0, 119648, 36, 119666, 141, 119673, 0, 119808, 36, 119893, 0, 119894, 36, 119965, 0, 119966, 36, 119968, 0, + 119970, 36, 119971, 0, 119973, 36, 119975, 0, 119977, 36, 119981, 0, 119982, 36, 119994, 0, 119995, 36, 119996, 0, 119997, 36, 120004, 0, + 120005, 36, 120070, 0, 120071, 36, 120075, 0, 120077, 36, 120085, 0, 120086, 36, 120093, 0, 120094, 36, 120122, 0, 120123, 36, 120127, 0, + 120128, 36, 120133, 0, 120134, 36, 120135, 0, 120138, 36, 120145, 0, 120146, 36, 120486, 0, 120488, 36, 120780, 0, 120782, 36, 120832, 0, + 121484, 275, 121499, 36, 121504, 275, 121505, 36, 121520, 275, 122624, 36, 122655, 1, 122661, 36, 122667, 1, 122880, 36, 122887, 134, 122888, 36, + 122905, 134, 122907, 36, 122914, 134, 122915, 36, 122917, 134, 122918, 36, 122923, 134, 122928, 36, 122990, 38, 123023, 36, 123024, 38, 123136, 36, + 123181, 276, 123184, 36, 123198, 276, 123200, 36, 123210, 276, 123214, 36, 123216, 276, 123536, 36, 123567, 277, 123584, 36, 123642, 278, 123647, 36, + 123648, 278, 124112, 36, 124154, 279, 124368, 36, 124411, 280, 124415, 36, 124416, 280, 124608, 36, 124639, 281, 124640, 36, 124662, 281, 124670, 36, + 124672, 281, 124896, 36, 124903, 86, 124904, 36, 124908, 86, 124909, 36, 124911, 86, 124912, 36, 124927, 86, 124928, 36, 125125, 282, 125127, 36, + 125143, 282, 125184, 36, 125260, 283, 125264, 36, 125274, 283, 125278, 36, 125280, 283, 126065, 36, 126133, 0, 126209, 36, 126270, 0, 126464, 36, + 126468, 45, 126469, 36, 126496, 45, 126497, 36, 126499, 45, 126500, 36, 126501, 45, 126503, 36, 126504, 45, 126505, 36, 126515, 45, 126516, 36, + 126520, 45, 126521, 36, 126522, 45, 126523, 36, 126524, 45, 126530, 36, 126531, 45, 126535, 36, 126536, 45, 126537, 36, 126538, 45, 126539, 36, + 126540, 45, 126541, 36, 126544, 45, 126545, 36, 126547, 45, 126548, 36, 126549, 45, 126551, 36, 126552, 45, 126553, 36, 126554, 45, 126555, 36, + 126556, 45, 126557, 36, 126558, 45, 126559, 36, 126560, 45, 126561, 36, 126563, 45, 126564, 36, 126565, 45, 126567, 36, 126571, 45, 126572, 36, + 126579, 45, 126580, 36, 126584, 45, 126585, 36, 126589, 45, 126590, 36, 126591, 45, 126592, 36, 126602, 45, 126603, 36, 126620, 45, 126625, 36, + 126628, 45, 126629, 36, 126634, 45, 126635, 36, 126652, 45, 126704, 36, 126706, 45, 126976, 36, 127020, 0, 127024, 36, 127124, 0, 127136, 36, + 127151, 0, 127153, 36, 127168, 0, 127169, 36, 127184, 0, 127185, 36, 127222, 0, 127232, 36, 127406, 0, 127462, 36, 127488, 0, 127489, 152, + 127491, 0, 127504, 36, 127548, 0, 127552, 36, 127561, 0, 127568, 36, 127570, 141, 127584, 36, 127590, 0, 127744, 36, 128729, 0, 128732, 36, + 128749, 0, 128752, 36, 128765, 0, 128768, 36, 128986, 0, 128992, 36, 129004, 0, 129008, 36, 129009, 0, 129024, 36, 129036, 0, 129040, 36, + 129096, 0, 129104, 36, 129114, 0, 129120, 36, 129160, 0, 129168, 36, 129198, 0, 129200, 36, 129212, 0, 129216, 36, 129218, 0, 129232, 36, + 129241, 0, 129280, 36, 129624, 0, 129632, 36, 129646, 0, 129648, 36, 129661, 0, 129664, 36, 129675, 0, 129678, 36, 129735, 0, 129736, 36, + 129737, 0, 129741, 36, 129757, 0, 129759, 36, 129771, 0, 129775, 36, 129785, 0, 129792, 36, 129939, 0, 129940, 36, 130043, 0, 131072, 36, + 173792, 141, 173824, 36, 178206, 141, 178208, 36, 183982, 141, 183984, 36, 191457, 141, 191472, 36, 192094, 141, 194560, 36, 195102, 141, 196608, 36, + 201547, 141, 201552, 36, 210042, 141, 917505, 36, 917506, 0, 917536, 36, 917632, 0, 917760, 36, 918000, 23, 1114112, 36 +]; +pub static SCRIPT_EXTENSION_OFFSETS: &[u32] = &[ + 0, 1, 2, 18, 25, 27, 29, 31, 32, 40, 48, 52, 57, 68, 74, 79, 88, 99, 101, 104, 108, 111, 113, 115, + 116, 119, 123, 129, 133, 135, 138, 141, 148, 149, 151, 154, 156, 157, 158, 159, 161, 163, 165, 166, 169, 170, 171, 178, + 181, 189, 198, 200, 203, 205, 206, 207, 208, 209, 210, 211, 226, 239, 260, 283, 287, 288, 291, 292, 294, 295, 297, 298, + 299, 301, 302, 303, 306, 307, 308, 309, 310, 311, 312, 315, 316, 319, 320, 321, 322, 323, 324, 325, 326, 327, 331, 332, + 333, 334, 335, 337, 338, 339, 340, 341, 342, 343, 344, 345, 346, 347, 351, 354, 359, 362, 365, 369, 371, 377, 379, 382, + 385, 388, 390, 394, 405, 407, 411, 412, 415, 418, 420, 426, 430, 433, 434, 435, 436, 438, 440, 447, 448, 451, 452, 454, + 461, 469, 474, 482, 491, 497, 499, 501, 504, 505, 506, 507, 508, 509, 510, 512, 513, 529, 544, 555, 567, 568, 569, 572, + 574, 575, 578, 579, 580, 582, 583, 584, 585, 587, 589, 590, 593, 595, 598, 599, 600, 602, 603, 604, 605, 606, 607, 608, + 609, 610, 611, 612, 613, 614, 615, 616, 617, 618, 619, 620, 621, 622, 623, 624, 625, 626, 627, 628, 629, 630, 632, 633, + 634, 635, 636, 637, 638, 639, 640, 641, 642, 643, 644, 645, 646, 647, 648, 649, 650, 651, 652, 653, 654, 655, 656, 657, + 658, 659, 660, 661, 662, 663, 664, 665, 666, 667, 668, 669, 670, 671, 672, 673, 674, 675, 676, 677, 678, 679, 680, 681, + 682, 683, 684, 685, 686, 687, 688, 689, 690, 691, 692, 693, 694, 695, 696, 697, 698, 699, 700, 701, 702 +]; +pub static SCRIPT_EXTENSION_TAGS: &[u32] = &[ + 1517910393, 1281455214, 1098281844, 1130459753, 1131376756, 1148547180, 1164730977, 1197830002, 1198285159, 1198485095, 1198486632, 1198679403, 1214344809, 1281455214, 1283023977, 1298229354, 1348825709, 1399349623, 1113943655, 1132032620, 1147500129, 1281455214, 1281979253, 1416126825, + 1416590447, 1114599535, 1281455214, 1281455214, 1281979253, 1281455214, 1416126825, 1114599535, 1130915186, 1131376756, 1132032620, 1198679403, 1281455214, 1348825709, 1400204917, 1415670885, 1130915186, 1132032620, 1198679403, 1281455214, 1332963173, 1400204917, 1415670885, 1416586354, + 1130915186, 1132032620, 1281455214, 1415999079, 1198285159, 1281455214, 1400204917, 1400468067, 1416126825, 1097295970, 1130915186, 1131376756, 1132032620, 1198486632, 1198679403, 1281455214, 1332963173, 1400468067, 1415999079, 1416586354, 1131376756, 1164730977, 1198285159, 1198486632, + 1264676449, 1281455214, 1132032620, 1198679403, 1281455214, 1348825709, 1415999079, 1131376756, 1148547180, 1214603890, 1281455214, 1348825709, 1400468067, 1415670885, 1415999079, 1416586354, 1098018158, 1132032620, 1148547180, 1198486632, 1198679403, 1214603890, 1281455214, 1348825709, + 1400468067, 1415670885, 1415999079, 1281455214, 1415999079, 1148547180, 1281455214, 1400468067, 1130915186, 1132032620, 1281455214, 1332963173, 1130915186, 1281455214, 1415670885, 1281455214, 1400204917, 1165256809, 1281455214, 1516858984, 1132032620, 1281455214, 1416586354, 1198679403, + 1281455214, 1348825709, 1416586354, 1130915186, 1148547180, 1264676449, 1281455214, 1400468067, 1415999079, 1130915186, 1148547180, 1281455214, 1400468067, 1281455214, 1400468067, 1281455214, 1400204917, 1400468067, 1130915186, 1281455214, 1400468067, 1097295970, 1130915186, 1198486632, + 1281455214, 1400204917, 1400468067, 1416126825, 1198679403, 1281455214, 1332963173, 1097295970, 1281455214, 1416586354, 1131376756, 1198679403, 1517976186, 1131376756, 1132032620, 1132032620, 1348825709, 1132032620, 1198285159, 1132032620, 1281455214, 1098018158, 1098018158, 1197830002, + 1198285159, 1214603890, 1098015074, 1098015074, 1197568609, 1315663727, 1383032935, 1400468067, 1416126817, 1499822697, 1098015074, 1400468067, 1416126817, 1097100397, 1098015074, 1197568609, 1315663727, 1383032935, 1400468067, 1416126817, 1499822697, 1097100397, 1098015074, 1298230884, + 1298230889, 1333094258, 1349020784, 1383032935, 1399809892, 1400468067, 1098015074, 1400468067, 1098015074, 1416126817, 1499822697, 1098015074, 1383032935, 1400468067, 1416126817, 1315663727, 1398893938, 1298230884, 1147500129, 1113943655, 1147500129, 1198678382, 1198877298, 1198879349, + 1265525857, 1281455214, 1298954605, 1315008100, 1315272545, 1332902241, 1399353956, 1415671148, 1415933045, 1416196712, 1113943655, 1147500129, 1198678382, 1198877298, 1198879349, 1265525857, 1281455214, 1298954605, 1315272545, 1332902241, 1415671148, 1415933045, 1416196712, 1113943655, + 1147500129, 1148151666, 1198485095, 1198485101, 1198678382, 1198877298, 1198879349, 1265525857, 1298229354, 1298954605, 1315008100, 1332633967, 1332902241, 1399418468, 1399418472, 1400466543, 1415670642, 1415671148, 1415933045, 1416196712, 1113943655, 1147500129, 1148151666, 1198485095, + 1198485101, 1198678382, 1198877298, 1198877544, 1198879349, 1265525857, 1281977698, 1298229354, 1298954605, 1315008100, 1332633967, 1332902241, 1399418468, 1399418472, 1400466543, 1415670642, 1415671148, 1415933045, 1416196712, 1147500129, 1148151666, 1265920105, 1298229354, 1113943655, + 1113943655, 1130457965, 1400466543, 1198879349, 1198879349, 1299541108, 1198877298, 1198877298, 1265135466, 1332902241, 1415671148, 1198678382, 1415671148, 1415933045, 1265525857, 1265525857, 1315008100, 1416983655, 1298954605, 1399418472, 1416126825, 1281453935, 1416192628, 1299803506, + 1130457965, 1299803506, 1415670885, 1197830002, 1197830002, 1198285159, 1281455214, 1214344807, 1165256809, 1130915186, 1130458739, 1332175213, 1383427698, 1416064103, 1214344815, 1114990692, 1214344815, 1415669602, 1416064103, 1114990692, 1415669602, 1265134962, 1299148391, 1299148391, + 1349017959, 1281977698, 1415670885, 1415670901, 1114990441, 1281453665, 1113681001, 1400204900, 1113683051, 1281716323, 1332503403, 1113943655, 1147500129, 1198678382, 1265525857, 1147500129, 1198678382, 1265525857, 1113943655, 1147500129, 1315272545, 1415933045, 1416196712, 1113943655, + 1147500129, 1415933045, 1147500129, 1315272545, 1399353956, 1113943655, 1147500129, 1315272545, 1415933045, 1147500129, 1399353956, 1147500129, 1265525857, 1298954605, 1332902241, 1415671148, 1415933045, 1113943655, 1147500129, 1147500129, 1315272545, 1416196712, 1147500129, 1315008100, + 1315272545, 1113943655, 1147500129, 1399353956, 1147500129, 1315272545, 1113943655, 1147500129, 1315272545, 1399353956, 1113943655, 1147500129, 1198678382, 1265525857, 1298954605, 1315008100, 1332902241, 1399418472, 1415933045, 1416196712, 1416983655, 1147500129, 1198678382, 1147500129, + 1198678382, 1265525857, 1416983655, 1315008100, 1132032620, 1281455214, 1400468067, 1281455214, 1299148391, 1349017959, 1097100397, 1098015074, 1130459753, 1197830002, 1198285159, 1215655527, 1283023721, 1332898664, 1130459753, 1198679403, 1215655527, 1298494063, 1147500129, 1198678382, + 1281455214, 1114792297, 1198285159, 1415999079, 1131376756, 1281455214, 1098281844, 1332898664, 1098281844, 1130459753, 1197830002, 1215655527, 1265920105, 1283023977, 1398893938, 1148547180, 1097100397, 1098015074, 1215655527, 1214344809, 1214344809, 1415671399, 1114599535, 1214344807, + 1214344809, 1214870113, 1264676449, 1299148391, 1500080489, 1114599535, 1214344807, 1214344809, 1214870113, 1264676449, 1299148391, 1349017959, 1500080489, 1114599535, 1214344807, 1214344809, 1214870113, 1264676449, 1114599535, 1214344807, 1214344809, 1214870113, 1264676449, 1299148391, + 1416192628, 1500080489, 1114599535, 1214344807, 1214344809, 1214870113, 1264676449, 1281979253, 1299148391, 1416192628, 1500080489, 1114599535, 1214344807, 1214344809, 1214870113, 1264676449, 1500080489, 1114599535, 1214344809, 1214870113, 1264676449, 1214344809, 1214870113, 1264676449, + 1214870113, 1264676449, 1500080489, 1281979253, 1449224553, 1113681269, 1214344809, 1281455214, 1400466543, 1147500129, 1148151666, 1198877298, 1198879349, 1265135466, 1265525857, 1265920105, 1298229354, 1298954605, 1299145833, 1315008100, 1399353956, 1399418468, 1415670642, 1416196712, + 1416983655, 1147500129, 1148151666, 1198877298, 1198879349, 1265135466, 1265525857, 1265920105, 1298229354, 1299145833, 1315008100, 1399353956, 1399418468, 1415670642, 1416196712, 1416983655, 1147500129, 1148151666, 1198877298, 1198879349, 1265135466, 1265920105, 1298229354, 1299145833, + 1399418468, 1415670642, 1416196712, 1147500129, 1148151666, 1198877298, 1198879349, 1265135466, 1265920105, 1298229354, 1299145833, 1399353956, 1399418468, 1415670642, 1416196712, 1349017959, 1398895986, 1113943655, 1147500129, 1416983655, 1147500129, 1415671148, 1264675945, 1264675945, + 1281455214, 1299803506, 1382706791, 1247901281, 1114990441, 1247901281, 1130914157, 1415673460, 1299473769, 1098015074, 1315663727, 1098015074, 1416126817, 1281977954, 1131441518, 1131442804, 1281977954, 1131442804, 1281977954, 1131442804, 1281977953, 1281977954, 1283023721, 1130459753, + 1098015074, 1131376756, 1232363884, 1198486632, 1348825709, 1432838514, 1483761007, 1148416628, 1399349623, 1332964705, 1332963173, 1164730977, 1097295970, 1449751656, 1416586354, 1281977953, 1131442804, 1098018153, 1348562029, 1315070324, 1214346354, 1349021304, 1283023977, 1399415924, + 1298494063, 1298494051, 1265131890, 1398895202, 1315009122, 1298230889, 1298230889, 1333094258, 1098281844, 1349678185, 1349020777, 1349020784, 1332898664, 1215655527, 1383032935, 1197568609, 1499822697, 1399809903, 1399809892, 1333094258, 1130918515, 1164736877, 1114792296, 1265920105, + 1399812705, 1130457965, 1298229354, 1399353956, 1265135466, 1299541108, 1399418468, 1198678382, 1416983655, 1315272545, 1416196712, 1399415908, 1299145833, 1415670642, 1097363309, 1148151666, 1466004065, 1147756907, 1516334690, 1399814511, 1348564323, 1400204917, 1114139507, 1298231907, + 1198485101, 1198485095, 1416588403, 1298230113, 1264678761, 1483961720, 1131441518, 1164409200, 1215067511, 1198877544, 1299345263, 1416524641, 1113682803, 1215131239, 1265787241, 1298490470, 1113944678, 1349284452, 1415671399, 1316186229, 1265202291, 1399287415, 1215131248, 1416590447, + 1466132591, 1315006317, 1332633967, 1415674223, 1298493028, 1097100397 +]; diff --git a/packages/text/rust/shaper/src/lib.rs b/packages/text/rust/shaper/src/lib.rs index ee3662fd..31fd492f 100644 --- a/packages/text/rust/shaper/src/lib.rs +++ b/packages/text/rust/shaper/src/lib.rs @@ -1,9 +1,13 @@ #![cfg_attr(not(feature = "std"), no_std)] +#![recursion_limit = "256"] extern crate alloc; mod abi_contract; pub mod bidi; +pub mod engine; +pub mod line_break; +pub mod unicode; #[cfg_attr(not(any(target_arch = "wasm32", test)), allow(dead_code))] mod wire; @@ -26,30 +30,61 @@ pub const STATUS_HANDLE_CONFLICT: u32 = 4; pub const STATUS_FONT_MISSING: u32 = 5; pub const STATUS_INVALID_REQUEST: u32 = 6; pub const STATUS_RESULT_TOO_LARGE: u32 = 7; +pub const STATUS_POLICY_CONFLICT: u32 = 8; +pub const STATUS_POLICY_MISSING: u32 = 9; +pub const STATUS_SESSION_CONFLICT: u32 = 10; +pub const STATUS_SESSION_MISSING: u32 = 11; +pub const STATUS_REVISION_CONFLICT: u32 = 12; +pub const STATUS_FONT_STACK_MISSING: u32 = 13; +pub const STATUS_FONT_IN_USE: u32 = 14; -const BUFFER_FLAGS_MASK: u32 = 0xff; const MAX_CACHED_PLANS_PER_FONT: usize = 64; +const DEFAULT_SHAPE_BUFFER_CAPACITY: usize = 32_768; +const DEFAULT_SHAPE_FEATURE_CAPACITY: usize = 128; -#[derive(Default)] pub struct ShaperRegistry { fonts: BTreeMap, - result: ResultArena, + shape_buffer: Option, + context_codepoints: Vec, + shape_features: Vec, } -#[derive(Default)] -struct ResultArena { - words: Vec, - byte_length: u32, +impl Default for ShaperRegistry { + fn default() -> Self { + Self { + fonts: BTreeMap::new(), + shape_buffer: Some(UnicodeBuffer::new()), + context_codepoints: Vec::new(), + shape_features: Vec::new(), + } + } } struct RegisteredFont { sfnt: Vec, extents: Vec, availability: Vec, + metrics: FontMetrics, data: ShaperData, plans: Vec, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct FontMetrics { + pub units_per_em: u16, + pub ascender: i16, + pub descender: i16, + pub line_gap: i16, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct FontGlyphExtents { + pub x_min: i32, + pub y_min: i32, + pub x_max: i32, + pub y_max: i32, +} + struct CachedPlan { key: PlanKey, plan: ShapePlan, @@ -70,7 +105,7 @@ struct PlanFeatureKey { global: bool, } -#[derive(Clone, Copy)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct FeatureRecord { pub tag: u32, pub value: u32, @@ -78,20 +113,8 @@ pub struct FeatureRecord { pub end: u32, } -pub struct RunRequest { - pub font_handle: u32, - pub text_start: u32, - pub text_end: u32, - pub script: u32, - pub language: Option>, - pub features: Vec, - pub direction: u8, - pub cluster_level: u8, - pub flags: u32, -} - -pub struct ReshapeRange { - pub run: u32, +#[derive(Clone, Copy)] +pub(crate) struct ShapeRangeRef { pub item_start: u32, pub item_end: u32, pub context_start: u32, @@ -100,34 +123,35 @@ pub struct ReshapeRange { } #[derive(Clone, Copy)] -struct SegmentRange { - item_start: u32, - item_end: u32, - context_start: u32, - context_end: u32, - flags: u32, -} - -pub struct ShapeBatchRequest { - pub text: Vec, - pub runs: Vec, -} - -pub struct ShapeBatchOutput { - pub font_handles: Vec, - pub run_font_slots: Vec, - pub run_glyph_starts: Vec, - pub run_glyph_counts: Vec, - pub glyph_ids: Vec, - pub clusters: Vec, - pub x_advances: Vec, - pub y_advances: Vec, - pub x_offsets: Vec, - pub y_offsets: Vec, - pub glyph_flags: Vec, +pub(crate) struct ShapeRunRef<'a> { + pub text_start: u32, + pub text_end: u32, + pub script: u32, + pub language: Option<&'a [u8]>, + pub features: &'a [FeatureRecord], + pub direction: u8, + pub cluster_level: u8, + pub flags: u32, } impl ShaperRegistry { + pub fn initialize(&mut self) -> Result<(), u32> { + let Some(shape_buffer) = self.shape_buffer.as_mut() else { + return Err(STATUS_INVALID_REQUEST); + }; + if !shape_buffer.reserve(DEFAULT_SHAPE_BUFFER_CAPACITY) { + return Err(STATUS_RESULT_TOO_LARGE); + } + self.context_codepoints + .try_reserve_exact( + DEFAULT_SHAPE_BUFFER_CAPACITY.saturating_sub(self.context_codepoints.len()), + ) + .map_err(|_| STATUS_RESULT_TOO_LARGE)?; + self.shape_features + .try_reserve_exact(DEFAULT_SHAPE_FEATURE_CAPACITY) + .map_err(|_| STATUS_RESULT_TOO_LARGE) + } + pub fn register_font( &mut self, handle: u32, @@ -135,7 +159,6 @@ impl ShaperRegistry { extents: &[u8], availability: &[u8], ) -> u32 { - self.result.clear(); if handle == 0 { return STATUS_INVALID_HANDLE; } @@ -150,6 +173,15 @@ impl ShaperRegistry { if !valid_extents(glyph_count, extents, availability) { return STATUS_INVALID_EXTENTS; } + let metrics = match (font.head(), font.hhea()) { + (Ok(head), Ok(hhea)) => FontMetrics { + units_per_em: head.units_per_em(), + ascender: hhea.ascender().to_i16(), + descender: hhea.descender().to_i16(), + line_gap: hhea.line_gap().to_i16(), + }, + _ => return STATUS_INVALID_FONT, + }; if let Some(existing) = self.fonts.get(&handle) { return if existing.sfnt == sfnt && existing.extents == extents @@ -167,6 +199,7 @@ impl ShaperRegistry { sfnt: sfnt.to_vec(), extents: extents.to_vec(), availability: availability.to_vec(), + metrics, data, plans: Vec::new(), }, @@ -174,8 +207,20 @@ impl ShaperRegistry { STATUS_OK } + pub(crate) fn font_metrics(&self, handle: u32) -> Option { + self.fonts.get(&handle).map(|font| font.metrics) + } + + pub(crate) fn font_glyph_extents(&self, handle: u32, glyph: u32) -> Option { + let font = self.fonts.get(&handle)?; + FlatExtents { + records: &font.extents, + availability: &font.availability, + } + .raw_extent_for_glyph(usize::try_from(glyph).ok()?) + } + pub fn dispose_font(&mut self, handle: u32) -> u32 { - self.result.clear(); if self.fonts.remove(&handle).is_some() { STATUS_OK } else { @@ -183,112 +228,61 @@ impl ShaperRegistry { } } - pub fn shape_batch(&mut self, request: &ShapeBatchRequest) -> Result { - self.result.clear(); - self.shape_segments(request, None) - } - - pub fn reshape_ranges( + pub(crate) fn with_shaped_run( &mut self, - request: &ShapeBatchRequest, - ranges: &[ReshapeRange], - ) -> Result { - self.result.clear(); - self.shape_segments(request, Some(ranges)) + font_handle: u32, + text: &[u16], + run: ShapeRunRef<'_>, + consume: impl FnOnce(&harfrust::GlyphBuffer) -> Result, + ) -> Result { + let range = ShapeRangeRef { + item_start: run.text_start, + item_end: run.text_end, + context_start: run.text_start, + context_end: run.text_end, + flags: run.flags, + }; + self.with_shaped_range(font_handle, text, run, range, consume) } - fn shape_segments( + pub(crate) fn with_shaped_range( &mut self, - request: &ShapeBatchRequest, - ranges: Option<&[ReshapeRange]>, - ) -> Result { - validate_request(request, ranges)?; - let segment_count = ranges.map_or(request.runs.len(), <[ReshapeRange]>::len); - let mut output = ShapeBatchOutput::with_run_capacity(segment_count); - let mut font_slots = BTreeMap::::new(); - - for segment in 0..segment_count { - let (run_index, range) = if let Some(ranges) = ranges { - let range = &ranges[segment]; - ( - usize::try_from(range.run).map_err(|_| STATUS_INVALID_REQUEST)?, - SegmentRange { - item_start: range.item_start, - item_end: range.item_end, - context_start: range.context_start, - context_end: range.context_end, - flags: range.flags, - }, - ) - } else { - let run = &request.runs[segment]; - ( - segment, - SegmentRange { - item_start: run.text_start, - item_end: run.text_end, - context_start: run.text_start, - context_end: run.text_end, - flags: run.flags, - }, - ) - }; - let run = &request.runs[run_index]; - let slot = if let Some(slot) = font_slots.get(&run.font_handle) { - *slot - } else { - let slot = u16::try_from(output.font_handles.len()) - .map_err(|_| STATUS_RESULT_TOO_LARGE)?; - output.font_handles.push(run.font_handle); - font_slots.insert(run.font_handle, slot); - slot - }; - let glyph_start = - u32::try_from(output.glyph_ids.len()).map_err(|_| STATUS_RESULT_TOO_LARGE)?; - let font = self - .fonts - .get_mut(&run.font_handle) - .ok_or(STATUS_FONT_MISSING)?; - let shaped = shape_segment(font, &request.text, run, range)?; - let glyph_count = u32::try_from(shaped.len()).map_err(|_| STATUS_RESULT_TOO_LARGE)?; - output.run_font_slots.push(slot); - output.run_glyph_starts.push(glyph_start); - output.run_glyph_counts.push(glyph_count); - for (info, position) in shaped.glyph_infos().iter().zip(shaped.glyph_positions()) { - output - .glyph_ids - .push(u16::try_from(info.glyph_id).map_err(|_| STATUS_RESULT_TOO_LARGE)?); - output.clusters.push(info.cluster); - output.x_advances.push(position.x_advance); - output.y_advances.push(position.y_advance); - output.x_offsets.push(position.x_offset); - output.y_offsets.push(position.y_offset); - output.glyph_flags.push( - u16::try_from(info.flags().to_bits()).map_err(|_| STATUS_RESULT_TOO_LARGE)?, - ); - } - } - Ok(output) - } - - pub fn clear_result(&mut self) { - self.result.clear(); + font_handle: u32, + text: &[u16], + run: ShapeRunRef<'_>, + range: ShapeRangeRef, + consume: impl FnOnce(&harfrust::GlyphBuffer) -> Result, + ) -> Result { + let font = self + .fonts + .get_mut(&font_handle) + .ok_or(STATUS_FONT_MISSING)?; + let shaped = shape_segment( + font, + text, + run, + range, + &mut self.shape_buffer, + &mut self.context_codepoints, + &mut self.shape_features, + )?; + let result = consume(&shaped); + self.shape_buffer = Some(shaped.clear()); + result } - pub fn set_result(&mut self, result: Vec) -> Result<(), u32> { - self.result.set(result) - } - - pub fn result_pointer(&self) -> *const u32 { - self.result.words.as_ptr() + pub fn font_count(&self) -> u32 { + self.fonts.len().try_into().unwrap_or(u32::MAX) } - pub fn result_length(&self) -> u32 { - self.result.byte_length + pub fn contains_font(&self, handle: u32) -> bool { + self.fonts.contains_key(&handle) } - pub fn font_count(&self) -> u32 { - self.fonts.len().try_into().unwrap_or(u32::MAX) + pub fn glyph_count(&self, handle: u32) -> Option { + self.fonts + .get(&handle) + .and_then(|font| u32::try_from(font.extents.len() / 8).ok()) } pub fn retained_font_bytes(&self) -> u32 { @@ -309,114 +303,66 @@ impl ShaperRegistry { } } -impl ResultArena { - fn clear(&mut self) { - self.words.clear(); - self.byte_length = 0; - } - - fn set(&mut self, bytes: Vec) -> Result<(), u32> { - self.words.clear(); - self.words - .try_reserve_exact(bytes.len().div_ceil(4)) - .map_err(|_| STATUS_RESULT_TOO_LARGE)?; - for chunk in bytes.chunks(4) { - let mut word = [0; 4]; - word[..chunk.len()].copy_from_slice(chunk); - self.words.push(u32::from_le_bytes(word)); +fn shape_segment( + font: &mut RegisteredFont, + text: &[u16], + run: ShapeRunRef<'_>, + range: ShapeRangeRef, + buffer_slot: &mut Option, + context_codepoints: &mut Vec, + features: &mut Vec, +) -> Result { + let mut buffer = buffer_slot.take().ok_or(STATUS_INVALID_REQUEST)?; + match shape_segment_inner( + font, + text, + run, + range, + &mut buffer, + context_codepoints, + features, + ) { + Ok(()) => {} + Err(status) => { + *buffer_slot = Some(buffer); + return Err(status); } - self.byte_length = u32::try_from(bytes.len()).map_err(|_| STATUS_RESULT_TOO_LARGE)?; - Ok(()) } -} -impl ShapeBatchOutput { - fn with_run_capacity(run_count: usize) -> Self { - Self { - font_handles: Vec::new(), - run_font_slots: Vec::with_capacity(run_count), - run_glyph_starts: Vec::with_capacity(run_count), - run_glyph_counts: Vec::with_capacity(run_count), - glyph_ids: Vec::new(), - clusters: Vec::new(), - x_advances: Vec::new(), - y_advances: Vec::new(), - x_offsets: Vec::new(), - y_offsets: Vec::new(), - glyph_flags: Vec::new(), + let font_ref = match FontRef::new(&font.sfnt) { + Ok(font_ref) => font_ref, + Err(_) => { + *buffer_slot = Some(buffer); + return Err(STATUS_INVALID_FONT); } - } -} - -fn validate_request( - request: &ShapeBatchRequest, - ranges: Option<&[ReshapeRange]>, -) -> Result<(), u32> { - let text_length = u32::try_from(request.text.len()).map_err(|_| STATUS_INVALID_REQUEST)?; - if request.runs.is_empty() { + }; + let shaper = font.data.shaper(&font_ref).build(); + let Some(plan) = font.plans.last().map(|cached| &cached.plan) else { + *buffer_slot = Some(buffer); return Err(STATUS_INVALID_REQUEST); - } - for run in &request.runs { - if run.font_handle == 0 - || run.text_start > run.text_end - || run.text_end > text_length - || run.direction > 1 - || run.cluster_level > 3 - || run.flags & !BUFFER_FLAGS_MASK != 0 - || !valid_script(run.script) - || !valid_utf16_boundary(&request.text, run.text_start) - || !valid_utf16_boundary(&request.text, run.text_end) - || run - .language - .as_ref() - .is_some_and(|value| parse_language(value).is_none()) - { - return Err(STATUS_INVALID_REQUEST); - } - for feature in &run.features { - if !valid_tag(feature.tag) - || feature.start > feature.end - || feature.end > text_length - || !valid_utf16_boundary(&request.text, feature.start) - || !valid_utf16_boundary(&request.text, feature.end) - { - return Err(STATUS_INVALID_REQUEST); - } - } - } - if let Some(ranges) = ranges { - if ranges.is_empty() { - return Err(STATUS_INVALID_REQUEST); - } - for range in ranges { - let run = request - .runs - .get(usize::try_from(range.run).map_err(|_| STATUS_INVALID_REQUEST)?) - .ok_or(STATUS_INVALID_REQUEST)?; - if range.context_start > range.item_start - || range.item_start > range.item_end - || range.item_end > range.context_end - || range.context_start < run.text_start - || range.context_end > run.text_end - || range.flags & !BUFFER_FLAGS_MASK != 0 - || !valid_utf16_boundary(&request.text, range.item_start) - || !valid_utf16_boundary(&request.text, range.item_end) - || !valid_utf16_boundary(&request.text, range.context_start) - || !valid_utf16_boundary(&request.text, range.context_end) - { - return Err(STATUS_INVALID_REQUEST); - } - } - } - Ok(()) + }; + let mut extents = FlatExtents { + records: &font.extents, + availability: &font.availability, + }; + Ok(shaper.shape( + buffer, + ShapeOptions::new() + .features(features) + .plan(Some(plan)) + .font_funcs(Some(&mut extents)), + )) } -fn shape_segment( +fn shape_segment_inner( font: &mut RegisteredFont, text: &[u16], - run: &RunRequest, - range: SegmentRange, -) -> Result { + run: ShapeRunRef<'_>, + range: ShapeRangeRef, + buffer: &mut UnicodeBuffer, + context_codepoints: &mut Vec, + features: &mut Vec, +) -> Result<(), u32> { let direction = match run.direction { 0 => Direction::LeftToRight, 1 => Direction::RightToLeft, @@ -426,36 +372,14 @@ fn shape_segment( .ok_or(STATUS_INVALID_REQUEST)?; let language = run .language - .as_ref() .map(|value| parse_language(value).ok_or(STATUS_INVALID_REQUEST)) .transpose()?; - let features = run - .features + shape_features(run, range, features)?; + if let Some(index) = font + .plans .iter() - .map(|feature| { - let global = feature.start <= range.item_start && feature.end >= range.item_end; - Feature { - tag: Tag::from_be_bytes(feature.tag.to_be_bytes()), - value: feature.value, - start: if global { 0 } else { feature.start }, - end: if global { u32::MAX } else { feature.end }, - } - }) - .collect::>(); - let key = PlanKey { - direction: run.direction, - script: run.script, - language: language.as_ref().map(|value| value.as_bytes().to_vec()), - features: features - .iter() - .map(|feature| PlanFeatureKey { - tag: u32::from_be_bytes(feature.tag.to_be_bytes()), - value: feature.value, - global: feature.start == 0 && feature.end == u32::MAX, - }) - .collect(), - }; - if let Some(index) = font.plans.iter().position(|cached| cached.key == key) { + .position(|cached| plan_key_matches(&cached.key, run, language.as_ref(), features)) + { let cached = font.plans.remove(index); font.plans.push(cached); } else { @@ -466,24 +390,42 @@ fn shape_segment( direction, Some(script), language.as_ref(), - &features, + features, ); if font.plans.len() == MAX_CACHED_PLANS_PER_FONT { font.plans.remove(0); } + let key = PlanKey { + direction: run.direction, + script: run.script, + language: language.as_ref().map(|value| value.as_bytes().to_vec()), + features: features + .iter() + .map(|feature| PlanFeatureKey { + tag: u32::from_be_bytes(feature.tag.to_be_bytes()), + value: feature.value, + global: feature.start == 0 && feature.end == u32::MAX, + }) + .collect(), + }; font.plans.push(CachedPlan { key, plan }); } - let mut buffer = UnicodeBuffer::new(); - add_utf16_range(&mut buffer, text, range.item_start, range.item_end)?; + buffer.clear(); + add_utf16_range(buffer, text, range.item_start, range.item_end)?; if range.context_start < range.item_start { - let mut pre_context = decode_utf16_range(text, range.context_start, range.item_start)?; - pre_context.reverse(); - buffer.set_pre_context_codepoints(&pre_context); + decode_utf16_range_into( + text, + range.context_start, + range.item_start, + context_codepoints, + )?; + context_codepoints.reverse(); + buffer.set_pre_context_codepoints(context_codepoints); } if range.item_end < range.context_end { - let post_context = decode_utf16_range(text, range.item_end, range.context_end)?; - buffer.set_post_context_codepoints(&post_context); + decode_utf16_range_into(text, range.item_end, range.context_end, context_codepoints)?; + buffer.set_post_context_codepoints(context_codepoints); } buffer.set_direction(direction); buffer.set_script(script); @@ -499,20 +441,45 @@ fn shape_segment( }); buffer.set_flags(BufferFlags::from_bits(range.flags).ok_or(STATUS_INVALID_REQUEST)?); - let font_ref = FontRef::new(&font.sfnt).map_err(|_| STATUS_INVALID_FONT)?; - let shaper = font.data.shaper(&font_ref).build(); - let plan = &font.plans.last().ok_or(STATUS_INVALID_REQUEST)?.plan; - let mut extents = FlatExtents { - records: &font.extents, - availability: &font.availability, - }; - Ok(shaper.shape( - buffer, - ShapeOptions::new() - .features(&features) - .plan(Some(plan)) - .font_funcs(Some(&mut extents)), - )) + Ok(()) +} + +fn plan_key_matches( + key: &PlanKey, + run: ShapeRunRef<'_>, + language: Option<&Language>, + features: &[Feature], +) -> bool { + key.direction == run.direction + && key.script == run.script + && key.language.as_deref() == language.map(Language::as_bytes) + && key.features.len() == features.len() + && key.features.iter().zip(features).all(|(cached, feature)| { + cached.tag == u32::from_be_bytes(feature.tag.to_be_bytes()) + && cached.value == feature.value + && cached.global == (feature.start == 0 && feature.end == u32::MAX) + }) +} + +fn shape_features( + run: ShapeRunRef<'_>, + range: ShapeRangeRef, + output: &mut Vec, +) -> Result<(), u32> { + output.clear(); + output + .try_reserve(run.features.len()) + .map_err(|_| STATUS_RESULT_TOO_LARGE)?; + output.extend(run.features.iter().map(|feature| { + let global = feature.start <= range.item_start && feature.end >= range.item_end; + Feature { + tag: Tag::from_be_bytes(feature.tag.to_be_bytes()), + value: feature.value, + start: if global { 0 } else { feature.start }, + end: if global { u32::MAX } else { feature.end }, + } + })); + Ok(()) } struct FlatExtents<'a> { @@ -532,6 +499,16 @@ impl FontFuncs for FlatExtents<'_> { impl FlatExtents<'_> { fn extent_for_glyph(&self, glyph: usize) -> Option { + let raw = self.raw_extent_for_glyph(glyph)?; + Some(GlyphExtents { + x_bearing: raw.x_min, + y_bearing: raw.y_max, + width: raw.x_max - raw.x_min, + height: raw.y_min - raw.y_max, + }) + } + + fn raw_extent_for_glyph(&self, glyph: usize) -> Option { if self .availability .get(glyph >> 3) @@ -546,11 +523,11 @@ impl FlatExtents<'_> { let y_min = i16::from_le_bytes([record[2], record[3]]) as i32; let x_max = i16::from_le_bytes([record[4], record[5]]) as i32; let y_max = i16::from_le_bytes([record[6], record[7]]) as i32; - Some(GlyphExtents { - x_bearing: x_min, - y_bearing: y_max, - width: x_max - x_min, - height: y_min - y_max, + Some(FontGlyphExtents { + x_min, + y_min, + x_max, + y_max, }) } } @@ -576,18 +553,33 @@ fn add_utf16_range( Ok(()) } +#[cfg(test)] fn decode_utf16_range(text: &[u16], start: u32, end: u32) -> Result, u32> { + let mut decoded = Vec::new(); + decode_utf16_range_into(text, start, end, &mut decoded)?; + Ok(decoded) +} + +fn decode_utf16_range_into( + text: &[u16], + start: u32, + end: u32, + decoded: &mut Vec, +) -> Result<(), u32> { let start = usize::try_from(start).map_err(|_| STATUS_INVALID_REQUEST)?; let end = usize::try_from(end).map_err(|_| STATUS_INVALID_REQUEST)?; let units = text.get(start..end).ok_or(STATUS_INVALID_REQUEST)?; - let mut decoded = Vec::new(); + decoded.clear(); + decoded + .try_reserve(units.len()) + .map_err(|_| STATUS_RESULT_TOO_LARGE)?; let mut local = 0; while local < units.len() { let (character, consumed) = decode_scalar(units, local); decoded.push(character as u32); local += consumed; } - Ok(decoded) + Ok(()) } fn decode_scalar(units: &[u16], index: usize) -> (char, usize) { @@ -613,46 +605,50 @@ fn decode_scalar(units: &[u16], index: usize) -> (char, usize) { } fn parse_language(bytes: &[u8]) -> Option { + if valid_language_bytes(bytes) { + Language::new(bytes) + } else { + None + } +} + +pub(crate) fn valid_language_bytes(bytes: &[u8]) -> bool { if bytes.len() > u16::MAX as usize { - return None; + return false; } let mut subtags = bytes.split(|byte| *byte == b'-'); - let primary = subtags.next()?; + let Some(primary) = subtags.next() else { + return false; + }; let primary_is_private_or_grandfathered = matches!(primary, [b'x' | b'X'] | [b'i' | b'I']); if !(2..=8).contains(&primary.len()) && !primary_is_private_or_grandfathered { - return None; + return false; } if !primary.iter().all(u8::is_ascii_alphabetic) { - return None; + return false; } let mut subtag_count = 0; for subtag in subtags { if subtag.is_empty() || subtag.len() > 8 || !subtag.iter().all(u8::is_ascii_alphanumeric) { - return None; + return false; } subtag_count += 1; } if primary_is_private_or_grandfathered && subtag_count == 0 { - return None; + return false; } - - Language::new(bytes) + true } -fn valid_tag(tag: u32) -> bool { +pub(crate) fn valid_tag(tag: u32) -> bool { tag.to_be_bytes() .iter() .all(|byte| (0x20..=0x7e).contains(byte)) } -fn valid_script(tag: u32) -> bool { - let bytes = tag.to_be_bytes(); - bytes[0].is_ascii_uppercase() && bytes[1..].iter().all(u8::is_ascii_lowercase) -} - -fn valid_utf16_boundary(text: &[u16], offset: u32) -> bool { +pub(crate) fn valid_utf16_boundary(text: &[u16], offset: u32) -> bool { let Ok(offset) = usize::try_from(offset) else { return false; }; @@ -734,8 +730,6 @@ mod tests { fn tags_reject_non_opentype_bytes() { assert!(valid_tag(u32::from_be_bytes(*b"Latn"))); assert!(!valid_tag(u32::from_be_bytes([b'L', 0, b't', b'n']))); - assert!(valid_script(u32::from_be_bytes(*b"Latn"))); - assert!(!valid_script(u32::from_be_bytes(*b"LATN"))); } #[test] diff --git a/packages/text/rust/shaper/src/line_break.rs b/packages/text/rust/shaper/src/line_break.rs new file mode 100644 index 00000000..98fe5e08 --- /dev/null +++ b/packages/text/rust/shaper/src/line_break.rs @@ -0,0 +1,575 @@ +// UAX #14 rule behavior is ported from @cto.af/linebreak 4.0.3. Its MIT license is retained in +// LICENSES/cto-af-linebreak-MIT.txt; the implementation is specialized to retained no_std Rust storage. +use alloc::vec::Vec; + +use crate::unicode::UnicodeError; + +#[allow(dead_code)] +mod generated { + include!("generated/line_break_data.rs"); +} + +const SOT: i16 = -1; +const EOT: i16 = -2; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LineBreak { + pub position: u32, + pub required: bool, +} + +#[derive(Clone, Copy, Debug)] +struct BreakChar { + code_point: u32, + class: i16, + start: u32, + end: u32, + properties: u32, + source_index: usize, + ignored: bool, +} + +impl BreakChar { + const fn sentinel(class: i16, position: u32) -> Self { + Self { + code_point: 0, + class, + start: position, + end: position, + properties: 0, + source_index: usize::MAX, + ignored: false, + } + } + + fn has(self, property: u32) -> bool { + self.properties & property != 0 + } +} + +#[derive(Default)] +pub struct LineBreakAnalysis { + characters: Vec, + breaks: Vec, +} + +impl LineBreakAnalysis { + pub(crate) fn clear(&mut self) { + self.characters.clear(); + self.breaks.clear(); + } + + pub fn reserve(&mut self, capacity: usize) -> Result<(), UnicodeError> { + reserve(&mut self.characters, capacity)?; + reserve(&mut self.breaks, capacity.saturating_add(1)) + } + + pub fn analyze(&mut self, text: &[u16]) -> Result<(), UnicodeError> { + self.clear(); + self.reserve(text.len())?; + let mut offset = 0usize; + while offset < text.len() { + let start = offset; + let (code_point, consumed) = decode_scalar(text, offset)?; + offset += consumed; + let properties = properties(code_point).ok_or(UnicodeError::InvalidUtf16)?; + self.characters.push(BreakChar { + code_point, + class: (properties & 0xff) as i16, + start: u32::try_from(start).map_err(|_| UnicodeError::ResultTooLarge)?, + end: u32::try_from(offset).map_err(|_| UnicodeError::ResultTooLarge)?, + properties, + source_index: self.characters.len(), + ignored: false, + }); + } + let mut state = BreakState::new(&self.characters); + for character in self.characters.iter().copied() { + state.push(character); + if let Some(line_break) = state.decide() { + self.breaks.push(line_break); + } + } + let end = u32::try_from(text.len()).map_err(|_| UnicodeError::ResultTooLarge)?; + state.push(BreakChar::sentinel(EOT, end)); + if let Some(line_break) = state.decide() { + self.breaks.push(line_break); + } + Ok(()) + } + + pub fn breaks(&self) -> &[LineBreak] { + &self.breaks + } +} + +struct BreakState<'a> { + source: &'a [BreakChar], + previous: BreakChar, + current: BreakChar, + next: BreakChar, + previous_chunk: u32, + lb8: bool, + spaces: bool, + regional_indicators: u32, +} + +impl<'a> BreakState<'a> { + fn new(source: &'a [BreakChar]) -> Self { + let start = BreakChar::sentinel(SOT, 0); + Self { + source, + previous: start, + current: start, + next: start, + previous_chunk: 0, + lb8: false, + spaces: false, + regional_indicators: 0, + } + } + + fn push(&mut self, character: BreakChar) { + if self.next.ignored { + self.current.end = self.next.end; + } else { + self.previous = self.current; + self.current = self.next; + } + self.next = character; + } + + #[allow(clippy::too_many_lines)] + fn decide(&mut self) -> Option { + use generated::*; + let cur = self.current.class; + let next = self.next.class; + + // LB2-LB6. + if cur == SOT && next != EOT { + return None; + } + if next == EOT && (self.current.end == 0 || self.current.end != self.previous_chunk) { + return self.emit(true); + } + if cur == i16::from(BK) { + return self.emit(true); + } + if cur == i16::from(CR) { + return if next == i16::from(LF) { + None + } else { + self.emit(true) + }; + } + if matches!(cur, value if value == i16::from(LF) || value == i16::from(NL)) { + return self.emit(true); + } + if matches!(next, value if value == i16::from(BK) || value == i16::from(CR) || value == i16::from(LF) || value == i16::from(NL)) + { + return None; + } + + // Shared state before LB7. + if cur != i16::from(RI) { + self.regional_indicators = 0; + } + if self.spaces { + if next != i16::from(SP) { + self.spaces = false; + } + return None; + } + + // LB7-LB10. + if next == i16::from(ZW) { + return None; + } + if next == i16::from(SP) + && !matches!(cur, value if value == i16::from(ZW) || value == i16::from(OP) || value == i16::from(QU) || value == i16::from(CL) || value == i16::from(CP) || value == i16::from(B2)) + { + return None; + } + if self.lb8 { + self.lb8 = false; + return self.emit(false); + } + if cur == i16::from(ZW) { + if next == i16::from(SP) { + self.lb8 = true; + return None; + } + return self.emit(false); + } + if cur == i16::from(ZWJ) { + return None; + } + if !is_one_of(cur, &[BK, CR, LF, NL, SP, ZW]) + && matches!(next, value if value == i16::from(CM) || value == i16::from(ZWJ)) + { + self.next.ignored = true; + return None; + } + if self.current.class == i16::from(CM) { + self.current.class = i16::from(AL); + } + if self.next.class == i16::from(CM) { + self.next.class = i16::from(AL); + } + let cur = self.current.class; + let next = self.next.class; + + // LB11-LB15d. + if cur == i16::from(WJ) || next == i16::from(WJ) || cur == i16::from(GL) { + return None; + } + if next == i16::from(GL) && !is_one_of(cur, &[SP, BA, HY, HH]) { + return None; + } + if is_one_of(next, &[CL, CP, EX, SY]) { + return None; + } + if cur == i16::from(OP) { + self.spaces = next == i16::from(SP); + return None; + } + if (is_one_of(self.previous.class, &[BK, CR, LF, NL, OP, QU, GL, SP, ZW]) + || self.previous.class == SOT) + && self.current.has(INITIAL_PUNCTUATION) + && cur == i16::from(QU) + { + self.spaces = true; + return None; + } + if self.next.has(FINAL_PUNCTUATION) && next == i16::from(QU) { + let after = self.after_next(1); + if after.is_none_or(|character| { + is_one_of( + character.class, + &[SP, GL, WJ, CL, QU, CP, EX, IS, SY, BK, CR, LF, NL, ZW], + ) + }) { + return None; + } + } + if cur == i16::from(SP) + && next == i16::from(IS) + && self + .after_next(1) + .is_some_and(|after| after.class == i16::from(NU)) + { + return self.emit(false); + } + if next == i16::from(IS) { + return None; + } + + // LB16-LB20a. + if is_one_of(cur, &[CL, CP]) { + if self.class_after_spaces(self.current.end) == i16::from(NS) { + self.spaces = next == i16::from(SP); + return None; + } + if next == i16::from(SP) { + return None; + } + } + if cur == i16::from(B2) { + if self.class_after_spaces(self.current.end) == i16::from(B2) { + self.spaces = next == i16::from(SP); + return None; + } + if next == i16::from(SP) { + return None; + } + } + if cur == i16::from(SP) { + return self.emit(false); + } + if next == i16::from(QU) && !self.next.has(INITIAL_PUNCTUATION) { + return None; + } + if cur == i16::from(QU) && !self.current.has(FINAL_PUNCTUATION) { + return None; + } + if !self.current.has(EAST_ASIAN) && next == i16::from(QU) { + return None; + } + if next == i16::from(QU) + && self + .after_next(1) + .is_none_or(|after| !after.has(EAST_ASIAN)) + { + return None; + } + if cur == i16::from(QU) && !self.next.has(EAST_ASIAN) { + return None; + } + if cur == i16::from(QU) && (self.previous.class == SOT || !self.previous.has(EAST_ASIAN)) { + return None; + } + if cur == i16::from(CB) || next == i16::from(CB) { + return self.emit(false); + } + if (self.previous.class == SOT + || is_one_of(self.previous.class, &[BK, CR, LF, NL, SP, ZW, CB, GL])) + && is_one_of(cur, &[HY, HH]) + && is_one_of(next, &[AL, HL]) + { + return None; + } + + // LB21-LB24. + if cur == i16::from(BB) || is_one_of(next, &[BA, HH, HY, NS]) { + return None; + } + if self.previous.class == i16::from(HL) + && is_one_of(cur, &[HY, HH]) + && next != i16::from(HL) + { + return None; + } + if cur == i16::from(SY) && next == i16::from(HL) { + return None; + } + if next == i16::from(IN) { + return None; + } + if (is_one_of(cur, &[AL, HL]) && next == i16::from(NU)) + || (cur == i16::from(NU) && is_one_of(next, &[AL, HL])) + { + return None; + } + if (cur == i16::from(PR) && is_one_of(next, &[ID, EB, EM])) + || (next == i16::from(PO) && is_one_of(cur, &[ID, EB, EM])) + { + return None; + } + if (is_one_of(cur, &[PR, PO]) && is_one_of(next, &[AL, HL])) + || (is_one_of(cur, &[AL, HL]) && is_one_of(next, &[PR, PO])) + { + return None; + } + + // LB25. + if is_one_of(next, &[PO, PR, NU]) { + let before = if is_one_of(next, &[PO, PR]) && is_one_of(cur, &[CL, CP]) { + self.previous.end + } else { + self.current.end + }; + if self.numeric_prefix_before(before) { + return None; + } + } + if is_one_of(cur, &[PO, PR]) { + if next == i16::from(OP) { + if let Some(after) = self.after_next(1) + && (after.class == i16::from(NU) + || (after.class == i16::from(IS) + && self + .after_next(2) + .is_some_and(|value| value.class == i16::from(NU)))) + { + return None; + } + } else if next == i16::from(NU) { + return None; + } + } + if is_one_of(cur, &[HY, IS]) && next == i16::from(NU) { + return None; + } + + // LB26-LB30b. + if (cur == i16::from(JL) && is_one_of(next, &[JL, JV, H2, H3])) + || (is_one_of(cur, &[JV, H2]) && is_one_of(next, &[JV, JT])) + || (is_one_of(cur, &[JT, H3]) && next == i16::from(JT)) + { + return None; + } + if (is_one_of(cur, &[JL, JV, JT, H2, H3]) && next == i16::from(PO)) + || (cur == i16::from(PR) && is_one_of(next, &[JL, JV, JT, H2, H3])) + { + return None; + } + if is_one_of(cur, &[AL, HL]) && is_one_of(next, &[AL, HL]) { + return None; + } + let dotted_circle = 0x25cc; + let current_ak = is_one_of(cur, &[AK, AS]) || self.current.code_point == dotted_circle; + let next_ak = is_one_of(next, &[AK, AS]) || self.next.code_point == dotted_circle; + let previous_ak = + is_one_of(self.previous.class, &[AK, AS]) || self.previous.code_point == dotted_circle; + if (cur == i16::from(AP) && next_ak) + || (current_ak && is_one_of(next, &[VF, VI])) + || (previous_ak + && cur == i16::from(VI) + && (next == i16::from(AK) || self.next.code_point == dotted_circle)) + || (current_ak + && next_ak + && self + .after_next(1) + .is_some_and(|after| after.class == i16::from(VF))) + { + return None; + } + if cur == i16::from(IS) && is_one_of(next, &[AL, HL]) { + return None; + } + if is_one_of(cur, &[AL, HL, NU]) && next == i16::from(OP) && !self.next.has(EAST_ASIAN) { + return None; + } + if cur == i16::from(CP) && !self.current.has(EAST_ASIAN) && is_one_of(next, &[AL, HL, NU]) { + return None; + } + if cur == i16::from(RI) && next == i16::from(RI) { + self.regional_indicators += 1; + if !self.regional_indicators.is_multiple_of(2) { + return None; + } + } else if cur != i16::from(RI) { + self.regional_indicators = 0; + } + if cur == i16::from(EB) && next == i16::from(EM) { + return None; + } + if next == i16::from(EM) && self.current.has(UNASSIGNED_EXTENDED_PICTOGRAPHIC) { + return None; + } + + // LB31. + self.emit(false) + } + + fn emit(&mut self, required: bool) -> Option { + let position = self.current.end; + self.previous_chunk = position; + Some(LineBreak { position, required }) + } + + fn after_next(&self, offset: usize) -> Option { + let index = self.next.source_index.checked_add(offset)?; + self.source.get(index).copied() + } + + fn class_after_spaces(&self, position: u32) -> i16 { + self.source + .iter() + .find(|character| { + character.start >= position && character.class != i16::from(generated::SP) + }) + .map_or(EOT, |character| character.class) + } + + fn numeric_prefix_before(&self, mut position: u32) -> bool { + while position > 0 { + let character = if position == self.current.end { + self.current + } else if position == self.previous.end { + self.previous + } else if let Some(character) = self + .source + .iter() + .rev() + .find(|character| character.end <= position) + .copied() + { + character + } else { + return false; + }; + if character.class == i16::from(generated::NU) { + return true; + } + if !is_one_of(character.class, &[generated::SY, generated::IS]) { + return false; + } + position = character.start; + } + false + } +} + +fn properties(code_point: u32) -> Option { + if code_point > 0x10_ffff { + return None; + } + let values = generated::LINE_BREAK_END_VALUES; + let mut low = 0usize; + let mut high = values.len() / 2; + while low < high { + let middle = low + (high - low) / 2; + if code_point >= values[middle * 2] { + low = middle + 1; + } else { + high = middle; + } + } + values.get(low.checked_mul(2)?.checked_add(1)?).copied() +} + +fn is_one_of(class: i16, values: &[u8]) -> bool { + values.iter().any(|value| class == i16::from(*value)) +} + +fn decode_scalar(text: &[u16], index: usize) -> Result<(u32, usize), UnicodeError> { + let first = text[index]; + if (0xd800..=0xdbff).contains(&first) { + let second = text + .get(index + 1) + .copied() + .filter(|unit| (0xdc00..=0xdfff).contains(unit)) + .ok_or(UnicodeError::InvalidUtf16)?; + return Ok(( + 0x1_0000 + (((u32::from(first) - 0xd800) << 10) | (u32::from(second) - 0xdc00)), + 2, + )); + } + if (0xdc00..=0xdfff).contains(&first) { + return Err(UnicodeError::InvalidUtf16); + } + Ok((u32::from(first), 1)) +} + +fn reserve(values: &mut Vec, capacity: usize) -> Result<(), UnicodeError> { + if values.capacity() < capacity { + values + .try_reserve_exact(capacity.saturating_sub(values.len())) + .map_err(|_| UnicodeError::ResultTooLarge)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finds_required_and_allowed_utf16_breaks() { + let mut analysis = LineBreakAnalysis::default(); + analysis + .analyze(&"one two".encode_utf16().collect::>()) + .unwrap(); + assert_eq!( + analysis.breaks(), + &[ + LineBreak { + position: 4, + required: false, + }, + LineBreak { + position: 7, + required: true, + }, + ] + ); + analysis + .analyze(&"one\r\ntwo".encode_utf16().collect::>()) + .unwrap(); + assert!(analysis.breaks().contains(&LineBreak { + position: 5, + required: true, + })); + } +} diff --git a/packages/text/rust/shaper/src/unicode.rs b/packages/text/rust/shaper/src/unicode.rs new file mode 100644 index 00000000..95fc1bd8 --- /dev/null +++ b/packages/text/rust/shaper/src/unicode.rs @@ -0,0 +1,406 @@ +use alloc::{string::String, vec::Vec}; +use unicode_segmentation::UnicodeSegmentation; + +use crate::line_break::{LineBreak, LineBreakAnalysis}; + +mod generated { + include!("generated/script_data.rs"); +} + +pub use generated::UNICODE_VERSION; + +pub const COMMON_SCRIPT: u32 = generated::COMMON_SCRIPT; +pub const INHERITED_SCRIPT: u32 = generated::INHERITED_SCRIPT; +pub const UNKNOWN_SCRIPT: u32 = generated::UNKNOWN_SCRIPT; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UnicodeError { + InvalidUtf16, + ResultTooLarge, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ScriptItem { + pub text_start: u32, + pub text_end: u32, + pub script: u32, +} + +#[derive(Default)] +pub struct UnicodeAnalysis { + utf8: String, + line_breaks: LineBreakAnalysis, + grapheme_boundaries: Vec, + grapheme_scripts: Vec, + candidate_offsets: Vec, + candidate_tags: Vec, + candidate_scratch: Vec, + script_items: Vec, +} + +impl UnicodeAnalysis { + pub fn reserve(&mut self, utf16_capacity: usize) -> Result<(), UnicodeError> { + reserve( + &mut self.grapheme_boundaries, + utf16_capacity.saturating_add(1), + )?; + reserve(&mut self.grapheme_scripts, utf16_capacity)?; + reserve( + &mut self.candidate_offsets, + utf16_capacity.saturating_add(1), + )?; + reserve(&mut self.candidate_tags, utf16_capacity)?; + reserve(&mut self.script_items, utf16_capacity)?; + self.line_breaks.reserve(utf16_capacity)?; + self.utf8 + .try_reserve(utf16_capacity.saturating_mul(3)) + .map_err(|_| UnicodeError::ResultTooLarge) + } + + pub fn analyze(&mut self, text: &[u16]) -> Result<(), UnicodeError> { + self.clear(); + self.reserve(text.len())?; + encode_utf16(text, &mut self.utf8)?; + self.line_breaks.analyze(text)?; + self.segment_graphemes()?; + self.resolve_grapheme_scripts(text)?; + self.resolve_neutral_scripts(); + self.itemize_scripts()?; + Ok(()) + } + + pub fn grapheme_boundaries(&self) -> &[u32] { + &self.grapheme_boundaries + } + + pub fn script_items(&self) -> &[ScriptItem] { + &self.script_items + } + + pub fn line_breaks(&self) -> &[LineBreak] { + self.line_breaks.breaks() + } + + pub(crate) fn reusable_for_ascii_letter_edit( + &self, + previous: &[u16], + next: &[u16], + start: usize, + previous_end: usize, + next_end: usize, + ) -> bool { + previous.len() == next.len() + && previous_end.saturating_sub(start) == next_end.saturating_sub(start) + && previous_end <= previous.len() + && next_end <= next.len() + && previous + .get(start..previous_end) + .zip(next.get(start..next_end)) + .is_some_and(|(previous, next)| { + !previous.is_empty() + && previous.iter().zip(next).all(|(previous, next)| { + is_ascii_letter(*previous) && is_ascii_letter(*next) + }) + }) + } + + pub(crate) fn clear(&mut self) { + self.utf8.clear(); + self.line_breaks.clear(); + self.grapheme_boundaries.clear(); + self.grapheme_scripts.clear(); + self.candidate_offsets.clear(); + self.candidate_tags.clear(); + self.candidate_scratch.clear(); + self.script_items.clear(); + } + + fn segment_graphemes(&mut self) -> Result<(), UnicodeError> { + self.grapheme_boundaries.push(0); + let mut utf16_end = 0usize; + for grapheme in self.utf8.graphemes(true) { + utf16_end = utf16_end + .checked_add(grapheme.chars().map(char::len_utf16).sum::()) + .ok_or(UnicodeError::ResultTooLarge)?; + self.grapheme_boundaries + .push(u32::try_from(utf16_end).map_err(|_| UnicodeError::ResultTooLarge)?); + } + Ok(()) + } + + fn resolve_grapheme_scripts(&mut self, text: &[u16]) -> Result<(), UnicodeError> { + self.candidate_offsets.push(0); + for boundary in self.grapheme_boundaries.windows(2) { + let start = usize::try_from(boundary[0]).map_err(|_| UnicodeError::ResultTooLarge)?; + let end = usize::try_from(boundary[1]).map_err(|_| UnicodeError::ResultTooLarge)?; + let mut preferred = COMMON_SCRIPT; + let mut intersected = false; + self.candidate_scratch.clear(); + let mut index = start; + while index < end { + let (character, consumed) = decode_scalar(text, index)?; + index += consumed; + let primary = script(character as u32).ok_or(UnicodeError::InvalidUtf16)?; + if !is_neutral_script(primary) && preferred == COMMON_SCRIPT { + preferred = primary; + } + let extensions = + script_extensions(character as u32).ok_or(UnicodeError::InvalidUtf16)?; + if !extensions + .iter() + .copied() + .any(|tag| !is_neutral_script(tag)) + { + continue; + } + if !intersected { + intersected = true; + self.candidate_scratch.extend( + extensions + .iter() + .copied() + .filter(|tag| !is_neutral_script(*tag)), + ); + } else { + self.candidate_scratch + .retain(|tag| extensions.contains(tag)); + } + } + self.grapheme_scripts.push(preferred); + if intersected + && !self.candidate_scratch.is_empty() + && !self.candidate_scratch.contains(&preferred) + { + self.candidate_tags + .extend_from_slice(&self.candidate_scratch); + } + self.candidate_offsets.push( + u32::try_from(self.candidate_tags.len()) + .map_err(|_| UnicodeError::ResultTooLarge)?, + ); + } + Ok(()) + } + + fn resolve_neutral_scripts(&mut self) { + let mut previous = COMMON_SCRIPT; + for index in 0..self.grapheme_scripts.len() { + let current = self.grapheme_scripts[index]; + if is_neutral_script(current) { + if self.accepts_context(index, previous) { + self.grapheme_scripts[index] = previous; + } + } else { + previous = current; + } + } + let mut next = COMMON_SCRIPT; + for index in (0..self.grapheme_scripts.len()).rev() { + let current = self.grapheme_scripts[index]; + if is_neutral_script(current) { + if self.accepts_context(index, next) { + self.grapheme_scripts[index] = next; + } + } else { + next = current; + } + } + } + + fn accepts_context(&self, index: usize, context: u32) -> bool { + if is_neutral_script(context) { + return false; + } + let start = self.candidate_offsets[index] as usize; + let end = self.candidate_offsets[index + 1] as usize; + start == end || self.candidate_tags[start..end].contains(&context) + } + + fn itemize_scripts(&mut self) -> Result<(), UnicodeError> { + for (index, boundaries) in self.grapheme_boundaries.windows(2).enumerate() { + let item = ScriptItem { + text_start: boundaries[0], + text_end: boundaries[1], + script: self.grapheme_scripts[index], + }; + if let Some(previous) = self.script_items.last_mut() + && previous.text_end == item.text_start + && previous.script == item.script + { + previous.text_end = item.text_end; + } else { + self.script_items.push(item); + } + } + if self.grapheme_scripts.len() + 1 != self.grapheme_boundaries.len() { + return Err(UnicodeError::InvalidUtf16); + } + Ok(()) + } +} + +pub fn script(code_point: u32) -> Option { + lookup_partition(generated::SCRIPT_END_VALUES, code_point) +} + +pub fn script_extensions(code_point: u32) -> Option<&'static [u32]> { + let set = usize::try_from(lookup_partition( + generated::SCRIPT_EXTENSION_END_VALUES, + code_point, + )?) + .ok()?; + let start = usize::try_from(*generated::SCRIPT_EXTENSION_OFFSETS.get(set)?).ok()?; + let end = usize::try_from(*generated::SCRIPT_EXTENSION_OFFSETS.get(set + 1)?).ok()?; + generated::SCRIPT_EXTENSION_TAGS.get(start..end) +} + +fn lookup_partition(end_values: &[u32], code_point: u32) -> Option { + if code_point > 0x10_ffff { + return None; + } + let mut low = 0; + let mut high = end_values.len() / 2; + while low < high { + let middle = low + (high - low) / 2; + let offset = middle * 2; + let end = end_values[offset]; + if code_point >= end { + low = middle + 1; + } else { + high = middle; + } + } + end_values.get(low.checked_mul(2)?.checked_add(1)?).copied() +} + +fn reserve(values: &mut Vec, capacity: usize) -> Result<(), UnicodeError> { + if values.capacity() < capacity { + values + .try_reserve_exact(capacity.saturating_sub(values.len())) + .map_err(|_| UnicodeError::ResultTooLarge)?; + } + Ok(()) +} + +fn encode_utf16(text: &[u16], output: &mut String) -> Result<(), UnicodeError> { + let mut index = 0; + while index < text.len() { + let (character, consumed) = decode_scalar(text, index)?; + output.push(character); + index += consumed; + } + Ok(()) +} + +fn decode_scalar(text: &[u16], index: usize) -> Result<(char, usize), UnicodeError> { + let first = text[index]; + if (0xd800..=0xdbff).contains(&first) { + let second = text + .get(index + 1) + .copied() + .filter(|unit| (0xdc00..=0xdfff).contains(unit)) + .ok_or(UnicodeError::InvalidUtf16)?; + let scalar = + 0x1_0000 + (((u32::from(first) - 0xd800) << 10) | (u32::from(second) - 0xdc00)); + return char::from_u32(scalar) + .map(|character| (character, 2)) + .ok_or(UnicodeError::InvalidUtf16); + } + if (0xdc00..=0xdfff).contains(&first) { + return Err(UnicodeError::InvalidUtf16); + } + char::from_u32(u32::from(first)) + .map(|character| (character, 1)) + .ok_or(UnicodeError::InvalidUtf16) +} + +fn is_neutral_script(script: u32) -> bool { + matches!(script, COMMON_SCRIPT | INHERITED_SCRIPT | UNKNOWN_SCRIPT) +} + +fn is_ascii_letter(unit: u16) -> bool { + matches!(unit, 0x41..=0x5a | 0x61..=0x7a) +} + +#[cfg(test)] +mod tests { + use super::*; + + const fn tag(value: &[u8; 4]) -> u32 { + u32::from_be_bytes(*value) + } + + #[test] + fn generated_tables_are_unicode_17_scalar_partitions() { + assert_eq!(UNICODE_VERSION, "17.0.0"); + assert_eq!(script('A' as u32), Some(tag(b"Latn"))); + assert_eq!(script('\u{4e00}' as u32), Some(tag(b"Hani"))); + assert_eq!(script(0x10_ffff), Some(UNKNOWN_SCRIPT)); + assert_eq!(script(0x11_0000), None); + } + + #[test] + fn script_extensions_preserve_shared_marks() { + let prolonged_sound_mark = script_extensions('\u{30fc}' as u32).expect("extensions"); + assert!(prolonged_sound_mark.contains(&tag(b"Hira"))); + assert!(prolonged_sound_mark.contains(&tag(b"Kana"))); + } + + #[test] + fn analysis_segments_emoji_and_resolves_neutral_scripts() { + let text: Vec = "Latin, हिन्दी 👩‍🚀 カー".encode_utf16().collect(); + let mut analysis = UnicodeAnalysis::default(); + analysis.reserve(64).unwrap(); + analysis.analyze(&text).unwrap(); + assert_eq!(analysis.grapheme_boundaries().first(), Some(&0)); + assert_eq!( + analysis.grapheme_boundaries().last(), + Some(&(text.len() as u32)) + ); + assert!( + analysis + .grapheme_boundaries() + .windows(2) + .any(|range| range[1] - range[0] == 5) + ); + assert!( + analysis + .script_items() + .iter() + .any(|item| item.script == tag(b"Deva")) + ); + assert!( + analysis + .script_items() + .iter() + .any(|item| item.script == tag(b"Kana")) + ); + } + + #[test] + fn analysis_reuses_reserved_storage_and_rejects_unpaired_surrogates() { + let mut analysis = UnicodeAnalysis::default(); + analysis.reserve(64).unwrap(); + analysis + .analyze(&"abc".encode_utf16().collect::>()) + .unwrap(); + let boundary_capacity = analysis.grapheme_boundaries.capacity(); + analysis + .analyze(&"def".encode_utf16().collect::>()) + .unwrap(); + assert_eq!(analysis.grapheme_boundaries.capacity(), boundary_capacity); + assert_eq!(analysis.analyze(&[0xd800]), Err(UnicodeError::InvalidUtf16)); + } + + #[test] + fn only_equal_length_ascii_letter_edits_reuse_unicode_structure() { + let analysis = UnicodeAnalysis::default(); + let previous: Vec = "alpha".encode_utf16().collect(); + let letters: Vec = "aloha".encode_utf16().collect(); + let digit: Vec = "al0ha".encode_utf16().collect(); + assert!(analysis.reusable_for_ascii_letter_edit(&previous, &letters, 2, 3, 3)); + assert!(!analysis.reusable_for_ascii_letter_edit(&previous, &digit, 2, 3, 3)); + assert!(!analysis.reusable_for_ascii_letter_edit(&previous, &letters, 2, 4, 3)); + assert!(!analysis.reusable_for_ascii_letter_edit(&previous, &letters, 6, 7, 7)); + } +} diff --git a/packages/text/rust/shaper/src/wasm.rs b/packages/text/rust/shaper/src/wasm.rs index 30fc449e..47db5b29 100644 --- a/packages/text/rust/shaper/src/wasm.rs +++ b/packages/text/rust/shaper/src/wasm.rs @@ -1,11 +1,14 @@ -use alloc::{boxed::Box, vec::Vec}; +use alloc::{boxed::Box, collections::BTreeMap, vec::Vec}; use core::sync::atomic::{AtomicUsize, Ordering}; use crate::{ - STATUS_INVALID_REQUEST, STATUS_RESULT_TOO_LARGE, ShaperRegistry, bidi, - wire::{ - pack_bidi_result, pack_result, parse_bidi_request, parse_reshape_request, - parse_shape_request, + STATUS_FONT_IN_USE, STATUS_FONT_STACK_MISSING, STATUS_INVALID_HANDLE, STATUS_INVALID_REQUEST, + STATUS_OK, STATUS_POLICY_CONFLICT, STATUS_POLICY_MISSING, STATUS_RESULT_TOO_LARGE, + STATUS_REVISION_CONFLICT, STATUS_SESSION_CONFLICT, STATUS_SESSION_MISSING, ShaperRegistry, + engine::{ + EngineError, TextEngine, font_binding_wire::parse_font_binding, frame::SessionRevision, + frame_wire::parse_update_request, render_plan_wire::publication_layout, + transport::FrameTransport, wire::parse_policy, }, }; @@ -22,6 +25,19 @@ fn panic(_info: &core::panic::PanicInfo<'_>) -> ! { core::arch::wasm32::unreachable() } +#[unsafe(no_mangle)] +pub extern "C" fn pmndrs_text_shaper_initialize() -> u32 { + with_state(|state| { + if let Err(status) = state.registry.initialize() { + return status; + } + match state.engine.initialize() { + Ok(()) => STATUS_OK, + Err(error) => engine_status(error), + } + }) +} + #[unsafe(no_mangle)] pub extern "C" fn pmndrs_text_shaper_alloc(length: u32) -> u32 { with_state(|state| state.allocate(length)) @@ -46,6 +62,7 @@ pub unsafe extern "C" fn pmndrs_text_shaper_register_font( let WasmState { registry, allocations, + .. } = state; let Some(sfnt) = owned_bytes(allocations, sfnt_pointer, sfnt_length) else { return 2; @@ -64,7 +81,17 @@ pub unsafe extern "C" fn pmndrs_text_shaper_register_font( #[unsafe(no_mangle)] pub extern "C" fn pmndrs_text_shaper_dispose_font(handle: u32) -> u32 { - with_state(|state| state.registry.dispose_font(handle)) + with_state(|state| { + if state.engine.references_shaping_font(handle) { + STATUS_FONT_IN_USE + } else { + let status = state.registry.dispose_font(handle); + if status == STATUS_OK { + state.engine.dispose_bindings_for_shaping_font(handle); + } + status + } + }) } #[unsafe(no_mangle)] @@ -83,85 +110,519 @@ pub extern "C" fn pmndrs_text_shaper_plan_count() -> u32 { } #[unsafe(no_mangle)] -pub unsafe extern "C" fn pmndrs_text_shaper_shape_batch(pointer: u32, length: u32) -> u32 { +pub unsafe extern "C" fn pmndrs_text_engine_register_font_stack( + handle: u32, + pointer: u32, + count: u32, +) -> u32 { with_state(|state| { - state.registry.clear_result(); - let Some(bytes) = owned_bytes(&state.allocations, pointer, length) else { + let Some(length) = count.checked_mul(4) else { return STATUS_INVALID_REQUEST; }; - let request = match parse_shape_request(bytes) { - Ok(request) => request, - Err(status) => return status, - }; - let output = match state.registry.shape_batch(&request) { - Ok(output) => output, - Err(status) => return status, + let Some(bytes) = owned_bytes(&state.allocations, pointer, length) else { + return STATUS_INVALID_REQUEST; }; - match pack_result(&output) { - Ok(result) => store_result(&mut state.registry, result), - Err(status) => status, + let mut fonts = Vec::new(); + if fonts.try_reserve_exact(count as usize).is_err() { + return STATUS_RESULT_TOO_LARGE; + } + for bytes in bytes.chunks_exact(4) { + let font = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); + let Some(shaping_handle) = state.engine.shaping_handle_for_binding(font) else { + return crate::STATUS_FONT_MISSING; + }; + if !state.registry.contains_font(shaping_handle) { + return crate::STATUS_FONT_MISSING; + } + fonts.push(font); + } + match state.engine.register_font_stack(handle, &fonts) { + Ok(()) => STATUS_OK, + Err(error) => engine_status(error), } }) } #[unsafe(no_mangle)] -pub unsafe extern "C" fn pmndrs_text_shaper_reshape_ranges(pointer: u32, length: u32) -> u32 { +pub extern "C" fn pmndrs_text_engine_dispose_font_stack(handle: u32) -> u32 { + with_state(|state| match state.engine.dispose_font_stack(handle) { + Ok(()) => STATUS_OK, + Err(error) => engine_status(error), + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pmndrs_text_engine_font_stack_count() -> u32 { + with_state(|state| state.engine.font_stack_count()) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pmndrs_text_engine_register_font_binding( + handle: u32, + shaping_handle: u32, + pointer: u32, + length: u32, +) -> u32 { with_state(|state| { - state.registry.clear_result(); + let Some(glyph_count) = state.registry.glyph_count(shaping_handle) else { + return crate::STATUS_FONT_MISSING; + }; let Some(bytes) = owned_bytes(&state.allocations, pointer, length) else { return STATUS_INVALID_REQUEST; }; - let (request, ranges) = match parse_reshape_request(bytes) { - Ok(request) => request, + let binding = match parse_font_binding(bytes) { + Ok(binding) => binding, Err(status) => return status, }; - let output = match state.registry.reshape_ranges(&request, &ranges) { - Ok(output) => output, - Err(status) => return status, - }; - match pack_result(&output) { - Ok(result) => store_result(&mut state.registry, result), - Err(status) => status, + match state + .engine + .register_font_binding(handle, shaping_handle, glyph_count, binding) + { + Ok(()) => STATUS_OK, + Err(error) => engine_status(error), } }) } #[unsafe(no_mangle)] -pub unsafe extern "C" fn pmndrs_text_shaper_analyze_bidi(pointer: u32, length: u32) -> u32 { +pub extern "C" fn pmndrs_text_engine_font_binding_count() -> u32 { + with_state(|state| state.engine.font_binding_count()) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pmndrs_text_engine_register_policy( + handle: u32, + pointer: u32, + length: u32, +) -> u32 { with_state(|state| { - state.registry.clear_result(); - let Some(bytes) = owned_bytes(&state.allocations, pointer, length) else { + let WasmState { + engine, + allocations, + .. + } = state; + let Some(bytes) = owned_bytes(allocations, pointer, length) else { return STATUS_INVALID_REQUEST; }; - let (text, direction) = match parse_bidi_request(bytes) { - Ok(request) => request, + let policy = match parse_policy(bytes) { + Ok(policy) => policy, Err(status) => return status, }; - let output = match bidi::analyze(&text, direction) { - Ok(output) => output, - Err(bidi::BidiError::InvalidDirection) => return STATUS_INVALID_REQUEST, - Err(bidi::BidiError::ResultTooLarge) => return STATUS_RESULT_TOO_LARGE, + match engine.register_policy(handle, policy) { + Ok(()) => 0, + Err(error) => engine_status(error), + } + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pmndrs_text_engine_dispose_policy(handle: u32) -> u32 { + with_state(|state| match state.engine.dispose_policy(handle) { + Ok(()) => 0, + Err(error) => engine_status(error), + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pmndrs_text_engine_policy_count() -> u32 { + with_state(|state| state.engine.policy_count()) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pmndrs_text_engine_create_session( + handle: u32, + request_capacity: u32, + result_capacity: u32, + text_capacity: u32, +) -> u32 { + with_state(|state| { + if handle == 0 { + return STATUS_INVALID_HANDLE; + } + if state.frames.contains_key(&handle) { + return STATUS_SESSION_CONFLICT; + } + let transport = match FrameTransport::new(request_capacity, result_capacity) { + Ok(transport) => transport, + Err(status) => return status, + }; + if let Err(error) = state.engine.create_session(handle) { + return engine_status(error); + } + let text_capacity = if text_capacity == 0 { + crate::engine::frame::DEFAULT_SESSION_TEXT_CAPACITY + } else { + text_capacity + }; + if let Err(error) = state.engine.reserve_session_text(handle, text_capacity) { + let _ = state.engine.dispose_session(handle); + return engine_status(error); + } + state.frames.insert(handle, transport); + 0 + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pmndrs_text_engine_reserve_session( + handle: u32, + request_capacity: u32, + result_capacity: u32, + text_capacity: u32, +) -> u32 { + with_state(|state| { + let Some(transport) = state.frames.get_mut(&handle) else { + return STATUS_SESSION_MISSING; }; - match pack_bidi_result(&output) { - Ok(result) => store_result(&mut state.registry, result), - Err(status) => status, + if let Err(status) = transport.reserve(request_capacity, result_capacity) { + return status; + } + if text_capacity != 0 + && let Err(error) = state.engine.reserve_session_text(handle, text_capacity) + { + return engine_status(error); + } + 0 + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pmndrs_text_engine_dispose_session(handle: u32) -> u32 { + with_state(|state| { + if !state.frames.contains_key(&handle) { + return STATUS_SESSION_MISSING; + } + if let Err(error) = state.engine.dispose_session(handle) { + return engine_status(error); } + state.frames.remove(&handle); + 0 + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pmndrs_text_engine_session_count() -> u32 { + with_state(|state| state.engine.session_count()) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pmndrs_text_engine_request_ptr(handle: u32) -> u32 { + with_state(|state| { + state + .frames + .get(&handle) + .and_then(|transport| u32::try_from(transport.request_pointer()).ok()) + .unwrap_or(0) }) } #[unsafe(no_mangle)] -pub extern "C" fn pmndrs_text_shaper_result_ptr() -> u32 { - with_state(|state| u32::try_from(state.registry.result_pointer() as usize).unwrap_or(0)) +pub extern "C" fn pmndrs_text_engine_request_capacity(handle: u32) -> u32 { + with_state(|state| { + state + .frames + .get(&handle) + .map_or(0, FrameTransport::request_capacity) + }) +} + +#[cfg(feature = "kernel-lab")] +#[unsafe(no_mangle)] +pub extern "C" fn pmndrs_text_kernel_lab_backend() -> u32 { + crate::engine::kernel_lab::BACKEND +} + +#[cfg(feature = "kernel-lab")] +#[unsafe(no_mangle)] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn pmndrs_text_kernel_lab_pack( + count: u32, + x_pointer: u32, + y_pointer: u32, + font_size_pointer: u32, + plane_left_pointer: u32, + plane_bottom_pointer: u32, + plane_right_pointer: u32, + plane_top_pointer: u32, + inverse_units_per_em: f32, + origins_pointer: u32, + sizes_pointer: u32, +) -> u32 { + // SAFETY: the test-only kernel validates every direct-memory region before creating slices. + unsafe { + crate::engine::kernel_lab::exported_pack( + count, + x_pointer, + y_pointer, + font_size_pointer, + plane_left_pointer, + plane_bottom_pointer, + plane_right_pointer, + plane_top_pointer, + inverse_units_per_em, + origins_pointer, + sizes_pointer, + ) + } +} + +#[cfg(feature = "kernel-lab")] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pmndrs_text_kernel_lab_break_masks( + count: u32, + flags_pointer: u32, + output_pointer: u32, +) -> u32 { + // SAFETY: the test-only kernel validates every direct-memory region before creating slices. + unsafe { crate::engine::kernel_lab::exported_break_masks(count, flags_pointer, output_pointer) } } +#[cfg(feature = "kernel-lab")] #[unsafe(no_mangle)] -pub extern "C" fn pmndrs_text_shaper_result_len() -> u32 { - with_state(|state| state.registry.result_length()) +pub unsafe extern "C" fn pmndrs_text_kernel_lab_bidi_masks( + count: u32, + levels_pointer: u32, + output_pointer: u32, +) -> u32 { + // SAFETY: the test-only kernel validates every direct-memory region before creating slices. + unsafe { crate::engine::kernel_lab::exported_bidi_masks(count, levels_pointer, output_pointer) } +} + +#[cfg(feature = "kernel-lab")] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pmndrs_text_kernel_lab_chunk_summaries( + count: u32, + chunk_size: u32, + advances_pointer: u32, + flags_pointer: u32, + advance_sums_pointer: u32, + break_counts_pointer: u32, +) -> u32 { + // SAFETY: the test-only kernel validates every direct-memory region before creating slices. + unsafe { + crate::engine::kernel_lab::exported_chunk_summaries( + count, + chunk_size, + advances_pointer, + flags_pointer, + advance_sums_pointer, + break_counts_pointer, + ) + } +} + +#[cfg(feature = "kernel-lab")] +#[unsafe(no_mangle)] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn pmndrs_text_kernel_lab_policy( + policy_handle: u32, + technique: u32, + variant: u32, + count: u32, + f32_input0_pointer: u32, + f32_input1_pointer: u32, + f32_input2_pointer: u32, + f32_input3_pointer: u32, + u32_input0_pointer: u32, + f32_output_pointer: u32, + u32_output_pointer: u32, + u16_output_pointer: u32, +) -> u32 { + let Some(f32_bytes) = count.checked_mul(core::mem::size_of::() as u32) else { + return STATUS_INVALID_REQUEST; + }; + let Some(f32_output_bytes) = f32_bytes.checked_mul(4) else { + return STATUS_INVALID_REQUEST; + }; + let Some(u16_bytes) = count.checked_mul(core::mem::size_of::() as u32) else { + return STATUS_INVALID_REQUEST; + }; + with_state(|state| { + let regions = [ + (f32_input0_pointer, f32_bytes), + (f32_input1_pointer, f32_bytes), + (f32_input2_pointer, f32_bytes), + (f32_input3_pointer, f32_bytes), + (u32_input0_pointer, f32_bytes), + (f32_output_pointer, f32_output_bytes), + (u32_output_pointer, f32_bytes), + (u16_output_pointer, u16_bytes), + ]; + if !regions + .iter() + .all(|&(pointer, length)| owns_region(&state.allocations, pointer, length)) + { + return STATUS_INVALID_REQUEST; + } + let policy = match state.engine.policy(policy_handle) { + Ok(policy) => policy, + Err(_) => return STATUS_POLICY_MISSING, + }; + // SAFETY: every direct-memory region belongs to a live caller allocation, so it cannot + // alias engine-owned state. The kernel validates alignment, bounds, and pairwise + // disjointness before creating slices, and this state borrow remains synchronous. + unsafe { + crate::engine::kernel_lab::exported_policy( + policy, + technique, + variant, + count, + f32_input0_pointer, + f32_input1_pointer, + f32_input2_pointer, + f32_input3_pointer, + u32_input0_pointer, + f32_output_pointer, + u32_output_pointer, + u16_output_pointer, + ) + } + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pmndrs_text_engine_update( + session_id: u32, + request_offset: u32, + request_len: u32, +) -> u32 { + with_state(|state| { + let revision = match state.engine.session_revision(session_id) { + Ok(revision) => revision, + Err(_) => return 0, + }; + let request = { + let Some(transport) = state.frames.get(&session_id) else { + return 0; + }; + let bytes = match transport.request_at(request_offset as usize, request_len) { + Ok(bytes) => bytes, + Err(status) => { + return publish_failure(state, session_id, revision, status, request_len, 0); + } + }; + match parse_update_request(bytes, session_id) { + Ok(request) => request, + Err(status) => { + return publish_failure(state, session_id, revision, status, 0, 0); + } + } + }; + let publication_generation = match state + .frames + .get(&session_id) + .and_then(|transport| transport.next_publication_generation().ok()) + { + Some(generation) => generation, + None => { + return publish_failure(state, session_id, revision, STATUS_RESULT_TOO_LARGE, 0, 0); + } + }; + let prepared = match state.engine.prepare_update_with_shaper( + &mut state.registry, + request, + publication_generation, + ) { + Ok(prepared) => prepared, + Err(error) => { + return publish_failure(state, session_id, revision, engine_status(error), 0, 0); + } + }; + let plan = match state.engine.prepared_plan(prepared) { + Ok(plan) => plan, + Err(error) => { + return publish_prepared_failure( + state, + prepared, + revision, + engine_status(error), + 0, + 0, + ); + } + }; + let semantic_views = match state.engine.prepared_semantic_views(prepared) { + Ok(views) => views, + Err(error) => { + return publish_prepared_failure( + state, + prepared, + revision, + engine_status(error), + 0, + 0, + ); + } + }; + let required_output = match publication_layout(plan, semantic_views) { + Ok(layout) => layout.byte_length, + Err(status) => { + return publish_prepared_failure(state, prepared, revision, status, 0, 0); + } + }; + if required_output > request.limits.max_output_bytes { + return publish_prepared_failure( + state, + prepared, + revision, + STATUS_RESULT_TOO_LARGE, + 0, + required_output, + ); + } + let Some(transport) = state.frames.get(&session_id) else { + let _ = state.engine.abort_update(prepared); + return 0; + }; + if let Err(status) = transport.ensure_publish_capacity(required_output) { + return publish_prepared_failure(state, prepared, revision, status, 0, required_output); + } + let staged = match state + .frames + .get_mut(&session_id) + .and_then(|transport| transport.stage_publication(plan, semantic_views).ok()) + { + Some(staged) => staged, + None => { + return publish_prepared_failure( + state, + prepared, + revision, + STATUS_RESULT_TOO_LARGE, + 0, + required_output, + ); + } + }; + let commit = match state.engine.commit_update(prepared) { + Ok(commit) => commit, + Err(error) => { + return publish_prepared_failure( + state, + prepared, + revision, + engine_status(error), + 0, + 0, + ); + } + }; + let Some(transport) = state.frames.get_mut(&session_id) else { + return 0; + }; + debug_assert_eq!( + transport.next_publication_generation().ok(), + Some(publication_generation) + ); + u32::try_from(transport.publish_success(commit, staged)).unwrap_or(0) + }) } #[derive(Default)] struct WasmState { registry: ShaperRegistry, + engine: TextEngine, + frames: BTreeMap, allocations: Vec, } @@ -227,13 +688,79 @@ fn owned_bytes(allocations: &[Allocation], pointer: u32, length: u32) -> Option< .map(|entry| entry.bytes.as_slice()) } -fn store_result(registry: &mut ShaperRegistry, result: Vec) -> u32 { - match registry.set_result(result) { - Ok(()) => 0, - Err(status) => status, +#[cfg(feature = "kernel-lab")] +fn owns_region(allocations: &[Allocation], pointer: u32, length: u32) -> bool { + let Some(end) = pointer.checked_add(length) else { + return false; + }; + length != 0 + && allocations.iter().any(|entry| { + entry + .pointer + .checked_add(entry.requested_length) + .is_some_and(|allocation_end| pointer >= entry.pointer && end <= allocation_end) + }) +} + +fn engine_status(error: EngineError) -> u32 { + match error { + EngineError::InvalidHandle => STATUS_INVALID_HANDLE, + EngineError::HandleConflict => STATUS_POLICY_CONFLICT, + EngineError::PolicyMissing => STATUS_POLICY_MISSING, + EngineError::FontStackMissing => STATUS_FONT_STACK_MISSING, + EngineError::SessionConflict => STATUS_SESSION_CONFLICT, + EngineError::SessionMissing => STATUS_SESSION_MISSING, + EngineError::RevisionConflict => STATUS_REVISION_CONFLICT, + EngineError::RevisionExhausted => STATUS_RESULT_TOO_LARGE, + EngineError::InvalidRequest => STATUS_INVALID_REQUEST, + EngineError::ResultTooLarge => STATUS_RESULT_TOO_LARGE, } } +fn publish_prepared_failure( + state: &mut WasmState, + prepared: crate::engine::frame::PreparedUpdate, + revision: SessionRevision, + status: u32, + required_request_capacity: u32, + required_result_capacity: u32, +) -> u32 { + let session_id = prepared.session_id(); + let _ = state.engine.abort_update(prepared); + publish_failure( + state, + session_id, + revision, + status, + required_request_capacity, + required_result_capacity, + ) +} + +fn publish_failure( + state: &mut WasmState, + session_id: u32, + revision: SessionRevision, + status: u32, + required_request_capacity: u32, + required_result_capacity: u32, +) -> u32 { + state + .frames + .get_mut(&session_id) + .and_then(|transport| { + u32::try_from(transport.publish_failure( + session_id, + revision, + status, + required_request_capacity, + required_result_capacity, + )) + .ok() + }) + .unwrap_or(0) +} + fn with_state(operation: impl FnOnce(&mut WasmState) -> Result) -> Result { let mut pointer = STATE.load(Ordering::Acquire); if pointer == 0 { diff --git a/packages/text/rust/shaper/src/wire.rs b/packages/text/rust/shaper/src/wire.rs index 898ae4e9..841c3a87 100644 --- a/packages/text/rust/shaper/src/wire.rs +++ b/packages/text/rust/shaper/src/wire.rs @@ -1,325 +1,12 @@ -use alloc::vec::Vec; +use crate::STATUS_INVALID_REQUEST; -use crate::{ - FeatureRecord, ReshapeRange, RunRequest, STATUS_INVALID_REQUEST, STATUS_RESULT_TOO_LARGE, - ShapeBatchOutput, ShapeBatchRequest, - abi_contract::{ - BIDI_DIRECTION, BIDI_REQUEST_HEADER_SIZE, BIDI_RESULT_BYTE_LENGTH, - BIDI_RESULT_CLASSES_OFFSET, BIDI_RESULT_HEADER_SIZE, BIDI_RESULT_LEVELS_OFFSET, - BIDI_RESULT_PARAGRAPH_COUNT, BIDI_RESULT_PARAGRAPH_ENDS_OFFSET, - BIDI_RESULT_PARAGRAPH_LEVELS_OFFSET, BIDI_RESULT_PARAGRAPH_STARTS_OFFSET, - BIDI_RESULT_TEXT_LENGTH, BIDI_TEXT_LENGTH, BIDI_TEXT_OFFSET, FEATURE_END, - FEATURE_RECORD_SIZE, FEATURE_START, FEATURE_TAG, FEATURE_VALUE, RANGE_CONTEXT_END, - RANGE_CONTEXT_START, RANGE_FLAGS, RANGE_ITEM_END, RANGE_ITEM_START, RANGE_RUN, - RESHAPE_RANGE_COUNT, RESHAPE_RANGE_RECORD_SIZE, RESHAPE_RANGES_OFFSET, - RESHAPE_REQUEST_HEADER_SIZE, RESULT_BYTE_LENGTH, RESULT_CLUSTERS_OFFSET, - RESULT_FONT_HANDLE_COUNT, RESULT_FONT_HANDLES_OFFSET, RESULT_GLYPH_COUNT, - RESULT_GLYPH_FLAGS_OFFSET, RESULT_GLYPH_IDS_OFFSET, RESULT_HEADER_SIZE, RESULT_RUN_COUNT, - RESULT_RUN_FONT_SLOTS_OFFSET, RESULT_RUN_GLYPH_COUNTS_OFFSET, - RESULT_RUN_GLYPH_STARTS_OFFSET, RESULT_X_ADVANCES_OFFSET, RESULT_X_OFFSETS_OFFSET, - RESULT_Y_ADVANCES_OFFSET, RESULT_Y_OFFSETS_OFFSET, RUN_CLUSTER_LEVEL, RUN_DIRECTION, - RUN_FEATURE_COUNT, RUN_FEATURE_START, RUN_FLAGS, RUN_FONT_HANDLE, RUN_LANGUAGE_OFFSET, - RUN_RECORD_SIZE, RUN_SCRIPT, RUN_TEXT_END, RUN_TEXT_START, SHAPE_FEATURE_COUNT, - SHAPE_FEATURES_OFFSET, SHAPE_LANGUAGES_LENGTH, SHAPE_LANGUAGES_OFFSET, - SHAPE_REQUEST_HEADER_SIZE, SHAPE_RUN_COUNT, SHAPE_RUNS_OFFSET, SHAPE_TEXT_LENGTH, - SHAPE_TEXT_OFFSET, - }, - bidi::BidiAnalysis, -}; - -const NO_LANGUAGE: u32 = u32::MAX; - -pub fn parse_bidi_request(bytes: &[u8]) -> Result<(Vec, u8), u32> { - if bytes.len() < BIDI_REQUEST_HEADER_SIZE as usize { - return Err(STATUS_INVALID_REQUEST); - } - let text_offset = read_u32(bytes, BIDI_TEXT_OFFSET)?; - let text_length = read_u32(bytes, BIDI_TEXT_LENGTH)?; - let direction = *bytes.get(BIDI_DIRECTION).ok_or(STATUS_INVALID_REQUEST)?; - if bytes.get(BIDI_DIRECTION + 1..BIDI_REQUEST_HEADER_SIZE as usize) != Some(&[0, 0, 0]) { - return Err(STATUS_INVALID_REQUEST); - } - let text_bytes = array(bytes, text_offset, text_length, 2, 2)?; - let mut text = - Vec::with_capacity(usize::try_from(text_length).map_err(|_| STATUS_INVALID_REQUEST)?); - for unit in text_bytes.chunks_exact(2) { - text.push(u16::from_le_bytes([unit[0], unit[1]])); - } - Ok((text, direction)) -} - -pub fn parse_shape_request(bytes: &[u8]) -> Result { - parse_request(bytes, false).map(|(request, _)| request) -} - -pub fn parse_reshape_request(bytes: &[u8]) -> Result<(ShapeBatchRequest, Vec), u32> { - let (request, ranges) = parse_request(bytes, true)?; - Ok((request, ranges.ok_or(STATUS_INVALID_REQUEST)?)) -} - -fn parse_request( +pub(crate) fn array( bytes: &[u8], - reshape: bool, -) -> Result<(ShapeBatchRequest, Option>), u32> { - let header_size = if reshape { - RESHAPE_REQUEST_HEADER_SIZE - } else { - SHAPE_REQUEST_HEADER_SIZE - } as usize; - if bytes.len() < header_size { - return Err(STATUS_INVALID_REQUEST); - } - let text_offset = read_u32(bytes, SHAPE_TEXT_OFFSET)?; - let text_length = read_u32(bytes, SHAPE_TEXT_LENGTH)?; - let runs_offset = read_u32(bytes, SHAPE_RUNS_OFFSET)?; - let run_count = read_u32(bytes, SHAPE_RUN_COUNT)?; - let features_offset = read_u32(bytes, SHAPE_FEATURES_OFFSET)?; - let feature_count = read_u32(bytes, SHAPE_FEATURE_COUNT)?; - let languages_offset = read_u32(bytes, SHAPE_LANGUAGES_OFFSET)?; - let languages_length = read_u32(bytes, SHAPE_LANGUAGES_LENGTH)?; - - let text_bytes = array(bytes, text_offset, text_length, 2, 2)?; - let mut text = - Vec::with_capacity(usize::try_from(text_length).map_err(|_| STATUS_INVALID_REQUEST)?); - for unit in text_bytes.chunks_exact(2) { - text.push(u16::from_le_bytes([unit[0], unit[1]])); - } - - let feature_bytes = array( - bytes, - features_offset, - feature_count, - FEATURE_RECORD_SIZE, - 4, - )?; - let mut features = - Vec::with_capacity(usize::try_from(feature_count).map_err(|_| STATUS_INVALID_REQUEST)?); - for record in feature_bytes.chunks_exact(FEATURE_RECORD_SIZE as usize) { - features.push(FeatureRecord { - tag: read_u32(record, FEATURE_TAG)?, - value: read_u32(record, FEATURE_VALUE)?, - start: read_u32(record, FEATURE_START)?, - end: read_u32(record, FEATURE_END)?, - }); - } - - let languages = array(bytes, languages_offset, languages_length, 1, 1)?; - let run_bytes = array(bytes, runs_offset, run_count, RUN_RECORD_SIZE, 4)?; - let mut runs = - Vec::with_capacity(usize::try_from(run_count).map_err(|_| STATUS_INVALID_REQUEST)?); - for record in run_bytes.chunks_exact(RUN_RECORD_SIZE as usize) { - let feature_start = usize::try_from(read_u32(record, RUN_FEATURE_START)?) - .map_err(|_| STATUS_INVALID_REQUEST)?; - let feature_count = usize::from(read_u16(record, RUN_FEATURE_COUNT)?); - let feature_end = feature_start - .checked_add(feature_count) - .ok_or(STATUS_INVALID_REQUEST)?; - let selected_features = features - .get(feature_start..feature_end) - .ok_or(STATUS_INVALID_REQUEST)? - .to_vec(); - let language_offset = read_u32(record, RUN_LANGUAGE_OFFSET)?; - let language = if language_offset == NO_LANGUAGE { - None - } else { - let offset = usize::try_from(language_offset).map_err(|_| STATUS_INVALID_REQUEST)?; - let length = usize::from(read_u16(languages, offset)?); - let start = offset.checked_add(2).ok_or(STATUS_INVALID_REQUEST)?; - let end = start.checked_add(length).ok_or(STATUS_INVALID_REQUEST)?; - Some( - languages - .get(start..end) - .ok_or(STATUS_INVALID_REQUEST)? - .to_vec(), - ) - }; - runs.push(RunRequest { - font_handle: read_u32(record, RUN_FONT_HANDLE)?, - text_start: read_u32(record, RUN_TEXT_START)?, - text_end: read_u32(record, RUN_TEXT_END)?, - script: read_u32(record, RUN_SCRIPT)?, - language, - features: selected_features, - direction: *record.get(RUN_DIRECTION).ok_or(STATUS_INVALID_REQUEST)?, - cluster_level: *record - .get(RUN_CLUSTER_LEVEL) - .ok_or(STATUS_INVALID_REQUEST)?, - flags: read_u32(record, RUN_FLAGS)?, - }); - } - - let ranges = if reshape { - let ranges_offset = read_u32(bytes, RESHAPE_RANGES_OFFSET)?; - let range_count = read_u32(bytes, RESHAPE_RANGE_COUNT)?; - let range_bytes = array( - bytes, - ranges_offset, - range_count, - RESHAPE_RANGE_RECORD_SIZE, - 4, - )?; - let mut ranges = - Vec::with_capacity(usize::try_from(range_count).map_err(|_| STATUS_INVALID_REQUEST)?); - for record in range_bytes.chunks_exact(RESHAPE_RANGE_RECORD_SIZE as usize) { - ranges.push(ReshapeRange { - run: read_u32(record, RANGE_RUN)?, - item_start: read_u32(record, RANGE_ITEM_START)?, - item_end: read_u32(record, RANGE_ITEM_END)?, - context_start: read_u32(record, RANGE_CONTEXT_START)?, - context_end: read_u32(record, RANGE_CONTEXT_END)?, - flags: read_u32(record, RANGE_FLAGS)?, - }); - } - Some(ranges) - } else { - None - }; - Ok((ShapeBatchRequest { text, runs }, ranges)) -} - -pub fn pack_result(output: &ShapeBatchOutput) -> Result, u32> { - let run_count = output.run_font_slots.len(); - let glyph_count = output.glyph_ids.len(); - if output.run_glyph_starts.len() != run_count - || output.run_glyph_counts.len() != run_count - || output.clusters.len() != glyph_count - || output.x_advances.len() != glyph_count - || output.y_advances.len() != glyph_count - || output.x_offsets.len() != glyph_count - || output.y_offsets.len() != glyph_count - || output.glyph_flags.len() != glyph_count - { - return Err(STATUS_RESULT_TOO_LARGE); - } - let mut bytes = result_bytes( - RESULT_HEADER_SIZE as usize, - &[ - (output.font_handles.len(), 4), - (output.run_font_slots.len(), 2), - (output.run_glyph_starts.len(), 4), - (output.run_glyph_counts.len(), 4), - (output.glyph_ids.len(), 2), - (output.clusters.len(), 4), - (output.x_advances.len(), 4), - (output.y_advances.len(), 4), - (output.x_offsets.len(), 4), - (output.y_offsets.len(), 4), - (output.glyph_flags.len(), 2), - ], - )?; - let font_handles_offset = append_u32(&mut bytes, &output.font_handles)?; - let run_font_slots_offset = append_u16(&mut bytes, &output.run_font_slots)?; - let run_glyph_starts_offset = append_u32(&mut bytes, &output.run_glyph_starts)?; - let run_glyph_counts_offset = append_u32(&mut bytes, &output.run_glyph_counts)?; - let glyph_ids_offset = append_u16(&mut bytes, &output.glyph_ids)?; - let clusters_offset = append_u32(&mut bytes, &output.clusters)?; - let x_advances_offset = append_i32(&mut bytes, &output.x_advances)?; - let y_advances_offset = append_i32(&mut bytes, &output.y_advances)?; - let x_offsets_offset = append_i32(&mut bytes, &output.x_offsets)?; - let y_offsets_offset = append_i32(&mut bytes, &output.y_offsets)?; - let glyph_flags_offset = append_u16(&mut bytes, &output.glyph_flags)?; - let byte_length = u32::try_from(bytes.len()).map_err(|_| STATUS_RESULT_TOO_LARGE)?; - - write_u32(&mut bytes, RESULT_BYTE_LENGTH, byte_length); - write_u32(&mut bytes, RESULT_FONT_HANDLES_OFFSET, font_handles_offset); - write_u32( - &mut bytes, - RESULT_FONT_HANDLE_COUNT, - u32::try_from(output.font_handles.len()).map_err(|_| STATUS_RESULT_TOO_LARGE)?, - ); - write_u32( - &mut bytes, - RESULT_RUN_FONT_SLOTS_OFFSET, - run_font_slots_offset, - ); - write_u32( - &mut bytes, - RESULT_RUN_GLYPH_STARTS_OFFSET, - run_glyph_starts_offset, - ); - write_u32( - &mut bytes, - RESULT_RUN_GLYPH_COUNTS_OFFSET, - run_glyph_counts_offset, - ); - write_u32( - &mut bytes, - RESULT_RUN_COUNT, - u32::try_from(run_count).map_err(|_| STATUS_RESULT_TOO_LARGE)?, - ); - write_u32(&mut bytes, RESULT_GLYPH_IDS_OFFSET, glyph_ids_offset); - write_u32(&mut bytes, RESULT_CLUSTERS_OFFSET, clusters_offset); - write_u32(&mut bytes, RESULT_X_ADVANCES_OFFSET, x_advances_offset); - write_u32(&mut bytes, RESULT_Y_ADVANCES_OFFSET, y_advances_offset); - write_u32(&mut bytes, RESULT_X_OFFSETS_OFFSET, x_offsets_offset); - write_u32(&mut bytes, RESULT_Y_OFFSETS_OFFSET, y_offsets_offset); - write_u32(&mut bytes, RESULT_GLYPH_FLAGS_OFFSET, glyph_flags_offset); - write_u32( - &mut bytes, - RESULT_GLYPH_COUNT, - u32::try_from(glyph_count).map_err(|_| STATUS_RESULT_TOO_LARGE)?, - ); - Ok(bytes) -} - -#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] -pub fn pack_bidi_result(output: &BidiAnalysis) -> Result, u32> { - let text_length = output.levels.len(); - let paragraph_count = output.paragraph_starts.len(); - if output.classes.len() != text_length - || output.paragraph_ends.len() != paragraph_count - || output.paragraph_levels.len() != paragraph_count - { - return Err(STATUS_RESULT_TOO_LARGE); - } - let mut bytes = result_bytes( - BIDI_RESULT_HEADER_SIZE as usize, - &[ - (output.levels.len(), 1), - (output.classes.len(), 1), - (output.paragraph_starts.len(), 4), - (output.paragraph_ends.len(), 4), - (output.paragraph_levels.len(), 1), - ], - )?; - let levels_offset = append_u8(&mut bytes, &output.levels)?; - let classes_offset = append_u8(&mut bytes, &output.classes)?; - let paragraph_starts_offset = append_u32(&mut bytes, &output.paragraph_starts)?; - let paragraph_ends_offset = append_u32(&mut bytes, &output.paragraph_ends)?; - let paragraph_levels_offset = append_u8(&mut bytes, &output.paragraph_levels)?; - let byte_length = u32::try_from(bytes.len()).map_err(|_| STATUS_RESULT_TOO_LARGE)?; - write_u32(&mut bytes, BIDI_RESULT_BYTE_LENGTH, byte_length); - write_u32(&mut bytes, BIDI_RESULT_LEVELS_OFFSET, levels_offset); - write_u32(&mut bytes, BIDI_RESULT_CLASSES_OFFSET, classes_offset); - write_u32( - &mut bytes, - BIDI_RESULT_TEXT_LENGTH, - u32::try_from(text_length).map_err(|_| STATUS_RESULT_TOO_LARGE)?, - ); - write_u32( - &mut bytes, - BIDI_RESULT_PARAGRAPH_STARTS_OFFSET, - paragraph_starts_offset, - ); - write_u32( - &mut bytes, - BIDI_RESULT_PARAGRAPH_ENDS_OFFSET, - paragraph_ends_offset, - ); - write_u32( - &mut bytes, - BIDI_RESULT_PARAGRAPH_LEVELS_OFFSET, - paragraph_levels_offset, - ); - write_u32( - &mut bytes, - BIDI_RESULT_PARAGRAPH_COUNT, - u32::try_from(paragraph_count).map_err(|_| STATUS_RESULT_TOO_LARGE)?, - ); - Ok(bytes) -} - -fn array(bytes: &[u8], offset: u32, count: u32, stride: u32, alignment: u32) -> Result<&[u8], u32> { + offset: u32, + count: u32, + stride: u32, + alignment: u32, +) -> Result<&[u8], u32> { if !offset.is_multiple_of(alignment) { return Err(STATUS_INVALID_REQUEST); } @@ -330,145 +17,24 @@ fn array(bytes: &[u8], offset: u32, count: u32, stride: u32, alignment: u32) -> bytes.get(offset..end).ok_or(STATUS_INVALID_REQUEST) } -fn read_u16(bytes: &[u8], offset: usize) -> Result { +pub(crate) fn read_u16(bytes: &[u8], offset: usize) -> Result { let value = bytes .get(offset..offset.checked_add(2).ok_or(STATUS_INVALID_REQUEST)?) .ok_or(STATUS_INVALID_REQUEST)?; Ok(u16::from_le_bytes([value[0], value[1]])) } -fn read_u32(bytes: &[u8], offset: usize) -> Result { +pub(crate) fn read_u32(bytes: &[u8], offset: usize) -> Result { let value = bytes .get(offset..offset.checked_add(4).ok_or(STATUS_INVALID_REQUEST)?) .ok_or(STATUS_INVALID_REQUEST)?; Ok(u32::from_le_bytes([value[0], value[1], value[2], value[3]])) } -fn write_u32(bytes: &mut [u8], offset: usize, value: u32) { - bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); -} - -#[inline(never)] -fn result_bytes(header_length: usize, fields: &[(usize, usize)]) -> Result, u32> { - let mut byte_length = header_length; - for &(element_count, element_size) in fields { - let padding = (element_size - byte_length % element_size) % element_size; - byte_length = byte_length - .checked_add(padding) - .and_then(|length| element_count.checked_mul(element_size)?.checked_add(length)) - .ok_or(STATUS_RESULT_TOO_LARGE)?; - } - u32::try_from(byte_length).map_err(|_| STATUS_RESULT_TOO_LARGE)?; - let mut bytes = Vec::new(); - bytes - .try_reserve_exact(byte_length) - .map_err(|_| STATUS_RESULT_TOO_LARGE)?; - bytes.resize(header_length, 0); - Ok(bytes) -} - -fn align(bytes: &mut Vec, alignment: usize) -> Result { - let padding = (alignment - bytes.len() % alignment) % alignment; - bytes.resize( - bytes - .len() - .checked_add(padding) - .ok_or(STATUS_RESULT_TOO_LARGE)?, - 0, - ); - u32::try_from(bytes.len()).map_err(|_| STATUS_RESULT_TOO_LARGE) -} - -fn append_u16(bytes: &mut Vec, values: &[u16]) -> Result { - let offset = align(bytes, 2)?; - for value in values { - bytes.extend_from_slice(&value.to_le_bytes()); - } - Ok(offset) -} - -#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] -fn append_u8(bytes: &mut Vec, values: &[u8]) -> Result { - let offset = u32::try_from(bytes.len()).map_err(|_| STATUS_RESULT_TOO_LARGE)?; - bytes.extend_from_slice(values); - Ok(offset) +pub(crate) fn read_f32(bytes: &[u8], offset: usize) -> Result { + Ok(f32::from_bits(read_u32(bytes, offset)?)) } -fn append_u32(bytes: &mut Vec, values: &[u32]) -> Result { - let offset = align(bytes, 4)?; - for value in values { - bytes.extend_from_slice(&value.to_le_bytes()); - } - Ok(offset) -} - -fn append_i32(bytes: &mut Vec, values: &[i32]) -> Result { - let offset = align(bytes, 4)?; - for value in values { - bytes.extend_from_slice(&value.to_le_bytes()); - } - Ok(offset) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn malformed_wire_ranges_fail_without_panicking() { - assert!(matches!( - parse_shape_request(&[]), - Err(STATUS_INVALID_REQUEST) - )); - assert!(matches!( - parse_reshape_request(&[]), - Err(STATUS_INVALID_REQUEST) - )); - assert!(matches!( - parse_bidi_request(&[]), - Err(STATUS_INVALID_REQUEST) - )); - let mut bytes = vec![0; SHAPE_REQUEST_HEADER_SIZE as usize]; - write_u32(&mut bytes, SHAPE_TEXT_OFFSET, u32::MAX); - write_u32(&mut bytes, SHAPE_TEXT_LENGTH, 1); - assert!(matches!( - parse_shape_request(&bytes), - Err(STATUS_INVALID_REQUEST) - )); - } - - #[test] - fn result_layout_overflow_returns_a_status_before_allocation() { - assert_eq!( - result_bytes(RESULT_HEADER_SIZE as usize, &[(usize::MAX, 4)]), - Err(STATUS_RESULT_TOO_LARGE), - ); - } - - #[test] - fn result_header_points_to_exact_soa_arrays() { - let output = ShapeBatchOutput { - font_handles: vec![7], - run_font_slots: vec![0], - run_glyph_starts: vec![0], - run_glyph_counts: vec![1], - glyph_ids: vec![42], - clusters: vec![3], - x_advances: vec![100], - y_advances: vec![0], - x_offsets: vec![-2], - y_offsets: vec![5], - glyph_flags: vec![3], - }; - let bytes = pack_result(&output).unwrap(); - assert_eq!( - read_u32(&bytes, RESULT_BYTE_LENGTH).unwrap() as usize, - bytes.len() - ); - assert_eq!(read_u32(&bytes, RESULT_FONT_HANDLE_COUNT).unwrap(), 1); - assert_eq!(read_u32(&bytes, RESULT_RUN_COUNT).unwrap(), 1); - assert_eq!(read_u32(&bytes, RESULT_GLYPH_COUNT).unwrap(), 1); - let glyph_offset = read_u32(&bytes, RESULT_GLYPH_IDS_OFFSET).unwrap() as usize; - assert_eq!(read_u16(&bytes, glyph_offset).unwrap(), 42); - } +pub(crate) fn write_u32(bytes: &mut [u8], offset: usize, value: u32) { + bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); } diff --git a/packages/text/rust/shaper/tests/unicode_line_break_conformance.rs b/packages/text/rust/shaper/tests/unicode_line_break_conformance.rs new file mode 100644 index 00000000..064f7777 --- /dev/null +++ b/packages/text/rust/shaper/tests/unicode_line_break_conformance.rs @@ -0,0 +1,60 @@ +use std::{fs::File, io::Read, path::PathBuf}; + +use flate2::read::GzDecoder; +use pmndrs_text_shaper::line_break::LineBreakAnalysis; + +const LINE_BREAK_TEST_CASES: usize = 19_338; + +#[test] +fn unicode_17_line_break_test_is_fully_conformant() { + let source = fixture("LineBreakTest.txt.gz"); + let mut analysis = LineBreakAnalysis::default(); + let mut cases = 0usize; + for (line_index, source) in source.lines().enumerate() { + let body = source.split('#').next().unwrap_or_default().trim(); + if body.is_empty() { + continue; + } + let tokens: Vec<&str> = body.split_whitespace().collect(); + let mut text = Vec::new(); + let mut expected = Vec::new(); + let mut cursor = 0usize; + while cursor < tokens.len() { + if tokens[cursor] == "÷" && !text.is_empty() { + expected.push(u32::try_from(text.len()).expect("UTF-16 offset")); + } + cursor += 1; + let Some(hexadecimal) = tokens.get(cursor) else { + break; + }; + let code_point = u32::from_str_radix(hexadecimal, 16).expect("code point"); + let character = char::from_u32(code_point).expect("Unicode scalar"); + let mut encoded = [0u16; 2]; + text.extend_from_slice(character.encode_utf16(&mut encoded)); + cursor += 1; + } + analysis + .analyze(&text) + .unwrap_or_else(|error| panic!("LineBreakTest:{}: {error:?}", line_index + 1)); + let actual: Vec = analysis + .breaks() + .iter() + .map(|line_break| line_break.position) + .collect(); + assert_eq!(actual, expected, "LineBreakTest:{}", line_index + 1); + cases += 1; + } + assert_eq!(cases, LINE_BREAK_TEST_CASES); +} + +fn fixture(name: &str) -> String { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/fixtures/unicode-17.0.0") + .join(name); + let mut decoder = GzDecoder::new(File::open(path).expect("open Unicode fixture")); + let mut source = String::new(); + decoder + .read_to_string(&mut source) + .expect("decode Unicode fixture"); + source +} diff --git a/packages/text/scripts/benchmark-engine-kernels.mts b/packages/text/scripts/benchmark-engine-kernels.mts new file mode 100644 index 00000000..8a07566f --- /dev/null +++ b/packages/text/scripts/benchmark-engine-kernels.mts @@ -0,0 +1,81 @@ +/* @workflow { + "name": "text:kernel-lab", + "summary": "Compares scalar, auto-vectorized, and explicit SIMD retained-engine kernels over real paragraph arrays.", + "requirements": "Built package and kernel-lab artifacts: node ./scripts/build-engine-kernel-lab.mjs. Accepts --json.", + "writes": "stdout only, or the JSON report path passed to --json" +} */ +import { readFile, writeFile } from 'node:fs/promises'; +import { brotliCompressSync, constants as zlibConstants } from 'node:zlib'; + +import { captureKernelWorkloads } from './support/engine-kernel-fixture.mts'; +import { benchmarkKernelArtifact } from './support/engine-kernel-runner.mjs'; + +const ARTIFACT_ROOT = new URL('../rust/shaper/target/kernel-lab/', import.meta.url); +const VARIANTS = ['scalar', 'auto', 'explicit'] as const; +const TARGET_GLYPHS = [22_000, 86_000] as const; +const WARMUP = 40; +const SAMPLES = 101; + +const workloads = await captureKernelWorkloads(TARGET_GLYPHS); + +const variants = []; +let oracleHashes: ReadonlyMap | undefined; +for (const name of VARIANTS) { + const wasm = await readFile(new URL(`${name}.wasm`, ARTIFACT_ROOT)); + const wat = await readFile(new URL(`${name}.wat`, ARTIFACT_ROOT), 'utf8'); + const workloadReports = []; + const hashes = new Map(); + for (const workload of workloads) { + const report = await benchmarkKernelArtifact(wasm, name, workload, { warmup: WARMUP, samples: SAMPLES }); + workloadReports.push(report); + hashes.set(workload.label, report.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 scalar oracle ${oracleHashes.get(label)} for ${label}`); + } + } + } + variants.push({ + name, + rawBytes: wasm.byteLength, + brotliBytes: brotliCompressSync(wasm, { + params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 11 }, + }).byteLength, + instructions: { + v128Loads: matches(wat, /\bv128\.load\b/g), + v128Stores: matches(wat, /\bv128\.store\b/g), + f32x4: matches(wat, /\bf32x4\.[a-z_]+\b/g), + bitmasks: matches(wat, /\bi8x16\.bitmask\b/g), + shuffles: matches(wat, /\bi8x16\.shuffle\b/g), + i64x2: matches(wat, /\bi64x2\.[a-z_]+\b/g), + }, + workloads: workloadReports, + }); +} + +const report = { + generatedBy: 'text:kernel-lab', + environment: { + runtime: `Node ${process.versions.node}`, + platform: `${process.platform}/${process.arch}`, + cpu: 'target machine; report with host inventory', + }, + warmup: WARMUP, + samples: SAMPLES, + variants, +}; +console.log(JSON.stringify(report, null, 2)); +const jsonPath = readArgument('--json'); +if (jsonPath !== undefined) await writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`); + +function matches(value: string, expression: RegExp): number { + return value.match(expression)?.length ?? 0; +} + +function readArgument(flag: string): string | undefined { + const index = process.argv.indexOf(flag); + return index < 0 ? undefined : process.argv[index + 1]; +} diff --git a/packages/text/scripts/benchmark-paragraph-layout.mts b/packages/text/scripts/benchmark-paragraph-layout.mts index ccfc5d6c..943bcbf0 100644 --- a/packages/text/scripts/benchmark-paragraph-layout.mts +++ b/packages/text/scripts/benchmark-paragraph-layout.mts @@ -1,15 +1,19 @@ /* @workflow { "name": "text:layout-benchmark", - "summary": "Measures paragraph preparation, layout, and packing cost per glyph across realistic scales, with phase attribution and allocation.", + "summary": "Measures public TextGroup updates externally through Rust text_update and Three render-plan application.", "requirements": "Built package: pnpm --filter @pmndrs/text build. Accepts --glyphs, --reps, --warmup, --case, --json.", "writes": "stdout only, or the JSON report path passed to --json" } */ -import { readFile, writeFile } from 'node:fs/promises'; +import { writeFile } from 'node:fs/promises'; import { setFlagsFromString } from 'node:v8'; import { runInNewContext } from 'node:vm'; -import { createRuntimeShaper, createTextRuntime, FontRegistry } from '../dist/index.js'; -import { bitmap } from '../dist/raster/bitmap-technique.js'; +import { + createBenchmarkParagraph, + disposeBenchmarkParagraph, + loadParagraphBenchmarkFixture, + paragraphTextForGlyphs, +} from './support/paragraph-benchmark-fixture.mts'; /** * Answers one question: how long does a paragraph batch take to reach uploadable instance data, per glyph, at the sizes @@ -17,8 +21,8 @@ import { bitmap } from '../dist/raster/bitmap-technique.js'; * * Every number here is a median of repetitions taken after the measured code has been warmed, because a first call * measures the optimizing compiler rather than the algorithm. The report carries the relative standard deviation beside - * each median so a reader can tell a real change from sampling noise, and the phase table attributes the median to the - * pipeline stage that spent it, so an optimization is aimed rather than guessed. + * each median so a reader can tell a real change from sampling noise. This outside-only workflow deliberately owns one + * host timer around the complete public update. `benchmark:paragraph-stress-timing` owns browser phase attribution. * * The cases are kept apart rather than averaged. They invalidate different caches: a size change reuses the shaped run * and recomputes every measurement, a width change reuses shaping and measurement and replans lines, and a text change @@ -35,16 +39,13 @@ const DEFAULT_REPETITIONS = 31; /** Glyph counts to sweep. The largest is past four columns of six thousand, which is the stated worst case. */ const DEFAULT_SCALES = [5_500, 11_000, 22_000, 33_000] as const; -const paragraphSource = [ - 'Typography is a moving system. AVATAR To Wa Yo repeat familiar kerning pairs while a responsive panel changes the space around them. The quick visual check is useful, but the benchmark records the cost of shaping, layout, upload, and every rendered frame.', - 'A practical interface mixes prose with 0123456789, prices such as 24.50, ranges from 8-512 px, and punctuation-"quotes", (parentheses), brackets, commas, and semicolons. Repeated office, affine, difficult, and shuffle words retain ff, fi, fl, ffi, and ffl candidates.', - 'Scientific copy adds x2+y2~z2, 0<=a<=1, and pi. Arrows point both ways. These symbols expose missing coverage, uneven baselines, bad advances, and atlas placement errors that plain alphabet samples can hide.', -].join('\n'); - type CaseName = 'cold' | 'font-size' | 'layout-width' | 'text'; -interface Sample { - readonly durationMs: number; +interface UpdateProfile { + readonly wallMs: number; +} + +interface Sample extends UpdateProfile { readonly glyphs: number; } @@ -64,12 +65,11 @@ interface CaseReport { const options = parseArguments(process.argv.slice(2)); const collectGarbage = exposeGarbageCollection(); -const root = new URL('../../../', import.meta.url); -const font = await loadFont(); +const font = await loadParagraphBenchmarkFixture(); const reports: CaseReport[] = []; for (const targetGlyphs of options.scales) { - const text = textForGlyphs(targetGlyphs); + const text = paragraphTextForGlyphs(targetGlyphs); for (const name of options.cases) { reports.push(await measureCase(name, text)); } @@ -81,45 +81,51 @@ if (options.jsonPath !== undefined) { console.log(`\nwrote ${options.jsonPath}`); } -font.runtime.dispose(); font.loaded.dispose(); +font.runtime.dispose(); async function measureCase(name: CaseName, text: string): Promise { const total = options.warmup + options.repetitions; const samples: Sample[] = []; - const { runtime } = font; - // A cold case must build a fresh batch every repetition; the others measure an update to a warm one, which is what a // frame actually does. Both still run the same warmup discipline. const heapDeltas: number[] = []; - const warm = name === 'cold' ? undefined : createParagraph(runtime, text, 600); - if (warm !== undefined) runtime.update(); + const counter = createBenchmarkParagraph(font, text, 600); + counter.group.updateMatrixWorld(true); + if (counter.group.error !== undefined) throw counter.group.error; + const calibratedGlyphs = counter.paragraph.measureLayout()?.glyphCount; + disposeBenchmarkParagraph(counter); + if (calibratedGlyphs === undefined) throw new Error('paragraph benchmark fixture did not publish a glyph count'); + const warm = name === 'cold' ? undefined : createBenchmarkParagraph(font, text, 600); + if (warm !== undefined) { + warm.group.updateMatrixWorld(true); + if (warm.group.error !== undefined) throw warm.group.error; + } for (let repetition = 0; repetition < total; repetition += 1) { const recording = repetition >= options.warmup; - const created = name === 'cold' ? createParagraph(runtime, text, 600) : undefined; + const created = name === 'cold' ? createBenchmarkParagraph(font, text, 600) : undefined; if (warm !== undefined) applyChange(name, warm.paragraph, repetition, text); collectGarbage(); const heapBefore = process.memoryUsage().heapUsed; - const started = performance.now(); - runtime.update(); - const durationMs = performance.now() - started; + const updated = created ?? warm!; + const profile = profileUpdate(() => updated.group.updateMatrixWorld(true)); const heapAfter = process.memoryUsage().heapUsed; - const glyphs = glyphCount(created?.batch ?? warm!.batch); - created?.batch.dispose(); + if (updated.group.error !== undefined) throw updated.group.error; + if (created !== undefined) disposeBenchmarkParagraph(created); if (recording) { - samples.push({ durationMs, glyphs }); + samples.push({ ...profile, glyphs: calibratedGlyphs }); heapDeltas.push(Math.max(0, heapAfter - heapBefore)); } } - warm?.batch.dispose(); + if (warm !== undefined) disposeBenchmarkParagraph(warm); - const durations = samples.map((sample) => sample.durationMs).sort((left, right) => left - right); + const durations = sorted(samples, 'wallMs'); const glyphs = samples[0]?.glyphs ?? 0; const mean = durations.reduce((sum, value) => sum + value, 0) / durations.length; const variance = durations.reduce((sum, value) => sum + (value - mean) ** 2, 0) / durations.length; @@ -139,6 +145,16 @@ async function measureCase(name: CaseName, text: string): Promise { }; } +function profileUpdate(update: () => void): UpdateProfile { + const started = performance.now(); + update(); + return { wallMs: performance.now() - started }; +} + +function sorted(samples: readonly Sample[], key: Key): number[] { + return samples.map((sample) => sample[key]).sort((left, right) => left - right); +} + function applyChange(name: CaseName, paragraph: ParagraphHandle, repetition: number, text: string): void { // Every repetition applies a value no earlier repetition used, so a retained per-constraint cache can never answer a // measured update. A repeating cycle would let the cache serve part of the run and report a median that no drag, @@ -149,63 +165,23 @@ function applyChange(name: CaseName, paragraph: ParagraphHandle, repetition: num } else paragraph.text = `${text.slice(0, text.length - repetition)}`; } -type TextRuntimeHandle = Awaited>; -type ParagraphBatchHandle = ReturnType; -type ParagraphHandle = ReturnType; - -function createParagraph(runtime: TextRuntimeHandle, text: string, width: number) { - const batch = runtime.createParagraphBatch({ technique: bitmap }); - const paragraph = batch.add({ - font: font.loaded, - text, - contentBox: { width: { mode: 'exact', size: width }, wrap: 'word' }, - style: { fontSize: 24 }, - }); - return { batch, paragraph }; -} - -function glyphCount(batch: ParagraphBatchHandle): number { - let total = 0; - for (const paragraph of batch.current.paragraphs) total += paragraph.layout.glyphIds.length; - return total; -} - -function textForGlyphs(target: number): string { - // The source paragraph is measured once, then repeated to reach the target. Repetition keeps the shaping work - // representative while making the scale exact enough to compare per-glyph costs across rows. - const perCopy = paragraphSource.replaceAll(/\s/gu, '').length; - const copies = Math.max(1, Math.round(target / perCopy)); - return Array.from({ length: copies }, () => paragraphSource).join('\n'); -} - -async function loadFont() { - const registry = new FontRegistry(); - const shaper = await createRuntimeShaper({ - registry, - wasm: await readFile(new URL('packages/text/dist/text_shaper.wasm', root)), - }); - const runtime = await createTextRuntime({ registry, shaper }); - const bytes = await readFile(new URL('apps/benchmarks/fixtures/rendering/inter-bitmap-16.font.glb', root)); - const loaded = await runtime.loadFont({ - input: { baked: `data:application/octet-stream;base64,${bytes.toString('base64')}` }, - raster: { technique: bitmap, options: { strikes: [16] } }, - }); - return { runtime, loaded }; -} +type ParagraphHandle = ReturnType['paragraph']; function printReport(rows: readonly CaseReport[]): void { console.log( `\nwarmup ${options.warmup} discarded · ${options.repetitions} recorded repetitions · budget ${budget60.toFixed(2)}ms @60Hz · ${budget120.toFixed(2)}ms @120Hz\n`, ); console.log( - `${'case'.padEnd(13)}${'glyphs'.padStart(8)}${'median'.padStart(10)}${'p95'.padStart(10)}${'min'.padStart(9)}${'rsd'.padStart(7)}${'µs/glyph'.padStart(10)}${'B/glyph'.padStart(9)} budget`, + `${'case'.padEnd(13)}${'glyphs'.padStart(8)}${'outside'.padStart(10)}${'p95'.padStart(11)}${'min'.padStart(10)}${'rsd'.padStart(7)} budget`, ); for (const row of rows) { const over = row.medianMs / budget120; console.log( - `${row.name.padEnd(13)}${String(row.glyphs).padStart(8)}${`${row.medianMs.toFixed(2)}ms`.padStart(10)}${`${row.p95Ms.toFixed(2)}ms`.padStart(10)}${`${row.minMs.toFixed(2)}ms`.padStart(9)}${`${row.rsdPercent.toFixed(1)}%`.padStart(7)}${row.perGlyphUs.toFixed(3).padStart(10)}${(row.bytesPerUpdate / Math.max(1, row.glyphs)).toFixed(0).padStart(9)} ${over <= 1 ? 'within 120Hz' : `${over.toFixed(1)}x over 120Hz`}`, + `${row.name.padEnd(13)}${String(row.glyphs).padStart(8)}${`${row.medianMs.toFixed(2)}ms`.padStart(10)}${`${row.p95Ms.toFixed(2)}ms`.padStart(11)}${`${row.minMs.toFixed(2)}ms`.padStart(10)}${`${row.rsdPercent.toFixed(1)}%`.padStart(7)} ${over <= 1 ? 'within 120Hz' : `${over.toFixed(1)}x over 120Hz`}`, ); } + console.log('\nThis public Three diagnostic is not the canonical Rust-vs-TypeScript comparison.'); + console.log('Use text:rust-layout-benchmark for the unchanged complete text_update + render-plan metric.'); } function parseArguments(argv: readonly string[]) { diff --git a/packages/text/scripts/benchmark-rust-layout-engine.mjs b/packages/text/scripts/benchmark-rust-layout-engine.mjs new file mode 100644 index 00000000..bffd5885 --- /dev/null +++ b/packages/text/scripts/benchmark-rust-layout-engine.mjs @@ -0,0 +1,444 @@ +/* @workflow { + "name": "text:rust-layout-benchmark", + "summary": "Measures the complete retained Rust text_update path with real font data and render-plan publication.", + "requirements": "Built @pmndrs/text and @pmndrs/text-font-baker packages. Accepts --glyphs, --reps, --warmup, and --json.", + "writes": "stdout and the optional JSON report path" +} */ +import { createHash } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; +import { gunzipSync } from 'node:zlib'; + +import { validateFontArtifact } from '@pmndrs/text-font-baker/validate'; +import { validateBitmapArtifact } from '@pmndrs/text/bakers/bitmap/validate'; +import { validateMsdfArtifact } from '@pmndrs/text/bakers/msdf/validate'; +import { validateSlugArtifact } from '@pmndrs/text/bakers/slug/validate'; +import { bitmapDescriptor } from '@pmndrs/text/raster/bitmap'; +import { msdfDescriptor } from '@pmndrs/text/raster/msdf'; +import { slugDescriptor } from '@pmndrs/text/raster/slug'; + +import { paragraphTextForGlyphs } from './support/paragraph-benchmark-fixture.mts'; +import { copyIntoAllocation, engineFrameUpdateBytes } from '../tests/support/engine-abi.mjs'; +import { techniqueProof } from './support/render-technique-proof.mjs'; + +const options = parseArguments(process.argv.slice(2)); +const sessionId = 1; +const policyHandle = 1; +const fontHandle = 1; +const fontStackHandle = 1; +const regionHeight = options.height; + +const [wasm, abi, artifact] = await Promise.all([ + readFile(options.wasm ?? new URL('../dist/text_shaper.wasm', import.meta.url)), + readFile(new URL('../dist/text-shaper-abi-v0.json', import.meta.url), 'utf8').then(JSON.parse), + loadArtifact(options.technique), +]); +const validated = await validateFontArtifact(artifact); +const raster = await validateRaster(options.technique, artifact, validated); +const technique = techniqueProof(abi, options.technique, raster, options.allocation); +const outputCapacity = technique.outputBytesPerGlyph > 48 ? 8 * 1024 * 1024 : 4 * 1024 * 1024; +const instance = await WebAssembly.instantiate(await WebAssembly.compile(wasm), {}); +const memory = instance.exports[abi.memory]; +const fn = Object.fromEntries( + Object.entries(abi.functions).map(([name, exported]) => [name, instance.exports[exported]]), +); +const memoryAtInstantiation = memory.buffer.byteLength; +requireStatus(fn.initialize(), 'initialize'); +const memoryAfterInitialize = memory.buffer.byteLength; +registerFont(); +registerBinding(); +registerStack(); +registerPolicy(); +const memoryAfterRegistration = memory.buffer.byteLength; + +const text = paragraphTextForGlyphs(options.glyphs); +const utf16 = stringToUtf16(text); +const limits = { + maxClusters: utf16.length + 1, + maxLines: utf16.length + 1, + maxOutputBytes: outputCapacity, +}; +const baseGeometry = { width: 600, height: regionHeight, maxLines: utf16.length + 1, revision: 1 }; +const baseStyle = { textEnd: utf16.length, fontSize: 24, lineHeight: 1.2, rasterPixelRatio: 1 }; +const initial = updateBytes({ + textMutation: { start: 0, deleteCount: 0, insert: utf16 }, + style: baseStyle, + geometry: baseGeometry, +}); +let sessionMemory; + +console.log( + `technique=${options.technique} allocation=${options.allocation} output=${technique.outputBytesPerGlyph} bytes/glyph · memory bytes: instantiate=${memoryAtInstantiation}, initialize=${memoryAfterInitialize}, registered=${memoryAfterRegistration}`, +); + +const reports = []; +const cases = ['cold', 'no-op', 'font-size', 'column-resize', 'suffix-edit', 'localized-edit', 'localized-splice']; +for (const name of options.case === undefined ? cases : [options.case]) { + reports.push(name === 'cold' ? measureCold() : measureWarm(name)); +} +printReport(reports); +if (options.jsonPath !== undefined) { + await writeFile( + options.jsonPath, + `${JSON.stringify( + { + schemaVersion: 0, + generatedBy: 'text:rust-layout-benchmark', + wasmSha256: createHash('sha256').update(wasm).digest('hex'), + technique: options.technique, + allocation: options.allocation, + glyphTarget: options.glyphs, + warmup: options.warmup, + repetitions: options.repetitions, + reports, + }, + undefined, + 2, + )}\n`, + ); + console.log(`wrote ${options.jsonPath}`); +} + +function measureCold() { + const samples = []; + const plans = []; + let glyphs = 0; + for (let index = 0; index < options.warmup + options.repetitions; index += 1) { + createSession(initial.byteLength); + const result = execute(initial, true); + glyphs = result.glyphCount; + if (index >= options.warmup) { + samples.push(result.durationMs); + plans.push(result); + } + requireStatus(fn.disposeSession(sessionId), 'dispose cold session'); + } + return summarize('cold', glyphs, samples, plans); +} + +function measureWarm(name) { + createSession(initial.byteLength); + let state = execute(initial, true); + const liveGlyphCount = state.glyphCount; + const localizedText = [...utf16]; + const spliceStart = Math.floor(utf16.length / 2); + let spliceInserted = false; + let suffixLength = utf16.length; + const samples = []; + const plans = []; + for (let index = 0; index < options.warmup + options.repetitions; index += 1) { + const revision = index + 2; + const common = { + expectedEngineRevision: state.engineRevision, + consumedPlanRevision: state.planRevision, + acknowledgedPublicationGeneration: state.publicationGeneration, + }; + let bytes; + if (name === 'font-size') { + bytes = updateBytes({ + ...common, + style: { ...baseStyle, fontSize: 12 + index * 0.5 }, + geometry: baseGeometry, + }); + } else if (name === 'column-resize') { + bytes = updateBytes({ + ...common, + geometry: { ...baseGeometry, width: 420 + index * 7, revision }, + }); + } else if (name === 'suffix-edit') { + const nextLength = utf16.length - index; + const deleteCount = suffixLength - nextLength; + bytes = + deleteCount === 0 + ? updateBytes({ ...common, geometry: baseGeometry }) + : updateBytes({ + ...common, + textMutation: { start: nextLength, deleteCount, insert: [] }, + style: { ...baseStyle, textEnd: nextLength }, + geometry: baseGeometry, + }); + suffixLength = nextLength; + } else if (name === 'localized-edit') { + const start = Math.floor(utf16.length / 2) + index; + const replacement = localizedText[start] === 0x61 ? 0x62 : 0x61; + localizedText[start] = replacement; + bytes = updateBytes({ + ...common, + textMutation: { start, deleteCount: 1, insert: [replacement] }, + geometry: baseGeometry, + }); + } else if (name === 'localized-splice') { + const insert = spliceInserted ? [] : [0x61]; + const deleteCount = spliceInserted ? 1 : 0; + spliceInserted = !spliceInserted; + const textEnd = utf16.length + Number(spliceInserted); + bytes = updateBytes({ + ...common, + textMutation: { start: spliceStart, deleteCount, insert }, + style: { ...baseStyle, textEnd }, + geometry: baseGeometry, + }); + } else { + bytes = updateBytes({ ...common, geometry: baseGeometry }); + } + state = execute(bytes, index < options.warmup, `${name}[${index}]`); + if (index >= options.warmup) { + samples.push(state.durationMs); + plans.push(state); + } + } + requireStatus(fn.disposeSession(sessionId), `dispose ${name} session`); + return summarize(name, liveGlyphCount, samples, plans); +} + +function createSession(requestCapacity) { + const beforeBytes = memory.buffer.byteLength; + requireStatus( + fn.createSession(sessionId, requestCapacity, outputCapacity, utf16.length + 1), + 'create benchmark session', + ); + if (sessionMemory === undefined) { + sessionMemory = { beforeBytes, afterBytes: memory.buffer.byteLength }; + } +} + +function execute(bytes, allowGrowth = false, operation = 'text_update') { + const requestPointer = fn.requestPointer(sessionId); + if (requestPointer === 0 || fn.requestCapacity(sessionId) < bytes.byteLength) { + throw new Error('benchmark request exceeds its pre-reserved arena'); + } + const buffer = memory.buffer; + const bufferBytes = buffer.byteLength; + const started = performance.now(); + new Uint8Array(buffer, requestPointer, bytes.byteLength).set(bytes); + const resultPointer = fn.textUpdate(sessionId, requestPointer, bytes.byteLength); + const durationMs = performance.now() - started; + if (memory.buffer !== buffer && !allowGrowth) { + throw new Error(`measured text_update grew Wasm memory from ${bufferBytes} to ${memory.buffer.byteLength} bytes`); + } + if (resultPointer === 0) throw new Error('text_update returned a null result'); + const layout = abi.layouts.engineResult; + const result = new DataView(memory.buffer, resultPointer, layout.size); + const status = result.getUint32(layout.status, true); + if (status !== abi.status.ok) { + const requiredRequestCapacity = result.getUint32(layout.requiredRequestCapacity, true); + const requiredResultCapacity = result.getUint32(layout.requiredResultCapacity, true); + throw new Error( + `${operation} failed with status ${status}; required request=${requiredRequestCapacity}, result=${requiredResultCapacity}`, + ); + } + const patchCount = result.getUint32(layout.patchCount, true); + const patchesOffset = result.getUint32(layout.patchesOffset, true); + const patchLayout = abi.layouts.enginePatch; + let writeBytes = 0; + for (let index = 0; index < patchCount; index += 1) { + const at = resultPointer + patchesOffset + index * patchLayout.size; + const patch = new DataView(memory.buffer, at, patchLayout.size); + if (patch.getUint8(patchLayout.opcode) === abi.engine.patchOpcodes.write) { + writeBytes += patch.getUint32(patchLayout.byteLength, true); + } + } + const primitiveCount = result.getUint32(layout.primitiveCount, true); + const primitivesOffset = result.getUint32(layout.primitivesOffset, true); + const primitiveLayout = abi.layouts.enginePrimitive; + let glyphCount = 0; + for (let index = 0; index < primitiveCount; index += 1) { + const at = resultPointer + primitivesOffset + index * primitiveLayout.size; + const primitive = new DataView(memory.buffer, at, primitiveLayout.size); + if (primitive.getUint8(primitiveLayout.kind) === abi.engine.primitiveKinds.glyph) { + glyphCount += primitive.getUint16(primitiveLayout.recordCount, true); + } + } + return { + durationMs, + engineRevision: result.getUint32(layout.engineRevision, true), + planRevision: result.getUint32(layout.planRevision, true), + publicationGeneration: result.getUint32(layout.publicationGeneration, true), + primitiveCount, + glyphCount, + patchCount, + writeBytes, + }; +} + +function updateBytes(fields) { + return engineFrameUpdateBytes(abi, { + sessionId, + policyHandle, + fontStackHandle, + limits, + ...fields, + }); +} + +function registerFont() { + const allocations = [validated.shapingSfnt, validated.glyphExtents, validated.glyphExtentsAvailability].map( + (bytes) => ({ + pointer: copyIntoAllocation(memory, fn.allocate, bytes), + length: bytes.byteLength, + }), + ); + requireStatus( + fn.registerFont( + fontHandle, + allocations[0].pointer, + allocations[0].length, + allocations[1].pointer, + allocations[1].length, + allocations[2].pointer, + allocations[2].length, + ), + 'register font', + ); + for (const allocation of allocations) fn.deallocate(allocation.pointer, allocation.length); +} + +function registerBinding() { + const bytes = technique.bindingBytes; + const pointer = copyIntoAllocation(memory, fn.allocate, bytes); + requireStatus(fn.registerFontBinding(fontHandle, fontHandle, pointer, bytes.byteLength), 'register font binding'); + fn.deallocate(pointer, bytes.byteLength); +} + +function registerStack() { + const bytes = Uint8Array.of(fontHandle, 0, 0, 0); + const pointer = copyIntoAllocation(memory, fn.allocate, bytes); + requireStatus(fn.registerFontStack(fontStackHandle, pointer, 1), 'register font stack'); + fn.deallocate(pointer, bytes.byteLength); +} + +function registerPolicy() { + const bytes = technique.policyBytes; + const pointer = copyIntoAllocation(memory, fn.allocate, bytes); + requireStatus(fn.registerPolicy(policyHandle, pointer, bytes.byteLength), 'register render policy'); + fn.deallocate(pointer, bytes.byteLength); +} + +function summarize(name, glyphs, samples, plans) { + if (glyphs < Math.floor(options.glyphs * 0.95)) { + throw new Error(`benchmark planned only ${glyphs} glyph records for a ${options.glyphs}-glyph fixture target`); + } + const sorted = samples.toSorted((left, right) => left - right); + const patchCounts = plans.map((plan) => plan.patchCount).toSorted((left, right) => left - right); + const writeBytes = plans.map((plan) => plan.writeBytes).toSorted((left, right) => left - right); + const mean = sorted.reduce((sum, value) => sum + value, 0) / sorted.length; + const variance = sorted.reduce((sum, value) => sum + (value - mean) ** 2, 0) / sorted.length; + return { + name, + glyphs, + medianMs: sorted[Math.floor(sorted.length / 2)], + p95Ms: sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))], + minMs: sorted[0], + rsdPercent: (Math.sqrt(variance) / mean) * 100, + patchCount: patchCounts[Math.floor(patchCounts.length / 2)], + writeBytes: writeBytes[Math.floor(writeBytes.length / 2)], + }; +} + +function printReport(caseReports) { + console.log( + `\ncomplete Rust text_update + ${options.technique} render plan · ${caseReports[0]?.glyphs ?? 0} renderable instances (${options.glyphs} fixture target) · ${options.warmup} warmup · ${options.repetitions} measured`, + ); + console.log( + `${'case'.padEnd(16)}${'instances'.padStart(9)}${'median'.padStart(11)}${'p95'.padStart(11)}${'min'.padStart(11)}${'rsd'.padStart(9)}${'patches'.padStart(9)}${'writes'.padStart(11)}`, + ); + for (const report of caseReports) { + console.log( + `${report.name.padEnd(16)}${String(report.glyphs).padStart(9)}${`${report.medianMs.toFixed(3)}ms`.padStart(11)}${`${report.p95Ms.toFixed(3)}ms`.padStart(11)}${`${report.minMs.toFixed(3)}ms`.padStart(11)}${`${report.rsdPercent.toFixed(1)}%`.padStart(9)}${String(report.patchCount).padStart(9)}${formatBytes(report.writeBytes).padStart(11)}`, + ); + } + console.log('column-resize is the existing layout-width case: one fully active column is reflowed end to end.'); + console.log( + 'suffix-edit matches the TypeScript text benchmark; localized-edit replaces one code unit; localized-splice alternates one middle insertion/deletion.', + ); + console.log(`Wasm memory after retained high-water mark: ${(memory.buffer.byteLength / 1024 / 1024).toFixed(2)} MiB`); +} + +function formatBytes(value) { + return value < 1024 ? `${value} B` : `${(value / 1024).toFixed(1)} KiB`; +} + +function stringToUtf16(value) { + return Array.from({ length: value.length }, (_, index) => value.charCodeAt(index)); +} + +function requireStatus(status, operation) { + if (status !== abi.status.ok) throw new Error(`${operation} failed with status ${status}`); +} + +function parseArguments(arguments_) { + const read = (name, fallback) => { + const index = arguments_.indexOf(name); + return index === -1 ? fallback : Number.parseInt(arguments_[index + 1], 10); + }; + return { + technique: normalizeTechnique(readString('--technique', 'bitmap')), + allocation: readAllocation('--allocation'), + wasm: readString('--wasm'), + case: readCase('--case'), + glyphs: read('--glyphs', 22_000), + height: read('--height', 100_000), + repetitions: read('--reps', 31), + warmup: read('--warmup', 8), + jsonPath: readString('--json'), + }; + + function readString(name, fallback) { + const index = arguments_.indexOf(name); + return index === -1 ? fallback : arguments_[index + 1]; + } + + function readCase(name) { + const value = readString(name); + if ( + value !== undefined && + !['cold', 'no-op', 'font-size', 'column-resize', 'suffix-edit', 'localized-edit', 'localized-splice'].includes( + value, + ) + ) { + throw new RangeError(`unknown benchmark case: ${value}`); + } + return value; + } + + function readAllocation(name) { + const value = readString(name, 'ordered'); + if (value !== 'ordered' && value !== 'stable') throw new RangeError(`unknown allocation strategy: ${value}`); + return value; + } +} + +function normalizeTechnique(value) { + const name = value === 'msdf' ? 'mtsdf' : value; + if (!['bitmap', 'mtsdf', 'slug'].includes(name)) { + throw new RangeError('--technique must be bitmap, mtsdf, msdf, or slug'); + } + return name; +} + +async function loadArtifact(techniqueName) { + const fixtures = { + bitmap: ['inter-bitmap-16.font.glb', false], + mtsdf: ['inter-mtsdf.font.glb.gz', true], + slug: ['inter-slug.font.glb.gz', true], + }; + const [file, compressed] = fixtures[techniqueName]; + const bytes = await readFile(new URL(`../../../apps/benchmarks/fixtures/rendering/${file}`, import.meta.url)); + return compressed ? gunzipSync(bytes) : bytes; +} + +async function validateRaster(techniqueName, bytes, core) { + const rasterIdentity = core.document.extensions.PMNDRS_font.rasters[0]; + const context = { + rasterKey: rasterIdentity.rasterKey, + shapingHash: core.shapingHash, + glyphCount: core.glyphCount, + glyphIdWidth: 16, + }; + if (techniqueName === 'bitmap') { + return validateBitmapArtifact(bytes, { ...context, descriptor: bitmapDescriptor({ strikes: [16] }) }); + } + if (techniqueName === 'mtsdf') { + return validateMsdfArtifact(bytes, { ...context, descriptor: msdfDescriptor() }); + } + return validateSlugArtifact(bytes, { ...context, descriptor: slugDescriptor() }); +} diff --git a/packages/text/scripts/build-engine-kernel-lab.mjs b/packages/text/scripts/build-engine-kernel-lab.mjs new file mode 100644 index 00000000..5cc1f348 --- /dev/null +++ b/packages/text/scripts/build-engine-kernel-lab.mjs @@ -0,0 +1,71 @@ +import { spawn } from 'node:child_process'; +import { mkdir } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +import { reproducibleRustEnvironment } from '../../font-baker/scripts/reproducible-rust-env.mjs'; + +const packageRoot = fileURLToPath(new URL('../', import.meta.url)); +const workspaceRoot = fileURLToPath(new URL('../../../', import.meta.url)); +const outputRoot = fileURLToPath(new URL('../rust/shaper/target/kernel-lab/', import.meta.url)); +const executable = process.platform === 'win32' ? 'wasm-opt.CMD' : 'wasm-opt'; +const wasmOpt = fileURLToPath(new URL(`../node_modules/.bin/${executable}`, import.meta.url)); +const disExecutable = process.platform === 'win32' ? 'wasm-dis.CMD' : 'wasm-dis'; +const wasmDis = fileURLToPath(new URL(`../node_modules/.bin/${disExecutable}`, import.meta.url)); +const encodedFlagSeparator = '\u001f'; +const variants = [ + { name: 'scalar', targetFeature: '-simd128', features: 'kernel-lab', simd: false }, + { name: 'auto', targetFeature: '+simd128', features: 'kernel-lab', simd: true }, + { name: 'explicit', targetFeature: '+simd128', features: 'kernel-lab,simd128', simd: true }, +]; + +await mkdir(outputRoot, { recursive: true }); +for (const variant of variants) { + const targetDirectory = `${outputRoot}/${variant.name}-target`; + const environment = reproducibleRustEnvironment(workspaceRoot); + environment.CARGO_ENCODED_RUSTFLAGS += `${encodedFlagSeparator}-C${encodedFlagSeparator}target-feature=${variant.targetFeature}`; + await run( + 'cargo', + [ + 'build', + '--manifest-path', + 'rust/shaper/Cargo.toml', + '--target', + 'wasm32-unknown-unknown', + '--target-dir', + targetDirectory, + '--release', + '--locked', + '--no-default-features', + '--features', + variant.features, + ], + environment, + ); + const rustWasm = `${targetDirectory}/wasm32-unknown-unknown/release/pmndrs_text_shaper.wasm`; + await run(wasmOpt, [ + '--enable-bulk-memory', + '--enable-nontrapping-float-to-int', + ...(variant.simd ? ['--enable-simd'] : []), + '-Oz', + rustWasm, + '-o', + `${outputRoot}/${variant.name}.wasm`, + ]); + await run(wasmDis, [ + ...(variant.simd ? ['--enable-simd'] : []), + `${outputRoot}/${variant.name}.wasm`, + '-o', + `${outputRoot}/${variant.name}.wat`, + ]); +} + +function run(command, arguments_, environment = process.env) { + return new Promise((resolve, reject) => { + const child = spawn(command, arguments_, { cwd: packageRoot, env: environment, stdio: 'inherit' }); + child.once('error', reject); + child.once('exit', (code, signal) => { + if (code === 0) resolve(); + else reject(new Error(`${command} exited with ${code ?? signal}`)); + }); + }); +} diff --git a/packages/text/scripts/build.mjs b/packages/text/scripts/build.mjs index d4b0a70f..0100638d 100644 --- a/packages/text/scripts/build.mjs +++ b/packages/text/scripts/build.mjs @@ -1,5 +1,6 @@ import { spawn } from 'node:child_process'; -import { chmod, mkdir, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { captureCommand } from '../../font-baker/scripts/capture-command.mjs'; @@ -12,23 +13,44 @@ const tsc = fileURLToPath( new URL(process.platform === 'win32' ? '../node_modules/.bin/tsc.CMD' : '../node_modules/.bin/tsc', import.meta.url), ); const rustEnvironment = reproducibleRustEnvironment(workspaceRoot); +const shaperSimdSetting = process.env.PMNDRS_TEXT_SHAPER_SIMD; +if (shaperSimdSetting !== undefined && shaperSimdSetting !== '0' && shaperSimdSetting !== '1') { + throw new Error('PMNDRS_TEXT_SHAPER_SIMD must be 0 or 1'); +} +const shaperSimd = shaperSimdSetting !== '0'; +const shaperTargetDirectory = fileURLToPath( + new URL(`../rust/shaper/target/wasm-${shaperSimd ? 'simd128' : 'scalar'}/`, import.meta.url), +); +const mtsdfArtifactTargetDirectory = fileURLToPath( + new URL('../rust/mtsdf-baker/target/artifact-baker-wasm/', import.meta.url), +); +const slugArtifactTargetDirectory = fileURLToPath( + new URL('../rust/slug-baker/target/artifact-baker-wasm/', import.meta.url), +); +const shaperRustEnvironment = { + ...rustEnvironment, + CARGO_TARGET_DIR: shaperTargetDirectory, + CARGO_ENCODED_RUSTFLAGS: `${rustEnvironment.CARGO_ENCODED_RUSTFLAGS}\u001f-C\u001ftarget-feature=${shaperSimd ? '+simd128' : '-simd128'}`, +}; +const mtsdfArtifactRustEnvironment = { + ...rustEnvironment, + CARGO_TARGET_DIR: mtsdfArtifactTargetDirectory, +}; +const slugArtifactRustEnvironment = { + ...rustEnvironment, + CARGO_TARGET_DIR: slugArtifactTargetDirectory, +}; const executable = process.platform === 'win32' ? 'wasm-opt.CMD' : 'wasm-opt'; const wasmOpt = fileURLToPath(new URL(`../node_modules/.bin/${executable}`, import.meta.url)); const rustWasm = fileURLToPath( new URL('../rust/bitmap-baker/target/wasm32-unknown-unknown/release/pmndrs_text_bitmap_baker.wasm', import.meta.url), ); const distributedWasm = fileURLToPath(new URL('../dist/bitmap_baker.wasm', import.meta.url)); -const shaperWasm = fileURLToPath( - new URL('../rust/shaper/target/wasm32-unknown-unknown/release/pmndrs_text_shaper.wasm', import.meta.url), -); +const shaperWasm = join(shaperTargetDirectory, 'wasm32-unknown-unknown/release/pmndrs_text_shaper.wasm'); const distributedShaperWasm = fileURLToPath(new URL('../dist/text_shaper.wasm', import.meta.url)); -const mtsdfWasm = fileURLToPath( - new URL('../rust/mtsdf-baker/target/wasm32-unknown-unknown/release/pmndrs_text_mtsdf_baker.wasm', import.meta.url), -); +const mtsdfWasm = join(mtsdfArtifactTargetDirectory, 'wasm32-unknown-unknown/release/pmndrs_text_mtsdf_baker.wasm'); const distributedMtsdfWasm = fileURLToPath(new URL('../dist/mtsdf_baker.wasm', import.meta.url)); -const slugWasm = fileURLToPath( - new URL('../rust/slug-baker/target/wasm32-unknown-unknown/release/pmndrs_text_slug_baker.wasm', import.meta.url), -); +const slugWasm = join(slugArtifactTargetDirectory, 'wasm32-unknown-unknown/release/pmndrs_text_slug_baker.wasm'); const distributedSlugWasm = fileURLToPath(new URL('../dist/slug_baker.wasm', import.meta.url)); const [bitmapAbiJson, shaperAbiJson, mtsdfAbiJson, slugAbiJson] = await Promise.all([ @@ -124,7 +146,7 @@ await run( '--features', 'artifact-baker', ], - rustEnvironment, + slugArtifactRustEnvironment, ); await run( 'cargo', @@ -140,7 +162,7 @@ await run( '--features', 'artifact-baker', ], - rustEnvironment, + mtsdfArtifactRustEnvironment, ); await run( 'cargo', @@ -153,14 +175,13 @@ await run( '--release', '--locked', '--no-default-features', + ...(shaperSimd ? ['--features', 'simd128'] : []), ], - rustEnvironment, + shaperRustEnvironment, ); -await run(tsc, ['-p', 'tsconfig.build.json']); +await rm(new URL('../dist/', import.meta.url), { recursive: true, force: true }); await mkdir(new URL('../dist/', import.meta.url), { recursive: true }); -await rm(new URL('../dist/font_baker.wasm', import.meta.url), { force: true }); -await rm(new URL('../dist/mtsdf-baker-abi-v0.json', import.meta.url), { force: true }); -await rm(new URL('../dist/slug-baker-abi-v1.json', import.meta.url), { force: true }); +await run(tsc, ['-p', 'tsconfig.build.json']); await run(wasmOpt, [ '--enable-bulk-memory', '--enable-nontrapping-float-to-int', @@ -172,6 +193,7 @@ await run(wasmOpt, [ await run(wasmOpt, [ '--enable-bulk-memory', '--enable-nontrapping-float-to-int', + ...(shaperSimd ? ['--enable-simd'] : []), '-Oz', shaperWasm, '-o', @@ -193,6 +215,10 @@ await run(wasmOpt, [ '-o', distributedSlugWasm, ]); +await Promise.all([ + assertMtsdfArtifactBakerExports(distributedMtsdfWasm, mtsdfAbiJson), + assertSlugArtifactBakerExports(distributedSlugWasm, slugAbiJson), +]); await writeFile(new URL('../dist/bitmap-baker-abi-v0.json', import.meta.url), bitmapAbiJson); await writeFile(new URL('../dist/text-shaper-abi-v0.json', import.meta.url), shaperAbiJson); await writeFile(new URL('../dist/mtsdf-baker-abi-v1.json', import.meta.url), mtsdfAbiJson); @@ -215,3 +241,27 @@ function run(command, args, environment = process.env) { function runCapture(command, args) { return captureCommand(command, args, { cwd: packageRoot }); } + +async function assertMtsdfArtifactBakerExports(wasmPath, abiJson) { + const abi = JSON.parse(abiJson); + await assertWasmExports(wasmPath, Object.values(abi.artifactBaker.functions), 'MTSDF'); +} + +async function assertSlugArtifactBakerExports(wasmPath, abiJson) { + const abi = JSON.parse(abiJson); + await assertWasmExports( + wasmPath, + [...Object.values(abi.functions), ...Object.values(abi.segmented.functions)], + 'Slug', + ); +} + +async function assertWasmExports(wasmPath, functions, label) { + const module = await WebAssembly.compile(await readFile(wasmPath)); + const exports = new Set(WebAssembly.Module.exports(module).map(({ name }) => name)); + for (const definition of functions) { + if (!exports.has(definition.export)) { + throw new Error(`${label} distributable Wasm is missing artifact-baker export ${definition.export}`); + } + } +} diff --git a/packages/text/scripts/generate-unicode-line-break-data.mjs b/packages/text/scripts/generate-unicode-line-break-data.mjs new file mode 100644 index 00000000..c8b2a1d9 --- /dev/null +++ b/packages/text/scripts/generate-unicode-line-break-data.mjs @@ -0,0 +1,83 @@ +import { createRequire } from 'node:module'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; + +import { EastAsianWidth } from '@cto.af/linebreak'; +import { LineBreak, names } from '@cto.af/linebreak/lib/LineBreak.js'; +import { resolve } from '@cto.af/linebreak/lib/state.js'; + +const require = createRequire(import.meta.url); +const output = new URL('../rust/shaper/src/generated/line_break_data.rs', import.meta.url); +const check = process.argv.includes('--check'); +const initial = codePointSet('General_Category/Initial_Punctuation'); +const final = codePointSet('General_Category/Final_Punctuation'); +const extendedPictographic = codePointSet('Binary_Property/Extended_Pictographic'); +const unassigned = codePointSet('General_Category/Unassigned'); + +const values = new Uint16Array(0x11_0000); +for (let codePoint = 0; codePoint < values.length; codePoint += 1) { + const character = String.fromCodePoint(codePoint); + const lineClass = resolve(LineBreak.get(codePoint), character); + values[codePoint] = + lineClass | + (initial.has(codePoint) ? 1 << 8 : 0) | + (final.has(codePoint) ? 1 << 9 : 0) | + (EastAsianWidth.get(codePoint) ? 1 << 10 : 0) | + (extendedPictographic.has(codePoint) && unassigned.has(codePoint) ? 1 << 11 : 0); +} + +const endValues = []; +let start = 0; +for (let codePoint = 1; codePoint <= values.length; codePoint += 1) { + if (codePoint === values.length || values[codePoint] !== values[start]) { + endValues.push(codePoint, values[start]); + start = codePoint; + } +} + +const constants = Object.entries(names) + .sort((left, right) => left[1] - right[1]) + .map(([name, value]) => `pub const ${name}: u8 = ${value};`) + .join('\n'); +const source = `// Generated by scripts/generate-unicode-line-break-data.mjs from +// @cto.af/linebreak@4.0.3 and @unicode/unicode-17.0.0@1.6.17. Do not edit by hand. + +pub const UNICODE_VERSION: &str = "17.0.0"; +pub const INITIAL_PUNCTUATION: u32 = 1 << 8; +pub const FINAL_PUNCTUATION: u32 = 1 << 9; +pub const EAST_ASIAN: u32 = 1 << 10; +pub const UNASSIGNED_EXTENDED_PICTOGRAPHIC: u32 = 1 << 11; + +${constants} + +pub const LINE_BREAK_END_VALUES: &[u32] = &[ +${wrapped(endValues)} +]; +`; + +if (check) { + let existing; + try { + existing = await readFile(output, 'utf8'); + } catch { + existing = undefined; + } + if (existing !== source) { + console.error('generated Unicode line-break data is stale; run pnpm run unicode generate-data'); + process.exitCode = 1; + } +} else { + await mkdir(new URL('../rust/shaper/src/generated/', import.meta.url), { recursive: true }); + await writeFile(output, source); +} + +function codePointSet(path) { + return new Set(require(`@unicode/unicode-17.0.0/${path}/code-points.js`)); +} + +function wrapped(entries) { + const lines = []; + for (let index = 0; index < entries.length; index += 24) { + lines.push(` ${entries.slice(index, index + 24).join(', ')},`); + } + return lines.join('\n'); +} diff --git a/packages/text/scripts/generate-unicode-script-data.mjs b/packages/text/scripts/generate-unicode-script-data.mjs index 7b4e6f9f..a0737f63 100644 --- a/packages/text/scripts/generate-unicode-script-data.mjs +++ b/packages/text/scripts/generate-unicode-script-data.mjs @@ -4,7 +4,16 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; const require = createRequire(import.meta.url); const unicode = require('@unicode/unicode-17.0.0'); const propertyAliases = require('unicode-property-value-aliases'); -const output = new URL('../src/generated/unicode-script-data.ts', import.meta.url); +const outputs = [ + { + url: new URL('../src/generated/unicode-script-data.ts', import.meta.url), + source: () => typescriptSource(), + }, + { + url: new URL('../rust/shaper/src/generated/script_data.rs', import.meta.url), + source: () => rustSource(), + }, +]; const check = process.argv.includes('--check'); const scriptAliases = propertyAliases.get('Script'); @@ -72,7 +81,8 @@ for (const values of extensionSets) { extensionOffsets.push(extensionTags.length); } -const source = `// Generated by scripts/generate-unicode-script-data.mjs from +function typescriptSource() { + return `// Generated by scripts/generate-unicode-script-data.mjs from // @unicode/unicode-17.0.0@1.6.17 and unicode-property-value-aliases@3.9.0. // Do not edit by hand. @@ -86,21 +96,43 @@ export const scriptExtensionRanges: Uint32Array = new Uint32Array(${numbers(exte export const scriptExtensionOffsets: Uint32Array = new Uint32Array(${numbers(extensionOffsets)}); export const scriptExtensionTags: Uint32Array = new Uint32Array(${numbers(extensionTags)}); `; +} + +function rustSource() { + return `// Generated by scripts/generate-unicode-script-data.mjs from +// @unicode/unicode-17.0.0@1.6.17 and unicode-property-value-aliases@3.9.0. +// Do not edit by hand. + +pub const UNICODE_VERSION: &str = "17.0.0"; +pub const COMMON_SCRIPT: u32 = ${tagToUint32('Zyyy')}; +pub const INHERITED_SCRIPT: u32 = ${tagToUint32('Zinh')}; +pub const UNKNOWN_SCRIPT: u32 = ${tagToUint32('Zzzz')}; + +pub static SCRIPT_END_VALUES: &[u32] = &${rustNumbers(compactPartition(primaryRanges))}; +pub static SCRIPT_EXTENSION_END_VALUES: &[u32] = &${rustNumbers(compactPartition(extensionRanges))}; +pub static SCRIPT_EXTENSION_OFFSETS: &[u32] = &${rustNumbers(extensionOffsets)}; +pub static SCRIPT_EXTENSION_TAGS: &[u32] = &${rustNumbers(extensionTags)}; +`; +} if (check) { - let existing; - try { - existing = await readFile(output, 'utf8'); - } catch { - existing = undefined; - } - if (existing !== source) { - console.error('generated Unicode script data is stale; run pnpm run unicode generate-data'); - process.exitCode = 1; + for (const output of outputs) { + let existing; + try { + existing = await readFile(output.url, 'utf8'); + } catch { + existing = undefined; + } + if (existing !== output.source()) { + console.error('generated Unicode script data is stale; run pnpm run unicode generate-data'); + process.exitCode = 1; + } } } else { - await mkdir(new URL('../src/generated/', import.meta.url), { recursive: true }); - await writeFile(output, source); + for (const output of outputs) { + await mkdir(new URL('.', output.url), { recursive: true }); + await writeFile(output.url, output.source()); + } } function event(events, position) { @@ -153,3 +185,15 @@ function numbers(values) { } return `[\n${rows.join(',\n')}\n]`; } + +function rustNumbers(values) { + const rows = []; + for (let index = 0; index < values.length; index += 24) { + rows.push(` ${values.slice(index, index + 24).join(', ')}`); + } + return `[\n${rows.join(',\n')}\n]`; +} + +function compactPartition(ranges) { + return ranges.flatMap(([, end, value]) => [end, value]); +} diff --git a/packages/text/scripts/support/engine-kernel-fixture.mts b/packages/text/scripts/support/engine-kernel-fixture.mts new file mode 100644 index 00000000..2be8fff3 --- /dev/null +++ b/packages/text/scripts/support/engine-kernel-fixture.mts @@ -0,0 +1,96 @@ +import { readFile } from 'node:fs/promises'; + +import { + createBenchmarkParagraph, + disposeBenchmarkParagraph, + loadParagraphBenchmarkFixture, + paragraphTextForGlyphs, +} from './paragraph-benchmark-fixture.mts'; +import { kernelPolicyBytes } from '../../tests/support/engine-abi.mjs'; + +export interface CapturedKernelInput { + readonly label: string; + readonly glyphs: number; + readonly x: Float32Array; + readonly y: Float32Array; + readonly fontSize: Float32Array; + readonly planeLeft: Float32Array; + readonly planeBottom: Float32Array; + readonly planeRight: Float32Array; + readonly planeTop: Float32Array; + readonly advances: Int32Array; + readonly flags: Uint8Array; + readonly levels: Uint8Array; + readonly policy: Uint8Array; +} + +export async function captureKernelWorkloads(targets: readonly number[]): Promise { + const [fixture, abi] = await Promise.all([ + loadParagraphBenchmarkFixture(), + readFile(new URL('../../dist/text-shaper-abi-v0.json', import.meta.url), 'utf8').then(JSON.parse), + ]); + const policy = kernelPolicyBytes(abi); + try { + return targets.map((target) => captureWorkload(fixture, target, policy)); + } finally { + fixture.loaded.dispose(); + fixture.runtime.dispose(); + } +} + +function captureWorkload( + fixture: Awaited>, + targetGlyphs: number, + policy: Uint8Array, +): CapturedKernelInput { + const text = paragraphTextForGlyphs(targetGlyphs); + const created = createBenchmarkParagraph(fixture, text, 600); + created.group.updateMatrixWorld(true); + if (created.group.error !== undefined) throw created.group.error; + const layout = created.paragraph.inspectLayout(); + if (layout === undefined) throw new Error('paragraph benchmark fixture did not publish a layout'); + const glyphs = layout.glyphIds.length; + const planeLeft = new Float32Array(glyphs); + const planeBottom = new Float32Array(glyphs); + const planeRight = new Float32Array(glyphs); + const planeTop = new Float32Array(glyphs); + const advances = new Int32Array(glyphs); + const flags = new Uint8Array(glyphs); + const levels = new Uint8Array(glyphs); + for (let index = 0; index < glyphs; index += 1) { + const glyphId = layout.glyphIds[index]!; + const left = (glyphId % 13) - 4; + const bottom = (glyphId % 7) - 3; + planeLeft[index] = left; + planeBottom[index] = bottom; + planeRight[index] = left + 6 + (glyphId % 9); + planeTop[index] = bottom + 8 + (glyphId % 11); + const nextX = layout.x[index + 1]; + const positionedAdvance = + nextX === undefined ? layout.glyphFontSizes[index]! * 0.5 : Math.abs(nextX - layout.x[index]!); + advances[index] = Math.max(1, Math.round(positionedAdvance * 64)); + const cluster = layout.clusters[index]!; + const codeUnit = text.charCodeAt(cluster); + flags[index] = codeUnit === 0x20 || codeUnit === 0x0a || codeUnit === 0x2d ? 1 : 0; + // Preserve long uniform spans and real cluster boundaries while injecting mixed-direction transitions. The kernel + // consumes resolved levels; script coverage and direction resolution themselves remain separate semantic gates. + levels[index] = ((Math.floor(cluster / 53) % 5) & 1) === 0 ? 0 : 1 + (glyphId & 1); + } + const captured = { + label: `${glyphs}-glyphs`, + glyphs, + x: layout.x.slice(), + y: layout.y.slice(), + fontSize: layout.glyphFontSizes.slice(), + planeLeft, + planeBottom, + planeRight, + planeTop, + advances, + flags, + levels, + policy, + }; + disposeBenchmarkParagraph(created); + return captured; +} diff --git a/packages/text/scripts/support/engine-kernel-runner.mjs b/packages/text/scripts/support/engine-kernel-runner.mjs new file mode 100644 index 00000000..a95866ec --- /dev/null +++ b/packages/text/scripts/support/engine-kernel-runner.mjs @@ -0,0 +1,249 @@ +const CHUNK_SIZES = [32, 64, 128]; + +export async function benchmarkKernelArtifact(wasm, name, input, options) { + const module = await WebAssembly.compile(wasm); + const instance = await WebAssembly.instantiate(module, {}); + const exports = instance.exports; + const expectedBackend = name === 'explicit' ? 1 : 0; + if (exports.pmndrs_text_kernel_lab_backend() !== expectedBackend) { + throw new Error(`${name} artifact selected the wrong compile-time kernel backend`); + } + registerPolicy(exports, input.policy); + const aligned = createMemoryFixture(exports, input, 0); + const unaligned = createMemoryFixture(exports, input, 4); + const memoryBefore = exports.memory.buffer; + const alignedHash = await executeAndHash(exports, aligned, false); + const verticalHash = await executeAndHash(exports, aligned, true); + const unalignedHash = await executeAndHash(exports, unaligned, false); + if (alignedHash !== unalignedHash) throw new Error(`${name} aligned and unaligned outputs differ`); + + const iterations = input.glyphs < 50_000 ? 16 : 8; + const timings = { + pack: measure(() => checkedCall(() => callPack(exports, aligned, false)), iterations, options), + breakMasks: measure( + () => + checkedCall(() => exports.pmndrs_text_kernel_lab_break_masks(input.glyphs, aligned.flags, aligned.breakMasks)), + iterations * 4, + options, + ), + bidiMasks: measure( + () => + checkedCall(() => exports.pmndrs_text_kernel_lab_bidi_masks(input.glyphs, aligned.levels, aligned.bidiMasks)), + iterations * 4, + options, + ), + policy: measure(() => checkedCall(() => callPolicy(exports, aligned, false)), iterations, options), + chunk32: measure(() => checkedCall(() => callSummaries(exports, aligned, 32)), iterations * 2, options), + chunk64: measure(() => checkedCall(() => callSummaries(exports, aligned, 64)), iterations * 2, options), + chunk128: measure(() => checkedCall(() => callSummaries(exports, aligned, 128)), iterations * 2, options), + }; + if (exports.memory.buffer !== memoryBefore) throw new Error(`${name} grew memory during a warm kernel`); + exports.pmndrs_text_shaper_dealloc(aligned.allocationPointer, aligned.allocationLength); + exports.pmndrs_text_shaper_dealloc(unaligned.allocationPointer, unaligned.allocationLength); + return { + label: input.label, + glyphs: input.glyphs, + outputHash: await hashParts([new TextEncoder().encode(alignedHash), new TextEncoder().encode(verticalHash)]), + alignedOutputHash: alignedHash, + unalignedOutputHash: unalignedHash, + verticalOutputHash: verticalHash, + warmMemoryGrowth: false, + timings, + }; +} + +function createMemoryFixture(exports, input, skew) { + const allocationLength = input.glyphs * 96 + 4_096; + const allocationPointer = exports.pmndrs_text_shaper_alloc(allocationLength); + if (allocationPointer === 0) throw new Error('kernel-lab allocation failed'); + let cursor = alignWithSkew(allocationPointer, 16, skew); + const reserve = (count, bytesPerElement, alignment) => { + const addressSkew = skew === 0 ? 0 : alignment === 8 ? 8 : 4; + cursor = alignWithSkew(cursor, 16, addressSkew); + const pointer = cursor; + cursor += count * bytesPerElement; + return pointer; + }; + const count = input.glyphs; + const x = reserve(count, 4, 4); + const y = reserve(count, 4, 4); + const fontSize = reserve(count, 4, 4); + const planeLeft = reserve(count, 4, 4); + const planeBottom = reserve(count, 4, 4); + const planeRight = reserve(count, 4, 4); + const planeTop = reserve(count, 4, 4); + const advances = reserve(count, 4, 4); + const flags = reserve(count, 1, 1); + const levels = reserve(count, 1, 1); + const origins = reserve(count * 2, 4, 4); + const sizes = reserve(count * 2, 4, 4); + const maskCount = Math.ceil(count / 16); + const breakMasks = reserve(maskCount, 2, 2); + const bidiMasks = reserve(maskCount, 2, 2); + const summaryCapacity = Math.ceil(count / 32); + const advanceSums = reserve(summaryCapacity, 8, 8); + const breakCounts = reserve(summaryCapacity, 4, 4); + const policyF32 = reserve(count * 4, 4, 4); + const policyU32 = reserve(count, 4, 4); + const policyU16 = reserve(count, 2, 2); + if (cursor > allocationPointer + allocationLength) throw new Error('kernel-lab memory layout exceeds its allocation'); + + new Float32Array(exports.memory.buffer, x, count).set(input.x); + new Float32Array(exports.memory.buffer, y, count).set(input.y); + new Float32Array(exports.memory.buffer, fontSize, count).set(input.fontSize); + new Float32Array(exports.memory.buffer, planeLeft, count).set(input.planeLeft); + new Float32Array(exports.memory.buffer, planeBottom, count).set(input.planeBottom); + new Float32Array(exports.memory.buffer, planeRight, count).set(input.planeRight); + new Float32Array(exports.memory.buffer, planeTop, count).set(input.planeTop); + new Int32Array(exports.memory.buffer, advances, count).set(input.advances); + new Uint8Array(exports.memory.buffer, flags, count).set(input.flags); + new Uint8Array(exports.memory.buffer, levels, count).set(input.levels); + return { + count, + allocationPointer, + allocationLength, + x, + y, + fontSize, + planeLeft, + planeBottom, + planeRight, + planeTop, + advances, + flags, + levels, + origins, + sizes, + breakMasks, + bidiMasks, + advanceSums, + breakCounts, + policyF32, + policyU32, + policyU16, + summaryCapacity, + memory: exports.memory, + }; +} + +async function executeAndHash(exports, fixture, vertical) { + checkedCall(() => callPack(exports, fixture, vertical)); + checkedCall(() => exports.pmndrs_text_kernel_lab_break_masks(fixture.count, fixture.flags, fixture.breakMasks)); + checkedCall(() => exports.pmndrs_text_kernel_lab_bidi_masks(fixture.count, fixture.levels, fixture.bidiMasks)); + checkedCall(() => callPolicy(exports, fixture, vertical)); + const parts = [ + bytes(fixture, fixture.origins, fixture.count * 2 * 4), + bytes(fixture, fixture.sizes, fixture.count * 2 * 4), + bytes(fixture, fixture.breakMasks, Math.ceil(fixture.count / 16) * 2), + bytes(fixture, fixture.bidiMasks, Math.ceil(fixture.count / 16) * 2), + bytes(fixture, fixture.policyF32, fixture.count * 4 * 4), + bytes(fixture, fixture.policyU32, fixture.count * 4), + bytes(fixture, fixture.policyU16, fixture.count * 2), + ]; + for (const chunkSize of CHUNK_SIZES) { + checkedCall(() => callSummaries(exports, fixture, chunkSize)); + const summaryCount = Math.ceil(fixture.count / chunkSize); + parts.push(bytes(fixture, fixture.advanceSums, summaryCount * 8)); + parts.push(bytes(fixture, fixture.breakCounts, summaryCount * 4)); + } + return hashParts(parts); +} + +function callPack(exports, fixture, vertical) { + return exports.pmndrs_text_kernel_lab_pack( + fixture.count, + vertical ? fixture.y : fixture.x, + vertical ? fixture.x : fixture.y, + fixture.fontSize, + fixture.planeLeft, + fixture.planeBottom, + fixture.planeRight, + fixture.planeTop, + 1 / 2_048, + fixture.origins, + fixture.sizes, + ); +} + +function callSummaries(exports, fixture, chunkSize) { + return exports.pmndrs_text_kernel_lab_chunk_summaries( + fixture.count, + chunkSize, + fixture.advances, + fixture.flags, + fixture.advanceSums, + fixture.breakCounts, + ); +} + +function callPolicy(exports, fixture, vertical) { + return exports.pmndrs_text_kernel_lab_policy( + 1, + 1, + 0, + fixture.count, + vertical ? fixture.y : fixture.x, + vertical ? fixture.x : fixture.y, + fixture.fontSize, + fixture.planeLeft, + fixture.advances, + fixture.policyF32, + fixture.policyU32, + fixture.policyU16, + ); +} + +function measure(operation, iterations, options) { + for (let sample = 0; sample < options.warmup; sample += 1) { + for (let index = 0; index < iterations; index += 1) operation(); + } + const values = []; + for (let sample = 0; sample < options.samples; sample += 1) { + const started = performance.now(); + for (let index = 0; index < iterations; index += 1) operation(); + values.push((performance.now() - started) / iterations); + } + values.sort((left, right) => left - right); + return { + p50Ms: percentile(values, 0.5), + p95Ms: percentile(values, 0.95), + p99Ms: percentile(values, 0.99), + }; +} + +function checkedCall(operation) { + const status = operation(); + if (status !== 0) throw new Error(`kernel-lab call failed with status ${status}`); +} + +function bytes(fixture, pointer, length) { + return new Uint8Array(fixture.memory.buffer, pointer, length).slice(); +} + +async function hashParts(parts) { + const byteLength = parts.reduce((total, part) => total + part.byteLength, 0); + const joined = new Uint8Array(byteLength); + let offset = 0; + for (const part of parts) { + joined.set(part, offset); + offset += part.byteLength; + } + const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', joined)); + return [...digest].map((value) => value.toString(16).padStart(2, '0')).join(''); +} + +function percentile(sorted, quantile) { + return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * quantile))] ?? 0; +} + +function alignWithSkew(value, alignment, skew) { + return Math.ceil((value - skew) / alignment) * alignment + skew; +} + +function registerPolicy(exports, policy) { + const pointer = exports.pmndrs_text_shaper_alloc(policy.byteLength); + if (pointer === 0) throw new Error('kernel-lab policy allocation failed'); + new Uint8Array(exports.memory.buffer, pointer, policy.byteLength).set(policy); + checkedCall(() => exports.pmndrs_text_engine_register_policy(1, pointer, policy.byteLength)); + exports.pmndrs_text_shaper_dealloc(pointer, policy.byteLength); +} diff --git a/packages/text/scripts/support/paragraph-benchmark-fixture.mts b/packages/text/scripts/support/paragraph-benchmark-fixture.mts new file mode 100644 index 00000000..5c701614 --- /dev/null +++ b/packages/text/scripts/support/paragraph-benchmark-fixture.mts @@ -0,0 +1,53 @@ +import { readFile } from 'node:fs/promises'; + +import { createTextRuntime, FontRegistry } from '../../dist/index.js'; +import { Text, TextGroup } from '../../dist/three.js'; +import { bitmap } from '../../dist/three/bitmap.js'; + +export const paragraphBenchmarkSource = [ + 'Typography is a moving system. AVATAR To Wa Yo repeat familiar kerning pairs while a responsive panel changes the space around them. The quick visual check is useful, but the benchmark records the cost of shaping, layout, upload, and every rendered frame.', + 'A practical interface mixes prose with 0123456789, prices such as 24.50, ranges from 8-512 px, and punctuation-"quotes", (parentheses), brackets, commas, and semicolons. Repeated office, affine, difficult, and shuffle words retain ff, fi, fl, ffi, and ffl candidates.', + 'Scientific copy adds x2+y2~z2, 0<=a<=1, and pi. Arrows point both ways. These symbols expose missing coverage, uneven baselines, bad advances, and atlas placement errors that plain alphabet samples can hide.', +].join('\n'); + +export async function loadParagraphBenchmarkFixture() { + const workspaceRoot = new URL('../../../../', import.meta.url); + const registry = new FontRegistry(); + const runtime = await createTextRuntime({ + registry, + wasm: await readFile(new URL('packages/text/dist/text_shaper.wasm', workspaceRoot)), + }); + const bytes = await readFile(new URL('apps/benchmarks/fixtures/rendering/inter-bitmap-16.font.glb', workspaceRoot)); + const loaded = await runtime.loadFont({ + input: { baked: `data:application/octet-stream;base64,${bytes.toString('base64')}` }, + raster: { technique: bitmap, options: { strikes: [16] } }, + }); + return { runtime, loaded }; +} + +export function createBenchmarkParagraph( + fixture: Awaited>, + text: string, + width: number, +) { + const group = new TextGroup({ capacity: { size: Math.max(256, text.length), policy: 'grow' } }); + const paragraph = new Text({ + font: fixture.loaded, + text, + contentBox: { width: { mode: 'exact', size: width }, wrap: 'word' }, + style: { fontSize: 24 }, + }); + group.add(paragraph); + return { group, paragraph }; +} + +export function disposeBenchmarkParagraph(created: ReturnType): void { + created.group.dispose(); + created.paragraph.dispose(); +} + +export function paragraphTextForGlyphs(target: number): string { + const perCopy = paragraphBenchmarkSource.replaceAll(/\s/gu, '').length; + const copies = Math.max(1, Math.round(target / perCopy)); + return Array.from({ length: copies }, () => paragraphBenchmarkSource).join('\n'); +} diff --git a/packages/text/scripts/support/render-technique-proof.mjs b/packages/text/scripts/support/render-technique-proof.mjs new file mode 100644 index 00000000..26744e68 --- /dev/null +++ b/packages/text/scripts/support/render-technique-proof.mjs @@ -0,0 +1,337 @@ +import { fontBindingBytes, renderPolicyBytesFromPrograms } from '../../tests/support/engine-abi.mjs'; + +const ABSENT_PAGE = 0xffff; +const MISSING_RESOURCE = 0xffff_ffff; +const TECHNIQUE_ID = 1; + +export function techniqueProof(abi, name, raster, allocation = 'ordered') { + if (name === 'bitmap') return bitmapProof(abi, raster, allocation); + if (name === 'mtsdf') return mtsdfProof(abi, raster, allocation); + if (name === 'slug') return slugProof(abi, raster, allocation); + throw new RangeError(`unknown render technique ${name}`); +} + +function bitmapProof(abi, raster, allocation) { + const strike = raster.strikes[0]; + const view = recordView(strike.records); + const fields = denseAtlasFields(view, raster.glyphCount, strike.planeUnitsPerEm, strike.pages); + return proof(abi, bitmapProgram(abi, 'strike'), allocation, { + glyphCount: raster.glyphCount, + strikes: [strike.ppem], + resources: strike.pages.map(resource), + resourceIndices: pageIndices(view, raster.glyphCount), + strikeF32: fields, + }); +} + +function mtsdfProof(abi, raster, allocation) { + const extension = raster.document.extensions.PMNDRS_font_distance_field; + const view = recordView(raster.records); + const binding = { + width: Math.max(...raster.pages.map((page) => page.width)), + height: Math.max(...raster.pages.map((page) => page.height)), + }; + const fields = denseAtlasFields(view, raster.glyphCount, extension.planeUnitsPerEm, raster.pages, binding); + fields.push( + field(raster.glyphCount, (record) => { + const page = view.getUint16(record + 16, true); + if (page === ABSENT_PAGE) return 0; + return view.getUint16(record + 12, true) / binding.width; + }), + field(raster.glyphCount, (record) => { + const page = view.getUint16(record + 16, true); + if (page === ABSENT_PAGE) return 0; + return view.getUint16(record + 14, true) / binding.height; + }), + ); + return proof(abi, mtsdfProgram(abi), allocation, { + glyphCount: raster.glyphCount, + strikes: [0], + resources: [{ id: 1, generation: 1, kind: 1, reference: 1 }], + resourceIndices: pageIndices(view, raster.glyphCount, true), + glyphF32: fields, + glyphU32: [field(raster.glyphCount, (record) => view.getUint16(record + 16, true))], + }); +} + +function slugProof(abi, raster, allocation) { + const extension = raster.document.extensions.PMNDRS_font_slug; + const view = recordView(raster.records); + const units = extension.planeUnitsPerEm; + const normalized = (offset) => field(raster.glyphCount, (record) => view.getInt16(record + offset, true) / units, 40); + const left = normalized(0); + const bottom = normalized(2); + const right = normalized(4); + const top = normalized(6); + const width = left.map((value, index) => right[index] - value); + const height = bottom.map((value, index) => top[index] - value); + const horizontalBands = field(raster.glyphCount, (record) => view.getUint16(record + 10, true), 40); + const verticalBands = field(raster.glyphCount, (record) => view.getUint16(record + 12, true), 40); + const bandScaleX = width.map((value, index) => (value === 0 ? 0 : verticalBands[index] / value)); + const bandScaleY = height.map((value, index) => (value === 0 ? 0 : horizontalBands[index] / value)); + const fields = [ + left, + top, + width, + height, + bandScaleX, + bandScaleY, + left.map((value, index) => -value * bandScaleX[index]), + bottom.map((value, index) => -value * bandScaleY[index]), + ]; + const integers = [ + field(raster.glyphCount, (record) => view.getUint32(record + 16, true), 40), + field(raster.glyphCount, (record) => view.getUint32(record + 24, true), 40), + field(raster.glyphCount, (record) => view.getUint32(record + 28, true), 40), + field(raster.glyphCount, (record) => view.getUint32(record + 32, true), 40), + horizontalBands, + verticalBands, + ]; + return proof(abi, slugProgram(abi), allocation, { + glyphCount: raster.glyphCount, + strikes: [0], + resources: raster.pages.map(resource), + resourceIndices: field( + raster.glyphCount, + (record) => { + const page = view.getUint16(record + 8, true); + return page === ABSENT_PAGE ? MISSING_RESOURCE : page; + }, + 40, + ), + glyphF32: fields, + glyphU32: integers, + }); +} + +function proof(abi, descriptor, allocation, binding) { + const allocationStrategy = + allocation === 'stable' + ? abi.policy.allocationStrategies.stableIndirect + : abi.policy.allocationStrategies.orderedDirect; + const selected = { ...descriptor, allocationStrategy }; + return { + policyBytes: renderPolicyBytesFromPrograms(abi, [selected]), + bindingBytes: fontBindingBytes(abi, { techniqueId: TECHNIQUE_ID, ...binding }), + outputBytesPerGlyph: selected.buffers.reduce( + (sum, buffer) => sum + buffer.vectorWidth * scalarBytes(abi, buffer.scalar), + 0, + ), + }; +} + +function bitmapProgram(abi, glyphScope) { + const context = programContext(abi, glyphScope, 8, 0); + const { loadF32, binary, storeF32 } = context; + loadF32(15); + binary('multiplyF32', 15, 7, 2); + binary('addF32', 16, 0, 15); + binary('multiplyF32', 17, 8, 2); + binary('subtractF32', 18, 1, 17); + binary('multiplyF32', 19, 9, 2); + binary('multiplyF32', 20, 10, 2); + stores(storeF32, [ + [1, [16, 18]], + [2, [19, 20]], + [3, [11, 12]], + [4, [13, 14]], + [5, [3, 4, 5, 6]], + ]); + return program(context, floatBuffers(abi, [2, 2, 2, 2, 4])); +} + +function mtsdfProgram(abi) { + const context = programContext(abi, 'glyph', 10, 1); + const { operations: ops, loadF32, loadU32, binary, constantF32, storeF32 } = context; + loadF32(17); + loadU32(17, 0); + binary('multiplyF32', 18, 7, 2); + binary('addF32', 19, 0, 18); + binary('multiplyF32', 20, 8, 2); + binary('subtractF32', 21, 1, 20); + binary('multiplyF32', 22, 9, 2); + binary('multiplyF32', 23, 10, 2); + ops.push({ opcode: abi.policy.opcodes.convertU32ToF32, target: 24, operand0: 17 }); + constantF32(25, 0); + stores(storeF32, [ + [1, [19, 21, 22, 23]], + [2, [11, 12, 13, 14]], + [3, [11, 12, 15, 16]], + [4, [3, 4, 5, 6]], + [5, [25, 25, 25, 25]], + [6, [25, 25, 25, 25]], + [7, [25, 25, 25, 24]], + ]); + return program(context, floatBuffers(abi, [4, 4, 4, 4, 4, 4, 4])); +} + +function slugProgram(abi) { + const context = programContext(abi, 'glyph', 8, 6, true); + const { loadF32, loadU32, binary, constantF32, constantU32, storeF32, storeU32 } = context; + loadF32(16); + for (let fieldIndex = 0; fieldIndex < 6; fieldIndex += 1) loadU32(21 + fieldIndex, fieldIndex); + binary('multiplyF32', 16, 8, 2); + binary('addF32', 17, 0, 16); + binary('multiplyF32', 18, 9, 2); + binary('subtractF32', 19, 1, 18); + binary('multiplyF32', 20, 10, 2); + binary('multiplyF32', 27, 11, 2); + constantF32(28, 0); + constantU32(29, 0); + stores(storeF32, [ + [1, [17, 19, 20, 27]], + [2, [8, 9, 10, 11]], + [3, [12, 13, 14, 15]], + [4, [3, 4, 5, 6]], + [5, [7, 28, 28, 28]], + ]); + stores(storeU32, [ + [6, [21, 22, 23, 24]], + [7, [25, 26, 29, 29]], + ]); + return program(context, [...floatBuffers(abi, [4, 4, 4, 4, 4]), ...uintBuffers(abi, [4, 4], 6)]); +} + +function programContext(abi, bindingScope, bindingF32Count, bindingU32Count, inverseFontSize = false) { + const operations = []; + const semantic = abi.engine.semanticF32Fields; + const inputs = [ + { scope: 'semantic', field: semantic.inlineOrigin }, + { scope: 'semantic', field: semantic.blockOrigin }, + { scope: 'semantic', field: semantic.fontSize }, + { scope: 'semantic', field: semantic.foregroundRed }, + { scope: 'semantic', field: semantic.foregroundGreen }, + { scope: 'semantic', field: semantic.foregroundBlue }, + { scope: 'semantic', field: semantic.foregroundAlpha }, + ...(inverseFontSize ? [{ scope: 'semantic', field: semantic.inverseFontSize }] : []), + ...Array.from({ length: bindingF32Count }, (_, fieldIndex) => ({ scope: bindingScope, field: fieldIndex })), + ...Array.from({ length: bindingU32Count }, (_, fieldIndex) => ({ scope: bindingScope, field: fieldIndex })), + ]; + const f32InputCount = 7 + (inverseFontSize ? 1 : 0) + bindingF32Count; + return { + inputs, + operations, + loadF32(count) { + for (let fieldIndex = 0; fieldIndex < count; fieldIndex += 1) { + operations.push({ opcode: abi.policy.opcodes.loadF32, target: fieldIndex, operand0: fieldIndex }); + } + }, + loadU32(target, fieldIndex) { + operations.push({ opcode: abi.policy.opcodes.loadU32, target, operand0: fieldIndex }); + }, + binary(name, target, left, right) { + operations.push({ opcode: abi.policy.opcodes[name], target, operand0: left, operand1: right }); + }, + constantF32(target, value) { + operations.push({ opcode: abi.policy.opcodes.constantF32, target, immediate0: f32Bits(value) }); + }, + constantU32(target, value) { + operations.push({ opcode: abi.policy.opcodes.constantU32, target, immediate0: value }); + }, + storeF32(buffer, lane, register) { + operations.push({ + opcode: abi.policy.opcodes.storeF32, + operand0: register, + operand1: lane, + immediate0: buffer, + }); + }, + storeU32(buffer, lane, register) { + operations.push({ + opcode: abi.policy.opcodes.storeU32, + operand0: register, + operand1: lane, + immediate0: buffer, + }); + }, + f32InputCount, + u32InputCount: bindingU32Count, + }; +} + +function program(context, buffers) { + return { + techniqueId: TECHNIQUE_ID, + programId: 1, + f32InputCount: context.f32InputCount, + u32InputCount: context.u32InputCount, + inputs: context.inputs, + buffers, + operations: context.operations, + }; +} + +function stores(write, groups) { + for (const [buffer, registers] of groups) { + for (const [lane, register] of registers.entries()) write(buffer, lane, register); + } +} + +function floatBuffers(abi, widths) { + return widths.map((vectorWidth, index) => ({ id: index + 1, scalar: abi.policy.scalarTypes.f32, vectorWidth })); +} + +function uintBuffers(abi, widths, firstId) { + return widths.map((vectorWidth, index) => ({ id: firstId + index, scalar: abi.policy.scalarTypes.u32, vectorWidth })); +} + +function denseAtlasFields(view, glyphCount, units, pages, binding) { + return [ + field(glyphCount, (record) => view.getInt16(record, true) / units), + field(glyphCount, (record) => view.getInt16(record + 6, true) / units), + field(glyphCount, (record) => (view.getInt16(record + 4, true) - view.getInt16(record, true)) / units), + field(glyphCount, (record) => (view.getInt16(record + 6, true) - view.getInt16(record + 2, true)) / units), + field(glyphCount, (record) => atlasValue(view, record, pages, binding, 8, 'width')), + field(glyphCount, (record) => atlasValue(view, record, pages, binding, 10, 'height')), + field(glyphCount, (record) => atlasSpan(view, record, pages, binding, 8, 12, 'width')), + field(glyphCount, (record) => atlasSpan(view, record, pages, binding, 10, 14, 'height')), + ]; +} + +function atlasValue(view, record, pages, binding, offset, dimension) { + const page = view.getUint16(record + 16, true); + return page === ABSENT_PAGE + ? 0 + : view.getUint16(record + offset, true) / (binding?.[dimension] ?? pages[page][dimension]); +} + +function atlasSpan(view, record, pages, binding, start, end, dimension) { + const page = view.getUint16(record + 16, true); + return page === ABSENT_PAGE + ? 0 + : (view.getUint16(record + end, true) - view.getUint16(record + start, true)) / + (binding?.[dimension] ?? pages[page][dimension]); +} + +function pageIndices(view, glyphCount, arrayResource = false, stride = 20) { + return field( + glyphCount, + (record) => { + const page = view.getUint16(record + 16, true); + return page === ABSENT_PAGE ? MISSING_RESOURCE : arrayResource ? 0 : page; + }, + stride, + ); +} + +function field(glyphCount, read, stride = 20) { + return Array.from({ length: glyphCount }, (_, glyph) => read(glyph * stride)); +} + +function recordView(records) { + return new DataView(records.buffer, records.byteOffset, records.byteLength); +} + +function resource(_, index) { + return { id: index + 1, generation: 1, kind: 1, reference: index + 1 }; +} + +function scalarBytes(abi, scalar) { + return scalar === abi.policy.scalarTypes.u16 ? 2 : 4; +} + +function f32Bits(value) { + const bytes = new ArrayBuffer(4); + new DataView(bytes).setFloat32(0, value, true); + return new DataView(bytes).getUint32(0, true); +} diff --git a/packages/text/scripts/test-mtsdf-core-wasm.mjs b/packages/text/scripts/test-mtsdf-core-wasm.mjs index e71f5d6a..7c1ac26e 100644 --- a/packages/text/scripts/test-mtsdf-core-wasm.mjs +++ b/packages/text/scripts/test-mtsdf-core-wasm.mjs @@ -1,10 +1,12 @@ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; const packageRoot = fileURLToPath(new URL('../', import.meta.url)); const manifest = 'rust/mtsdf-baker/Cargo.toml'; +const targetDirectory = fileURLToPath(new URL('../rust/mtsdf-baker/target/kernel-only-wasm/', import.meta.url)); execFileSync( 'cargo', [ @@ -17,12 +19,10 @@ execFileSync( '--locked', '--no-default-features', ], - { cwd: packageRoot, stdio: 'inherit' }, + { cwd: packageRoot, env: { ...process.env, CARGO_TARGET_DIR: targetDirectory }, stdio: 'inherit' }, ); -const bytes = await readFile( - new URL('../rust/mtsdf-baker/target/wasm32-unknown-unknown/release/pmndrs_text_mtsdf_baker.wasm', import.meta.url), -); +const bytes = await readFile(join(targetDirectory, 'wasm32-unknown-unknown/release/pmndrs_text_mtsdf_baker.wasm')); const module = await WebAssembly.compile(bytes); assert.deepEqual(WebAssembly.Module.imports(module), []); const { exports } = await WebAssembly.instantiate(module, {}); diff --git a/packages/text/scripts/unicode.mts b/packages/text/scripts/unicode.mts index aa818df9..d431c028 100644 --- a/packages/text/scripts/unicode.mts +++ b/packages/text/scripts/unicode.mts @@ -43,10 +43,12 @@ export async function runUnicode(arguments_: readonly string[]): Promise { case 'generate-data': await runNode('scripts/generate-unicode-script-data.mjs', rest); await runNode('scripts/generate-unicode-bidi-data.mjs', rest); + await runNode('scripts/generate-unicode-line-break-data.mjs', rest); return; case 'check-data': await runNode('scripts/generate-unicode-script-data.mjs', ['--check', ...rest]); await runNode('scripts/generate-unicode-bidi-data.mjs', ['--check', ...rest]); + await runNode('scripts/generate-unicode-line-break-data.mjs', ['--check', ...rest]); return; case 'sync-tests': await runNode('scripts/sync-unicode-test-data.mjs', rest); diff --git a/packages/text/src/bakers/bitmap.ts b/packages/text/src/bakers/bitmap.ts index c5fc7263..e1c2c75d 100644 --- a/packages/text/src/bakers/bitmap.ts +++ b/packages/text/src/bakers/bitmap.ts @@ -17,6 +17,8 @@ import { } from '../internal/bitmap-contract.js'; import type { RasterCoverage } from '../raster-coverage.js'; +export { bitmapBakerAbi } from '../generated/bitmap-baker-abi.js'; + export interface BitmapBakerOptions { readonly strikes: readonly [number, ...number[]]; readonly coverage?: RasterCoverage; @@ -83,8 +85,7 @@ export async function createBitmapBaker(source: BitmapBakerWasmSource): Promise< } export function createBitmapBakerFromInstance(instance: WebAssembly.Instance): BitmapBakerCore { - const abi = readBitmapBakerAbi(instance); - return createDirectRasterBakerFromInstance(instance, abi, { + return createDirectRasterBakerFromInstance(instance, bitmapBakerAbi, { label: 'bitmap baker', kind: BITMAP_KIND, extension: BITMAP_EXTENSION, @@ -94,11 +95,6 @@ export function createBitmapBakerFromInstance(instance: WebAssembly.Instance): B }); } -export function readBitmapBakerAbi(instance: WebAssembly.Instance): BitmapBakerAbiV0 { - void instance; - return bitmapBakerAbi; -} - export function bitmapBakerFromCore( core: BitmapBakerCore, ): RasterBakerModule<'bitmap', BitmapBakerOptions, BitmapDescriptorV0> { diff --git a/packages/text/src/bakers/msdf.ts b/packages/text/src/bakers/msdf.ts index e328530f..fdf53df8 100644 --- a/packages/text/src/bakers/msdf.ts +++ b/packages/text/src/bakers/msdf.ts @@ -23,6 +23,8 @@ import { type MsdfDescriptorV0, } from '../internal/msdf-contract.js'; +export { mtsdfBakerAbi as msdfBakerAbi } from '../generated/mtsdf-baker-abi.js'; + export type MsdfBakerOptions = MsdfOptions | undefined; export interface MsdfBakerRequestV0 { @@ -86,7 +88,7 @@ export async function createMsdfBaker(source: MsdfBakerWasmSource): Promise { return { kind: MSDF_KIND, diff --git a/packages/text/src/bakers/slug.ts b/packages/text/src/bakers/slug.ts index ab789790..52610383 100644 --- a/packages/text/src/bakers/slug.ts +++ b/packages/text/src/bakers/slug.ts @@ -20,6 +20,8 @@ import { } from '../internal/slug-contract.js'; import { cacheSuccessfulPromise } from '../internal/successful-promise-cache.js'; +export { slugBakerAbi } from '../generated/slug-baker-abi.js'; + export type SlugBakerOptions = undefined; export interface SlugBakerRequestV0 { @@ -84,7 +86,7 @@ export async function createSlugBaker(source: SlugBakerWasmSource): Promise( instance, - readSlugBakerAbi(instance) satisfies DirectRasterBakerAbi, + slugBakerAbi satisfies DirectRasterBakerAbi, { label: 'Slug baker', kind: SLUG_KIND, @@ -96,11 +98,6 @@ export function createSlugBakerFromInstance(instance: WebAssembly.Instance): Slu ); } -export function readSlugBakerAbi(instance: WebAssembly.Instance): SlugBakerAbiV0 { - void instance; - return slugBakerAbi; -} - export function slugBakerFromCore( core: SlugBakerCore, ): RasterBakerModule { diff --git a/packages/text/src/font.ts b/packages/text/src/font.ts index 4f0006bb..1de8c975 100644 --- a/packages/text/src/font.ts +++ b/packages/text/src/font.ts @@ -1,14 +1,5 @@ -import type { - AnyRasterModule, - AnyRasterInput, - LoadedRaster, - RasterLoadOptions, - RasterReference, - RasterRequest, - RasterModuleOptionsOf, - RasterSelection, - RegisteredRaster, -} from './raster.js'; +import type { RasterLoadOptions, RasterReference, RasterSelection, RegisteredRaster } from './raster.js'; +import type { AnyRasterTechnique, RasterTechniqueInput, RasterTechniqueRequest } from './raster-technique.js'; import type { FontHandle, FontKey, RasterKey, Sha256Hex } from './identity.js'; export interface FontMetrics { @@ -52,48 +43,34 @@ export interface BakedFontSource { export type FontInput = string | URL | FontSourceOverride | BakedFontSource; -export interface FontToken { +export interface FontToken { readonly input: Input; - readonly raster: RasterRequest; + readonly raster: RasterTechniqueRequest; } export interface AnyFontToken { readonly input: FontInput; readonly raster: { - readonly module: AnyRasterModule; + readonly technique: AnyRasterTechnique; readonly options?: unknown; }; } -/** @deprecated Merged-v0 loaded font/raster pair retained only by the v0 React harness. */ -export interface LoadedFontV0 { - readonly input: Input; - readonly font: RegisteredFont; - readonly raster: LoadedRaster; -} - export type FontInputOf = Token['input']; -export type FontRasterModuleOf = Token['raster']['module']; - -export function defineFont( - input: Input, - raster: Module & - ([RasterModuleOptionsOf] extends [never] - ? unknown - : undefined extends RasterModuleOptionsOf - ? unknown - : never), -): FontToken; +export type FontRasterTechniqueOf = Token['raster']['technique']; -export function defineFont( +export function defineFont( input: Input, - raster: RasterRequest, -): FontToken; + raster: RasterTechniqueInput, +): FontToken; -export function defineFont(input: FontInput, raster: AnyRasterInput): AnyFontToken { +export function defineFont( + input: FontInput, + raster: AnyRasterTechnique | RasterTechniqueRequest, +): AnyFontToken { return { input, - raster: 'module' in raster ? raster : { module: raster }, + raster: 'technique' in raster ? raster : { technique: raster }, }; } diff --git a/packages/text/src/formatted-text.ts b/packages/text/src/formatted-text.ts index 25bc8b59..1ce84cf3 100644 --- a/packages/text/src/formatted-text.ts +++ b/packages/text/src/formatted-text.ts @@ -1,6 +1,6 @@ import { statedProperties } from './internal/span-cascade.js'; import type { FontSelection } from './loaded-font.js'; -import type { ParagraphStyle } from './paragraph.js'; +import type { ParagraphStyle } from './text-properties.js'; import type { AnyRasterTechnique } from './raster-technique.js'; declare const textLiteralTechnique: unique symbol; @@ -16,13 +16,12 @@ export interface GlyphPaintInput { readonly shadow?: { readonly color: ColorInput; readonly offset: readonly [number, number] }; } -export interface ParagraphSpan { +export interface ParagraphSpan { readonly start: number; readonly end: number; readonly font?: FontSelection; readonly style?: ParagraphStyle; readonly paint?: GlyphPaintInput; - readonly renderVariant?: Variant; } export interface TextLiteral { diff --git a/packages/text/src/generated/text-shaper-abi.ts b/packages/text/src/generated/text-shaper-abi.ts index 5ac42ba7..fda8662b 100644 --- a/packages/text/src/generated/text-shaper-abi.ts +++ b/packages/text/src/generated/text-shaper-abi.ts @@ -1,71 +1,563 @@ // Generated from Rust compiler layout facts. Do not edit. export const textShaperAbi = { - "bidi": { - "classes": { - "AL": 2, - "AN": 6, - "B": 10, - "BN": 9, - "CS": 7, - "EN": 3, - "ES": 4, - "ET": 5, - "FSI": 21, - "L": 0, - "LRE": 14, - "LRI": 19, - "LRO": 15, - "NSM": 8, - "ON": 13, - "PDF": 18, - "PDI": 22, - "R": 1, - "RLE": 16, - "RLI": 20, - "RLO": 17, - "S": 11, - "WS": 12 - }, - "directions": { - "auto": 0, - "ltr": 1, - "rtl": 2 + "endianness": "little", + "engine": { + "axisModes": { + "atMost": 2, + "exact": 3, + "unconstrained": 1 + }, + "blockAlignments": { + "center": 2, + "end": 3, + "start": 1 + }, + "bufferStrategies": { + "orderedDirect": 1, + "stableIndirect": 2 + }, + "decorationFlags": { + "all": 15, + "lineThrough": 4, + "overline": 2, + "skipInk": 8, + "underline": 1 + }, + "decorationStyles": { + "dashed": 4, + "dotted": 3, + "double": 2, + "none": 0, + "solid": 1, + "wavy": 5 + }, + "defaultSessionTextCapacity": 1024, + "exclusionWrapSides": { + "both": 1, + "inlineEnd": 3, + "inlineStart": 2, + "largest": 4 + }, + "flowShapeKinds": { + "polygon": 2, + "rectangle": 1 + }, + "frameFlags": { + "compositingIndependent": 1 + }, + "inlineAlignments": { + "center": 2, + "end": 3, + "justify": 4, + "start": 1 + }, + "inlineObjectBaselines": { + "alphabetic": 1, + "middle": 3, + "textBottom": 4, + "textTop": 2 + }, + "internalBufferBindings": { + "order": 65535 + }, + "measurementFlags": { + "overflowed": 1 + }, + "overflowModes": { + "clip": 2, + "ellipsis": 3, + "visible": 1 + }, + "paragraphMutationOpcodes": { + "remove": 2, + "upsert": 1 + }, + "patchOpcodes": { + "allocateOrResize": 1, + "copy": 4, + "fill": 3, + "retire": 5, + "write": 2 + }, + "primitiveKinds": { + "clip": 4, + "decoration": 2, + "glyph": 1, + "inlineObject": 3, + "policy": 5 + }, + "resourceActions": { + "create": 1, + "retain": 3, + "update": 2 + }, + "resultFlags": { + "checkpoint": 1 + }, + "retirementKinds": { + "buffer": 2, + "outputBytes": 4, + "resource": 1, + "slotRange": 3 + }, + "semanticF32Fields": { + "blockExtent": 3, + "blockOrigin": 7, + "blockStart": 1, + "fontSize": 4, + "foregroundAlpha": 11, + "foregroundBlue": 10, + "foregroundGreen": 9, + "foregroundRed": 8, + "inlineExtent": 2, + "inlineOrigin": 6, + "inlineStart": 0, + "inverseFontSize": 12, + "rasterPixelRatio": 5 + }, + "semanticKinds": { + "caret": 5, + "cluster": 4, + "fragment": 2, + "glyph": 9, + "insertedGlyph": 7, + "line": 1, + "paragraphMeasurement": 8, + "run": 3, + "selection": 6 + }, + "semanticU32Fields": { + "clusterId": 1, + "flowThreadId": 3, + "foregroundRgba": 0, + "regionId": 2, + "stableGlyphId": 5, + "transformIndex": 4 + }, + "semanticViewMasks": { + "all": 3, + "layoutInspection": 2, + "measurement": 1 + }, + "styleFields": { + "all": 8191, + "baselineShift": 256, + "decoration": 4096, + "direction": 1024, + "features": 8, + "fontSize": 16, + "fontStack": 1, + "foreground": 2048, + "language": 4, + "letterSpacing": 64, + "lineHeight": 32, + "material": 2, + "rasterPixelRatio": 512, + "wordSpacing": 128 + }, + "styleFlags": { + "root": 1 + }, + "styleMutationOpcodes": { + "remove": 2, + "upsert": 1 + }, + "textEncodings": { + "utf16Le": 1 + }, + "textMutationOpcodes": { + "replaceUtf16": 1 + }, + "textOrientations": { + "mixed": 1, + "sideways": 3, + "upright": 2 + }, + "wrapModes": { + "character": 3, + "none": 1, + "word": 2 + }, + "writingModes": { + "horizontalTb": 1, + "verticalLr": 3, + "verticalRl": 2 } }, - "endianness": "little", "functions": { "allocate": "pmndrs_text_shaper_alloc", - "analyzeBidi": "pmndrs_text_shaper_analyze_bidi", + "createSession": "pmndrs_text_engine_create_session", "deallocate": "pmndrs_text_shaper_dealloc", "disposeFont": "pmndrs_text_shaper_dispose_font", + "disposeFontStack": "pmndrs_text_engine_dispose_font_stack", + "disposePolicy": "pmndrs_text_engine_dispose_policy", + "disposeSession": "pmndrs_text_engine_dispose_session", + "fontBindingCount": "pmndrs_text_engine_font_binding_count", "fontCount": "pmndrs_text_shaper_font_count", + "fontStackCount": "pmndrs_text_engine_font_stack_count", + "initialize": "pmndrs_text_shaper_initialize", "planCount": "pmndrs_text_shaper_plan_count", + "policyCount": "pmndrs_text_engine_policy_count", "registerFont": "pmndrs_text_shaper_register_font", - "reshapeRanges": "pmndrs_text_shaper_reshape_ranges", - "resultLength": "pmndrs_text_shaper_result_len", - "resultPointer": "pmndrs_text_shaper_result_ptr", + "registerFontBinding": "pmndrs_text_engine_register_font_binding", + "registerFontStack": "pmndrs_text_engine_register_font_stack", + "registerPolicy": "pmndrs_text_engine_register_policy", + "requestCapacity": "pmndrs_text_engine_request_capacity", + "requestPointer": "pmndrs_text_engine_request_ptr", + "reserveSession": "pmndrs_text_engine_reserve_session", "retainedFontBytes": "pmndrs_text_shaper_retained_font_bytes", - "shapeBatch": "pmndrs_text_shaper_shape_batch" + "sessionCount": "pmndrs_text_engine_session_count", + "textUpdate": "pmndrs_text_engine_update" }, "layouts": { - "bidiRequest": { + "engineBuffer": { "alignment": 4, - "direction": 8, - "size": 12, - "textLength": 4, - "textOffset": 0 + "byteLength": 28, + "capacityRecords": 24, + "flags": 18, + "generation": 4, + "id": 0, + "liveRecords": 20, + "orderBufferId": 32, + "policyBufferId": 12, + "programId": 8, + "scalarType": 14, + "size": 36, + "strategy": 16, + "vectorWidth": 15 }, - "bidiResult": { + "engineConstraint": { + "align": 47, "alignment": 4, - "byteLength": 0, - "classesOffset": 8, - "levelsOffset": 4, - "paragraphCount": 28, - "paragraphEndsOffset": 20, - "paragraphLevelsOffset": 24, - "paragraphStartsOffset": 16, - "size": 32, - "textLength": 12 + "blockAlign": 49, + "flags": 50, + "flowThreadId": 0, + "geometryRevision": 4, + "height": 12, + "heightMode": 45, + "maxLines": 28, + "overflow": 48, + "paragraphId": 52, + "regionCount": 40, + "regionStart": 32, + "resumeBlockOffset": 24, + "resumeCluster": 36, + "resumeRegion": 42, + "size": 56, + "viewportBlockEnd": 20, + "viewportBlockStart": 16, + "width": 8, + "widthMode": 44, + "wrap": 46 + }, + "engineDiagnostic": { + "alignment": 4, + "code": 0, + "durationNanosHigh": 20, + "durationNanosLow": 16, + "phase": 3, + "severity": 2, + "size": 24, + "subjectId": 4, + "value0": 8, + "value1": 12 + }, + "engineDraw": { + "alignment": 4, + "bufferCount": 40, + "bufferStart": 36, + "clipId": 16, + "depthKey": 20, + "flags": 10, + "id": 0, + "indirectBufferId": 56, + "indirectOffset": 60, + "materialId": 12, + "orderToken": 52, + "primitiveCount": 32, + "primitiveStart": 28, + "programId": 4, + "programVariant": 8, + "resourceCount": 48, + "resourceStart": 44, + "size": 64, + "transformId": 24 + }, + "engineExclusion": { + "alignment": 4, + "blockEnd": 36, + "blockStart": 28, + "flags": 18, + "geometryRevision": 8, + "id": 0, + "inlineEnd": 32, + "inlineStart": 24, + "marginBlock": 44, + "marginInline": 40, + "regionId": 4, + "reserved0": 22, + "shape": 20, + "size": 48, + "vertexCount": 16, + "verticesOffset": 12, + "wrapSide": 21 + }, + "engineFlowVertex": { + "alignment": 4, + "block": 4, + "inline": 0, + "size": 8 + }, + "engineInlineObject": { + "alignment": 4, + "baselineAlignment": 52, + "baselineOffset": 32, + "blockExtent": 28, + "contentRevision": 4, + "flags": 53, + "id": 0, + "inlineExtent": 24, + "marginBlockEnd": 48, + "marginBlockStart": 44, + "marginInlineEnd": 40, + "marginInlineStart": 36, + "materialId": 12, + "paragraphId": 56, + "reserved0": 54, + "resourceGeneration": 20, + "resourceId": 16, + "size": 60, + "textOffset": 8 + }, + "engineParagraphMutation": { + "alignment": 4, + "flags": 1, + "opcode": 0, + "order": 8, + "paragraphId": 4, + "reserved0": 2, + "size": 12 + }, + "enginePatch": { + "alignment": 4, + "bufferGeneration": 8, + "bufferId": 4, + "byteLength": 16, + "destinationOffset": 12, + "fillValue": 32, + "flags": 2, + "opcode": 0, + "payloadOffset": 20, + "size": 36, + "sourceBufferId": 24, + "sourceOffset": 28 + }, + "enginePrimitive": { + "alignment": 4, + "blockExtent": 60, + "blockStart": 52, + "bufferId": 28, + "clipId": 40, + "flags": 6, + "id": 0, + "inlineExtent": 56, + "inlineStart": 48, + "kind": 4, + "logicalOrder": 36, + "programId": 20, + "programVariant": 24, + "recordCount": 26, + "recordIndex": 32, + "resourceGeneration": 16, + "resourceId": 12, + "semanticId": 44, + "size": 64, + "techniqueId": 8 + }, + "engineRegion": { + "alignment": 4, + "blockEnd": 40, + "blockStart": 32, + "clipBlockEnd": 56, + "clipBlockStart": 48, + "clipInlineEnd": 52, + "clipInlineStart": 44, + "exclusionCount": 20, + "exclusionStart": 18, + "flags": 22, + "geometryRevision": 4, + "id": 0, + "inlineEnd": 36, + "inlineStart": 28, + "reserved0": 27, + "shape": 24, + "size": 60, + "textOrientation": 26, + "transformIndex": 8, + "vertexCount": 16, + "verticesOffset": 12, + "writingMode": 25 + }, + "engineResource": { + "action": 14, + "alignment": 4, + "auxiliary0": 32, + "auxiliary1": 36, + "flags": 16, + "generation": 4, + "id": 0, + "lowerBound": 24, + "referenceId": 20, + "resourceKind": 12, + "size": 40, + "techniqueId": 8, + "upperBound": 28 + }, + "engineResult": { + "abiVersion": 0, + "alignment": 16, + "bufferCount": 92, + "buffersOffset": 88, + "byteLength": 4, + "capabilitySet": 60, + "diagnosticCount": 132, + "diagnosticsOffset": 128, + "drawCount": 116, + "drawsOffset": 112, + "engineRevision": 20, + "flags": 12, + "outputSlot": 36, + "patchCount": 100, + "patchesOffset": 96, + "planRevision": 24, + "policyFingerprintHigh": 68, + "policyFingerprintLow": 64, + "policyHandle": 56, + "primitiveCount": 108, + "primitivesOffset": 104, + "publicationGeneration": 32, + "requestCapacity": 40, + "requiredBaseRevision": 28, + "requiredRequestCapacity": 44, + "requiredResultCapacity": 52, + "resourceCount": 84, + "resourcesOffset": 80, + "resultCapacity": 48, + "retirementCount": 124, + "retirementsOffset": 120, + "semanticViewCount": 76, + "semanticViewsOffset": 72, + "sessionId": 16, + "size": 144, + "status": 8 + }, + "engineRetirement": { + "afterPublicationGeneration": 12, + "alignment": 4, + "byteLength": 20, + "byteOffset": 16, + "flags": 2, + "generation": 8, + "id": 4, + "kind": 0, + "size": 24 + }, + "engineSemanticView": { + "alignment": 4, + "blockExtent": 40, + "blockStart": 32, + "flags": 6, + "id": 0, + "inlineExtent": 36, + "inlineStart": 28, + "itemCount": 24, + "itemStart": 20, + "kind": 4, + "parentId": 8, + "size": 44, + "textEnd": 16, + "textStart": 12 + }, + "engineStyleMutation": { + "alignment": 4, + "baselineShift": 60, + "cascadeOrder": 8, + "decorationFlags": 76, + "decorationOffset": 84, + "decorationRgba": 72, + "decorationStyle": 2, + "decorationThickness": 80, + "direction": 1, + "featureCount": 38, + "featuresOffset": 40, + "fieldMask": 12, + "flags": 3, + "fontSize": 44, + "fontStackHandle": 24, + "foregroundRgba": 68, + "languageLength": 36, + "languageOffset": 32, + "letterSpacing": 52, + "lineHeight": 48, + "materialId": 28, + "opcode": 0, + "paragraphId": 88, + "rasterPixelRatio": 64, + "size": 92, + "styleId": 4, + "textEnd": 20, + "textStart": 16, + "wordSpacing": 56 + }, + "engineTextMutation": { + "alignment": 4, + "deleteCount": 8, + "encoding": 1, + "insertCount": 16, + "insertOffset": 12, + "opcode": 0, + "paragraphId": 20, + "reserved0": 2, + "size": 24, + "textStart": 4 + }, + "engineUpdateRequest": { + "abiVersion": 0, + "acknowledgedPublicationGeneration": 20, + "alignment": 4, + "byteLength": 4, + "capabilitySet": 28, + "constraintCount": 88, + "constraintsOffset": 84, + "consumedPlanRevision": 16, + "exclusionCount": 104, + "exclusionsOffset": 100, + "expectedEngineRevision": 12, + "flags": 32, + "inlineObjectCount": 112, + "inlineObjectsOffset": 108, + "maxClusters": 40, + "maxExclusions": 52, + "maxInlineObjects": 56, + "maxLines": 44, + "maxOutputBytes": 64, + "maxParagraphs": 124, + "maxRegions": 48, + "maxSlotsPerBand": 60, + "paragraphMutationCount": 132, + "paragraphMutationsOffset": 128, + "policyHandle": 24, + "policyParametersLength": 120, + "policyParametersOffset": 116, + "regionCount": 96, + "regionsOffset": 92, + "semanticViewMask": 36, + "sessionId": 8, + "size": 136, + "styleMutationCount": 80, + "styleMutationsOffset": 76, + "textMutationCount": 72, + "textMutationsOffset": 68 }, "feature": { "alignment": 4, @@ -75,80 +567,210 @@ export const textShaperAbi = { "tag": 0, "value": 4 }, - "reshapeRange": { + "fontBindingRequest": { + "abiVersion": 0, "alignment": 4, - "contextEnd": 16, - "contextStart": 12, - "flags": 20, - "itemEnd": 8, - "itemStart": 4, - "run": 0, - "size": 24 + "byteLength": 4, + "glyphCount": 16, + "glyphF32FieldCount": 28, + "glyphF32Offset": 48, + "glyphU32FieldCount": 29, + "glyphU32Offset": 52, + "programVariant": 12, + "reserved0": 14, + "reserved1": 34, + "reserved2": 72, + "resourceCount": 24, + "resourceF32FieldCount": 32, + "resourceF32Offset": 64, + "resourceIndicesOffset": 44, + "resourceU32FieldCount": 33, + "resourceU32Offset": 68, + "resourcesOffset": 40, + "size": 76, + "strikeCount": 20, + "strikeF32FieldCount": 30, + "strikeF32Offset": 56, + "strikeU32FieldCount": 31, + "strikeU32Offset": 60, + "strikesOffset": 36, + "techniqueId": 8 }, - "reshapeRequest": { + "fontBindingResource": { "alignment": 4, - "rangeCount": 36, - "rangesOffset": 32, - "size": 40 + "generation": 4, + "id": 0, + "kind": 8, + "reference": 12, + "reserved": 10, + "size": 16 }, - "result": { + "fontBindingStrike": { "alignment": 4, - "byteLength": 0, - "clustersOffset": 32, - "fontHandleCount": 8, - "fontHandlesOffset": 4, - "glyphCount": 56, - "glyphFlagsOffset": 52, - "glyphIdsOffset": 28, - "runCount": 24, - "runFontSlotsOffset": 12, - "runGlyphCountsOffset": 20, - "runGlyphStartsOffset": 16, - "size": 60, - "xAdvancesOffset": 36, - "xOffsetsOffset": 44, - "yAdvancesOffset": 40, - "yOffsetsOffset": 48 - }, - "run": { - "alignment": 4, - "clusterLevel": 27, - "direction": 26, - "featureCount": 24, - "featureStart": 20, - "flags": 28, - "fontHandle": 0, - "languageOffset": 16, - "script": 12, - "size": 32, - "textEnd": 8, - "textStart": 4 + "ppem": 0, + "reserved": 4, + "size": 8 + }, + "policyBuffer": { + "alignment": 4, + "capacityClass": 12, + "id": 0, + "reserved0": 14, + "scalar": 2, + "size": 16, + "stride": 6, + "usage": 8, + "vectorWidth": 3 + }, + "policyCapabilitySet": { + "alignment": 4, + "coalesceGapBytes": 16, + "flags": 4, + "fragmentationBudget": 30, + "id": 0, + "maxBufferBytes": 8, + "maxBuffersPerDraw": 24, + "maxIndirectDraws": 28, + "maxResourcesPerDraw": 26, + "rangeCallPenaltyBytes": 20, + "reserved": 34, + "size": 40, + "updateAlignment": 12, + "wholeBufferThresholdBasisPoints": 32 + }, + "policyInput": { + "alignment": 2, + "field": 1, + "reserved": 2, + "scope": 0, + "size": 4 + }, + "policyOperation": { + "alignment": 4, + "immediate0": 4, + "immediate1": 8, + "immediate2": 12, + "opcode": 0, + "operand0": 2, + "operand1": 3, + "size": 16, + "target": 1 }, - "shapeRequest": { - "alignment": 4, - "featureCount": 20, - "featuresOffset": 16, - "languagesLength": 28, - "languagesOffset": 24, - "runCount": 12, - "runsOffset": 8, - "size": 32, - "textLength": 4, - "textOffset": 0 + "policyProgram": { + "alignment": 4, + "allocationStrategy": 46, + "bufferCount": 42, + "bufferStart": 32, + "capabilitySetId": 8, + "compositingCapabilities": 28, + "drawKeyMask": 52, + "f32InputCount": 48, + "inputCount": 60, + "inputStart": 56, + "operationCount": 44, + "operationStart": 36, + "paintCapabilities": 24, + "programId": 4, + "reserved0": 50, + "reserved1": 62, + "resourceKindMask": 12, + "semanticViewMask": 16, + "size": 64, + "storageKeyMask": 20, + "techniqueId": 0, + "u32InputCount": 49, + "variant": 40 + }, + "policyRequest": { + "alignment": 4, + "bufferCount": 24, + "buffersOffset": 20, + "byteLength": 0, + "capabilitySetCount": 8, + "capabilitySetsOffset": 4, + "inputCount": 40, + "inputsOffset": 36, + "operationCount": 32, + "operationsOffset": 28, + "programCount": 16, + "programsOffset": 12, + "size": 44 } }, "memory": "memory", "name": "pmndrs-text-shaper", "pointerWidth": 32, + "policy": { + "allocationStrategies": { + "orderedDirect": 1, + "stableIndirect": 2 + }, + "batchFields": { + "clip": 16, + "depth": 32, + "material": 8, + "order": 64, + "program": 4, + "resource": 2, + "technique": 1, + "transform": 128 + }, + "bufferUsage": { + "copyDst": 4, + "storage": 2, + "vertex": 1 + }, + "capabilityFlags": { + "aliasVec2": 4, + "aliasVec4": 8, + "indirectDraws": 2, + "orderedDirect": 16, + "stableIndirect": 32, + "storageBuffers": 1 + }, + "inputScopes": { + "glyph": 2, + "resource": 3, + "semantic": 1, + "strike": 4 + }, + "opcodes": { + "addF32": 5, + "constantF32": 3, + "constantU32": 4, + "convertU32ToF32": 10, + "lessThanF32": 8, + "loadF32": 1, + "loadU32": 2, + "multiplyF32": 7, + "selectF32": 9, + "storeF32": 11, + "storeU16": 13, + "storeU32": 12, + "subtractF32": 6 + }, + "scalarTypes": { + "f32": 1, + "u16": 3, + "u32": 2 + } + }, "status": { + "fontInUse": 14, "fontMissing": 5, + "fontStackMissing": 13, "handleConflict": 4, "invalidExtents": 3, "invalidFont": 2, "invalidHandle": 1, "invalidRequest": 6, "ok": 0, - "resultTooLarge": 7 + "policyConflict": 8, + "policyMissing": 9, + "resultTooLarge": 7, + "revisionConflict": 12, + "sessionConflict": 10, + "sessionMissing": 11 }, "version": 0, "versions": { diff --git a/packages/text/src/index.ts b/packages/text/src/index.ts index 46183ba8..0af55fa0 100644 --- a/packages/text/src/index.ts +++ b/packages/text/src/index.ts @@ -25,9 +25,8 @@ export type { BakedFontSource, FontInput, FontInputOf, - LoadedFontV0, FontMetrics, - FontRasterModuleOf, + FontRasterTechniqueOf, FontSourceOverride, FontToken, RegisteredFont, @@ -36,7 +35,13 @@ export { defineFont } from './font.js'; export type { FontHandle, FontKey, FontSlot, LocalGlyphId, RasterHandle, RasterKey, Sha256Hex } from './identity.js'; -export type { FontSlotRecord, ParagraphLayout, ParagraphMeasurement } from './layout.js'; +export type { + FontSlotRecord, + ParagraphLayout, + ParagraphLayoutInspection, + ParagraphLayoutSummary, + ParagraphMeasurement, +} from './layout.js'; export type { FontLoadDiagnostic, @@ -53,38 +58,14 @@ export type { FontSelection, FontStack, LoadedFont } from './loaded-font.js'; export { createFontStack, FontLeaseError } from './loaded-font.js'; export type { - GlyphBatchKey, GlyphBufferCapacity, - GlyphCapacityOverflow, - GlyphOriginUpdate, - GlyphSnapshot, - GlyphTopology, - Paragraph, ParagraphAxisConstraint, ParagraphBaseProperties, - ParagraphBatch, - ParagraphBatchObserver, - ParagraphBatchOptions, ParagraphContentBox, ParagraphContentProperties, - ParagraphId, ParagraphProperties, - ParagraphSnapshot, - ParagraphUpdate, - PreparedGlyphBatch, - PreparedGlyphRun, - PreparedParagraph, - PreparedParagraphBatchRevision, - TextPreparationError, -} from './paragraph-batch.js'; -export type { - ParagraphBatchAttachment, - ParagraphBatchTarget, - ParagraphBatchTargetError, - ParagraphBatchTargetRevision, - ParagraphBatchTargetStage, - ParagraphBatchTargetUpdate, -} from './paragraph-batch-attachment.js'; + ParagraphStyle, +} from './text-properties.js'; export type { ColorInput, @@ -107,37 +88,16 @@ export { SpanNestingError } from './internal/span-cascade.js'; export type { GlyphPaint, LinearRgba, ResolvedPaint } from './paint.js'; export type { - ParagraphConstraints, - ParagraphEngine, - ParagraphEngineOptions, - ParagraphInput, - ParagraphStyle, -} from './paragraph.js'; -export type { Paragraph as LayoutParagraph } from './paragraph.js'; -export type { ParagraphAxisConstraint as LayoutParagraphAxisConstraint } from './paragraph.js'; -export { createParagraphEngine } from './paragraph.js'; - -export type { - AnyRasterModule, - AnyRasterInput, JsonValue, - LoadedRaster, - RasterBatchOf, - RasterBatchStage, RasterKind, RasterKindOf, RasterLoadOptions, - RasterModule, - RasterInput, RasterReference, - RasterRequest, RasterResolver, RasterResolverContext, RasterResourceResolver, RasterResourceResolverContext, RasterResourceSource, - RasterResourceOf, - RasterModuleOptionsOf, RasterOptionsArgument, RasterSelection, RasterSource, @@ -147,27 +107,18 @@ export type { RuntimeRasterBakerLoader, RuntimeRasterBakerModule, } from './raster.js'; -export { defineRaster, defineRasterBatchStage } from './raster.js'; -export { RasterRuntime } from './raster-runtime.js'; -export type { RasterDrawBatch, RasterObjectDrawBatch } from './raster.js'; export type { AnyRasterTechnique, - GlyphBatchStorage, - GlyphBatchStorageOf, - GlyphBatchStorageShape, - GlyphRange, - RasterBindingOf, RasterDataOf, - RasterGlyphInput, - RasterGlyphSelection, - RasterGlyphWriteInput, RasterResourceId, RasterTechnique, RasterTechniqueDescriptorOf, RasterTechniqueId, RasterOptionsOf, RasterTechniqueOptionsOf, + RasterTechniqueInput, + RasterTechniqueRequest, RasterTechniqueTypesOf, } from './raster-technique.js'; export { defineRasterResourceId, defineRasterTechnique } from './raster-technique.js'; @@ -182,33 +133,7 @@ export { normalizeRasterCoverage, } from './raster-coverage.js'; -export type { - BidiAnalysisViews, - BidiDirection, - RuntimeShaper, - RuntimeShaperMemoryReport, - RuntimeShaperOptions, - ReshapeBatchRequest, - ReshapeRange, - ShapeBatchRequest, - ShapeRunRequest, - ShapedBatchViews, - TextShaperWasmSource, -} from './shaper.js'; -export { createRuntimeShaper } from './shaper.js'; export type { FontFeature, ResolvedFontFeature } from './font-feature.js'; -export type { - AsyncTextUpdateOptions, - LoadedFontInput, - LoadedFontRequest, - TextPreparationWorker, - TextRuntime, - TextRuntimeOptions, - TextRuntimeRevision, - TextUpdateCallback, - TextUpdateOutcome, - TextUpdateProgress, - TextUpdateResult, -} from './text-runtime.js'; -export { createTextPreparationWorker, createTextRuntime } from './text-runtime.js'; +export type { LoadedFontInput, LoadedFontRequest, TextRuntime, TextRuntimeOptions } from './text-runtime.js'; +export { createTextRuntime } from './text-runtime.js'; diff --git a/packages/text/src/internal/engine-frame-wire.ts b/packages/text/src/internal/engine-frame-wire.ts new file mode 100644 index 00000000..acb5e238 --- /dev/null +++ b/packages/text/src/internal/engine-frame-wire.ts @@ -0,0 +1,666 @@ +import { textShaperAbi } from '../generated/text-shaper-abi.js'; + +const MAX_U32 = 0xffff_ffff; +const encoder = new TextEncoder(); + +export interface TextEngineFrameLimits { + readonly maxParagraphs: number; + readonly maxClusters: number; + readonly maxLines: number; + readonly maxRegions: number; + readonly maxExclusions: number; + readonly maxInlineObjects: number; + readonly maxSlotsPerBand: number; + readonly maxOutputBytes: number; +} + +export type TextEngineParagraphMutation = + | { readonly opcode: 'upsert'; readonly paragraphId: number; readonly order: number } + | { readonly opcode: 'remove'; readonly paragraphId: number }; + +export interface TextEngineTextMutation { + readonly paragraphId: number; + readonly start: number; + readonly deleteCount: number; + readonly insert: string; +} + +export interface TextEngineFeature { + readonly tag: string; + readonly value: number; + readonly start: number; + readonly end: number; +} + +export interface TextEngineDecoration { + readonly style: 'none' | 'solid' | 'double' | 'dotted' | 'dashed' | 'wavy'; + readonly rgba: number; + readonly underline?: boolean; + readonly overline?: boolean; + readonly lineThrough?: boolean; + readonly skipInk?: boolean; + readonly thickness: number; + readonly offset: number; +} + +export interface TextEngineStyleValue { + readonly fontStackHandle?: number; + readonly materialId?: number; + readonly language?: string; + readonly features?: readonly TextEngineFeature[]; + readonly fontSize?: number; + readonly lineHeight?: number; + readonly letterSpacing?: number; + readonly wordSpacing?: number; + readonly baselineShift?: number; + readonly rasterPixelRatio?: number; + readonly direction?: 'auto' | 'ltr' | 'rtl'; + readonly foregroundRgba?: number; + readonly decoration?: TextEngineDecoration; +} + +export type TextEngineStyleMutation = + | { readonly opcode: 'remove'; readonly paragraphId: number; readonly styleId: number } + | { + readonly opcode: 'upsert'; + readonly paragraphId: number; + readonly styleId: number; + readonly cascadeOrder: number; + readonly start: number; + readonly end: number; + readonly root?: boolean; + readonly value: TextEngineStyleValue; + }; + +export interface TextEngineConstraint { + readonly paragraphId: number; + readonly flowThreadId: number; + readonly geometryRevision: number; + readonly width: number; + readonly height: number; + readonly viewportBlockStart: number; + readonly viewportBlockEnd: number; + readonly resumeBlockOffset: number; + readonly maxLines: number; + readonly regionStart: number; + readonly resumeCluster: number; + readonly regionCount: number; + readonly resumeRegion: number; + readonly widthMode: 'unconstrained' | 'at-most' | 'exact'; + readonly heightMode: 'unconstrained' | 'at-most' | 'exact'; + readonly wrap: 'none' | 'word' | 'character'; + readonly align: 'start' | 'center' | 'end' | 'justify'; + readonly overflow: 'visible' | 'clip' | 'ellipsis'; + readonly blockAlign: 'start' | 'center' | 'end'; +} + +export interface TextEngineFlowVertex { + readonly inline: number; + readonly block: number; +} + +export interface TextEngineRegion { + readonly id: number; + readonly geometryRevision: number; + /** Stable compact slot in the renderer-owned region transform table. Defaults to `id`. */ + readonly transformIndex?: number; + readonly shape: 'rectangle' | 'polygon'; + readonly vertices?: readonly TextEngineFlowVertex[]; + readonly exclusionStart: number; + readonly exclusionCount: number; + readonly writingMode: 'horizontal-tb' | 'vertical-rl' | 'vertical-lr'; + readonly textOrientation: 'mixed' | 'upright' | 'sideways'; + readonly inlineStart: number; + readonly blockStart: number; + readonly inlineEnd: number; + readonly blockEnd: number; + readonly clipInlineStart: number; + readonly clipBlockStart: number; + readonly clipInlineEnd: number; + readonly clipBlockEnd: number; +} + +export interface TextEngineExclusion { + readonly id: number; + readonly regionId: number; + readonly geometryRevision: number; + readonly shape: 'rectangle' | 'polygon'; + readonly vertices?: readonly TextEngineFlowVertex[]; + readonly wrapSide: 'both' | 'inline-start' | 'inline-end' | 'largest'; + readonly inlineStart: number; + readonly blockStart: number; + readonly inlineEnd: number; + readonly blockEnd: number; + readonly marginInline: number; + readonly marginBlock: number; +} + +export interface TextEngineInlineObject { + readonly paragraphId: number; + readonly id: number; + readonly contentRevision: number; + readonly textOffset: number; + readonly materialId: number; + readonly resourceId: number; + readonly resourceGeneration: number; + readonly inlineExtent: number; + readonly blockExtent: number; + readonly baselineOffset: number; + readonly marginInlineStart: number; + readonly marginInlineEnd: number; + readonly marginBlockStart: number; + readonly marginBlockEnd: number; + readonly baselineAlignment: 'alphabetic' | 'text-top' | 'middle' | 'text-bottom'; +} + +export interface TextEngineFrameUpdate { + readonly sessionId: number; + readonly policyHandle: number; + readonly capabilitySet: number; + readonly expectedEngineRevision: number; + readonly consumedPlanRevision: number; + readonly acknowledgedPublicationGeneration: number; + readonly semanticViewMask?: number; + readonly compositingIndependent?: boolean; + readonly limits: TextEngineFrameLimits; + readonly paragraphMutations?: readonly TextEngineParagraphMutation[]; + readonly textMutations?: readonly TextEngineTextMutation[]; + readonly styleMutations?: readonly TextEngineStyleMutation[]; + readonly constraints?: readonly TextEngineConstraint[]; + readonly regions?: readonly TextEngineRegion[]; + readonly exclusions?: readonly TextEngineExclusion[]; + readonly inlineObjects?: readonly TextEngineInlineObject[]; + readonly policyParameters?: Uint8Array; +} + +/** Serialize mutations and constraints only; shaping, layout, planning, and packing remain Rust-owned. */ +export function compileTextEngineFrameUpdate(frame: TextEngineFrameUpdate): Uint8Array { + const abi = textShaperAbi; + const request = abi.layouts.engineUpdateRequest; + const paragraphMutations = frame.paragraphMutations ?? []; + const textMutations = frame.textMutations ?? []; + const styleMutations = frame.styleMutations ?? []; + const constraints = frame.constraints ?? []; + const regions = frame.regions ?? []; + const exclusions = frame.exclusions ?? []; + const inlineObjects = frame.inlineObjects ?? []; + let cursor: number = request.size; + const allocate = (count: number, stride: number, alignment: number, label: string): number => { + if (count === 0) return 0; + const offset = align(cursor, alignment); + cursor = checkedAdd(offset, checkedProduct(count, stride, label), label); + return offset; + }; + const paragraphOffset = allocate( + paragraphMutations.length, + abi.layouts.engineParagraphMutation.size, + abi.layouts.engineParagraphMutation.alignment, + 'paragraph mutations', + ); + const textOffset = allocate(textMutations.length, abi.layouts.engineTextMutation.size, 4, 'text mutations'); + const styleOffset = allocate(styleMutations.length, abi.layouts.engineStyleMutation.size, 4, 'style mutations'); + const constraintOffset = allocate(constraints.length, abi.layouts.engineConstraint.size, 4, 'constraints'); + const regionOffset = allocate(regions.length, abi.layouts.engineRegion.size, 4, 'regions'); + const exclusionOffset = allocate(exclusions.length, abi.layouts.engineExclusion.size, 4, 'exclusions'); + const inlineObjectOffset = allocate(inlineObjects.length, abi.layouts.engineInlineObject.size, 4, 'inline objects'); + const textPayloads = textMutations.map((mutation) => allocate(mutation.insert.length, 2, 2, 'text mutation payload')); + const languageBytes = styleMutations.map((mutation) => + mutation.opcode === 'upsert' && mutation.value.language !== undefined + ? encoder.encode(mutation.value.language) + : new Uint8Array(), + ); + const languageOffsets = languageBytes.map((bytes) => allocate(bytes.length, 1, 1, 'style language')); + const featureOffsets = styleMutations.map((mutation) => + allocate( + mutation.opcode === 'upsert' ? (mutation.value.features?.length ?? 0) : 0, + abi.layouts.feature.size, + abi.layouts.feature.alignment, + 'style features', + ), + ); + const regionVertexOffsets = regions.map((region) => + allocate(region.vertices?.length ?? 0, abi.layouts.engineFlowVertex.size, 4, 'region vertices'), + ); + const exclusionVertexOffsets = exclusions.map((exclusion) => + allocate(exclusion.vertices?.length ?? 0, abi.layouts.engineFlowVertex.size, 4, 'exclusion vertices'), + ); + const policyParameters = frame.policyParameters ?? new Uint8Array(); + const policyParametersOffset = allocate(policyParameters.length, 1, 1, 'policy parameters'); + const bytes = new Uint8Array(cursor); + const view = new DataView(bytes.buffer); + + writeHeader(view, frame, bytes.length, { + textOffset, + paragraphOffset, + styleOffset, + constraintOffset, + regionOffset, + exclusionOffset, + inlineObjectOffset, + policyParametersOffset, + }); + writeParagraphMutations(view, paragraphOffset, paragraphMutations); + writeTextMutations(view, textOffset, textMutations, textPayloads); + writeStyleMutations(view, bytes, styleOffset, styleMutations, languageBytes, languageOffsets, featureOffsets); + writeConstraints(view, constraintOffset, constraints); + writeRegions(view, regionOffset, regions, regionVertexOffsets); + writeExclusions(view, exclusionOffset, exclusions, exclusionVertexOffsets); + writeInlineObjects(view, inlineObjectOffset, inlineObjects); + bytes.set(policyParameters, policyParametersOffset); + return bytes; +} + +interface HeaderOffsets { + readonly paragraphOffset: number; + readonly textOffset: number; + readonly styleOffset: number; + readonly constraintOffset: number; + readonly regionOffset: number; + readonly exclusionOffset: number; + readonly inlineObjectOffset: number; + readonly policyParametersOffset: number; +} + +function writeHeader(view: DataView, frame: TextEngineFrameUpdate, byteLength: number, offsets: HeaderOffsets): void { + const layout = textShaperAbi.layouts.engineUpdateRequest; + const limits = frame.limits; + view.setUint32( + layout.flags, + frame.compositingIndependent === true ? textShaperAbi.engine.frameFlags.compositingIndependent : 0, + true, + ); + for (const [field, value] of [ + ['abiVersion', textShaperAbi.version], + ['byteLength', byteLength], + ['sessionId', frame.sessionId], + ['expectedEngineRevision', frame.expectedEngineRevision], + ['consumedPlanRevision', frame.consumedPlanRevision], + ['acknowledgedPublicationGeneration', frame.acknowledgedPublicationGeneration], + ['policyHandle', frame.policyHandle], + ['capabilitySet', frame.capabilitySet], + ['semanticViewMask', frame.semanticViewMask ?? 0], + ['maxParagraphs', limits.maxParagraphs], + ['maxClusters', limits.maxClusters], + ['maxLines', limits.maxLines], + ['maxRegions', limits.maxRegions], + ['maxExclusions', limits.maxExclusions], + ['maxInlineObjects', limits.maxInlineObjects], + ['maxSlotsPerBand', limits.maxSlotsPerBand], + ['maxOutputBytes', limits.maxOutputBytes], + ['paragraphMutationsOffset', offsets.paragraphOffset], + ['paragraphMutationCount', frame.paragraphMutations?.length ?? 0], + ['textMutationsOffset', offsets.textOffset], + ['textMutationCount', frame.textMutations?.length ?? 0], + ['styleMutationsOffset', offsets.styleOffset], + ['styleMutationCount', frame.styleMutations?.length ?? 0], + ['constraintsOffset', offsets.constraintOffset], + ['constraintCount', frame.constraints?.length ?? 0], + ['regionsOffset', offsets.regionOffset], + ['regionCount', frame.regions?.length ?? 0], + ['exclusionsOffset', offsets.exclusionOffset], + ['exclusionCount', frame.exclusions?.length ?? 0], + ['inlineObjectsOffset', offsets.inlineObjectOffset], + ['inlineObjectCount', frame.inlineObjects?.length ?? 0], + ['policyParametersOffset', offsets.policyParametersOffset], + ['policyParametersLength', frame.policyParameters?.length ?? 0], + ] as const) { + view.setUint32(layout[field], u32(value, field), true); + } +} + +function writeParagraphMutations( + view: DataView, + tableOffset: number, + mutations: readonly TextEngineParagraphMutation[], +): void { + const layout = textShaperAbi.layouts.engineParagraphMutation; + const opcodes = textShaperAbi.engine.paragraphMutationOpcodes; + for (const [index, mutation] of mutations.entries()) { + const offset = tableOffset + index * layout.size; + view.setUint8(offset + layout.opcode, opcodes[mutation.opcode]); + view.setUint32(offset + layout.paragraphId, u32(mutation.paragraphId, 'paragraph ID'), true); + if (mutation.opcode === 'upsert') { + view.setUint32(offset + layout.order, u32(mutation.order, 'paragraph order'), true); + } + } +} + +function writeTextMutations( + view: DataView, + tableOffset: number, + mutations: readonly TextEngineTextMutation[], + payloadOffsets: readonly number[], +): void { + const layout = textShaperAbi.layouts.engineTextMutation; + for (const [index, mutation] of mutations.entries()) { + const offset = tableOffset + index * layout.size; + const payloadOffset = payloadOffsets[index]!; + view.setUint8(offset + layout.opcode, textShaperAbi.engine.textMutationOpcodes.replaceUtf16); + view.setUint8(offset + layout.encoding, textShaperAbi.engine.textEncodings.utf16Le); + view.setUint32(offset + layout.paragraphId, u32(mutation.paragraphId, 'paragraph ID'), true); + view.setUint32(offset + layout.textStart, u32(mutation.start, 'text mutation start'), true); + view.setUint32(offset + layout.deleteCount, u32(mutation.deleteCount, 'text mutation delete count'), true); + view.setUint32(offset + layout.insertOffset, payloadOffset, true); + view.setUint32(offset + layout.insertCount, u32(mutation.insert.length, 'text mutation insert count'), true); + for (let unit = 0; unit < mutation.insert.length; unit += 1) { + view.setUint16(payloadOffset + unit * 2, mutation.insert.charCodeAt(unit), true); + } + } +} + +function writeStyleMutations( + view: DataView, + bytes: Uint8Array, + tableOffset: number, + mutations: readonly TextEngineStyleMutation[], + languages: readonly Uint8Array[], + languageOffsets: readonly number[], + featureOffsets: readonly number[], +): void { + const layout = textShaperAbi.layouts.engineStyleMutation; + for (const [index, mutation] of mutations.entries()) { + const offset = tableOffset + index * layout.size; + view.setUint32(offset + layout.paragraphId, u32(mutation.paragraphId, 'paragraph ID'), true); + view.setUint32(offset + layout.styleId, u32(mutation.styleId, 'style ID'), true); + if (mutation.opcode === 'remove') { + view.setUint8(offset + layout.opcode, textShaperAbi.engine.styleMutationOpcodes.remove); + continue; + } + const value = mutation.value; + const fields = textShaperAbi.engine.styleFields; + const fieldMask = + present(value.fontStackHandle, fields.fontStack) | + present(value.materialId, fields.material) | + present(value.language, fields.language) | + present(value.features, fields.features) | + present(value.fontSize, fields.fontSize) | + present(value.lineHeight, fields.lineHeight) | + present(value.letterSpacing, fields.letterSpacing) | + present(value.wordSpacing, fields.wordSpacing) | + present(value.baselineShift, fields.baselineShift) | + present(value.rasterPixelRatio, fields.rasterPixelRatio) | + present(value.direction, fields.direction) | + present(value.foregroundRgba, fields.foreground) | + present(value.decoration, fields.decoration); + view.setUint8(offset + layout.opcode, textShaperAbi.engine.styleMutationOpcodes.upsert); + view.setUint8(offset + layout.direction, direction(value.direction)); + view.setUint8(offset + layout.flags, mutation.root === true ? textShaperAbi.engine.styleFlags.root : 0); + view.setUint32(offset + layout.cascadeOrder, u32(mutation.cascadeOrder, 'style cascade order'), true); + view.setUint32(offset + layout.fieldMask, fieldMask, true); + view.setUint32(offset + layout.textStart, u32(mutation.start, 'style start'), true); + view.setUint32(offset + layout.textEnd, u32(mutation.end, 'style end'), true); + optionalU32(view, offset + layout.fontStackHandle, value.fontStackHandle, 'font stack handle'); + optionalU32(view, offset + layout.materialId, value.materialId, 'material ID'); + const language = languages[index]!; + view.setUint32(offset + layout.languageOffset, languageOffsets[index]!, true); + view.setUint16(offset + layout.languageLength, u16(language.length, 'language byte length'), true); + bytes.set(language, languageOffsets[index]!); + const features = value.features ?? []; + const featureOffset = featureOffsets[index]!; + view.setUint16(offset + layout.featureCount, u16(features.length, 'feature count'), true); + view.setUint32(offset + layout.featuresOffset, featureOffset, true); + writeFeatures(view, featureOffset, features); + optionalF32(view, offset + layout.fontSize, value.fontSize, 'font size'); + optionalF32(view, offset + layout.lineHeight, value.lineHeight, 'line height'); + optionalF32(view, offset + layout.letterSpacing, value.letterSpacing, 'letter spacing'); + optionalF32(view, offset + layout.wordSpacing, value.wordSpacing, 'word spacing'); + optionalF32(view, offset + layout.baselineShift, value.baselineShift, 'baseline shift'); + optionalF32(view, offset + layout.rasterPixelRatio, value.rasterPixelRatio, 'raster pixel ratio'); + optionalU32(view, offset + layout.foregroundRgba, value.foregroundRgba, 'foreground RGBA'); + writeDecoration(view, offset, value.decoration); + } +} + +function writeFeatures(view: DataView, tableOffset: number, features: readonly TextEngineFeature[]): void { + const layout = textShaperAbi.layouts.feature; + for (const [index, feature] of features.entries()) { + const offset = tableOffset + index * layout.size; + view.setUint32(offset + layout.tag, tag(feature.tag), true); + view.setUint32(offset + layout.value, u32(feature.value, 'feature value'), true); + view.setUint32(offset + layout.start, u32(feature.start, 'feature start'), true); + view.setUint32(offset + layout.end, u32(feature.end, 'feature end'), true); + } +} + +function writeDecoration(view: DataView, offset: number, decoration: TextEngineDecoration | undefined): void { + if (decoration === undefined) return; + const layout = textShaperAbi.layouts.engineStyleMutation; + const styles = textShaperAbi.engine.decorationStyles; + const flags = textShaperAbi.engine.decorationFlags; + view.setUint8(offset + layout.decorationStyle, styles[decoration.style]); + view.setUint32(offset + layout.decorationRgba, u32(decoration.rgba, 'decoration RGBA'), true); + view.setUint32( + offset + layout.decorationFlags, + (decoration.underline === true ? flags.underline : 0) | + (decoration.overline === true ? flags.overline : 0) | + (decoration.lineThrough === true ? flags.lineThrough : 0) | + (decoration.skipInk === true ? flags.skipInk : 0), + true, + ); + view.setFloat32(offset + layout.decorationThickness, finite(decoration.thickness, 'decoration thickness'), true); + view.setFloat32(offset + layout.decorationOffset, finite(decoration.offset, 'decoration offset'), true); +} + +function writeConstraints(view: DataView, tableOffset: number, constraints: readonly TextEngineConstraint[]): void { + const layout = textShaperAbi.layouts.engineConstraint; + const engine = textShaperAbi.engine; + for (const [index, value] of constraints.entries()) { + const offset = tableOffset + index * layout.size; + view.setUint32(offset + layout.paragraphId, u32(value.paragraphId, 'paragraph ID'), true); + for (const [field, number] of [ + ['flowThreadId', value.flowThreadId], + ['geometryRevision', value.geometryRevision], + ['maxLines', value.maxLines], + ['regionStart', value.regionStart], + ['resumeCluster', value.resumeCluster], + ] as const) { + view.setUint32(offset + layout[field], u32(number, field), true); + } + view.setUint16(offset + layout.regionCount, u16(value.regionCount, 'constraint region count'), true); + view.setUint16(offset + layout.resumeRegion, u16(value.resumeRegion, 'constraint resume region'), true); + for (const [field, number] of [ + ['width', value.width], + ['height', value.height], + ['viewportBlockStart', value.viewportBlockStart], + ['viewportBlockEnd', value.viewportBlockEnd], + ['resumeBlockOffset', value.resumeBlockOffset], + ] as const) { + view.setFloat32(offset + layout[field], finite(number, field), true); + } + view.setUint8(offset + layout.widthMode, axisMode(value.widthMode)); + view.setUint8(offset + layout.heightMode, axisMode(value.heightMode)); + view.setUint8(offset + layout.wrap, engine.wrapModes[value.wrap]); + view.setUint8(offset + layout.align, engine.inlineAlignments[value.align]); + view.setUint8(offset + layout.overflow, engine.overflowModes[value.overflow]); + view.setUint8(offset + layout.blockAlign, engine.blockAlignments[value.blockAlign]); + } +} + +function writeRegions( + view: DataView, + tableOffset: number, + regions: readonly TextEngineRegion[], + vertexOffsets: readonly number[], +): void { + const layout = textShaperAbi.layouts.engineRegion; + for (const [index, value] of regions.entries()) { + const offset = tableOffset + index * layout.size; + view.setUint32(offset + layout.id, u32(value.id, 'region ID'), true); + view.setUint32(offset + layout.geometryRevision, u32(value.geometryRevision, 'region geometry revision'), true); + view.setUint32( + offset + layout.transformIndex, + u32(value.transformIndex ?? value.id, 'region transform index'), + true, + ); + view.setUint32(offset + layout.verticesOffset, vertexOffsets[index]!, true); + view.setUint16(offset + layout.vertexCount, u16(value.vertices?.length ?? 0, 'region vertex count'), true); + view.setUint16(offset + layout.exclusionStart, u16(value.exclusionStart, 'region exclusion start'), true); + view.setUint16(offset + layout.exclusionCount, u16(value.exclusionCount, 'region exclusion count'), true); + view.setUint8(offset + layout.shape, textShaperAbi.engine.flowShapeKinds[value.shape]); + view.setUint8(offset + layout.writingMode, writingMode(value.writingMode)); + view.setUint8(offset + layout.textOrientation, textOrientation(value.textOrientation)); + writeBounds(view, offset, layout, value); + writeVertices(view, vertexOffsets[index]!, value.vertices ?? []); + } +} + +function writeExclusions( + view: DataView, + tableOffset: number, + exclusions: readonly TextEngineExclusion[], + vertexOffsets: readonly number[], +): void { + const layout = textShaperAbi.layouts.engineExclusion; + for (const [index, value] of exclusions.entries()) { + const offset = tableOffset + index * layout.size; + view.setUint32(offset + layout.id, u32(value.id, 'exclusion ID'), true); + view.setUint32(offset + layout.regionId, u32(value.regionId, 'exclusion region ID'), true); + view.setUint32(offset + layout.geometryRevision, u32(value.geometryRevision, 'exclusion geometry revision'), true); + view.setUint32(offset + layout.verticesOffset, vertexOffsets[index]!, true); + view.setUint16(offset + layout.vertexCount, u16(value.vertices?.length ?? 0, 'exclusion vertex count'), true); + view.setUint8(offset + layout.shape, textShaperAbi.engine.flowShapeKinds[value.shape]); + view.setUint8(offset + layout.wrapSide, exclusionWrap(value.wrapSide)); + writeBounds(view, offset, layout, value); + view.setFloat32(offset + layout.marginInline, finite(value.marginInline, 'exclusion inline margin'), true); + view.setFloat32(offset + layout.marginBlock, finite(value.marginBlock, 'exclusion block margin'), true); + writeVertices(view, vertexOffsets[index]!, value.vertices ?? []); + } +} + +function writeBounds( + view: DataView, + offset: number, + layout: Record, + value: TextEngineRegion | TextEngineExclusion, +): void { + for (const field of ['inlineStart', 'blockStart', 'inlineEnd', 'blockEnd'] as const) { + view.setFloat32(offset + layout[field]!, finite(value[field], field), true); + } + if ('clipInlineStart' in value) { + view.setFloat32(offset + layout.clipInlineStart!, finite(value.clipInlineStart, 'clipInlineStart'), true); + view.setFloat32(offset + layout.clipBlockStart!, finite(value.clipBlockStart, 'clipBlockStart'), true); + view.setFloat32(offset + layout.clipInlineEnd!, finite(value.clipInlineEnd, 'clipInlineEnd'), true); + view.setFloat32(offset + layout.clipBlockEnd!, finite(value.clipBlockEnd, 'clipBlockEnd'), true); + } +} + +function writeVertices(view: DataView, tableOffset: number, vertices: readonly TextEngineFlowVertex[]): void { + const layout = textShaperAbi.layouts.engineFlowVertex; + for (const [index, vertex] of vertices.entries()) { + const offset = tableOffset + index * layout.size; + view.setFloat32(offset + layout.inline, finite(vertex.inline, 'vertex inline'), true); + view.setFloat32(offset + layout.block, finite(vertex.block, 'vertex block'), true); + } +} + +function writeInlineObjects(view: DataView, tableOffset: number, objects: readonly TextEngineInlineObject[]): void { + const layout = textShaperAbi.layouts.engineInlineObject; + for (const [index, value] of objects.entries()) { + const offset = tableOffset + index * layout.size; + view.setUint32(offset + layout.paragraphId, u32(value.paragraphId, 'paragraph ID'), true); + for (const [field, number] of [ + ['id', value.id], + ['contentRevision', value.contentRevision], + ['textOffset', value.textOffset], + ['materialId', value.materialId], + ['resourceId', value.resourceId], + ['resourceGeneration', value.resourceGeneration], + ] as const) { + view.setUint32(offset + layout[field], u32(number, `inline object ${field}`), true); + } + for (const [field, number] of [ + ['inlineExtent', value.inlineExtent], + ['blockExtent', value.blockExtent], + ['baselineOffset', value.baselineOffset], + ['marginInlineStart', value.marginInlineStart], + ['marginInlineEnd', value.marginInlineEnd], + ['marginBlockStart', value.marginBlockStart], + ['marginBlockEnd', value.marginBlockEnd], + ] as const) { + view.setFloat32(offset + layout[field], finite(number, `inline object ${field}`), true); + } + view.setUint8(offset + layout.baselineAlignment, inlineBaseline(value.baselineAlignment)); + } +} + +function present(value: unknown, bit: number): number { + return value === undefined ? 0 : bit; +} + +function optionalU32(view: DataView, offset: number, value: number | undefined, label: string): void { + if (value !== undefined) view.setUint32(offset, u32(value, label), true); +} + +function optionalF32(view: DataView, offset: number, value: number | undefined, label: string): void { + if (value !== undefined) view.setFloat32(offset, finite(value, label), true); +} + +function direction(value: TextEngineStyleValue['direction']): number { + return value === undefined || value === 'auto' ? 0 : value === 'ltr' ? 1 : 2; +} + +function axisMode(value: TextEngineConstraint['widthMode']): number { + const modes = textShaperAbi.engine.axisModes; + return value === 'at-most' ? modes.atMost : modes[value]; +} + +function writingMode(value: TextEngineRegion['writingMode']): number { + const modes = textShaperAbi.engine.writingModes; + return value === 'horizontal-tb' ? modes.horizontalTb : value === 'vertical-rl' ? modes.verticalRl : modes.verticalLr; +} + +function textOrientation(value: TextEngineRegion['textOrientation']): number { + return textShaperAbi.engine.textOrientations[value]; +} + +function exclusionWrap(value: TextEngineExclusion['wrapSide']): number { + const sides = textShaperAbi.engine.exclusionWrapSides; + return value === 'inline-start' ? sides.inlineStart : value === 'inline-end' ? sides.inlineEnd : sides[value]; +} + +function inlineBaseline(value: TextEngineInlineObject['baselineAlignment']): number { + const baselines = textShaperAbi.engine.inlineObjectBaselines; + return value === 'text-top' ? baselines.textTop : value === 'text-bottom' ? baselines.textBottom : baselines[value]; +} + +function tag(value: string): number { + if (value.length !== 4) throw new RangeError('feature tag must contain exactly four bytes'); + let packed = 0; + for (let index = 0; index < 4; index += 1) { + const byte = value.charCodeAt(index); + if (byte < 0x20 || byte > 0x7e) throw new RangeError('feature tag must contain printable ASCII bytes'); + packed = (packed << 8) | byte; + } + return packed >>> 0; +} + +function finite(value: number, label: string): number { + if (!Number.isFinite(value)) throw new RangeError(`${label} must be finite`); + return value; +} + +function u16(value: number, label: string): number { + if (!Number.isSafeInteger(value) || value < 0 || value > 0xffff) throw new RangeError(`${label} must be a u16`); + return value; +} + +function u32(value: number, label: string): number { + if (!Number.isSafeInteger(value) || value < 0 || value > MAX_U32) throw new RangeError(`${label} must be a u32`); + return value; +} + +function align(value: number, alignment: number): number { + return checkedAdd(value, (alignment - (value % alignment)) % alignment, 'aligned frame offset'); +} + +function checkedProduct(left: number, right: number, label: string): number { + const value = left * right; + if (!Number.isSafeInteger(value) || value > MAX_U32) throw new RangeError(`${label} exceeds the frame ABI`); + return value; +} + +function checkedAdd(left: number, right: number, label: string): number { + const value = left + right; + if (!Number.isSafeInteger(value) || value > MAX_U32) throw new RangeError(`${label} exceeds the frame ABI`); + return value; +} diff --git a/packages/text/src/internal/font-binding-wire.ts b/packages/text/src/internal/font-binding-wire.ts new file mode 100644 index 00000000..16e9730e --- /dev/null +++ b/packages/text/src/internal/font-binding-wire.ts @@ -0,0 +1,429 @@ +import { textShaperAbi } from '../generated/text-shaper-abi.js'; +import type { LoadedFont } from '../loaded-font.js'; +import { bitmap, type BitmapData } from '../raster/bitmap-technique.js'; +import { msdf, type MsdfData } from '../raster/msdf.js'; +import { slug, type SlugData } from '../raster/slug-technique.js'; +import type { AnyRasterTechnique, RasterResourceId } from '../raster-technique.js'; +import { RenderWireIdentityRegistry, type FirstPartyTechniqueWireIds } from './render-policy-wire.js'; + +const MAX_U32 = 0xffff_ffff; +const ABSENT_PAGE = 0xffff; +const MISSING_RESOURCE = 0xffff_ffff; + +export interface BindingResource { + readonly key: RasterResourceId; + readonly id: number; + readonly generation: number; + readonly kind: number; + readonly reference: number; +} + +export interface FontBindingFieldTable { + readonly rows: number; + readonly fields: readonly ((row: number) => number)[]; +} + +export interface FontBindingDescriptor { + readonly techniqueId: number; + readonly programVariant: number; + readonly glyphCount: number; + readonly strikes: readonly number[]; + readonly resources: readonly BindingResource[]; + readonly resourceIndex: (row: number) => number; + readonly glyphF32: FontBindingFieldTable; + readonly glyphU32: FontBindingFieldTable; + readonly strikeF32: FontBindingFieldTable; + readonly strikeU32: FontBindingFieldTable; + readonly resourceF32: FontBindingFieldTable; + readonly resourceU32: FontBindingFieldTable; +} + +/** Compile one first-party loaded font into the Rust engine's field-major immutable binding. */ +export function firstPartyFontBindingBytes( + font: LoadedFont, + identities: RenderWireIdentityRegistry = new RenderWireIdentityRegistry(), +): Uint8Array { + const techniqueIds: FirstPartyTechniqueWireIds = { + bitmap: identities.resolve(bitmap.id), + msdf: identities.resolve(msdf.id), + slug: identities.resolve(slug.id), + }; + if (font.technique.id === bitmap.id && isBitmapData(font.data)) { + return compileBitmap(font.font.glyphCount, font.data, techniqueIds.bitmap, identities); + } + if (font.technique.id === msdf.id && isMsdfData(font.data)) { + return compileMsdf(font.font.glyphCount, font.data, techniqueIds.msdf, identities); + } + if (font.technique.id === slug.id && isSlugData(font.data)) { + return compileSlug(font.font.glyphCount, font.data, techniqueIds.slug, identities); + } + throw new TypeError(`no first-party font-binding compiler is registered for "${font.technique.id}"`); +} + +function isBitmapData(value: unknown): value is BitmapData { + return ( + isRecord(value) && + Array.isArray(value.strikes) && + value.strikes.length !== 0 && + value.strikes.every( + (strike) => + isRecord(strike) && + Number.isSafeInteger(strike.ppem) && + Number.isSafeInteger(strike.planeUnitsPerEm) && + strike.records instanceof Uint8Array && + Array.isArray(strike.pages), + ) + ); +} + +function isMsdfData(value: unknown): value is MsdfData { + return ( + isRecord(value) && + typeof value.resource === 'string' && + Number.isSafeInteger(value.planeUnitsPerEm) && + value.records instanceof Uint8Array && + Array.isArray(value.pages) + ); +} + +function isSlugData(value: unknown): value is SlugData { + return ( + isRecord(value) && + Number.isSafeInteger(value.planeUnitsPerEm) && + value.records instanceof Uint8Array && + Array.isArray(value.pages) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function compileBitmap( + glyphCount: number, + data: BitmapData, + techniqueId: number, + identities: RenderWireIdentityRegistry, +): Uint8Array { + const entries = data.strikes.flatMap((strike) => strike.pages.map((page) => page.resource)); + const { resources, indexFor } = fontBindingResources(entries, identities); + const views = data.strikes.map((strike) => recordView(strike.records)); + const rows = checkedProduct(glyphCount, data.strikes.length, 'bitmap strike rows'); + const strikeRecord = (row: number): { readonly view: DataView; readonly record: number; readonly strike: number } => { + const strike = Math.floor(row / glyphCount); + return { view: views[strike]!, record: (row % glyphCount) * 20, strike }; + }; + const atlas = (row: number, offset: number, dimension: 'width' | 'height'): number => { + const { view, record, strike } = strikeRecord(row); + const page = view.getUint16(record + 16, true); + return page === ABSENT_PAGE + ? 0 + : view.getUint16(record + offset, true) / data.strikes[strike]!.pages[page]![dimension]; + }; + const span = (row: number, start: number, end: number, dimension: 'width' | 'height'): number => { + const { view, record, strike } = strikeRecord(row); + const page = view.getUint16(record + 16, true); + return page === ABSENT_PAGE + ? 0 + : (view.getUint16(record + end, true) - view.getUint16(record + start, true)) / + data.strikes[strike]!.pages[page]![dimension]; + }; + return compileFontBinding({ + techniqueId, + programVariant: 0, + glyphCount, + strikes: data.strikes.map((strike) => strike.ppem), + resources, + resourceIndex(row) { + const { view, record, strike } = strikeRecord(row); + const page = view.getUint16(record + 16, true); + return page === ABSENT_PAGE ? MISSING_RESOURCE : indexFor(data.strikes[strike]!.pages[page]!.resource); + }, + glyphF32: emptyFontBindingTable(glyphCount), + glyphU32: emptyFontBindingTable(glyphCount), + strikeF32: { + rows, + fields: [ + (row) => { + const { view, record, strike } = strikeRecord(row); + return view.getInt16(record, true) / data.strikes[strike]!.planeUnitsPerEm; + }, + (row) => { + const { view, record, strike } = strikeRecord(row); + return view.getInt16(record + 6, true) / data.strikes[strike]!.planeUnitsPerEm; + }, + (row) => { + const { view, record, strike } = strikeRecord(row); + return ( + (view.getInt16(record + 4, true) - view.getInt16(record, true)) / data.strikes[strike]!.planeUnitsPerEm + ); + }, + (row) => { + const { view, record, strike } = strikeRecord(row); + return ( + (view.getInt16(record + 6, true) - view.getInt16(record + 2, true)) / data.strikes[strike]!.planeUnitsPerEm + ); + }, + (row) => atlas(row, 8, 'width'), + (row) => atlas(row, 10, 'height'), + (row) => span(row, 8, 12, 'width'), + (row) => span(row, 10, 14, 'height'), + ], + }, + strikeU32: emptyFontBindingTable(rows), + resourceF32: emptyFontBindingTable(resources.length), + resourceU32: emptyFontBindingTable(resources.length), + }); +} + +function compileMsdf( + glyphCount: number, + data: MsdfData, + techniqueId: number, + identities: RenderWireIdentityRegistry, +): Uint8Array { + const { resources, indexFor } = fontBindingResources([data.resource], identities); + const view = recordView(data.records); + const rowRecord = (row: number): number => row * 20; + const pageAt = (row: number): number => view.getUint16(rowRecord(row) + 16, true); + const atlas = (row: number, offset: number, dimension: 'width' | 'height'): number => { + const page = pageAt(row); + return page === ABSENT_PAGE ? 0 : view.getUint16(rowRecord(row) + offset, true) / data.binding[dimension]; + }; + const span = (row: number, start: number, end: number, dimension: 'width' | 'height'): number => { + const page = pageAt(row); + const record = rowRecord(row); + return page === ABSENT_PAGE + ? 0 + : (view.getUint16(record + end, true) - view.getUint16(record + start, true)) / data.binding[dimension]; + }; + return compileFontBinding({ + techniqueId, + programVariant: 0, + glyphCount, + strikes: [0], + resources, + resourceIndex(row) { + return pageAt(row) === ABSENT_PAGE ? MISSING_RESOURCE : indexFor(data.resource); + }, + glyphF32: { + rows: glyphCount, + fields: [ + (row) => view.getInt16(rowRecord(row), true) / data.planeUnitsPerEm, + (row) => view.getInt16(rowRecord(row) + 6, true) / data.planeUnitsPerEm, + (row) => (view.getInt16(rowRecord(row) + 4, true) - view.getInt16(rowRecord(row), true)) / data.planeUnitsPerEm, + (row) => + (view.getInt16(rowRecord(row) + 6, true) - view.getInt16(rowRecord(row) + 2, true)) / data.planeUnitsPerEm, + (row) => atlas(row, 8, 'width'), + (row) => atlas(row, 10, 'height'), + (row) => span(row, 8, 12, 'width'), + (row) => span(row, 10, 14, 'height'), + (row) => atlas(row, 12, 'width'), + (row) => atlas(row, 14, 'height'), + ], + }, + glyphU32: { rows: glyphCount, fields: [(row) => pageAt(row)] }, + strikeF32: emptyFontBindingTable(glyphCount), + strikeU32: emptyFontBindingTable(glyphCount), + resourceF32: emptyFontBindingTable(resources.length), + resourceU32: emptyFontBindingTable(resources.length), + }); +} + +function compileSlug( + glyphCount: number, + data: SlugData, + techniqueId: number, + identities: RenderWireIdentityRegistry, +): Uint8Array { + const { resources, indexFor } = fontBindingResources( + data.pages.map((page) => page.resource), + identities, + ); + const view = recordView(data.records); + const record = (row: number): number => row * 40; + const pageAt = (row: number): number => view.getUint16(record(row) + 8, true); + const normalized = (row: number, offset: number): number => + view.getInt16(record(row) + offset, true) / data.planeUnitsPerEm; + const width = (row: number): number => normalized(row, 4) - normalized(row, 0); + const height = (row: number): number => normalized(row, 6) - normalized(row, 2); + const horizontalBands = (row: number): number => view.getUint16(record(row) + 10, true); + const verticalBands = (row: number): number => view.getUint16(record(row) + 12, true); + const bandScaleX = (row: number): number => (width(row) === 0 ? 0 : verticalBands(row) / width(row)); + const bandScaleY = (row: number): number => (height(row) === 0 ? 0 : horizontalBands(row) / height(row)); + return compileFontBinding({ + techniqueId, + programVariant: 0, + glyphCount, + strikes: [0], + resources, + resourceIndex(row) { + const page = pageAt(row); + return page === ABSENT_PAGE ? MISSING_RESOURCE : indexFor(data.pages[page]!.resource); + }, + glyphF32: { + rows: glyphCount, + fields: [ + (row) => normalized(row, 0), + (row) => normalized(row, 6), + (row) => width(row), + (row) => height(row), + (row) => bandScaleX(row), + (row) => bandScaleY(row), + (row) => -normalized(row, 0) * bandScaleX(row), + (row) => -normalized(row, 2) * bandScaleY(row), + ], + }, + glyphU32: { + rows: glyphCount, + fields: [ + (row) => view.getUint32(record(row) + 16, true), + (row) => view.getUint32(record(row) + 24, true), + (row) => view.getUint32(record(row) + 28, true), + (row) => view.getUint32(record(row) + 32, true), + (row) => horizontalBands(row), + (row) => verticalBands(row), + ], + }, + strikeF32: emptyFontBindingTable(glyphCount), + strikeU32: emptyFontBindingTable(glyphCount), + resourceF32: emptyFontBindingTable(resources.length), + resourceU32: emptyFontBindingTable(resources.length), + }); +} + +export function fontBindingResources( + keys: readonly RasterResourceId[], + identities: RenderWireIdentityRegistry, +): { + readonly resources: readonly BindingResource[]; + readonly indexFor: (key: RasterResourceId) => number; +} { + const byKey = new Map(); + const byId = new Map(); + for (const key of keys) { + if (byKey.has(key)) continue; + const id = identities.resolve(key); + const collision = byId.get(id); + if (collision !== undefined && collision !== key) { + throw new TypeError(`raster resource wire identity collision between "${collision}" and "${key}"`); + } + byId.set(id, key); + byKey.set(key, { key, id, generation: 1, kind: 1, reference: id }); + } + const resources = [...byKey.values()].sort((left, right) => left.id - right.id); + const indexes = new Map(resources.map((resource, index) => [resource.key, index])); + return { + resources, + indexFor(key) { + const index = indexes.get(key); + if (index === undefined) throw new TypeError(`font binding references unknown raster resource "${key}"`); + return index; + }, + }; +} + +export function compileFontBinding(descriptor: FontBindingDescriptor): Uint8Array { + const request = textShaperAbi.layouts.fontBindingRequest; + const strike = textShaperAbi.layouts.fontBindingStrike; + const resource = textShaperAbi.layouts.fontBindingResource; + const tables = [ + ['glyphF32', descriptor.glyphF32], + ['glyphU32', descriptor.glyphU32], + ['strikeF32', descriptor.strikeF32], + ['strikeU32', descriptor.strikeU32], + ['resourceF32', descriptor.resourceF32], + ['resourceU32', descriptor.resourceU32], + ] as const; + let length: number = request.size; + const allocate = (count: number, stride: number, alignment: number): number => { + if (count === 0) return 0; + const offset = align(length, alignment); + length = checkedAdd(offset, checkedProduct(count, stride, 'font binding table'), 'font binding bytes'); + return offset; + }; + const strikesOffset = allocate(descriptor.strikes.length, strike.size, strike.alignment); + const resourcesOffset = allocate(descriptor.resources.length, resource.size, resource.alignment); + const resourceIndicesOffset = allocate( + checkedProduct(descriptor.glyphCount, descriptor.strikes.length, 'font resource rows'), + 4, + 4, + ); + const tableOffsets = tables.map(([, table]) => + allocate(checkedProduct(table.rows, table.fields.length, 'font binding fields'), 4, 4), + ); + const bytes = new Uint8Array(length); + const view = new DataView(bytes.buffer); + view.setUint32(request.abiVersion, textShaperAbi.version, true); + view.setUint32(request.byteLength, bytes.byteLength, true); + view.setUint32(request.techniqueId, descriptor.techniqueId, true); + view.setUint16(request.programVariant, descriptor.programVariant, true); + view.setUint32(request.glyphCount, descriptor.glyphCount, true); + view.setUint32(request.strikeCount, descriptor.strikes.length, true); + view.setUint32(request.resourceCount, descriptor.resources.length, true); + view.setUint32(request.strikesOffset, strikesOffset, true); + view.setUint32(request.resourcesOffset, resourcesOffset, true); + view.setUint32(request.resourceIndicesOffset, resourceIndicesOffset, true); + + for (const [index, ppem] of descriptor.strikes.entries()) { + view.setUint32(strikesOffset + index * strike.size + strike.ppem, ppem, true); + } + for (const [index, value] of descriptor.resources.entries()) { + const offset = resourcesOffset + index * resource.size; + view.setUint32(offset + resource.id, value.id, true); + view.setUint32(offset + resource.generation, value.generation, true); + view.setUint16(offset + resource.kind, value.kind, true); + view.setUint32(offset + resource.reference, value.reference, true); + } + const resourceRows = descriptor.glyphCount * descriptor.strikes.length; + for (let row = 0; row < resourceRows; row += 1) { + view.setUint32(resourceIndicesOffset + row * 4, descriptor.resourceIndex(row), true); + } + for (const [tableIndex, [name, table]] of tables.entries()) { + if (table.fields.length > 32) throw new RangeError(`${name} has more than 32 fields`); + view.setUint8(request[`${name}FieldCount`], table.fields.length); + view.setUint32(request[`${name}Offset`], tableOffsets[tableIndex]!, true); + for (const [fieldIndex, read] of table.fields.entries()) { + const fieldOffset = tableOffsets[tableIndex]! + fieldIndex * table.rows * 4; + for (let row = 0; row < table.rows; row += 1) { + const value = read(row); + if (name.endsWith('F32')) { + if (!Number.isFinite(value)) throw new TypeError(`${name} produced a nonfinite value`); + view.setFloat32(fieldOffset + row * 4, value, true); + } else { + view.setUint32(fieldOffset + row * 4, uint32(value, `${name} value`), true); + } + } + } + } + return bytes; +} + +export function emptyFontBindingTable(rows: number): FontBindingFieldTable { + return { rows, fields: [] }; +} + +function recordView(bytes: Uint8Array): DataView { + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); +} + +function align(value: number, alignment: number): number { + return Math.ceil(value / alignment) * alignment; +} + +function checkedAdd(left: number, right: number, label: string): number { + const value = left + right; + if (!Number.isSafeInteger(value) || value > MAX_U32) throw new RangeError(`${label} exceeds u32`); + return value; +} + +function checkedProduct(left: number, right: number, label: string): number { + const value = left * right; + if (!Number.isSafeInteger(value) || value > MAX_U32) throw new RangeError(`${label} exceeds u32`); + return value; +} + +function uint32(value: number, label: string): number { + if (!Number.isSafeInteger(value) || value < 0 || value > MAX_U32) throw new RangeError(`${label} must be a u32`); + return value; +} diff --git a/packages/text/src/internal/frame-transfer-pool.ts b/packages/text/src/internal/frame-transfer-pool.ts new file mode 100644 index 00000000..a005c628 --- /dev/null +++ b/packages/text/src/internal/frame-transfer-pool.ts @@ -0,0 +1,325 @@ +export const FRAME_TRANSFER_PROTOCOL_VERSION = 0 as const; + +const FRAME_PUBLICATION_TYPE = 'pmndrs-text-frame-v0' as const; +const FRAME_RETURN_TYPE = 'pmndrs-text-frame-return-v0' as const; + +export interface FrameTransferPoolLimits { + readonly minimumCapacity: number; + readonly maximumBufferBytes: number; + readonly maximumOutstandingTransfers: number; + readonly maximumOutstandingBytes: number; + readonly maximumPooledBuffers: number; + readonly maximumPooledBytes: number; +} + +export interface FrameTransferPublicationV0 { + readonly type: typeof FRAME_PUBLICATION_TYPE; + readonly protocolVersion: typeof FRAME_TRANSFER_PROTOCOL_VERSION; + readonly transferId: number; + readonly sessionId: number; + readonly planRevision: number; + readonly byteLength: number; + readonly capacity: number; + readonly buffer: ArrayBuffer; +} + +export interface FrameTransferReturnV0 { + readonly type: typeof FRAME_RETURN_TYPE; + readonly protocolVersion: typeof FRAME_TRANSFER_PROTOCOL_VERSION; + readonly transferId: number; + readonly capacity: number; + readonly buffer: ArrayBuffer; +} + +export interface FrameTransferPoolStats { + readonly allocations: number; + readonly poolHits: number; + readonly transfers: number; + readonly returns: number; + readonly rejectedReturns: number; + readonly discardedReturns: number; + readonly detachedTransferFailures: number; + readonly backpressureEvents: number; + readonly bytesCopied: number; + readonly transferredBytes: number; + readonly outstandingTransfers: number; + readonly outstandingBytes: number; + readonly pooledBuffers: number; + readonly pooledBytes: number; +} + +export type FrameTransferResult = + | Readonly<{ ok: true; publication: FrameTransferPublicationV0 }> + | Readonly<{ ok: false; reason: 'backpressure' | 'oversized' | 'transfer-failed'; error?: unknown }>; + +export type FrameReturnResult = + | Readonly<{ ok: true; pooled: boolean }> + | Readonly<{ ok: false; reason: 'invalid-message' | 'unknown-transfer' | 'capacity-mismatch' }>; + +export interface FrameTransferPool { + transfer( + bytes: Uint8Array, + publication: Readonly<{ sessionId: number; planRevision: number }>, + send: (message: FrameTransferPublicationV0, transfer: readonly Transferable[]) => void, + ): FrameTransferResult; + acceptReturn(message: unknown): FrameReturnResult; + stats(): FrameTransferPoolStats; +} + +interface MutableStats { + allocations: number; + poolHits: number; + transfers: number; + returns: number; + rejectedReturns: number; + discardedReturns: number; + detachedTransferFailures: number; + backpressureEvents: number; + bytesCopied: number; + transferredBytes: number; +} + +/** + * Owns the asynchronous copy boundary for raw frame bytes. The pool never decodes the compiler-defined Wasm layout. + * A successful `send` must transfer (and therefore detach) the supplied buffer before it returns. + */ +export function createFrameTransferPool(limits: FrameTransferPoolLimits): FrameTransferPool { + validateLimits(limits); + const pooled: ArrayBuffer[] = []; + const outstanding = new Map(); + const stats: MutableStats = { + allocations: 0, + poolHits: 0, + transfers: 0, + returns: 0, + rejectedReturns: 0, + discardedReturns: 0, + detachedTransferFailures: 0, + backpressureEvents: 0, + bytesCopied: 0, + transferredBytes: 0, + }; + let pooledBytes = 0; + let outstandingBytes = 0; + let nextTransferId = 1; + + return { + transfer(bytes, publication, send) { + assertPublicationMetadata(publication); + const minimumCapacity = transferCapacity(bytes.byteLength, limits); + if (minimumCapacity === undefined) return { ok: false, reason: 'oversized' }; + const availableOutstandingBytes = limits.maximumOutstandingBytes - outstandingBytes; + if (outstanding.size >= limits.maximumOutstandingTransfers || minimumCapacity > availableOutstandingBytes) { + stats.backpressureEvents += 1; + return { ok: false, reason: 'backpressure' }; + } + + const pooledIndex = bestFitIndex(pooled, minimumCapacity, availableOutstandingBytes); + const buffer = pooledIndex < 0 ? new ArrayBuffer(minimumCapacity) : pooled.splice(pooledIndex, 1)[0]!; + if (pooledIndex < 0) stats.allocations += 1; + else { + pooledBytes -= buffer.byteLength; + stats.poolHits += 1; + } + new Uint8Array(buffer, 0, bytes.byteLength).set(bytes); + stats.bytesCopied += bytes.byteLength; + + const transferId = nextAvailableTransferId(nextTransferId, outstanding); + nextTransferId = transferId === 0xffff_ffff ? 1 : transferId + 1; + const message: FrameTransferPublicationV0 = { + type: FRAME_PUBLICATION_TYPE, + protocolVersion: FRAME_TRANSFER_PROTOCOL_VERSION, + transferId, + sessionId: publication.sessionId, + planRevision: publication.planRevision, + byteLength: bytes.byteLength, + capacity: buffer.byteLength, + buffer, + }; + + try { + send(message, [buffer]); + } catch (error) { + if (buffer.byteLength > 0) pooledBytes = retainWorkerBuffer(pooled, pooledBytes, buffer, limits); + else { + outstanding.set(transferId, message.capacity); + outstandingBytes += message.capacity; + stats.detachedTransferFailures += 1; + } + return { ok: false, reason: 'transfer-failed', error }; + } + if (buffer.byteLength !== 0) { + pooledBytes = retainWorkerBuffer(pooled, pooledBytes, buffer, limits); + return { + ok: false, + reason: 'transfer-failed', + error: new TypeError('frame transfer sender returned without detaching its buffer'), + }; + } + + outstanding.set(transferId, message.capacity); + outstandingBytes += message.capacity; + stats.transfers += 1; + stats.transferredBytes += message.capacity; + return { ok: true, publication: message }; + }, + + acceptReturn(message) { + if (!isFrameTransferReturnV0(message)) { + stats.rejectedReturns += 1; + return { ok: false, reason: 'invalid-message' }; + } + const expectedCapacity = outstanding.get(message.transferId); + if (expectedCapacity === undefined) { + stats.rejectedReturns += 1; + return { ok: false, reason: 'unknown-transfer' }; + } + if (message.capacity !== expectedCapacity || message.buffer.byteLength !== expectedCapacity) { + stats.rejectedReturns += 1; + return { ok: false, reason: 'capacity-mismatch' }; + } + + outstanding.delete(message.transferId); + outstandingBytes -= expectedCapacity; + stats.returns += 1; + const canPool = + pooled.length < limits.maximumPooledBuffers && pooledBytes + expectedCapacity <= limits.maximumPooledBytes; + if (!canPool) { + stats.discardedReturns += 1; + return { ok: true, pooled: false }; + } + pooled.push(message.buffer); + pooledBytes += expectedCapacity; + return { ok: true, pooled: true }; + }, + + stats() { + return { + ...stats, + outstandingTransfers: outstanding.size, + outstandingBytes, + pooledBuffers: pooled.length, + pooledBytes, + }; + }, + }; +} + +/** Transfer a retired root-owned publication back to its originating worker. */ +export function returnFrameTransfer( + publication: FrameTransferPublicationV0, + send: (message: FrameTransferReturnV0, transfer: readonly Transferable[]) => void, +): void { + if (!isFrameTransferPublicationV0(publication)) throw new TypeError('invalid frame transfer publication'); + if (publication.buffer.byteLength !== publication.capacity) { + throw new TypeError('frame transfer is detached or has the wrong capacity'); + } + const message: FrameTransferReturnV0 = { + type: FRAME_RETURN_TYPE, + protocolVersion: FRAME_TRANSFER_PROTOCOL_VERSION, + transferId: publication.transferId, + capacity: publication.capacity, + buffer: publication.buffer, + }; + send(message, [publication.buffer]); + if (publication.buffer.byteLength !== 0) { + throw new TypeError('frame return sender returned without detaching its buffer'); + } +} + +export function isFrameTransferPublicationV0(value: unknown): value is FrameTransferPublicationV0 { + if (!isRecord(value) || value.type !== FRAME_PUBLICATION_TYPE || value.protocolVersion !== 0) return false; + return ( + positiveU32(value.transferId) && + positiveU32(value.sessionId) && + nonnegativeU32(value.planRevision) && + nonnegativeU32(value.byteLength) && + positiveU32(value.capacity) && + value.byteLength <= value.capacity && + value.buffer instanceof ArrayBuffer + ); +} + +export function isFrameTransferReturnV0(value: unknown): value is FrameTransferReturnV0 { + if (!isRecord(value) || value.type !== FRAME_RETURN_TYPE || value.protocolVersion !== 0) return false; + return positiveU32(value.transferId) && positiveU32(value.capacity) && value.buffer instanceof ArrayBuffer; +} + +function validateLimits(limits: FrameTransferPoolLimits): void { + assertPositiveU32('minimumCapacity', limits.minimumCapacity); + assertPositiveU32('maximumBufferBytes', limits.maximumBufferBytes); + assertPositiveU32('maximumOutstandingTransfers', limits.maximumOutstandingTransfers); + assertPositiveU32('maximumOutstandingBytes', limits.maximumOutstandingBytes); + assertU32('maximumPooledBuffers', limits.maximumPooledBuffers); + assertU32('maximumPooledBytes', limits.maximumPooledBytes); + if (limits.minimumCapacity > limits.maximumBufferBytes) { + throw new RangeError('minimumCapacity cannot exceed maximumBufferBytes'); + } + if (limits.maximumBufferBytes > limits.maximumOutstandingBytes) { + throw new RangeError('maximumBufferBytes cannot exceed maximumOutstandingBytes'); + } +} + +function assertPublicationMetadata(value: Readonly<{ sessionId: number; planRevision: number }>): void { + if (!positiveU32(value.sessionId)) throw new RangeError('sessionId must be a positive u32'); + if (!nonnegativeU32(value.planRevision)) throw new RangeError('planRevision must be a u32'); +} + +function transferCapacity(byteLength: number, limits: FrameTransferPoolLimits): number | undefined { + if (!Number.isSafeInteger(byteLength) || byteLength < 0 || byteLength > limits.maximumBufferBytes) return undefined; + let capacity = limits.minimumCapacity; + while (capacity < byteLength && capacity <= Math.floor(limits.maximumBufferBytes / 2)) capacity *= 2; + return capacity < byteLength ? byteLength : capacity; +} + +function bestFitIndex(buffers: readonly ArrayBuffer[], minimumCapacity: number, maximumCapacity: number): number { + let found = -1; + for (let index = 0; index < buffers.length; index += 1) { + const candidate = buffers[index]!; + if (candidate.byteLength < minimumCapacity || candidate.byteLength > maximumCapacity) continue; + if (found < 0 || candidate.byteLength < buffers[found]!.byteLength) found = index; + } + return found; +} + +function retainWorkerBuffer( + buffers: ArrayBuffer[], + pooledBytes: number, + buffer: ArrayBuffer, + limits: FrameTransferPoolLimits, +): number { + if (buffers.length >= limits.maximumPooledBuffers || pooledBytes + buffer.byteLength > limits.maximumPooledBytes) { + return pooledBytes; + } + buffers.push(buffer); + return pooledBytes + buffer.byteLength; +} + +function nextAvailableTransferId(start: number, outstanding: ReadonlyMap): number { + let candidate = start; + do { + if (!outstanding.has(candidate)) return candidate; + candidate = candidate === 0xffff_ffff ? 1 : candidate + 1; + } while (candidate !== start); + throw new RangeError('frame transfer identifiers are exhausted'); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function positiveU32(value: unknown): value is number { + return Number.isInteger(value) && Number(value) > 0 && Number(value) <= 0xffff_ffff; +} + +function nonnegativeU32(value: unknown): value is number { + return Number.isInteger(value) && Number(value) >= 0 && Number(value) <= 0xffff_ffff; +} + +function assertPositiveU32(name: string, value: number): void { + if (!positiveU32(value)) throw new RangeError(`${name} must be a positive u32`); +} + +function assertU32(name: string, value: number): void { + if (!nonnegativeU32(value)) throw new RangeError(`${name} must be a u32`); +} diff --git a/packages/text/src/internal/layout-query-view.ts b/packages/text/src/internal/layout-query-view.ts new file mode 100644 index 00000000..181a2b77 --- /dev/null +++ b/packages/text/src/internal/layout-query-view.ts @@ -0,0 +1,242 @@ +import { textShaperAbi } from '../generated/text-shaper-abi.js'; +import type { ParagraphLayoutInspection, ParagraphLayoutSummary } from '../layout.js'; +import type { TextEnginePublication } from './text-engine-host.js'; + +/** Reads an explicitly requested semantic sidecar. Rendering never calls this reader. */ +export function readTextEngineMeasurements( + publication: TextEnginePublication, +): ReadonlyMap { + const view = new SemanticViewReader(publication); + const table = view.table(); + const recordLayout = textShaperAbi.layouts.engineSemanticView; + const kinds = textShaperAbi.engine.semanticKinds; + const measurements = new Map(); + for (let index = 0; index < table.count; index += 1) { + const record = view.record(table, index); + if (view.u16(record + recordLayout.kind) !== kinds.paragraphMeasurement) continue; + const paragraphId = view.u32(record + recordLayout.id); + const lineStart = view.u32(record + recordLayout.itemStart); + const lineCount = view.u32(record + recordLayout.itemCount); + if (lineStart + lineCount > table.count) + throw new RangeError('paragraph measurement line span is outside the query'); + let firstBaseline = 0; + let lastBaseline = 0; + for (let lineIndex = 0; lineIndex < lineCount; lineIndex += 1) { + const line = view.record(table, lineStart + lineIndex); + if (view.u16(line + recordLayout.kind) !== kinds.line || view.u32(line + recordLayout.parentId) !== paragraphId) { + throw new TypeError('paragraph measurement references a foreign semantic line'); + } + const baseline = view.f32(line + recordLayout.blockStart); + if (lineIndex === 0) firstBaseline = baseline; + lastBaseline = baseline; + } + if (measurements.has(paragraphId)) throw new TypeError('text engine returned duplicate paragraph measurements'); + measurements.set( + paragraphId, + Object.freeze({ + width: view.f32(record + recordLayout.inlineStart), + height: view.f32(record + recordLayout.blockStart), + contentWidth: view.f32(record + recordLayout.inlineExtent), + contentHeight: view.f32(record + recordLayout.blockExtent), + firstBaseline, + lastBaseline, + overflowed: (view.u16(record + recordLayout.flags) & textShaperAbi.engine.measurementFlags.overflowed) !== 0, + glyphCount: view.u32(record + recordLayout.parentId), + lineCount, + missingGlyphCount: view.u32(record + recordLayout.textStart), + }), + ); + } + return measurements; +} + +/** Copies one explicitly requested retained layout out of borrowed Wasm publication memory. */ +export function readTextEngineLayouts( + publication: TextEnginePublication, +): ReadonlyMap { + const view = new SemanticViewReader(publication); + const table = view.table(); + const recordLayout = textShaperAbi.layouts.engineSemanticView; + const kinds = textShaperAbi.engine.semanticKinds; + const measurements = readTextEngineMeasurements(publication); + const layouts = new Map(); + for (let index = 0; index < table.count; index += 1) { + const summary = view.record(table, index); + if (view.u16(summary + recordLayout.kind) !== kinds.paragraphMeasurement) continue; + const paragraphId = view.u32(summary + recordLayout.id); + const measurement = measurements.get(paragraphId); + if (measurement === undefined) throw new TypeError('layout inspection has no paragraph measurement'); + const lineStart = view.u32(summary + recordLayout.itemStart); + const lineCount = view.u32(summary + recordLayout.itemCount); + const glyphStart = checkedAdd(lineStart, lineCount, 'layout inspection glyph start'); + const glyphCount = measurement.glyphCount; + if (checkedAdd(glyphStart, glyphCount, 'layout inspection glyph end') > table.count) { + throw new RangeError('layout inspection glyph span is outside the query'); + } + + const fontHandles: number[] = []; + const fontSlots = new Map(); + const glyphStableIds = new Uint32Array(glyphCount); + const glyphFontSlots = new Uint16Array(glyphCount); + const glyphIds = new Uint16Array(glyphCount); + const clusters = new Uint32Array(glyphCount); + const glyphFontSizes = new Float32Array(glyphCount); + const x = new Float32Array(glyphCount); + const y = new Float32Array(glyphCount); + const glyphFlags = new Uint16Array(glyphCount); + for (let glyphIndex = 0; glyphIndex < glyphCount; glyphIndex += 1) { + const glyph = view.record(table, glyphStart + glyphIndex); + if ( + view.u16(glyph + recordLayout.kind) !== kinds.glyph || + view.u32(glyph + recordLayout.parentId) !== paragraphId + ) { + throw new TypeError('layout inspection references a foreign semantic glyph'); + } + const fontHandle = view.u32(glyph + recordLayout.textEnd); + let fontSlot = fontSlots.get(fontHandle); + if (fontSlot === undefined) { + fontSlot = fontHandles.length; + if (fontSlot > 0xffff) throw new RangeError('layout inspection exceeds the font-slot range'); + fontSlots.set(fontHandle, fontSlot); + fontHandles.push(fontHandle); + } + glyphStableIds[glyphIndex] = view.u32(glyph + recordLayout.id); + glyphFontSlots[glyphIndex] = fontSlot; + glyphIds[glyphIndex] = view.u32(glyph + recordLayout.itemStart); + clusters[glyphIndex] = view.u32(glyph + recordLayout.textStart); + glyphFontSizes[glyphIndex] = view.f32(glyph + recordLayout.inlineExtent); + x[glyphIndex] = view.f32(glyph + recordLayout.inlineStart); + y[glyphIndex] = view.f32(glyph + recordLayout.blockStart); + glyphFlags[glyphIndex] = view.u16(glyph + recordLayout.flags); + } + + const lineTextStarts = new Uint32Array(lineCount); + const lineTextEnds = new Uint32Array(lineCount); + const lineGlyphStarts = new Uint32Array(lineCount); + const lineGlyphCounts = new Uint32Array(lineCount); + const lineBaselines = new Float32Array(lineCount); + const lineAdvances = new Float32Array(lineCount); + for (let lineIndex = 0; lineIndex < lineCount; lineIndex += 1) { + const line = view.record(table, lineStart + lineIndex); + if (view.u16(line + recordLayout.kind) !== kinds.line || view.u32(line + recordLayout.parentId) !== paragraphId) { + throw new TypeError('layout inspection references a foreign semantic line'); + } + const absoluteGlyphStart = view.u32(line + recordLayout.itemStart); + const count = view.u32(line + recordLayout.itemCount); + if ( + absoluteGlyphStart < glyphStart || + checkedAdd(absoluteGlyphStart, count, 'line glyph end') > glyphStart + glyphCount + ) { + throw new RangeError('layout inspection line glyph span is outside its paragraph'); + } + lineTextStarts[lineIndex] = view.u32(line + recordLayout.textStart); + lineTextEnds[lineIndex] = view.u32(line + recordLayout.textEnd); + lineGlyphStarts[lineIndex] = absoluteGlyphStart - glyphStart; + lineGlyphCounts[lineIndex] = count; + lineBaselines[lineIndex] = view.f32(line + recordLayout.blockStart); + lineAdvances[lineIndex] = view.f32(line + recordLayout.inlineExtent); + } + + layouts.set( + paragraphId, + Object.freeze({ + ...measurement, + fontHandles: Uint32Array.from(fontHandles), + glyphStableIds, + glyphFontSlots, + glyphIds, + clusters, + glyphFontSizes, + x, + y, + glyphFlags, + lineTextStarts, + lineTextEnds, + lineGlyphStarts, + lineGlyphCounts, + lineBaselines, + lineAdvances, + }), + ); + } + return layouts; +} + +function checkedAdd(left: number, right: number, label: string): number { + const value = left + right; + if (!Number.isSafeInteger(value) || value < 0) throw new RangeError(`${label} overflows`); + return value; +} + +interface SemanticViewTable { + readonly offset: number; + readonly count: number; + readonly stride: number; +} + +class SemanticViewReader { + readonly #publication: TextEnginePublication; + readonly #view: DataView; + + constructor(publication: TextEnginePublication) { + if (publication.bytes.buffer !== publication.memoryBuffer) { + throw new TypeError('text-engine query bytes do not belong to the reported Wasm memory'); + } + this.#publication = publication; + this.#view = new DataView(publication.memoryBuffer); + } + + table(): SemanticViewTable { + const result = textShaperAbi.layouts.engineResult; + const record = textShaperAbi.layouts.engineSemanticView; + const offset = this.u32(result.semanticViewsOffset); + const count = this.u32(result.semanticViewCount); + if (count !== this.#publication.semanticViewCount) { + throw new TypeError('text-engine query metadata disagrees with its publication'); + } + if (count === 0) { + if (offset !== 0) throw new RangeError('empty text-engine semantic view has a nonzero offset'); + return { offset: 0, count: 0, stride: record.size }; + } + const byteLength = count * record.size; + if (!Number.isSafeInteger(byteLength) || offset % record.alignment !== 0 || offset < result.size) { + throw new RangeError('text-engine semantic view has an invalid span'); + } + this.#assertRange(offset, byteLength); + return { offset, count, stride: record.size }; + } + + record(table: SemanticViewTable, index: number): number { + if (!Number.isSafeInteger(index) || index < 0 || index >= table.count) { + throw new RangeError('text-engine semantic-view record index is outside its table'); + } + return table.offset + index * table.stride; + } + + u16(offset: number): number { + this.#assertRange(offset, 2); + return this.#view.getUint16(this.#publication.bytes.byteOffset + offset, true); + } + + u32(offset: number): number { + this.#assertRange(offset, 4); + return this.#view.getUint32(this.#publication.bytes.byteOffset + offset, true); + } + + f32(offset: number): number { + this.#assertRange(offset, 4); + return this.#view.getFloat32(this.#publication.bytes.byteOffset + offset, true); + } + + #assertRange(offset: number, byteLength: number): void { + if ( + !Number.isSafeInteger(offset) || + !Number.isSafeInteger(byteLength) || + offset < 0 || + byteLength < 0 || + offset + byteLength > this.#publication.bytes.byteLength + ) { + throw new RangeError('text-engine semantic-view read is outside the publication'); + } + } +} diff --git a/packages/text/src/internal/mtsdf-generator.ts b/packages/text/src/internal/mtsdf-generator.ts index 427e7060..5ca178e8 100644 --- a/packages/text/src/internal/mtsdf-generator.ts +++ b/packages/text/src/internal/mtsdf-generator.ts @@ -1,3 +1,7 @@ +import { mtsdfBakerAbi, type MtsdfBakerAbi } from '../generated/mtsdf-baker-abi.js'; + +export const mtsdfGeneratorAbi: MtsdfBakerAbi = mtsdfBakerAbi; + export type MtsdfGeneratorWasmSource = BufferSource | WebAssembly.Module; export type MtsdfOutlineCommand = @@ -83,17 +87,18 @@ export async function createMtsdfGenerator(source: MtsdfGeneratorWasmSource): Pr } export function createMtsdfGeneratorFromInstance(instance: WebAssembly.Instance): MtsdfGenerator { - const abi = readMtsdfGeneratorAbi(instance); - const exports = readExports(instance.exports, abi); + const exports = readExports(instance.exports, mtsdfGeneratorAbi); return { generate(request) { - const encoded = encodeRequest(request, abi); + const encoded = encodeRequest(request, mtsdfGeneratorAbi); const pointer = exports.allocate(encoded.byteLength); if (pointer === 0) throw new RangeError('MTSDF generator Wasm allocation failed'); try { copyToMemory(exports.memory, pointer, encoded); const status = exports.generate(pointer, encoded.byteLength); - if (status !== abi.status.ok) throw new MtsdfGenerationError(statusCode(status, abi)); + if (status !== mtsdfGeneratorAbi.status.ok) { + throw new MtsdfGenerationError(statusCode(status, mtsdfGeneratorAbi)); + } const width = checkedDimension(request.region.innerWidth, request.region.paddingX, 'width'); const height = checkedDimension(request.region.innerHeight, request.region.paddingY, 'height'); @@ -115,11 +120,6 @@ export function createMtsdfGeneratorFromInstance(instance: WebAssembly.Instance) }; } -export function readMtsdfGeneratorAbi(instance: WebAssembly.Instance): MtsdfGeneratorAbiV1 { - readExports(instance.exports, mtsdfBakerAbi); - return mtsdfBakerAbi; -} - function encodeRequest(request: MtsdfGlyphRequest, abi: MtsdfGeneratorAbiV1): Uint8Array { validateRequest(request); const requestLayout = abi.layouts.request; @@ -282,5 +282,3 @@ function checkedSum(...values: number[]): number { } return sum; } - -import { mtsdfBakerAbi, type MtsdfBakerAbi } from '../generated/mtsdf-baker-abi.js'; diff --git a/packages/text/src/internal/render-plan-view.ts b/packages/text/src/internal/render-plan-view.ts new file mode 100644 index 00000000..9347cb4b --- /dev/null +++ b/packages/text/src/internal/render-plan-view.ts @@ -0,0 +1,139 @@ +import { textShaperAbi } from '../generated/text-shaper-abi.js'; +import type { TextEnginePublication } from './text-engine-host.js'; + +export interface RenderPlanTable { + readonly offset: number; + readonly count: number; + readonly stride: number; +} + +type TableName = 'resources' | 'buffers' | 'patches' | 'primitives' | 'draws' | 'retirements' | 'diagnostics'; + +const resultLayout = textShaperAbi.layouts.engineResult; +const tableLayouts = { + resources: { + offset: resultLayout.resourcesOffset, + count: resultLayout.resourceCount, + record: textShaperAbi.layouts.engineResource, + }, + buffers: { + offset: resultLayout.buffersOffset, + count: resultLayout.bufferCount, + record: textShaperAbi.layouts.engineBuffer, + }, + patches: { + offset: resultLayout.patchesOffset, + count: resultLayout.patchCount, + record: textShaperAbi.layouts.enginePatch, + }, + primitives: { + offset: resultLayout.primitivesOffset, + count: resultLayout.primitiveCount, + record: textShaperAbi.layouts.enginePrimitive, + }, + draws: { offset: resultLayout.drawsOffset, count: resultLayout.drawCount, record: textShaperAbi.layouts.engineDraw }, + retirements: { + offset: resultLayout.retirementsOffset, + count: resultLayout.retirementCount, + record: textShaperAbi.layouts.engineRetirement, + }, + diagnostics: { + offset: resultLayout.diagnosticsOffset, + count: resultLayout.diagnosticCount, + record: textShaperAbi.layouts.engineDiagnostic, + }, +} as const; + +/** Reusable zero-copy reader over one borrowed Rust render-plan publication. */ +export class TextEngineRenderPlanView { + #memoryBuffer: ArrayBuffer | undefined; + #view: DataView | undefined; + #baseOffset = 0; + #byteLength = 0; + + bind(publication: TextEnginePublication): this { + const bytes = publication.bytes; + if (bytes.buffer !== publication.memoryBuffer) { + throw new TypeError('text-engine publication bytes do not belong to the reported Wasm memory'); + } + if (this.#memoryBuffer !== publication.memoryBuffer) { + this.#memoryBuffer = publication.memoryBuffer; + this.#view = new DataView(publication.memoryBuffer); + } + this.#baseOffset = bytes.byteOffset; + this.#byteLength = bytes.byteLength; + if (this.#byteLength < resultLayout.size || this.u32(resultLayout.byteLength) !== this.#byteLength) { + throw new RangeError('text-engine publication header has an invalid byte length'); + } + for (const name of Object.keys(tableLayouts) as TableName[]) this.table(name); + return this; + } + + table(name: TableName): RenderPlanTable { + const layout = tableLayouts[name]; + const offset = this.u32(layout.offset); + const count = this.u32(layout.count); + if (count === 0) { + if (offset !== 0) throw new RangeError(`empty text-engine ${name} table has a nonzero offset`); + return { offset: 0, count: 0, stride: layout.record.size }; + } + if (offset % layout.record.alignment !== 0) throw new RangeError(`text-engine ${name} table is misaligned`); + const byteLength = checkedProduct(count, layout.record.size, `${name} table`); + if (offset < resultLayout.size || offset + byteLength > this.#byteLength) { + throw new RangeError(`text-engine ${name} table is outside the publication`); + } + return { offset, count, stride: layout.record.size }; + } + + record(table: RenderPlanTable, index: number): number { + if (!Number.isSafeInteger(index) || index < 0 || index >= table.count) { + throw new RangeError('text-engine render-plan record index is outside its table'); + } + return table.offset + index * table.stride; + } + + u8(offset: number): number { + this.#assertRange(offset, 1); + return this.#view!.getUint8(this.#baseOffset + offset); + } + + u16(offset: number): number { + this.#assertRange(offset, 2); + return this.#view!.getUint16(this.#baseOffset + offset, true); + } + + u32(offset: number): number { + this.#assertRange(offset, 4); + return this.#view!.getUint32(this.#baseOffset + offset, true); + } + + f32(offset: number): number { + this.#assertRange(offset, 4); + return this.#view!.getFloat32(this.#baseOffset + offset, true); + } + + bytes(offset: number, byteLength: number): Uint8Array { + this.#assertRange(offset, byteLength); + return new Uint8Array(this.#memoryBuffer!, this.#baseOffset + offset, byteLength); + } + + #assertRange(offset: number, byteLength: number): void { + if ( + this.#view === undefined || + !Number.isSafeInteger(offset) || + !Number.isSafeInteger(byteLength) || + offset < 0 || + byteLength < 0 || + offset + byteLength > this.#byteLength + ) { + throw new RangeError('text-engine render-plan read is outside the publication'); + } + } +} + +function checkedProduct(left: number, right: number, label: string): number { + const value = left * right; + if (!Number.isSafeInteger(value)) + throw new RangeError(`${label} byte length exceeds JavaScript's safe integer range`); + return value; +} diff --git a/packages/text/src/internal/render-policy-wire.ts b/packages/text/src/internal/render-policy-wire.ts new file mode 100644 index 00000000..229840d9 --- /dev/null +++ b/packages/text/src/internal/render-policy-wire.ts @@ -0,0 +1,623 @@ +import { textShaperAbi } from '../generated/text-shaper-abi.js'; + +const MAX_U32 = 0xffff_ffff; +const encoder = new TextEncoder(); +export const FIRST_PARTY_TRANSFORM_BUFFER_ID = 15; +export const FIRST_PARTY_STABLE_GLYPH_BUFFER_ID = 14; + +export type PolicyInputScope = keyof typeof textShaperAbi.policy.inputScopes; + +export interface PolicyInput { + readonly scope: PolicyInputScope; + readonly field: number; +} + +export interface PolicyBuffer { + readonly id: number; + readonly scalar: number; + readonly vectorWidth: number; + readonly alignment?: number; + readonly stride?: number; + readonly usage?: number; + readonly capacityClass?: number; +} + +export interface PolicyOperation { + readonly opcode: number; + readonly target?: number; + readonly operand0?: number; + readonly operand1?: number; + readonly immediate0?: number; + readonly immediate1?: number; + readonly immediate2?: number; +} + +export interface PolicyProgram { + readonly techniqueId: number; + readonly programId: number; + readonly capabilitySetId?: number; + readonly resourceKindMask?: number; + readonly semanticViewMask?: number; + readonly storageKeyMask?: number; + readonly drawKeyMask?: number; + readonly variant?: number; + readonly f32InputCount: number; + readonly u32InputCount: number; + readonly paintCapabilities?: number; + readonly compositingCapabilities?: number; + readonly allocationStrategy?: number; + readonly inputs: readonly PolicyInput[]; + readonly buffers: readonly PolicyBuffer[]; + readonly operations: readonly PolicyOperation[]; +} + +export interface PolicyCapabilitySet { + readonly id: number; + readonly flags: number; + readonly maxBufferBytes: number; + readonly updateAlignment: number; + readonly coalesceGapBytes: number; + readonly rangeCallPenaltyBytes: number; + readonly maxBuffersPerDraw: number; + readonly maxResourcesPerDraw: number; + readonly maxIndirectDraws: number; + readonly fragmentationBudget: number; + readonly wholeBufferThresholdBasisPoints: number; +} + +export interface PolicyDescriptor { + readonly capabilitySets: readonly PolicyCapabilitySet[]; + readonly programs: readonly PolicyProgram[]; +} + +/** Deterministic UTF-8 FNV-1a mapping used by both policy and font-binding compilers. */ +export function renderWireId(id: string): number { + if (typeof id !== 'string' || id.length === 0) throw new TypeError('render identity must be a nonempty string'); + let hash = 0x811c_9dc5; + for (const byte of encoder.encode(id)) hash = Math.imul(hash ^ byte, 0x0100_0193) >>> 0; + if (hash === 0) throw new RangeError('raster technique ID hashes to the reserved zero wire identity'); + return hash; +} + +/** Runtime-scoped collision proof for every string identity lowered into the shared u32 wire namespace. */ +export class RenderWireIdentityRegistry { + readonly #strings = new Map(); + + resolve(id: string): number { + const wireId = renderWireId(id); + const collision = this.#strings.get(wireId); + if (collision !== undefined && collision !== id) { + throw new TypeError(`render wire identity collision between "${collision}" and "${id}"`); + } + this.#strings.set(wireId, id); + return wireId; + } +} + +export interface FirstPartyTechniqueWireIds { + readonly bitmap: number; + readonly msdf: number; + readonly slug: number; +} + +export type ThreeTransformMode = 'direct' | 'indexed'; +export type ThreeAllocationMode = 'ordered' | 'stable'; + +export interface ThreeTechniqueTransformModes { + readonly bitmap: ThreeTransformMode; + readonly msdf: ThreeTransformMode; + readonly slug: ThreeTransformMode; +} + +export const firstPartyTechniqueWireIds: FirstPartyTechniqueWireIds = Object.freeze({ + bitmap: renderWireId('pmndrs.bitmap'), + msdf: renderWireId('pmndrs.msdf'), + slug: renderWireId('pmndrs.slug'), +}); + +/** Compiler-mapped Three policy covering every first-party raster technique in one registration. */ +export function firstPartyThreeRenderPolicyBytes( + identities: RenderWireIdentityRegistry = new RenderWireIdentityRegistry(), + transformMode: ThreeTransformMode | ThreeTechniqueTransformModes = 'indexed', + additionalPrograms: readonly PolicyProgram[] = [], + allocationMode: ThreeAllocationMode = 'ordered', +): Uint8Array { + const bitmap = identities.resolve('pmndrs.bitmap'); + const msdf = identities.resolve('pmndrs.msdf'); + const slug = identities.resolve('pmndrs.slug'); + const modes = + typeof transformMode === 'string' + ? { bitmap: transformMode, msdf: transformMode, slug: transformMode } + : transformMode; + const programs: PolicyProgram[] = [ + bitmapProgram(bitmap, 1, modes.bitmap, allocationMode), + msdfProgram(msdf, 2, modes.msdf, allocationMode), + slugProgram(slug, 3, modes.slug, allocationMode), + ...additionalPrograms, + ]; + if (new Set(programs.map((program) => program.techniqueId)).size !== programs.length) { + throw new TypeError('first-party raster technique wire identities collide'); + } + return compileRenderPolicy({ capabilitySets: [threeCapabilitySet()], programs }); +} + +function threeCapabilitySet(): PolicyCapabilitySet { + const flags = textShaperAbi.policy.capabilityFlags; + return { + id: 1, + flags: flags.storageBuffers | flags.aliasVec2 | flags.aliasVec4 | flags.orderedDirect | flags.stableIndirect, + maxBufferBytes: 64 * 1024 * 1024, + updateAlignment: 4, + coalesceGapBytes: 128, + rangeCallPenaltyBytes: 256, + maxBuffersPerDraw: 16, + maxResourcesPerDraw: 16, + maxIndirectDraws: 0, + fragmentationBudget: 8, + wholeBufferThresholdBasisPoints: 7_500, + }; +} + +function bitmapProgram( + techniqueId: number, + programId: number, + transformMode: ThreeTransformMode, + allocationMode: ThreeAllocationMode, +): PolicyProgram { + const context = programContext('strike', 8, 0); + const { loadF32, loadU32, binary, storeF32, storeU32 } = context; + loadF32(15); + loadU32(31, 0); + loadU32(30, 1); + binary('multiplyF32', 15, 7, 2); + binary('addF32', 16, 0, 15); + binary('multiplyF32', 17, 8, 2); + binary('subtractF32', 18, 1, 17); + binary('multiplyF32', 19, 9, 2); + binary('multiplyF32', 20, 10, 2); + stores(storeF32, [ + [1, [16, 18]], + [2, [19, 20]], + [3, [11, 12]], + [4, [13, 14]], + [5, [3, 4, 5, 6]], + ]); + if (transformMode === 'indexed') storeU32(FIRST_PARTY_TRANSFORM_BUFFER_ID, 0, 31); + storeU32(FIRST_PARTY_STABLE_GLYPH_BUFFER_ID, 0, 30); + return createProgram( + techniqueId, + programId, + context, + transformMode === 'indexed' + ? [...floatBuffers([2, 2, 2, 2, 4]), stableGlyphIdBuffer(), transformIndexBuffer()] + : [...floatBuffers([2, 2, 2, 2, 4]), stableGlyphIdBuffer()], + transformMode, + allocationMode, + ); +} + +function msdfProgram( + techniqueId: number, + programId: number, + transformMode: ThreeTransformMode, + allocationMode: ThreeAllocationMode, +): PolicyProgram { + const context = programContext('glyph', 10, 1); + const { operations, loadF32, loadU32, binary, constantF32, storeF32, storeU32 } = context; + loadF32(17); + loadU32(17, 2); + loadU32(31, 0); + loadU32(30, 1); + binary('multiplyF32', 18, 7, 2); + binary('addF32', 19, 0, 18); + binary('multiplyF32', 20, 8, 2); + binary('subtractF32', 21, 1, 20); + binary('multiplyF32', 22, 9, 2); + binary('multiplyF32', 23, 10, 2); + operations.push({ opcode: textShaperAbi.policy.opcodes.convertU32ToF32, target: 24, operand0: 17 }); + constantF32(25, 0); + stores(storeF32, [ + [1, [19, 21, 22, 23]], + [2, [11, 12, 13, 14]], + [3, [11, 12, 15, 16]], + [4, [3, 4, 5, 6]], + [5, [25, 25, 25, 25]], + [6, [25, 25, 25, 25]], + [7, [25, 25, 25, 24]], + ]); + if (transformMode === 'indexed') storeU32(FIRST_PARTY_TRANSFORM_BUFFER_ID, 0, 31); + storeU32(FIRST_PARTY_STABLE_GLYPH_BUFFER_ID, 0, 30); + return createProgram( + techniqueId, + programId, + context, + [ + ...floatBuffers([4, 4, 4, 4, 4, 4, 4]), + stableGlyphIdBuffer(), + ...(transformMode === 'indexed' ? [transformIndexBuffer()] : []), + ], + transformMode, + allocationMode, + ); +} + +function slugProgram( + techniqueId: number, + programId: number, + transformMode: ThreeTransformMode, + allocationMode: ThreeAllocationMode, +): PolicyProgram { + const context = programContext('glyph', 8, 6, true); + const { loadF32, loadU32, binary, constantF32, constantU32, storeF32, storeU32 } = context; + loadF32(16); + loadU32(31, 0); + loadU32(30, 1); + for (let field = 0; field < 6; field += 1) loadU32(21 + field, field + 2); + binary('multiplyF32', 16, 8, 2); + binary('addF32', 17, 0, 16); + binary('multiplyF32', 18, 9, 2); + binary('subtractF32', 19, 1, 18); + binary('multiplyF32', 20, 10, 2); + binary('multiplyF32', 27, 11, 2); + constantF32(28, 0); + constantU32(29, 0); + stores(storeF32, [ + [1, [17, 19, 20, 27]], + [2, [8, 9, 10, 11]], + [3, [12, 13, 14, 15]], + [4, [3, 4, 5, 6]], + [5, [7, 28, 28, 28]], + ]); + stores(storeU32, [ + [6, [21, 22, 23, 24]], + [7, [25, 26, 29, 29]], + ]); + if (transformMode === 'indexed') storeU32(FIRST_PARTY_TRANSFORM_BUFFER_ID, 0, 31); + storeU32(FIRST_PARTY_STABLE_GLYPH_BUFFER_ID, 0, 30); + return createProgram( + techniqueId, + programId, + context, + [ + ...floatBuffers([4, 4, 4, 4, 4]), + ...u32Buffers([4, 4], 6), + stableGlyphIdBuffer(), + ...(transformMode === 'indexed' ? [transformIndexBuffer()] : []), + ], + transformMode, + allocationMode, + ); +} + +interface ProgramContext { + readonly inputs: PolicyInput[]; + readonly operations: PolicyOperation[]; + readonly f32InputCount: number; + readonly u32InputCount: number; + readonly loadF32: (count: number) => void; + readonly loadU32: (target: number, field: number) => void; + readonly binary: ( + name: 'addF32' | 'subtractF32' | 'multiplyF32', + target: number, + left: number, + right: number, + ) => void; + readonly constantF32: (target: number, value: number) => void; + readonly constantU32: (target: number, value: number) => void; + readonly storeF32: (buffer: number, lane: number, register: number) => void; + readonly storeU32: (buffer: number, lane: number, register: number) => void; +} + +function programContext( + bindingScope: PolicyInputScope, + bindingF32Count: number, + bindingU32Count: number, + inverseFontSize = false, +): ProgramContext { + const operations: PolicyOperation[] = []; + const semantic = textShaperAbi.engine.semanticF32Fields; + const semanticU32 = textShaperAbi.engine.semanticU32Fields; + const inputs: PolicyInput[] = [ + { scope: 'semantic', field: semantic.inlineOrigin }, + { scope: 'semantic', field: semantic.blockOrigin }, + { scope: 'semantic', field: semantic.fontSize }, + { scope: 'semantic', field: semantic.foregroundRed }, + { scope: 'semantic', field: semantic.foregroundGreen }, + { scope: 'semantic', field: semantic.foregroundBlue }, + { scope: 'semantic', field: semantic.foregroundAlpha }, + ...(inverseFontSize ? [{ scope: 'semantic' as const, field: semantic.inverseFontSize }] : []), + ...Array.from({ length: bindingF32Count }, (_, field) => ({ scope: bindingScope, field })), + { scope: 'semantic', field: semanticU32.transformIndex }, + { scope: 'semantic', field: semanticU32.stableGlyphId }, + ...Array.from({ length: bindingU32Count }, (_, field) => ({ scope: bindingScope, field })), + ]; + return { + inputs, + operations, + f32InputCount: 7 + (inverseFontSize ? 1 : 0) + bindingF32Count, + u32InputCount: bindingU32Count + 2, + loadF32(count) { + for (let field = 0; field < count; field += 1) { + operations.push({ opcode: textShaperAbi.policy.opcodes.loadF32, target: field, operand0: field }); + } + }, + loadU32(target, field) { + operations.push({ opcode: textShaperAbi.policy.opcodes.loadU32, target, operand0: field }); + }, + binary(name, target, left, right) { + operations.push({ opcode: textShaperAbi.policy.opcodes[name], target, operand0: left, operand1: right }); + }, + constantF32(target, value) { + operations.push({ opcode: textShaperAbi.policy.opcodes.constantF32, target, immediate0: f32Bits(value) }); + }, + constantU32(target, value) { + operations.push({ opcode: textShaperAbi.policy.opcodes.constantU32, target, immediate0: value }); + }, + storeF32(buffer, lane, register) { + operations.push({ + opcode: textShaperAbi.policy.opcodes.storeF32, + operand0: register, + operand1: lane, + immediate0: buffer, + }); + }, + storeU32(buffer, lane, register) { + operations.push({ + opcode: textShaperAbi.policy.opcodes.storeU32, + operand0: register, + operand1: lane, + immediate0: buffer, + }); + }, + }; +} + +function createProgram( + techniqueId: number, + programId: number, + context: ProgramContext, + buffers: readonly PolicyBuffer[], + transformMode: ThreeTransformMode, + allocationMode: ThreeAllocationMode, +): PolicyProgram { + const batch = textShaperAbi.policy.batchFields; + return { + techniqueId, + programId, + f32InputCount: context.f32InputCount, + u32InputCount: context.u32InputCount, + inputs: context.inputs, + buffers, + operations: context.operations, + allocationStrategy: + allocationMode === 'stable' + ? textShaperAbi.policy.allocationStrategies.stableIndirect + : textShaperAbi.policy.allocationStrategies.orderedDirect, + storageKeyMask: batch.technique | batch.program | batch.resource, + drawKeyMask: + batch.technique | + batch.program | + batch.resource | + batch.material | + batch.clip | + batch.depth | + batch.order | + (transformMode === 'direct' ? batch.transform : 0), + }; +} + +function stores( + write: (buffer: number, lane: number, register: number) => void, + groups: readonly (readonly [number, readonly number[]])[], +): void { + for (const [buffer, registers] of groups) { + for (const [lane, register] of registers.entries()) write(buffer, lane, register); + } +} + +function floatBuffers(widths: readonly number[]): PolicyBuffer[] { + return widths.map((vectorWidth, index) => ({ + id: index + 1, + scalar: textShaperAbi.policy.scalarTypes.f32, + vectorWidth, + })); +} + +function u32Buffers(widths: readonly number[], firstId: number): PolicyBuffer[] { + return widths.map((vectorWidth, index) => ({ + id: firstId + index, + scalar: textShaperAbi.policy.scalarTypes.u32, + vectorWidth, + })); +} + +function transformIndexBuffer(): PolicyBuffer { + return { + id: FIRST_PARTY_TRANSFORM_BUFFER_ID, + scalar: textShaperAbi.policy.scalarTypes.u32, + vectorWidth: 1, + }; +} + +function stableGlyphIdBuffer(): PolicyBuffer { + return { + id: FIRST_PARTY_STABLE_GLYPH_BUFFER_ID, + scalar: textShaperAbi.policy.scalarTypes.u32, + vectorWidth: 1, + }; +} + +export function compileRenderPolicy(descriptor: PolicyDescriptor): Uint8Array { + const request = textShaperAbi.layouts.policyRequest; + const capability = textShaperAbi.layouts.policyCapabilitySet; + const programLayout = textShaperAbi.layouts.policyProgram; + const bufferLayout = textShaperAbi.layouts.policyBuffer; + const operationLayout = textShaperAbi.layouts.policyOperation; + const inputLayout = textShaperAbi.layouts.policyInput; + const programs = descriptor.programs; + const bufferCount = sum(programs, (program) => program.buffers.length); + const operationCount = sum(programs, (program) => program.operations.length); + const inputCount = sum(programs, (program) => program.inputs.length); + const capabilitiesOffset = align(request.size, capability.alignment); + const programsOffset = align( + checkedAdd( + capabilitiesOffset, + checkedProduct(capability.size, descriptor.capabilitySets.length, 'policy capabilities'), + 'policy programs', + ), + programLayout.alignment, + ); + const buffersOffset = align( + checkedAdd( + programsOffset, + checkedProduct(programLayout.size, programs.length, 'policy programs'), + 'policy buffers', + ), + bufferLayout.alignment, + ); + const operationsOffset = align( + checkedAdd(buffersOffset, checkedProduct(bufferLayout.size, bufferCount, 'policy buffers'), 'policy operations'), + operationLayout.alignment, + ); + const inputsOffset = align( + checkedAdd( + operationsOffset, + checkedProduct(operationLayout.size, operationCount, 'policy operations'), + 'policy inputs', + ), + inputLayout.alignment, + ); + const byteLength = checkedAdd( + inputsOffset, + checkedProduct(inputLayout.size, inputCount, 'policy inputs'), + 'policy bytes', + ); + const bytes = new Uint8Array(byteLength); + const view = new DataView(bytes.buffer); + view.setUint32(request.byteLength, bytes.byteLength, true); + view.setUint32(request.capabilitySetsOffset, capabilitiesOffset, true); + view.setUint32(request.capabilitySetCount, descriptor.capabilitySets.length, true); + view.setUint32(request.programsOffset, programsOffset, true); + view.setUint32(request.programCount, programs.length, true); + view.setUint32(request.buffersOffset, buffersOffset, true); + view.setUint32(request.bufferCount, bufferCount, true); + view.setUint32(request.operationsOffset, operationsOffset, true); + view.setUint32(request.operationCount, operationCount, true); + view.setUint32(request.inputsOffset, inputsOffset, true); + view.setUint32(request.inputCount, inputCount, true); + + for (const [index, value] of descriptor.capabilitySets.entries()) { + const offset = capabilitiesOffset + index * capability.size; + view.setUint32(offset + capability.id, value.id, true); + view.setUint32(offset + capability.flags, value.flags, true); + view.setUint32(offset + capability.maxBufferBytes, value.maxBufferBytes, true); + view.setUint32(offset + capability.updateAlignment, value.updateAlignment, true); + view.setUint32(offset + capability.coalesceGapBytes, value.coalesceGapBytes, true); + view.setUint32(offset + capability.rangeCallPenaltyBytes, value.rangeCallPenaltyBytes, true); + view.setUint16(offset + capability.maxBuffersPerDraw, value.maxBuffersPerDraw, true); + view.setUint16(offset + capability.maxResourcesPerDraw, value.maxResourcesPerDraw, true); + view.setUint16(offset + capability.maxIndirectDraws, value.maxIndirectDraws, true); + view.setUint16(offset + capability.fragmentationBudget, value.fragmentationBudget, true); + view.setUint16(offset + capability.wholeBufferThresholdBasisPoints, value.wholeBufferThresholdBasisPoints, true); + } + + let bufferStart = 0; + let operationStart = 0; + let inputStart = 0; + for (const [index, value] of programs.entries()) { + const offset = programsOffset + index * programLayout.size; + view.setUint32(offset + programLayout.techniqueId, value.techniqueId, true); + view.setUint32(offset + programLayout.programId, value.programId, true); + view.setUint32(offset + programLayout.capabilitySetId, value.capabilitySetId ?? 0, true); + view.setUint32(offset + programLayout.resourceKindMask, value.resourceKindMask ?? 1, true); + view.setUint32(offset + programLayout.semanticViewMask, value.semanticViewMask ?? 0, true); + view.setUint32(offset + programLayout.storageKeyMask, value.storageKeyMask ?? 0, true); + view.setUint32(offset + programLayout.drawKeyMask, value.drawKeyMask ?? 0, true); + view.setUint32(offset + programLayout.paintCapabilities, value.paintCapabilities ?? 0, true); + view.setUint32(offset + programLayout.compositingCapabilities, value.compositingCapabilities ?? 0, true); + view.setUint32(offset + programLayout.bufferStart, bufferStart, true); + view.setUint32(offset + programLayout.operationStart, operationStart, true); + view.setUint16(offset + programLayout.variant, value.variant ?? 0, true); + view.setUint16(offset + programLayout.bufferCount, value.buffers.length, true); + view.setUint16(offset + programLayout.operationCount, value.operations.length, true); + view.setUint16( + offset + programLayout.allocationStrategy, + value.allocationStrategy ?? textShaperAbi.policy.allocationStrategies.orderedDirect, + true, + ); + view.setUint8(offset + programLayout.f32InputCount, value.f32InputCount); + view.setUint8(offset + programLayout.u32InputCount, value.u32InputCount); + view.setUint32(offset + programLayout.inputStart, inputStart, true); + view.setUint16(offset + programLayout.inputCount, value.inputs.length, true); + bufferStart += value.buffers.length; + operationStart += value.operations.length; + inputStart += value.inputs.length; + } + + let bufferIndex = 0; + let operationIndex = 0; + let inputIndex = 0; + for (const value of programs) { + for (const buffer of value.buffers) { + const offset = buffersOffset + bufferIndex * bufferLayout.size; + const scalarBytes = buffer.scalar === textShaperAbi.policy.scalarTypes.u16 ? 2 : 4; + view.setUint16(offset + bufferLayout.id, buffer.id, true); + view.setUint8(offset + bufferLayout.scalar, buffer.scalar); + view.setUint8(offset + bufferLayout.vectorWidth, buffer.vectorWidth); + view.setUint16(offset + bufferLayout.alignment, buffer.alignment ?? scalarBytes, true); + view.setUint16(offset + bufferLayout.stride, buffer.stride ?? scalarBytes * buffer.vectorWidth, true); + view.setUint32( + offset + bufferLayout.usage, + buffer.usage ?? textShaperAbi.policy.bufferUsage.storage | textShaperAbi.policy.bufferUsage.copyDst, + true, + ); + view.setUint16(offset + bufferLayout.capacityClass, buffer.capacityClass ?? 1, true); + bufferIndex += 1; + } + for (const operation of value.operations) { + const offset = operationsOffset + operationIndex * operationLayout.size; + view.setUint8(offset + operationLayout.opcode, operation.opcode); + view.setUint8(offset + operationLayout.target, operation.target ?? 0); + view.setUint8(offset + operationLayout.operand0, operation.operand0 ?? 0); + view.setUint8(offset + operationLayout.operand1, operation.operand1 ?? 0); + view.setUint32(offset + operationLayout.immediate0, operation.immediate0 ?? 0, true); + view.setUint32(offset + operationLayout.immediate1, operation.immediate1 ?? 0, true); + view.setUint32(offset + operationLayout.immediate2, operation.immediate2 ?? 0, true); + operationIndex += 1; + } + for (const input of value.inputs) { + const offset = inputsOffset + inputIndex * inputLayout.size; + view.setUint8(offset + inputLayout.scope, textShaperAbi.policy.inputScopes[input.scope]); + view.setUint8(offset + inputLayout.field, input.field); + inputIndex += 1; + } + } + return bytes; +} + +function sum(values: readonly T[], measure: (value: T) => number): number { + return values.reduce((total, value) => checkedAdd(total, measure(value), 'policy record count'), 0); +} + +function align(value: number, alignment: number): number { + return Math.ceil(value / alignment) * alignment; +} + +function checkedAdd(left: number, right: number, label: string): number { + const value = left + right; + if (!Number.isSafeInteger(value) || value > MAX_U32) throw new RangeError(`${label} exceeds u32`); + return value; +} + +function checkedProduct(left: number, right: number, label: string): number { + const value = left * right; + if (!Number.isSafeInteger(value) || value > MAX_U32) throw new RangeError(`${label} exceeds u32`); + return value; +} + +function f32Bits(value: number): number { + const bytes = new ArrayBuffer(4); + const view = new DataView(bytes); + view.setFloat32(0, value, true); + return view.getUint32(0, true); +} diff --git a/packages/text/src/internal/slug-shaders/index.ts b/packages/text/src/internal/slug-shaders/index.ts index 1bd005d6..a8b601a6 100644 --- a/packages/text/src/internal/slug-shaders/index.ts +++ b/packages/text/src/internal/slug-shaders/index.ts @@ -1,7 +1,7 @@ /** Internal Slug TSL primitives. Adapted from three-flatland Slug at 2935a89f (MIT). */ export { calcCoverage } from './calc-coverage.js'; export { calcRootCode } from './calc-root-code.js'; -export { slugDilate } from './slug-dilate.js'; +export { slugDilate, slugDilateMatrix } from './slug-dilate.js'; export { MAX_SAFE_SLUG_BAND_CURVES, slugRender, diff --git a/packages/text/src/internal/slug-shaders/slug-dilate.ts b/packages/text/src/internal/slug-shaders/slug-dilate.ts index 66e6b7f4..a9721e5a 100644 --- a/packages/text/src/internal/slug-shaders/slug-dilate.ts +++ b/packages/text/src/internal/slug-shaders/slug-dilate.ts @@ -1,6 +1,6 @@ /** Adapted from three-flatland Slug at 2935a89f (MIT). */ import type { Node } from 'three/webgpu'; -import { add, div, dot, mul, normalize, sqrt, sub, vec2 } from 'three/tsl'; +import { add, div, dot, mul, normalize, sqrt, sub, vec2, vec4 } from 'three/tsl'; export interface SlugDilationNodes { readonly position: Node<'vec2'>; @@ -21,14 +21,57 @@ export function slugDilate( const normal = normalize(outwardNormal).toVar('slugDilateNormal'); const homogeneousW = add(dot(mvpRow3.xy, position), mvpRow3.w).toVar('slugDilateW'); const wGradient = dot(mvpRow3.xy, normal).toVar('slugDilateWGradient'); - const projectedX = mul( + return dilateFromProjection( + position, + normal, + textureCoordinate, + inverseScale, + homogeneousW, + wGradient, sub(mul(homogeneousW, dot(mvpRow0.xy, normal)), mul(wGradient, add(dot(mvpRow0.xy, position), mvpRow0.w))), - viewport.x, - ).toVar('slugDilateProjectedX'); - const projectedY = mul( sub(mul(homogeneousW, dot(mvpRow1.xy, normal)), mul(wGradient, add(dot(mvpRow1.xy, position), mvpRow1.w))), - viewport.y, - ).toVar('slugDilateProjectedY'); + viewport, + ); +} + +/** Expand a glyph quad using the exact per-instance model-view-projection matrix selected by a batched draw. */ +export function slugDilateMatrix( + position: Node<'vec2'>, + outwardNormal: Node<'vec2'>, + textureCoordinate: Node<'vec2'>, + inverseScale: Node<'float'>, + modelViewProjection: Node<'mat4'>, + viewport: Node<'vec2'>, +): SlugDilationNodes { + const normal = normalize(outwardNormal).toVar('slugDilateNormal'); + const clipPosition = modelViewProjection.mul(vec4(position, 0, 1)).toVar('slugDilateClipPosition'); + const clipNormal = modelViewProjection.mul(vec4(normal, 0, 0)).toVar('slugDilateClipNormal'); + return dilateFromProjection( + position, + normal, + textureCoordinate, + inverseScale, + clipPosition.w, + clipNormal.w, + sub(mul(clipPosition.w, clipNormal.x), mul(clipNormal.w, clipPosition.x)), + sub(mul(clipPosition.w, clipNormal.y), mul(clipNormal.w, clipPosition.y)), + viewport, + ); +} + +function dilateFromProjection( + position: Node<'vec2'>, + normal: Node<'vec2'>, + textureCoordinate: Node<'vec2'>, + inverseScale: Node<'float'>, + homogeneousW: Node<'float'>, + wGradient: Node<'float'>, + projectedXValue: Node<'float'>, + projectedYValue: Node<'float'>, + viewport: Node<'vec2'>, +): SlugDilationNodes { + const projectedX = mul(projectedXValue, viewport.x).toVar('slugDilateProjectedX'); + const projectedY = mul(projectedYValue, viewport.y).toVar('slugDilateProjectedY'); const squaredW = mul(homogeneousW, homogeneousW).toVar('slugDilateSquaredW'); const wTimesGradient = mul(homogeneousW, wGradient).toVar('slugDilateWTimesGradient'); const projectedLengthSquared = add(mul(projectedX, projectedX), mul(projectedY, projectedY)).toVar( diff --git a/packages/text/src/internal/text-engine-host.ts b/packages/text/src/internal/text-engine-host.ts new file mode 100644 index 00000000..5792d624 --- /dev/null +++ b/packages/text/src/internal/text-engine-host.ts @@ -0,0 +1,301 @@ +import { textShaperAbi } from '../generated/text-shaper-abi.js'; +import { runtimeShaperEngineExports, type RuntimeShaper } from '../shaper.js'; +import { RenderWireIdentityRegistry } from './render-policy-wire.js'; + +const MAX_U32 = 0xffff_ffff; + +export interface TextEngineSessionOptions { + readonly handle: number; + readonly requestCapacity: number; + readonly resultCapacity: number; + readonly textCapacity?: number; +} + +/** + * One borrowed A/B render-plan publication. Its bytes remain readable until the next call into the same Wasm module; + * synchronous renderers must consume them before updating, reserving, disposing, or otherwise calling the shaper. + */ +export interface TextEnginePublication { + readonly bytes: Uint8Array; + readonly memoryBuffer: ArrayBuffer; + readonly memoryGrew: boolean; + readonly engineRevision: number; + readonly planRevision: number; + readonly requiredBaseRevision: number; + readonly publicationGeneration: number; + readonly outputSlot: number; + readonly flags: number; + readonly policyHandle: number; + readonly capabilitySet: number; + readonly semanticViewCount: number; + readonly primitiveCount: number; + readonly patchCount: number; + readonly drawCount: number; +} + +export class TextEngineStatusError extends Error { + readonly status: number; + readonly requiredRequestCapacity: number; + readonly requiredResultCapacity: number; + + constructor(operation: string, status: number, requiredRequestCapacity = 0, requiredResultCapacity = 0) { + super( + `${operation} failed with text-engine status ${status}` + + (requiredRequestCapacity === 0 && requiredResultCapacity === 0 + ? '' + : ` (required request=${requiredRequestCapacity}, result=${requiredResultCapacity})`), + ); + this.name = 'TextEngineStatusError'; + this.status = status; + this.requiredRequestCapacity = requiredRequestCapacity; + this.requiredResultCapacity = requiredResultCapacity; + } +} + +/** Internal lifecycle owner for retained policy, font-stack, and session state in a RuntimeShaper's Wasm instance. */ +export class TextEngineHost { + readonly wireIdentities: RenderWireIdentityRegistry = new RenderWireIdentityRegistry(); + readonly #exports; + readonly #sessions = new Set(); + readonly #policies = new Set(); + readonly #fontStacks = new Set(); + #disposed = false; + + constructor(shaper: RuntimeShaper) { + this.#exports = runtimeShaperEngineExports(shaper); + } + + registerFontBinding(bindingHandle: number, shapingFontHandle: number, bytes: Uint8Array): void { + this.#assertActive(); + uint32Handle(bindingHandle, 'font binding handle'); + uint32Handle(shapingFontHandle, 'shaping font handle'); + this.#withBytes(bytes, (pointer, length) => + requireStatus( + this.#exports.registerFontBinding(bindingHandle, shapingFontHandle, pointer, length), + 'register font binding', + ), + ); + } + + registerFontStack(handle: number, fontHandles: readonly number[]): void { + this.#assertActive(); + uint32Handle(handle, 'font stack handle'); + if (fontHandles.length === 0) throw new RangeError('font stack must contain at least one font'); + const bytes = new Uint8Array(checkedProduct(fontHandles.length, 4, 'font stack bytes')); + const view = new DataView(bytes.buffer); + for (const [index, fontHandle] of fontHandles.entries()) { + view.setUint32(index * 4, uint32Handle(fontHandle, 'font handle'), true); + } + this.#withBytes(bytes, (pointer) => + requireStatus(this.#exports.registerFontStack(handle, pointer, fontHandles.length), 'register font stack'), + ); + this.#fontStacks.add(handle); + } + + disposeFontStack(handle: number): void { + this.#assertActive(); + uint32Handle(handle, 'font stack handle'); + if (!this.#fontStacks.has(handle)) throw new Error(`font stack ${handle} is not owned by this text engine host`); + requireStatus(this.#exports.disposeFontStack(handle), 'dispose font stack'); + this.#fontStacks.delete(handle); + } + + registerPolicy(handle: number, bytes: Uint8Array): void { + this.#assertActive(); + uint32Handle(handle, 'policy handle'); + this.#withBytes(bytes, (pointer, length) => + requireStatus(this.#exports.registerPolicy(handle, pointer, length), 'register render policy'), + ); + this.#policies.add(handle); + } + + createSession(options: TextEngineSessionOptions): TextEngineSession { + this.#assertActive(); + const handle = uint32Handle(options.handle, 'session handle'); + const requestCapacity = uint32(options.requestCapacity, 'request capacity'); + const resultCapacity = uint32(options.resultCapacity, 'result capacity'); + const textCapacity = uint32(options.textCapacity ?? 0, 'text capacity'); + requireStatus( + this.#exports.createSession(handle, requestCapacity, resultCapacity, textCapacity), + 'create text session', + ); + const session = new TextEngineSession(this.#exports, handle, requestCapacity, resultCapacity, textCapacity, () => + this.#sessions.delete(session), + ); + this.#sessions.add(session); + return session; + } + + dispose(): void { + if (this.#disposed) return; + for (const session of [...this.#sessions]) session.dispose(); + for (const handle of this.#fontStacks) requireStatus(this.#exports.disposeFontStack(handle), 'dispose font stack'); + for (const handle of this.#policies) requireStatus(this.#exports.disposePolicy(handle), 'dispose render policy'); + this.#fontStacks.clear(); + this.#policies.clear(); + this.#disposed = true; + } + + #withBytes(bytes: Uint8Array, call: (pointer: number, length: number) => void): void { + if (!(bytes instanceof Uint8Array) || bytes.byteLength === 0) { + throw new TypeError('text-engine registration bytes must be a nonempty Uint8Array'); + } + const length = uint32(bytes.byteLength, 'registration byte length'); + const pointer = this.#exports.allocate(length); + if (pointer === 0) + throw new TextEngineStatusError('allocate registration bytes', textShaperAbi.status.resultTooLarge); + try { + new Uint8Array(this.#exports.memory.buffer, pointer, length).set(bytes); + call(pointer, length); + } finally { + this.#exports.deallocate(pointer, length); + } + } + + #assertActive(): void { + if (this.#disposed) throw new Error('text engine host is disposed'); + } +} + +export class TextEngineSession { + readonly #exports; + readonly #handle: number; + readonly #onDispose: () => void; + #requestCapacity: number; + #resultCapacity: number; + #textCapacity: number; + #disposed = false; + + constructor( + exports: ReturnType, + handle: number, + requestCapacity: number, + resultCapacity: number, + textCapacity: number, + onDispose: () => void, + ) { + this.#exports = exports; + this.#handle = handle; + this.#requestCapacity = requestCapacity; + this.#resultCapacity = resultCapacity; + this.#textCapacity = textCapacity; + this.#onDispose = onDispose; + } + + get handle(): number { + return this.#handle; + } + + reserve(requestCapacity: number, resultCapacity: number, textCapacity: number = this.#textCapacity): void { + this.#assertActive(); + requestCapacity = uint32(requestCapacity, 'request capacity'); + resultCapacity = uint32(resultCapacity, 'result capacity'); + textCapacity = uint32(textCapacity, 'text capacity'); + requireStatus( + this.#exports.reserveSession(this.#handle, requestCapacity, resultCapacity, textCapacity), + 'reserve text session', + ); + this.#requestCapacity = Math.max(this.#requestCapacity, requestCapacity); + this.#resultCapacity = Math.max(this.#resultCapacity, resultCapacity); + this.#textCapacity = Math.max(this.#textCapacity, textCapacity); + } + + update(request: Uint8Array): TextEnginePublication { + this.#assertActive(); + if (!(request instanceof Uint8Array) || request.byteLength === 0) { + throw new TypeError('text update request must be a nonempty Uint8Array'); + } + const requestLength = uint32(request.byteLength, 'text update byte length'); + const initialMemoryBuffer = this.#exports.memory.buffer; + if (requestLength > this.#requestCapacity || requestLength > this.#exports.requestCapacity(this.#handle)) { + this.reserve(requestLength, this.#resultCapacity); + } + let retriedResultGrowth = false; + for (;;) { + const requestPointer = this.#exports.requestPointer(this.#handle); + if (requestPointer === 0) + throw new TextEngineStatusError('resolve text request arena', textShaperAbi.status.sessionMissing); + const pinnedMemoryBuffer = this.#exports.memory.buffer; + new Uint8Array(pinnedMemoryBuffer, requestPointer, requestLength).set(request); + const resultPointer = this.#exports.textUpdate(this.#handle, requestPointer, requestLength); + const memoryBuffer = this.#exports.memory.buffer; + if (resultPointer === 0) + throw new TextEngineStatusError('publish text update', textShaperAbi.status.resultTooLarge); + const layout = textShaperAbi.layouts.engineResult; + if (resultPointer + layout.size > memoryBuffer.byteLength) { + throw new RangeError('text engine returned an out-of-bounds result header'); + } + const header = new DataView(memoryBuffer, resultPointer, layout.size); + const status = header.getUint32(layout.status, true); + const requiredRequestCapacity = header.getUint32(layout.requiredRequestCapacity, true); + const requiredResultCapacity = header.getUint32(layout.requiredResultCapacity, true); + if ( + status === textShaperAbi.status.resultTooLarge && + !retriedResultGrowth && + requiredResultCapacity > this.#resultCapacity + ) { + retriedResultGrowth = true; + this.reserve(Math.max(requestLength, requiredRequestCapacity), requiredResultCapacity); + continue; + } + if (status !== textShaperAbi.status.ok) { + throw new TextEngineStatusError('publish text update', status, requiredRequestCapacity, requiredResultCapacity); + } + const byteLength = header.getUint32(layout.byteLength, true); + if (byteLength < layout.size || resultPointer + byteLength > memoryBuffer.byteLength) { + throw new RangeError('text engine returned an out-of-bounds publication'); + } + this.#requestCapacity = header.getUint32(layout.requestCapacity, true); + this.#resultCapacity = header.getUint32(layout.resultCapacity, true); + return { + bytes: new Uint8Array(memoryBuffer, resultPointer, byteLength), + memoryBuffer, + memoryGrew: memoryBuffer !== initialMemoryBuffer, + engineRevision: header.getUint32(layout.engineRevision, true), + planRevision: header.getUint32(layout.planRevision, true), + requiredBaseRevision: header.getUint32(layout.requiredBaseRevision, true), + publicationGeneration: header.getUint32(layout.publicationGeneration, true), + outputSlot: header.getUint32(layout.outputSlot, true), + flags: header.getUint32(layout.flags, true), + policyHandle: header.getUint32(layout.policyHandle, true), + capabilitySet: header.getUint32(layout.capabilitySet, true), + semanticViewCount: header.getUint32(layout.semanticViewCount, true), + primitiveCount: header.getUint32(layout.primitiveCount, true), + patchCount: header.getUint32(layout.patchCount, true), + drawCount: header.getUint32(layout.drawCount, true), + }; + } + } + + dispose(): void { + if (this.#disposed) return; + requireStatus(this.#exports.disposeSession(this.#handle), 'dispose text session'); + this.#disposed = true; + this.#onDispose(); + } + + #assertActive(): void { + if (this.#disposed) throw new Error('text engine session is disposed'); + } +} + +function requireStatus(status: number, operation: string): void { + if (status !== textShaperAbi.status.ok) throw new TextEngineStatusError(operation, status); +} + +function uint32(value: number, label: string): number { + if (!Number.isSafeInteger(value) || value < 0 || value > MAX_U32) throw new RangeError(`${label} must be a u32`); + return value; +} + +function uint32Handle(value: number, label: string): number { + value = uint32(value, label); + if (value === 0) throw new RangeError(`${label} must be nonzero`); + return value; +} + +function checkedProduct(left: number, right: number, label: string): number { + const value = left * right; + if (!Number.isSafeInteger(value) || value > MAX_U32) throw new RangeError(`${label} exceeds u32`); + return value; +} diff --git a/packages/text/src/internal/text-preparation-worker-protocol.ts b/packages/text/src/internal/text-preparation-worker-protocol.ts deleted file mode 100644 index c8876f37..00000000 --- a/packages/text/src/internal/text-preparation-worker-protocol.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { FontHandle } from '../identity.js'; -import type { ParagraphLayout } from '../layout.js'; -import type { RuntimeShaperFontData } from '../shaper.js'; -import type { WorkerParagraphLayoutInput } from '../paragraph-batch.js'; - -export interface TextPreparationRequestV1 { - readonly type: 'pmndrs-text-prepare-v1'; - readonly id: number; - readonly fonts: readonly RuntimeShaperFontData[]; - readonly paragraphs: readonly { - readonly batch: number; - readonly paragraph: number; - readonly input: WorkerParagraphLayoutInput; - }[]; -} - -export interface TextPreparationCancelV1 { - readonly type: 'pmndrs-text-cancel-v1'; - readonly id: number; -} - -export interface TextPreparationProgressV1 { - readonly type: 'pmndrs-text-progress-v1'; - readonly id: number; - readonly preparedParagraphs: number; - readonly totalParagraphs: number; - readonly stagedGlyphs: number; -} - -export interface TextPreparationSuccessV1 { - readonly type: 'pmndrs-text-success-v1'; - readonly id: number; - readonly layouts: readonly { - readonly batch: number; - readonly paragraph: number; - readonly layout: ParagraphLayout; - }[]; -} - -export interface TextPreparationFailureV1 { - readonly type: 'pmndrs-text-failure-v1'; - readonly id: number; - readonly error: Readonly<{ name: string; message: string; stack?: string }>; -} - -export type TextPreparationWorkerMessageV1 = TextPreparationRequestV1 | TextPreparationCancelV1; -export type TextPreparationWorkerResultV1 = - | TextPreparationProgressV1 - | TextPreparationSuccessV1 - | TextPreparationFailureV1; - -export function isTextPreparationWorkerResultV1(value: unknown): value is TextPreparationWorkerResultV1 { - if (typeof value !== 'object' || value === null || !('type' in value) || !('id' in value)) return false; - const candidate = value as Record; - if (!nonnegativeInteger(candidate.id)) return false; - if (candidate.type === 'pmndrs-text-progress-v1') - return ( - nonnegativeInteger(candidate.preparedParagraphs) && - nonnegativeInteger(candidate.totalParagraphs) && - nonnegativeInteger(candidate.stagedGlyphs) && - candidate.preparedParagraphs <= candidate.totalParagraphs - ); - if (candidate.type === 'pmndrs-text-failure-v1') { - if (typeof candidate.error !== 'object' || candidate.error === null) return false; - const error = candidate.error as Record; - return ( - typeof error.name === 'string' && - typeof error.message === 'string' && - (error.stack === undefined || typeof error.stack === 'string') - ); - } - if (candidate.type !== 'pmndrs-text-success-v1' || !Array.isArray(candidate.layouts)) return false; - return candidate.layouts.every((entry: unknown) => { - if (typeof entry !== 'object' || entry === null) return false; - const layout = entry as Record; - return nonnegativeInteger(layout.batch) && nonnegativeInteger(layout.paragraph) && isParagraphLayout(layout.layout); - }); -} - -export function fontHandle(value: number): FontHandle { - return value as FontHandle; -} - -function isParagraphLayout(value: unknown): value is ParagraphLayout { - if (typeof value !== 'object' || value === null) return false; - const layout = value as Record; - for (const field of ['width', 'height', 'contentWidth', 'contentHeight', 'firstBaseline', 'lastBaseline']) - if (typeof layout[field] !== 'number' || !Number.isFinite(layout[field])) return false; - if (typeof layout.overflowed !== 'boolean') return false; - return ( - layout.fontHandles instanceof Uint32Array && - layout.glyphFontSlots instanceof Uint16Array && - layout.glyphIds instanceof Uint16Array && - layout.clusters instanceof Uint32Array && - layout.glyphFontSizes instanceof Float32Array && - layout.x instanceof Float32Array && - layout.y instanceof Float32Array && - layout.glyphFlags instanceof Uint16Array && - layout.lineTextStarts instanceof Uint32Array && - layout.lineTextEnds instanceof Uint32Array && - layout.lineGlyphStarts instanceof Uint32Array && - layout.lineGlyphCounts instanceof Uint32Array && - layout.lineBaselines instanceof Float32Array && - layout.lineAdvances instanceof Float32Array - ); -} - -function nonnegativeInteger(value: unknown): value is number { - return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; -} diff --git a/packages/text/src/layout.ts b/packages/text/src/layout.ts index b05b16fc..9b7997db 100644 --- a/packages/text/src/layout.ts +++ b/packages/text/src/layout.ts @@ -20,6 +20,18 @@ export interface ParagraphMeasurement { readonly overflowed: boolean; } +/** + * Bounded aggregate inspection of one retained layout. Unlike `ParagraphLayout`, this contains no per-glyph arrays and + * is suitable for positioning UI, telemetry, and missing-glyph admission checks. + */ +export interface ParagraphLayoutSummary extends ParagraphMeasurement { + /** Positioned glyphs retained by layout, including non-rendering glyphs such as spaces. */ + readonly glyphCount: number; + readonly lineCount: number; + /** Positioned `.notdef` glyphs (`glyphId === 0`). */ + readonly missingGlyphCount: number; +} + /** * Positioned glyph output in paragraph-local coordinates. The origin is the * paragraph box's top-left corner; positive X is right and positive Y is down. @@ -42,6 +54,11 @@ export interface ParagraphLayout extends ParagraphMeasurement { readonly lineAdvances: Float32Array; } +/** Explicit demand-shaped inspection of retained Rust layout, including stable identities for directed augmentation. */ +export interface ParagraphLayoutInspection extends ParagraphLayout, ParagraphLayoutSummary { + readonly glyphStableIds: Uint32Array; +} + export interface FontSlotRecord { readonly slot: number; readonly font: FontHandle; diff --git a/packages/text/src/loaded-font.ts b/packages/text/src/loaded-font.ts index d357c6c6..70ce73ca 100644 --- a/packages/text/src/loaded-font.ts +++ b/packages/text/src/loaded-font.ts @@ -16,10 +16,11 @@ export interface LoadedFont { export type FontSelection = LoadedFont | FontStack; export interface FontStack { - readonly technique: Technique; readonly fonts: readonly [LoadedFont, ...LoadedFont[]]; } +type TechniqueOfLoadedFont = Font extends LoadedFont ? Technique : never; + export class FontLeaseError extends Error { readonly leaseCount: number; @@ -39,11 +40,12 @@ interface LoadedFontState { const loadedFontState = new WeakMap, LoadedFontState>(); -export function createFontStack( - primary: LoadedFont, - ...fallback: readonly LoadedFont>[] -): FontStack { - const fonts = [primary, ...fallback] as [LoadedFont, ...LoadedFont[]]; +export function createFontStack< + Primary extends AnyRasterTechnique, + const Fallback extends readonly LoadedFont[], +>(primary: LoadedFont, ...fallback: Fallback): FontStack> { + type Technique = Primary | TechniqueOfLoadedFont; + const fonts = [primary, ...fallback] as unknown as [LoadedFont, ...LoadedFont[]]; assertLoadedFont(primary); const unique = new Set>([primary]); for (const font of fallback) { @@ -52,10 +54,8 @@ export function createFontStack( unique.add(font); if (font.runtime !== primary.runtime) throw new TypeError('font stack members must belong to the same text runtime'); - if (font.technique !== primary.technique) - throw new TypeError('font stack members must use the same raster technique'); } - return Object.freeze({ technique: primary.technique, fonts: Object.freeze(fonts) }); + return Object.freeze({ fonts: Object.freeze(fonts) }); } export class LoadedFontImpl implements LoadedFont { @@ -119,6 +119,24 @@ export function acquireFontSelection( } } +/** @internal Acquire one renderer lease without imposing a single raster technique on the selection. */ +export function acquireFontSelectionForRuntime( + selection: FontSelection, + runtime: TextRuntime, +): void { + const acquired: LoadedFont[] = []; + try { + for (const font of concreteFonts(selection)) { + assertFontForRuntime(font, runtime); + stateOf(font).leases += 1; + acquired.push(font); + } + } catch (error) { + for (const font of acquired) stateOf(font).leases -= 1; + throw error; + } +} + /** @internal Release one retained paragraph lease from every concrete font. */ export function releaseFontSelection(selection: FontSelection): void { for (const font of concreteFonts(selection)) { @@ -134,12 +152,17 @@ export function assertFontSelection( runtime: TextRuntime, technique: Technique, ): void { - if (isFontStack(selection) && selection.technique !== technique) { - throw new TypeError('font stack does not use the paragraph batch technique'); - } for (const font of concreteFonts(selection)) assertCompatibleFont(font, runtime, technique); } +/** @internal Validate renderer ownership without imposing a single raster technique on the selection. */ +export function assertFontSelectionForRuntime( + selection: FontSelection, + runtime: TextRuntime, +): void { + for (const font of concreteFonts(selection)) assertFontForRuntime(font, runtime); +} + /** @internal Return the immutable concrete fallback order. */ export function concreteFonts( selection: FontSelection, @@ -184,9 +207,13 @@ function assertCompatibleFont( runtime: TextRuntime, technique: Technique, ): void { + assertFontForRuntime(font, runtime); + if (font.technique !== technique) throw new TypeError('font does not use the paragraph batch technique'); +} + +function assertFontForRuntime(font: LoadedFont, runtime: TextRuntime): void { assertLoadedFont(font); if (font.runtime !== runtime) throw new TypeError('font belongs to another text runtime'); - if (font.technique !== technique) throw new TypeError('font does not use the paragraph batch technique'); } function assertLoadedFont(font: LoadedFont): void { diff --git a/packages/text/src/loader.ts b/packages/text/src/loader.ts index 70f7be12..17ff65d5 100644 --- a/packages/text/src/loader.ts +++ b/packages/text/src/loader.ts @@ -24,7 +24,6 @@ import type { RegisteredRaster, } from './raster.js'; import type { BakeProgressListener } from './bake.js'; -import type { RuntimeShaperFontData } from './shaper.js'; const DEFAULT_MAX_ARTIFACT_BYTES = 64 * 1024 * 1024; const DEFAULT_MAX_BUFFER_VIEWS = 4_096; @@ -135,38 +134,6 @@ export class FontRegistry { return this.#fontsByHandle.get(handle); } - /** @internal Register a shaping-only font replica inside a preparation Worker. */ - _registerShapingFont(data: RuntimeShaperFontData): RegisteredFont { - const existing = this.#fontsByHandle.get(data.handle); - if (existing !== undefined) { - if (existing.shapingHash !== data.shapingHash) - throw new TypeError('Worker font handle is already registered with another shaping identity'); - return existing; - } - const font = new RegisteredFontImpl({ - registry: this, - key: data.key, - handle: data.handle, - shapingHash: data.shapingHash, - glyphCount: data.glyphCount, - metrics: data.metrics, - }); - setRegisteredFontData(font, { - fontFaceIndex: data.fontFaceIndex, - sourceHash: data.sourceHash, - sourceCandidates: [], - shapingSfnt: data.shapingSfnt, - glyphExtents: data.glyphExtents, - glyphExtentsAvailability: data.glyphExtentsAvailability, - rasterSources: new Map(), - unicodeVersion: data.unicodeVersion, - }); - this.#fontsByKey.set(data.key, font); - this.#fontsByHash.set(data.shapingHash, font); - this.#fontsByHandle.set(data.handle, font); - return font; - } - /** @internal */ async _registerAsset(bytes: ArrayBufferView, context: FontAssetContext = {}): Promise { this.#checkArtifactSize(bytes.byteLength); diff --git a/packages/text/src/paragraph-batch-attachment.ts b/packages/text/src/paragraph-batch-attachment.ts deleted file mode 100644 index e97e724b..00000000 --- a/packages/text/src/paragraph-batch-attachment.ts +++ /dev/null @@ -1,220 +0,0 @@ -import type { ParagraphBatch, ParagraphBatchObserver, PreparedParagraphBatchRevision } from './paragraph-batch.js'; -import type { AnyRasterTechnique } from './raster-technique.js'; - -export interface ParagraphBatchTargetRevision { - readonly sourceRevision: number; - dispose(): void; -} - -export interface ParagraphBatchTargetStage { - readonly sourceRevision: number; - commit(): TargetRevision; - abort(): void; -} - -export type ParagraphBatchTargetUpdate = - | { readonly status: 'ready'; readonly stage: ParagraphBatchTargetStage } - | { - readonly status: 'pending'; - readonly ready: Promise>; - cancel(reason?: unknown): void; - }; - -export interface ParagraphBatchTarget< - Technique extends AnyRasterTechnique, - Variant, - TargetRevision extends ParagraphBatchTargetRevision, -> { - readonly technique: Technique; - stage( - previous: TargetRevision | undefined, - next: PreparedParagraphBatchRevision, - options?: { readonly signal?: AbortSignal }, - ): ParagraphBatchTargetUpdate; - dispose(): void; -} - -export interface ParagraphBatchTargetError { - readonly kind: 'target-failed'; - readonly sourceRevision: number; - readonly cause: unknown; -} - -export interface ParagraphBatchAttachment< - Technique extends AnyRasterTechnique, - Variant, - TargetRevision extends ParagraphBatchTargetRevision, -> { - readonly source: PreparedParagraphBatchRevision; - readonly current: TargetRevision | undefined; - readonly candidate: ParagraphBatchTargetStage | undefined; - readonly error: ParagraphBatchTargetError | undefined; - prepare(): void; - commit(): TargetRevision | undefined; - retry(): void; - dispose(): void; -} - -interface PendingStage { - readonly revision: number; - readonly cancel: (reason?: unknown) => void; -} - -export function attachParagraphBatch< - Technique extends AnyRasterTechnique, - Variant, - TargetRevision extends ParagraphBatchTargetRevision, ->( - batch: ParagraphBatch, - target: ParagraphBatchTarget, -): ParagraphBatchAttachment { - if (target.technique !== batch.technique) throw new TypeError('paragraph batch target uses another raster technique'); - return new ParagraphBatchAttachmentImpl(batch, target); -} - -class ParagraphBatchAttachmentImpl< - Technique extends AnyRasterTechnique, - Variant, - TargetRevision extends ParagraphBatchTargetRevision, -> implements ParagraphBatchAttachment { - readonly #target: ParagraphBatchTarget; - readonly #unsubscribe: () => void; - #source: PreparedParagraphBatchRevision; - #current: TargetRevision | undefined; - #candidate: ParagraphBatchTargetStage | undefined; - #pending: PendingStage | undefined; - #error: ParagraphBatchTargetError | undefined; - #attemptedRevision = -1; - #disposed = false; - - constructor( - batch: ParagraphBatch, - target: ParagraphBatchTarget, - ) { - this.#target = target; - this.#source = batch.current; - const observer: ParagraphBatchObserver = { - next: (source) => { - this.#source = source; - }, - complete: () => this.dispose(), - }; - this.#unsubscribe = batch.subscribe(observer); - } - - get source(): PreparedParagraphBatchRevision { - return this.#source; - } - get current(): TargetRevision | undefined { - return this.#current; - } - get candidate(): ParagraphBatchTargetStage | undefined { - return this.#candidate; - } - get error(): ParagraphBatchTargetError | undefined { - return this.#error; - } - - prepare(): void { - this.#assertActive(); - const sourceRevision = this.#source.revision; - if (this.#current?.sourceRevision === sourceRevision) return; - if (this.#candidate?.sourceRevision === sourceRevision) return; - if (this.#pending?.revision === sourceRevision) return; - if (this.#attemptedRevision === sourceRevision) return; - this.#discardCandidate(new DOMException('A newer source revision is being prepared', 'AbortError')); - this.#attemptedRevision = sourceRevision; - const controller = new AbortController(); - let update: ParagraphBatchTargetUpdate; - try { - update = this.#target.stage(this.#current, this.#source, { signal: controller.signal }); - } catch (cause) { - this.#recordError(sourceRevision, cause); - return; - } - if (update.status === 'ready') { - this.#acceptStage(sourceRevision, update.stage); - return; - } - this.#pending = { - revision: sourceRevision, - cancel: (reason) => { - controller.abort(reason); - update.cancel(reason); - }, - }; - void update.ready.then( - (stage) => { - if (this.#disposed || this.#pending?.revision !== sourceRevision || this.#source.revision !== sourceRevision) { - stage.abort(); - return; - } - this.#pending = undefined; - this.#acceptStage(sourceRevision, stage); - }, - (cause: unknown) => { - if (this.#pending?.revision !== sourceRevision) return; - this.#pending = undefined; - if (!this.#disposed && this.#source.revision === sourceRevision) this.#recordError(sourceRevision, cause); - }, - ); - } - - commit(): TargetRevision | undefined { - this.#assertActive(); - const stage = this.#candidate; - if (stage === undefined || stage.sourceRevision !== this.#source.revision) return this.#current; - const next = stage.commit(); - if (next.sourceRevision !== stage.sourceRevision) { - next.dispose(); - throw new TypeError('paragraph batch target committed the wrong source revision'); - } - const previous = this.#current; - this.#candidate = undefined; - this.#current = next; - this.#error = undefined; - previous?.dispose(); - return next; - } - - retry(): void { - this.#assertActive(); - if (this.#pending?.revision === this.#source.revision) return; - this.#attemptedRevision = -1; - } - - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - this.#unsubscribe(); - this.#discardCandidate(new DOMException('Paragraph batch attachment was disposed', 'AbortError')); - this.#current?.dispose(); - this.#current = undefined; - this.#target.dispose(); - } - - #acceptStage(sourceRevision: number, stage: ParagraphBatchTargetStage): void { - if (stage.sourceRevision !== sourceRevision) { - stage.abort(); - this.#recordError(sourceRevision, new TypeError('paragraph batch target staged the wrong source revision')); - return; - } - this.#candidate = stage; - this.#error = undefined; - } - - #discardCandidate(reason: unknown): void { - this.#pending?.cancel(reason); - this.#pending = undefined; - this.#candidate?.abort(); - this.#candidate = undefined; - } - - #recordError(sourceRevision: number, cause: unknown): void { - this.#error = Object.freeze({ kind: 'target-failed', sourceRevision, cause }); - } - - #assertActive(): void { - if (this.#disposed) throw new Error('paragraph batch attachment has been disposed'); - } -} diff --git a/packages/text/src/paragraph-batch.ts b/packages/text/src/paragraph-batch.ts deleted file mode 100644 index 54b782cd..00000000 --- a/packages/text/src/paragraph-batch.ts +++ /dev/null @@ -1,1630 +0,0 @@ -import { - acquireFontSelection, - assertFontSelection, - concreteFonts, - releaseFontSelection, - type FontSelection, - type LoadedFont, -} from './loaded-font.js'; -import type { ParagraphLayout } from './layout.js'; -import type { FontHandle } from './identity.js'; -import { - createParagraphEngine, - type Paragraph as EngineParagraph, - type ParagraphEngine, - type ParagraphStyle, -} from './paragraph.js'; -import type { ResolvedPaint } from './paint.js'; -import type { - AnyRasterTechnique, - GlyphBatchStorageOf, - GlyphRange, - RasterBindingOf, - RasterGlyphInput, - RasterGlyphSelection, - RasterResourceId, - RasterTechniqueId, -} from './raster-technique.js'; -import type { RuntimeShaper } from './shaper.js'; -import type { TextRuntime } from './text-runtime.js'; -import type { FormattedText, GlyphPaintInput, ParagraphSpan, TextInput } from './formatted-text.js'; -import { - assertSpanNesting, - resolveSpanCascade, - statedProperties, - type SpanCascadeSegment, -} from './internal/span-cascade.js'; -import { - attachParagraphBatch, - type ParagraphBatchAttachment, - type ParagraphBatchTarget, - type ParagraphBatchTargetRevision, -} from './paragraph-batch-attachment.js'; - -declare const paragraphIdBrand: unique symbol; -declare const glyphTopologyBrand: unique symbol; - -export type ParagraphId = number & { readonly [paragraphIdBrand]: true }; -export type GlyphTopology = number & { readonly [glyphTopologyBrand]: true }; - -export interface GlyphBufferCapacity { - readonly size: number; - readonly policy: 'grow' | 'chunk' | 'fixed'; -} - -export type ParagraphAxisConstraint = - | { readonly mode: 'unconstrained' } - | { readonly mode: 'at-most'; readonly size: number } - | { readonly mode: 'exact'; readonly size: number }; - -export interface ParagraphContentBox { - readonly width?: ParagraphAxisConstraint; - readonly height?: ParagraphAxisConstraint; - readonly maxLines?: number; - readonly wrap?: 'none' | 'word' | 'character'; - readonly align?: 'start' | 'center' | 'end' | 'justify'; - readonly overflow?: 'visible' | 'clip' | 'ellipsis'; -} - -export interface ParagraphBaseProperties { - readonly font: FontSelection; - readonly contentBox?: ParagraphContentBox; - readonly style?: ParagraphStyle; - readonly paint?: GlyphPaintInput; - readonly rasterPixelRatio?: number; - readonly order?: number; - readonly renderVariant?: Variant; -} - -export type ParagraphContentProperties = - | Readonly<{ text: string; spans?: readonly ParagraphSpan[] }> - | Readonly<{ text: FormattedText; spans?: never }>; - -export type ParagraphProperties = ParagraphBaseProperties< - Technique, - Variant -> & - ParagraphContentProperties; - -export type ParagraphUpdate = - | (Partial> & - Readonly<{ text?: string; spans?: readonly ParagraphSpan[] }>) - | (Partial> & - Readonly<{ text: FormattedText; spans?: never }>); - -export interface ParagraphSnapshot { - readonly font: FontSelection; - readonly text: string; - readonly spans: readonly ParagraphSpan[]; - readonly contentBox: ParagraphContentBox; - readonly style: ParagraphStyle; - readonly paint: GlyphPaintInput; - readonly rasterPixelRatio: number; - readonly order: number; - readonly renderVariant: Variant | undefined; -} - -export interface GlyphSnapshot { - readonly topology: GlyphTopology; - readonly glyphIds: Uint32Array; - readonly clusters: Uint32Array; - readonly fontSlots: Uint16Array; - readonly shapedX: Float32Array; - readonly shapedY: Float32Array; - readonly displayedX: Float32Array; - readonly displayedY: Float32Array; -} - -export interface GlyphOriginUpdate { - readonly topology: GlyphTopology; - readonly x: Float32Array; - readonly y: Float32Array; -} - -export interface PreparedParagraph { - readonly id: ParagraphId; - readonly layout: ParagraphLayout; - readonly topology: GlyphTopology; -} - -export interface GlyphBatchKey { - readonly technique: RasterTechniqueId; - readonly resource: RasterResourceId; - readonly pipelineVariant: number; - readonly generation: number; - readonly chunk: number; -} - -export interface PreparedGlyphBatch { - readonly key: GlyphBatchKey; - readonly technique: Technique; - readonly font: LoadedFont; - readonly capacity: number; - readonly instanceCount: number; - readonly binding: RasterBindingOf; - readonly storage: GlyphBatchStorageOf; - readonly dirtyRanges: readonly GlyphRange[]; -} - -export interface PreparedGlyphRun { - readonly batch: GlyphBatchKey; - readonly paragraph: ParagraphId; - readonly renderVariant: Variant | undefined; - readonly start: number; - readonly count: number; -} - -export interface PreparedParagraphBatchRevision { - readonly paragraphBatch: ParagraphBatch; - readonly revision: number; - readonly technique: Technique; - readonly paragraphs: readonly PreparedParagraph[]; - readonly glyphBatches: readonly PreparedGlyphBatch[]; - readonly glyphRuns: readonly PreparedGlyphRun[]; -} - -export interface GlyphCapacityOverflow { - readonly resourceKey: GlyphBatchKey; - readonly required: number; -} - -export type TextPreparationError = - | { - readonly kind: 'capacity-exceeded'; - readonly batch: ParagraphBatch; - readonly capacity: number; - readonly required: number; - readonly overflows: readonly GlyphCapacityOverflow[]; - } - | { readonly kind: 'preparation-failed'; readonly cause: unknown }; - -export interface ParagraphBatchOptions { - readonly technique: Technique; - readonly capacity?: GlyphBufferCapacity; - readonly rasterPixelRatio?: number; - readonly renderVariant?: Variant; -} - -export interface ParagraphBatchObserver { - next(revision: PreparedParagraphBatchRevision): void; - complete(): void; -} - -export interface Paragraph { - readonly id: ParagraphId; - readonly batch: ParagraphBatch; - readonly disposed: boolean; - readonly committed: PreparedParagraph | undefined; - font: FontSelection; - text: TextInput; - spans: readonly ParagraphSpan[]; - contentBox: ParagraphContentBox; - style: ParagraphStyle; - paint: GlyphPaintInput; - rasterPixelRatio: number; - order: number; - renderVariant: Variant | undefined; - set(properties: ParagraphUpdate): void; - setSpan(index: number, span: ParagraphSpan): void; - removeSpan(index: number): void; - snapshotGlyphs(): GlyphSnapshot; - setGlyphOrigins(update: GlyphOriginUpdate): void; - clearGlyphOriginOverrides(): void; - snapshotProperties(): ParagraphSnapshot; - dispose(): void; -} - -export interface ParagraphBatch { - readonly runtime: TextRuntime; - readonly technique: Technique; - readonly capacity: GlyphBufferCapacity; - readonly current: PreparedParagraphBatchRevision; - readonly paragraphCount: number; - readonly hasPendingChanges: boolean; - readonly preparationError: TextPreparationError | undefined; - readonly disposed: boolean; - rasterPixelRatio: number; - renderVariant: Variant | undefined; - add(properties: ParagraphProperties): Paragraph; - setCapacity(capacity: GlyphBufferCapacity): void; - has(paragraph: Paragraph): boolean; - subscribe(observer: ParagraphBatchObserver): () => void; - attach( - target: ParagraphBatchTarget, - ): ParagraphBatchAttachment; - dispose(): void; -} - -export interface ParagraphBatchHost { - readonly runtime: TextRuntime; - readonly shaper: RuntimeShaper; - dirty(): void; - remove(batch: ParagraphBatchController): void; -} - -export interface ParagraphBatchController { - readonly publicBatch: ParagraphBatch; - readonly dirty: boolean; - capture(): ParagraphBatchPreparation; - prepare( - snapshot: ParagraphBatchPreparation, - layouts?: ReadonlyMap, - ): PreparedParagraphBatchCandidate; - publish(candidate: PreparedParagraphBatchCandidate): void; - discard(candidate: PreparedParagraphBatchCandidate): void; - release(snapshot: ParagraphBatchPreparation): void; -} - -export interface ParagraphBatchPreparation { - readonly controller: ParagraphBatchController; - readonly desiredRevision: number; - readonly paragraphs: readonly CapturedParagraph[]; - readonly layouts: readonly ParagraphLayoutPreparation[]; - readonly capacity: GlyphBufferCapacity; - readonly capacityChanged: boolean; - readonly rasterPixelRatio: number; - readonly renderVariant: unknown; - readonly previous: PreparedParagraphBatchRevision; - readonly leases: readonly LoadedFont[]; -} - -export interface ParagraphLayoutPreparation { - readonly paragraph: ParagraphId; - readonly input?: WorkerParagraphLayoutInput; -} - -export interface PreparedParagraphBatchCandidate { - readonly snapshot: ParagraphBatchPreparation; - readonly revision: PreparedParagraphBatchRevision; - readonly paragraphs: readonly PreparedOwnedParagraph[]; -} - -const DEFAULT_CAPACITY = Object.freeze({ size: 4_096, policy: 'chunk' as const }); -const EMPTY_BOX = Object.freeze({}); -const EMPTY_STYLE = Object.freeze({}); -const EMPTY_PAINT = Object.freeze({}); - -export function createParagraphBatch( - host: ParagraphBatchHost, - options: ParagraphBatchOptions, -): ParagraphBatchController { - return new ParagraphBatchImpl(host, options) as ParagraphBatchController; -} - -class ParagraphBatchImpl - implements ParagraphBatch, ParagraphBatchController -{ - readonly runtime: TextRuntime; - readonly technique: Technique; - readonly #host: ParagraphBatchHost; - readonly #paragraphs = new Set>(); - readonly #observers = new Set>(); - readonly #spareStorage = new Map>(); - readonly #glyphInputs: MutableGlyphInput['data']>[] = []; - #capacity: GlyphBufferCapacity; - #rasterPixelRatio: number; - #renderVariant: Variant | undefined; - #revision = 0; - #desiredRevision = 0; - #capacityChanged = false; - #nextParagraphId = 1; - #dirty = false; - #disposed = false; - #preparationError: TextPreparationError | undefined; - #current: PreparedParagraphBatchRevision; - - constructor(host: ParagraphBatchHost, options: ParagraphBatchOptions) { - if (options === undefined || options.technique === undefined) - throw new TypeError('paragraph batch requires a technique'); - this.#host = host; - this.runtime = host.runtime; - this.technique = options.technique; - this.#capacity = normalizeCapacity(options.capacity ?? DEFAULT_CAPACITY); - this.#rasterPixelRatio = positive(options.rasterPixelRatio ?? 1, 'rasterPixelRatio'); - this.#renderVariant = options.renderVariant; - this.#current = Object.freeze({ - paragraphBatch: this, - revision: 0, - technique: this.technique, - paragraphs: Object.freeze([]), - glyphBatches: Object.freeze([]), - glyphRuns: Object.freeze([]), - }); - } - - get publicBatch(): ParagraphBatch { - return this as ParagraphBatch; - } - get capacity(): GlyphBufferCapacity { - return this.#capacity; - } - get current(): PreparedParagraphBatchRevision { - return this.#current; - } - get paragraphCount(): number { - return this.#paragraphs.size; - } - get hasPendingChanges(): boolean { - return this.#dirty; - } - get dirty(): boolean { - return this.#dirty; - } - get preparationError(): TextPreparationError | undefined { - return this.#preparationError; - } - get disposed(): boolean { - return this.#disposed; - } - get rasterPixelRatio(): number { - return this.#rasterPixelRatio; - } - set rasterPixelRatio(value: number) { - const next = positive(value, 'rasterPixelRatio'); - if (next !== this.#rasterPixelRatio) { - this.#rasterPixelRatio = next; - this.#markDirty(); - } - } - get renderVariant(): Variant | undefined { - return this.#renderVariant; - } - set renderVariant(value: Variant | undefined) { - if (!Object.is(value, this.#renderVariant)) { - this.#renderVariant = value; - this.#markDirty(); - } - } - - add(properties: ParagraphProperties): Paragraph { - this.#assertActive(); - const paragraph = new ParagraphImpl(this, this.#nextParagraphId++ as ParagraphId, properties); - this.#paragraphs.add(paragraph); - this.#markDirty(); - return paragraph; - } - - setCapacity(value: GlyphBufferCapacity): void { - this.#assertActive(); - const next = normalizeCapacity(value); - if (next.size === this.#capacity.size && next.policy === this.#capacity.policy) return; - this.#capacity = next; - this.#capacityChanged = true; - this.#markDirty(); - } - - has(paragraph: Paragraph): boolean { - return this.#paragraphs.has(paragraph as ParagraphImpl); - } - - subscribe(observer: ParagraphBatchObserver): () => void { - this.#assertActive(); - observer.next(this.#current); - this.#observers.add(observer); - let active = true; - return () => { - if (active) { - active = false; - this.#observers.delete(observer); - } - }; - } - - attach( - target: ParagraphBatchTarget, - ): ParagraphBatchAttachment { - this.#assertActive(); - return attachParagraphBatch(this, target); - } - - capture(): ParagraphBatchPreparation { - this.#assertActive(); - const paragraphs = [...this.#paragraphs] - .sort((a, b) => a.order - b.order || a.id - b.id) - .map((paragraph) => paragraph.capture()); - const leases = [...new Set(paragraphs.flatMap((paragraph) => selectedFonts(paragraph.state)))]; - acquireFonts(leases, this.runtime, this.technique); - return Object.freeze({ - controller: this, - desiredRevision: this.#desiredRevision, - paragraphs, - layouts: Object.freeze( - paragraphs.map((paragraph) => - Object.freeze({ - paragraph: paragraph.owner.id, - ...(paragraph.needsShape ? { input: paragraphLayoutInput(paragraph.state) } : {}), - }), - ), - ), - capacity: this.#capacity, - capacityChanged: this.#capacityChanged, - rasterPixelRatio: this.#rasterPixelRatio, - renderVariant: this.#renderVariant, - previous: this.#current, - leases, - }) as ParagraphBatchPreparation; - } - - prepare( - snapshot: ParagraphBatchPreparation, - layouts?: ReadonlyMap, - ): PreparedParagraphBatchCandidate { - this.#assertSnapshot(snapshot); - try { - const paragraphs = snapshot.paragraphs as unknown as readonly CapturedParagraph[]; - const renderVariant = snapshot.renderVariant as Variant | undefined; - const previous = snapshot.previous as unknown as PreparedParagraphBatchRevision; - const prepared = paragraphs.map((paragraph) => - paragraph.owner.prepare( - paragraph, - this.#host.shaper, - snapshot.rasterPixelRatio, - renderVariant, - layouts?.get(paragraph.owner.id), - ), - ); - const packed = pack(this, prepared, snapshot.capacity, previous, snapshot.capacityChanged); - const revision = Object.freeze({ - paragraphBatch: this, - revision: this.#revision + 1, - technique: this.technique, - paragraphs: Object.freeze(prepared.map((entry) => entry.publicParagraph)), - glyphBatches: Object.freeze(packed.batches), - glyphRuns: Object.freeze(packed.runs), - }) as PreparedParagraphBatchRevision; - return Object.freeze({ snapshot, revision, paragraphs: prepared }) as PreparedParagraphBatchCandidate; - } catch (cause) { - const error = - cause instanceof CapacityOverflow - ? Object.freeze({ - kind: 'capacity-exceeded' as const, - batch: this as ParagraphBatch, - capacity: snapshot.capacity.size, - required: cause.required, - overflows: Object.freeze(cause.overflows), - }) - : Object.freeze({ kind: 'preparation-failed' as const, cause }); - if (snapshot.desiredRevision === this.#desiredRevision) { - this.#dirty = false; - this.#preparationError = error; - } - throw error; - } - } - - publish(candidate: PreparedParagraphBatchCandidate): void { - this.#assertSnapshot(candidate.snapshot); - const value = candidate.revision; - const nextKeys = new Set(value.glyphBatches.map((glyphBatch) => glyphBatch.key)); - for (const glyphBatch of this.#current.glyphBatches) - if (nextKeys.has(glyphBatch.key)) this.#spareStorage.set(glyphBatch.key, glyphBatch.storage); - this.#current = value as PreparedParagraphBatchRevision; - this.#revision = value.revision; - this.#dirty = candidate.snapshot.desiredRevision !== this.#desiredRevision; - this.#preparationError = undefined; - if (!this.#dirty) this.#capacityChanged = false; - for (const paragraph of candidate.paragraphs) - paragraph.owner.setPrepared(paragraph as PreparedOwnedParagraph); - for (const observer of this.#observers) observer.next(this.#current); - } - - discard(candidate: PreparedParagraphBatchCandidate): void { - this.#assertSnapshot(candidate.snapshot); - const currentKeys = new Set(this.#current.glyphBatches.map((glyphBatch) => glyphBatch.key)); - for (const glyphBatch of candidate.revision.glyphBatches) - if (currentKeys.has(glyphBatch.key)) - this.#spareStorage.set(glyphBatch.key, glyphBatch.storage as GlyphBatchStorageOf); - } - - release(snapshot: ParagraphBatchPreparation): void { - this.#assertSnapshot(snapshot); - releaseFonts(snapshot.leases as readonly LoadedFont[]); - } - storage(key: GlyphBatchKey, capacity: number): GlyphBatchStorageOf { - const spare = this.#spareStorage.get(key); - if (spare !== undefined) { - this.#spareStorage.delete(key); - return spare; - } - return packingOperations(this.technique).createStorage(capacity); - } - /** - * Packing rewrites one input per glyph on every update and no prepared batch - * retains them, so the batch keeps the objects and overwrites them in place - * rather than handing the collector a glyph-sized set of corpses each update. - */ - glyphInputs(): MutableGlyphInput['data']>[] { - return this.#glyphInputs; - } - - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - for (const paragraph of [...this.#paragraphs]) paragraph.dispose(); - this.#paragraphs.clear(); - for (const observer of this.#observers) observer.complete(); - this.#observers.clear(); - this.#spareStorage.clear(); - this.#glyphInputs.length = 0; - this.#host.remove(this); - } - - removeParagraph(paragraph: ParagraphImpl): void { - if (this.#paragraphs.delete(paragraph)) this.#markDirty(); - } - markParagraphDirty(): void { - this.#markDirty(); - } - #markDirty(): void { - this.#desiredRevision += 1; - this.#preparationError = undefined; - if (!this.#dirty) { - this.#dirty = true; - this.#host.dirty(); - } - } - #assertActive(): void { - if (this.#disposed) throw new Error('paragraph batch has been disposed'); - } - #assertSnapshot(snapshot: ParagraphBatchPreparation): void { - if (snapshot.controller !== this) throw new TypeError('paragraph batch preparation belongs to another batch'); - } -} - -interface CapturedParagraph { - readonly owner: ParagraphImpl; - readonly desiredRevision: number; - readonly state: ParagraphSnapshot; - readonly origins: GlyphOriginUpdate | undefined; - readonly prepared: PreparedOwnedParagraph | undefined; - readonly needsShape: boolean; - readonly topology: number; - readonly hasDensity: boolean; -} - -/** A pooled glyph input while core still owns it, before the technique observes it. */ -type MutableGlyphInput = { -readonly [Field in keyof RasterGlyphInput]: RasterGlyphInput[Field] }; - -/** Shares one hidden class across the pool by stating every field up front. */ -function blankGlyphInput(data: Data, paint: ResolvedPaint): MutableGlyphInput { - return { data, glyphId: 0, fontSize: 0, originX: 0, originY: 0, rasterPixelRatio: 0, paint }; -} - -/** - * Cascade-resolved paint and render variant for one prepared revision. A - * paragraph whose cascade states a single segment attributes every glyph - * identically, so it carries one value instead of two arrays as long as the - * glyph count. - */ -type GlyphAttribution = - | { readonly kind: 'uniform'; readonly paint: ResolvedPaint; readonly variant: Variant | undefined } - | { - readonly kind: 'per-glyph'; - /** Parallel to `layout.glyphIds`. */ - readonly paints: readonly ResolvedPaint[]; - /** Parallel to `layout.glyphIds`. */ - readonly variants: readonly (Variant | undefined)[]; - }; - -interface PreparedOwnedParagraph { - readonly owner: ParagraphImpl; - readonly capture: CapturedParagraph; - readonly state: ParagraphSnapshot; - readonly publicParagraph: PreparedParagraph; - readonly layout: ParagraphLayout; - readonly fonts: ReadonlyMap>; - readonly displayedX: Float32Array; - readonly displayedY: Float32Array; - readonly rasterPixelRatio: number; - readonly batchRenderVariant: Variant | undefined; - readonly attribution: GlyphAttribution; -} - -class ParagraphImpl implements Paragraph { - readonly id: ParagraphId; - readonly batch: ParagraphBatchImpl; - #state: ParagraphSnapshot; - #disposed = false; - #prepared: PreparedOwnedParagraph | undefined; - #origins: GlyphOriginUpdate | undefined; - #topology = 0; - #desiredRevision = 0; - #leasedFonts: readonly LoadedFont[]; - #needsShape = true; - #layoutSession: ParagraphLayoutSession | undefined; - readonly hasDensity: boolean; - - constructor( - batch: ParagraphBatchImpl, - id: ParagraphId, - properties: ParagraphProperties, - ) { - this.batch = batch; - this.id = id; - this.hasDensity = properties.rasterPixelRatio !== undefined; - this.#state = normalizeProperties(properties, batch.runtime, batch.technique); - this.#leasedFonts = selectedFonts(this.#state); - acquireFonts(this.#leasedFonts, batch.runtime, batch.technique); - } - get disposed(): boolean { - return this.#disposed; - } - get committed(): PreparedParagraph | undefined { - return this.#prepared?.publicParagraph; - } - get font(): FontSelection { - return this.#state.font; - } - set font(value: FontSelection) { - this.set({ font: value }); - } - get text(): string { - return this.#state.text; - } - set text(value: TextInput) { - this.set({ text: value } as ParagraphUpdate); - } - get spans(): readonly ParagraphSpan[] { - return this.#state.spans; - } - set spans(value: readonly ParagraphSpan[]) { - this.set({ spans: value }); - } - get contentBox(): ParagraphContentBox { - return this.#state.contentBox; - } - set contentBox(value: ParagraphContentBox) { - this.set({ contentBox: value }); - } - get style(): ParagraphStyle { - return this.#state.style; - } - set style(value: ParagraphStyle) { - this.set({ style: value }); - } - get paint(): GlyphPaintInput { - return this.#state.paint; - } - set paint(value: GlyphPaintInput) { - this.set({ paint: value }); - } - get rasterPixelRatio(): number { - return this.#state.rasterPixelRatio; - } - set rasterPixelRatio(value: number) { - this.set({ rasterPixelRatio: value }); - } - get order(): number { - return this.#state.order; - } - set order(value: number) { - this.set({ order: value }); - } - get renderVariant(): Variant | undefined { - return this.#state.renderVariant; - } - set renderVariant(value: Variant | undefined) { - this.set({ renderVariant: value } as ParagraphUpdate); - } - - set(update: ParagraphUpdate): void { - this.#assertActive(); - const next = normalizeProperties( - { ...this.#state, ...replacedContent(update) } as ParagraphProperties, - this.batch.runtime, - this.batch.technique, - ); - const nextFonts = selectedFonts(next); - acquireFonts(nextFonts, this.batch.runtime, this.batch.technique); - releaseFonts(this.#leasedFonts); - this.#leasedFonts = nextFonts; - if ('font' in update || 'text' in update || 'spans' in update || 'contentBox' in update || 'style' in update) { - this.#needsShape = true; - } - this.#state = next; - this.#origins = undefined; - this.#desiredRevision += 1; - this.batch.markParagraphDirty(); - } - setSpan(index: number, value: ParagraphSpan): void { - const spans = [...this.spans]; - if (!Number.isSafeInteger(index) || index < 0 || index >= spans.length) - throw new RangeError('span index is outside the paragraph'); - spans[index] = value; - this.spans = spans; - } - removeSpan(index: number): void { - const spans = [...this.spans]; - if (!Number.isSafeInteger(index) || index < 0 || index >= spans.length) - throw new RangeError('span index is outside the paragraph'); - spans.splice(index, 1); - this.spans = spans; - } - snapshotProperties(): ParagraphSnapshot { - this.#assertActive(); - return this.#state; - } - snapshotGlyphs(): GlyphSnapshot { - this.#assertActive(); - const prepared = this.#prepared; - if (prepared === undefined) throw new Error('paragraph has not been prepared'); - const layout = prepared.layout; - const displayedX = this.#origins?.x ?? layout.x; - const displayedY = this.#origins?.y ?? layout.y; - return { - topology: prepared.publicParagraph.topology, - glyphIds: Uint32Array.from(layout.glyphIds), - clusters: layout.clusters.slice(), - fontSlots: layout.glyphFontSlots.slice(), - shapedX: layout.x.slice(), - shapedY: layout.y.slice(), - displayedX: displayedX.slice(), - displayedY: displayedY.slice(), - }; - } - setGlyphOrigins(update: GlyphOriginUpdate): void { - this.#assertActive(); - const prepared = this.#prepared; - if (prepared === undefined || update.topology !== prepared.publicParagraph.topology) - throw new TypeError('glyph origins do not match the committed topology'); - if (update.x.length !== prepared.layout.x.length || update.y.length !== prepared.layout.y.length) - throw new RangeError('glyph origin arrays do not match the glyph count'); - this.#origins = { topology: update.topology, x: update.x.slice(), y: update.y.slice() }; - this.#desiredRevision += 1; - this.batch.markParagraphDirty(); - } - clearGlyphOriginOverrides(): void { - this.#assertActive(); - if (this.#origins !== undefined) { - this.#origins = undefined; - this.#desiredRevision += 1; - this.batch.markParagraphDirty(); - } - } - - capture(): CapturedParagraph { - return Object.freeze({ - owner: this, - desiredRevision: this.#desiredRevision, - state: this.#state, - origins: this.#origins, - prepared: this.#prepared, - needsShape: this.#needsShape, - topology: this.#topology, - hasDensity: this.hasDensity, - }); - } - prepare( - capture: CapturedParagraph, - shaper: RuntimeShaper, - batchRasterPixelRatio: number, - batchRenderVariant: Variant | undefined, - preparedLayout?: ParagraphLayout, - ): PreparedOwnedParagraph { - if (capture.owner !== this) throw new TypeError('paragraph preparation belongs to another paragraph'); - const fonts = collectFonts(capture.state); - const layout = - !capture.needsShape && capture.prepared !== undefined - ? capture.prepared.layout - : (preparedLayout ?? this.#session(shaper).layout(paragraphLayoutInput(capture.state))); - const topology = ( - !capture.needsShape && capture.prepared !== undefined - ? capture.prepared.publicParagraph.topology - : capture.topology + 1 - ) as GlyphTopology; - return { - owner: this, - capture, - state: capture.state, - layout, - fonts, - displayedX: capture.origins?.x ?? layout.x, - displayedY: capture.origins?.y ?? layout.y, - rasterPixelRatio: capture.hasDensity ? capture.state.rasterPixelRatio : batchRasterPixelRatio, - batchRenderVariant, - attribution: resolveGlyphAttribution(capture.state, layout, batchRenderVariant), - publicParagraph: Object.freeze({ id: this.id, layout, topology }), - }; - } - publish(): void {} - setPrepared(value: PreparedOwnedParagraph): void { - this.#prepared = value; - this.#topology = value.publicParagraph.topology; - if (value.capture.desiredRevision === this.#desiredRevision) this.#needsShape = false; - } - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - releaseFonts(this.#leasedFonts); - this.#leasedFonts = []; - this.batch.removeParagraph(this); - this.#prepared = undefined; - this.#layoutSession?.dispose(); - this.#layoutSession = undefined; - } - /** Retained so preparation survives a change that only moves the content box. */ - #session(shaper: RuntimeShaper): ParagraphLayoutSession { - this.#layoutSession ??= new ParagraphLayoutSession(shaper); - return this.#layoutSession; - } - - #assertActive(): void { - if (this.#disposed) throw new Error('paragraph has been disposed'); - } -} - -function pack( - batch: ParagraphBatchImpl, - prepared: readonly PreparedOwnedParagraph[], - capacity: GlyphBufferCapacity, - previous: PreparedParagraphBatchRevision, - forceReplacement: boolean, -): { batches: PreparedGlyphBatch[]; runs: PreparedGlyphRun[] } { - const technique = packingOperations(batch.technique); - type Entry = { - font: LoadedFont; - selection: RasterGlyphSelection>; - glyphs: RasterGlyphInput['data']>[]; - }; - type LogicalRun = { - entry: Entry; - paragraph: ParagraphImpl; - variant: Variant | undefined; - start: number; - count: number; - }; - /** - * A batching key is the resource/pipeline-variant pair, so it is looked up - * through nested maps rather than a per-glyph composite string. `orderedEntries` - * preserves the first-seen order that batch identity and run offsets depend on. - */ - const entries = new Map>(); - const orderedEntries: Entry[] = []; - const orderedRuns: LogicalRun[] = []; - const pool = batch.glyphInputs(); - /** Advances only past an input a selection accepted, so a skip reuses the slot. */ - let pooled = 0; - let previousRun: LogicalRun | undefined; - let cachedResource: RasterResourceId | undefined; - let cachedPipelineVariant = -1; - let cachedEntry: Entry | undefined; - for (const value of prepared) { - const { layout, owner, attribution } = value; - for (let index = 0; index < layout.glyphIds.length; index += 1) { - const handle = layout.fontHandles[layout.glyphFontSlots[index]!]!; - const font = value.fonts.get(handle); - if (font === undefined) throw new Error('paragraph layout referenced an unresolved loaded font'); - const paint = attribution.kind === 'uniform' ? attribution.paint : attribution.paints[index]!; - const input = (pool[pooled] ??= blankGlyphInput(font.data, paint)); - input.data = font.data; - input.glyphId = layout.glyphIds[index]!; - input.fontSize = layout.glyphFontSizes[index]!; - input.originX = value.displayedX[index]!; - input.originY = value.displayedY[index]!; - input.rasterPixelRatio = value.rasterPixelRatio; - input.paint = paint; - const selection = technique.select(input); - if (selection === undefined) continue; - pooled += 1; - /** Consecutive glyphs almost always reselect the same batch. */ - let entry: Entry; - if ( - cachedEntry !== undefined && - selection.resource === cachedResource && - selection.pipelineVariant === cachedPipelineVariant - ) { - entry = cachedEntry; - } else { - let variants = entries.get(selection.resource); - if (variants === undefined) { - variants = new Map(); - entries.set(selection.resource, variants); - } - let existing = variants.get(selection.pipelineVariant); - if (existing === undefined) { - existing = { font, selection, glyphs: [] }; - variants.set(selection.pipelineVariant, existing); - orderedEntries.push(existing); - } - entry = existing; - cachedResource = selection.resource; - cachedPipelineVariant = selection.pipelineVariant; - cachedEntry = existing; - } - const variant = attribution.kind === 'uniform' ? attribution.variant : attribution.variants[index]; - if ( - previousRun !== undefined && - previousRun.entry === entry && - previousRun.paragraph === owner && - Object.is(previousRun.variant, variant) && - previousRun.start + previousRun.count === entry.glyphs.length - ) - previousRun.count += 1; - else { - previousRun = { entry, paragraph: owner, variant, start: entry.glyphs.length, count: 1 }; - orderedRuns.push(previousRun); - } - entry.glyphs.push(input); - } - } - const batches: PreparedGlyphBatch[] = []; - const runLookup = new Map(); - if (capacity.policy === 'fixed') { - const overflows: GlyphCapacityOverflow[] = []; - for (const entry of orderedEntries) { - if (entry.glyphs.length <= capacity.size) continue; - overflows.push({ - resourceKey: Object.freeze({ - technique: batch.technique.id, - resource: entry.selection.resource, - pipelineVariant: entry.selection.pipelineVariant, - generation: nextGeneration(previous, entry.selection.resource, entry.selection.pipelineVariant, 0), - chunk: 0, - }), - required: entry.glyphs.length, - }); - } - if (overflows.length !== 0) throw new CapacityOverflow(overflows); - } - for (const entry of orderedEntries) { - const required = entry.glyphs.length; - const prior = matchingBatch(previous, entry.selection.resource, entry.selection.pipelineVariant, 0); - const chunkSize = - capacity.policy === 'grow' - ? prior !== undefined && !forceReplacement && prior.capacity >= required - ? prior.capacity - : grownCapacity(forceReplacement ? capacity.size : (prior?.capacity ?? capacity.size), required) - : capacity.size; - const chunks = Math.max(1, Math.ceil(required / chunkSize)); - const keys: { key: GlyphBatchKey; offset: number; capacity: number }[] = []; - for (let chunk = 0; chunk < chunks; chunk += 1) { - const start = chunk * chunkSize; - const count = Math.min(chunkSize, required - start); - const old = matchingBatch(previous, entry.selection.resource, entry.selection.pipelineVariant, chunk); - const reusable = old !== undefined && !forceReplacement && old.capacity === chunkSize; - const key = reusable - ? old.key - : Object.freeze({ - technique: batch.technique.id, - resource: entry.selection.resource, - pipelineVariant: entry.selection.pipelineVariant, - generation: old === undefined ? 0 : old.key.generation + 1, - chunk, - }); - const storage = batch.storage(key, chunkSize); - /** A single chunk already spans the whole entry, so it needs no copy. */ - const glyphs = - start === 0 && count === entry.glyphs.length ? entry.glyphs : entry.glyphs.slice(start, start + count); - technique.writeStorage( - storage, - { start: 0, count }, - { data: entry.font.data, binding: entry.selection.binding, glyphs }, - ); - batches.push( - Object.freeze({ - key, - technique: batch.technique, - font: entry.font, - capacity: chunkSize, - instanceCount: count, - binding: entry.selection.binding, - storage, - dirtyRanges: Object.freeze(storageDirtyRanges(reusable ? old.storage : undefined, storage, count, chunkSize)), - }), - ); - keys.push({ key, offset: start, capacity: chunkSize }); - } - runLookup.set(entry, keys); - } - const runs: PreparedGlyphRun[] = []; - for (const run of orderedRuns) { - const { entry } = run; - let remaining = run.count; - let cursor = run.start; - while (remaining > 0) { - const chunk = capacity.policy === 'grow' ? 0 : Math.floor(cursor / capacity.size); - const target = runLookup.get(entry)![chunk]!; - const local = cursor - target.offset; - const count = Math.min(remaining, target.capacity - local); - runs.push( - Object.freeze({ - batch: target.key, - paragraph: run.paragraph.id, - renderVariant: run.variant, - start: local, - count, - }), - ); - cursor += count; - remaining -= count; - } - } - return { batches, runs }; -} - -function storageDirtyRanges( - previous: Readonly>> | undefined, - next: Readonly>>, - count: number, - capacity: number, -): GlyphRange[] { - if (count === 0) return []; - if (previous === undefined) return [{ start: 0, count }]; - const fields = Reflect.ownKeys(next); - if (fields.length !== Reflect.ownKeys(previous).length) return [{ start: 0, count }]; - const previousBytes: Uint8Array[] = []; - const nextBytes: Uint8Array[] = []; - const strides: number[] = []; - for (const field of fields) { - const before = previous[field]; - const after = next[field]; - if ( - before === undefined || - after === undefined || - before.byteLength !== after.byteLength || - after.byteLength % capacity !== 0 - ) - return [{ start: 0, count }]; - previousBytes.push(new Uint8Array(before.buffer, before.byteOffset, before.byteLength)); - nextBytes.push(new Uint8Array(after.buffer, after.byteOffset, after.byteLength)); - strides.push(after.byteLength / capacity); - } - const ranges: GlyphRange[] = []; - let start = -1; - for (let slot = 0; slot < count; slot += 1) { - let changed = false; - for (let field = 0; field < fields.length && !changed; field += 1) { - const stride = strides[field]!; - const offset = slot * stride; - const before = previousBytes[field]!; - const after = nextBytes[field]!; - for (let byte = 0; byte < stride; byte += 1) - if (before[offset + byte] !== after[offset + byte]) { - changed = true; - break; - } - } - if (changed && start === -1) start = slot; - if (!changed && start !== -1) { - ranges.push({ start, count: slot - start }); - start = -1; - } - } - if (start !== -1) ranges.push({ start, count: count - start }); - return ranges; -} - -function matchingBatch( - revision: PreparedParagraphBatchRevision, - resource: RasterResourceId, - pipelineVariant: number, - chunk: number, -): PreparedGlyphBatch | undefined { - return revision.glyphBatches.find( - (batch) => - batch.key.resource === resource && batch.key.pipelineVariant === pipelineVariant && batch.key.chunk === chunk, - ); -} - -function nextGeneration( - revision: PreparedParagraphBatchRevision, - resource: RasterResourceId, - pipelineVariant: number, - chunk: number, -): number { - return (matchingBatch(revision, resource, pipelineVariant, chunk)?.key.generation ?? -1) + 1; -} - -class CapacityOverflow extends Error { - readonly overflows: readonly GlyphCapacityOverflow[]; - readonly required: number; - constructor(overflows: readonly GlyphCapacityOverflow[]) { - super('fixed glyph capacity is smaller than the prepared generation'); - this.name = 'CapacityOverflow'; - this.overflows = overflows; - this.required = Math.max(...overflows.map((overflow) => overflow.required)); - } -} - -/** - * Replacement text carries its own formatting: a literal brings its spans and a - * plain string brings none. Retaining the previous spans would reinterpret them - * against unrelated text, so an update that replaces text without stating spans - * clears the ones it replaced. - */ -function replacedContent( - update: ParagraphUpdate, -): ParagraphUpdate { - if (!('text' in update) || 'spans' in update) return update; - return { ...update, spans: [] } as ParagraphUpdate; -} - -function normalizeProperties( - properties: ParagraphProperties, - runtime: TextRuntime, - technique: Technique, -): ParagraphSnapshot { - if (properties === undefined) throw new TypeError('paragraph properties are required'); - assertFontSelection(properties.font, runtime, technique); - const formatted = typeof properties.text === 'string' ? undefined : properties.text; - const text = typeof properties.text === 'string' ? properties.text : properties.text.text; - const spans = Object.freeze([ - ...((formatted?.spans ?? properties.spans ?? []) as readonly ParagraphSpan[]), - ]); - for (const span of spans) { - if ( - !Number.isSafeInteger(span.start) || - !Number.isSafeInteger(span.end) || - span.start < 0 || - span.end < span.start || - span.end > text.length - ) - throw new RangeError('paragraph span is outside the text'); - if (span.font !== undefined) assertFontSelection(span.font, runtime, technique); - } - // Resolution folds covering spans from the outermost inward, so a pair that - // overlaps without nesting has no innermost span and must fail here rather - // than resolve to whichever span a consumer happened to visit last. - assertSpanNesting(spans, 'paragraph span'); - const rasterPixelRatio = positive(properties.rasterPixelRatio ?? 1, 'rasterPixelRatio'); - const order = finite(properties.order ?? 0, 'order'); - return Object.freeze({ - font: properties.font, - text, - spans, - contentBox: Object.freeze({ ...(properties.contentBox ?? EMPTY_BOX) }), - style: Object.freeze({ ...(properties.style ?? EMPTY_STYLE) }), - paint: Object.freeze({ ...(properties.paint ?? EMPTY_PAINT) }), - rasterPixelRatio, - order, - renderVariant: properties.renderVariant, - }); -} - -function collectFonts( - state: ParagraphSnapshot, -): ReadonlyMap> { - const map = new Map>(); - for (const font of concreteFonts(state.font)) map.set(font.font.handle, font); - for (const span of state.spans) - if (span.font !== undefined) for (const font of concreteFonts(span.font)) map.set(font.font.handle, font); - return map; -} -function selectedFonts( - state: ParagraphSnapshot, -): readonly LoadedFont[] { - return [...new Set(collectFonts(state).values())]; -} -function acquireFonts( - fonts: readonly LoadedFont[], - runtime: TextRuntime, - technique: Technique, -): void { - const acquired: LoadedFont[] = []; - try { - for (const font of fonts) { - acquireFontSelection(font, runtime, technique); - acquired.push(font); - } - } catch (error) { - releaseFonts(acquired); - throw error; - } -} -function releaseFonts(fonts: readonly LoadedFont[]): void { - for (const font of fonts) releaseFontSelection(font); -} -function shapingSpans( - state: WorkerParagraphLayoutInput, - fallbacks: ReadonlyMap = new Map(), -): import('./paragraph.js').ParagraphSpan[] { - const authored = state.spans.map((span) => ({ - start: span.start, - end: span.end, - ...(span.fonts === undefined ? {} : { font: span.fonts[0] }), - ...(span.style ?? {}), - })); - // A fallback overlay is machine-generated rather than authored, so it is split - // at the authored boundaries it crosses. Each piece then sits inside exactly - // one span or in a gap, which keeps the overlay inside the nesting invariant. - const boundaries = [ - ...new Set([0, state.text.length, ...state.spans.flatMap((span) => [span.start, span.end])]), - ].sort((left, right) => left - right); - const starts = [...fallbacks.keys()].sort((left, right) => left - right); - for (let index = 0; index < starts.length; index += 1) { - const start = starts[index]!; - const end = starts[index + 1] ?? state.text.length; - const font = fallbacks.get(start)!; - let cursor = start; - for (const boundary of boundaries) { - if (boundary <= cursor || boundary >= end) continue; - authored.push({ start: cursor, end: boundary, font }); - cursor = boundary; - } - if (cursor < end) authored.push({ start: cursor, end, font }); - } - return authored; -} - -/** - * The shaping layer receives the cascade already resolved into disjoint - * segments, so a span's font and shaping style reach run segmentation with the - * same answer that packing later paints with. - */ -function paragraphLayoutInput( - state: ParagraphSnapshot, -): WorkerParagraphLayoutInput { - return Object.freeze({ - text: state.text, - fonts: Object.freeze(concreteFonts(state.font).map((font) => font.font.handle)), - spans: Object.freeze( - paragraphCascade(state).flatMap((segment) => { - const font = segment.properties.font; - const style = shapingStyleOf(segment.properties); - if (font === undefined && style === undefined) return []; - return [ - Object.freeze({ - start: segment.start, - end: segment.end, - ...(font === undefined - ? {} - : { fonts: Object.freeze(concreteFonts(font).map((value) => value.font.handle)) }), - ...(style === undefined ? {} : { style }), - }), - ]; - }), - ), - style: state.style, - contentBox: state.contentBox, - }); -} - -export interface WorkerParagraphLayoutInput { - readonly text: string; - readonly fonts: readonly FontHandle[]; - readonly spans: readonly { - readonly start: number; - readonly end: number; - readonly fonts?: readonly FontHandle[]; - readonly style?: Partial; - }[]; - readonly style: ParagraphStyle; - readonly contentBox: ParagraphContentBox; -} - -/** - * Retains one prepared paragraph across updates. - * - * Preparation resolves Unicode segmentation, bidi levels, style and run segmentation, shaping, and cluster metrics from - * the text, the fonts, the spans, and the style. A content box is none of those: it is a layout constraint the retained - * paragraph already answers per call, against caches it keeps for exactly that purpose. Building a fresh paragraph for - * every change recomputed the whole preparation and then discarded those caches, so a width drag paid a cold build and - * shaping could never be reused. - * - * @internal - */ -export class ParagraphLayoutSession { - readonly #engine: ParagraphEngine; - #paragraph: EngineParagraph | undefined; - #preparedFrom: string | undefined; - #preparedText: string | undefined; - - constructor(shaper: RuntimeShaper) { - this.#engine = createParagraphEngine({ shaper }); - } - - layout(state: WorkerParagraphLayoutInput): ParagraphLayout { - const paragraph = this.#paragraph; - const preparedFrom = preparationKey(state); - if (paragraph !== undefined && this.#preparedText === state.text && this.#preparedFrom === preparedFrom) { - return paragraph.layout(layoutConstraints(state.contentBox)); - } - const layout = this.#prepare(state); - this.#preparedText = state.text; - this.#preparedFrom = preparedFrom; - return layout; - } - - dispose(): void { - this.#paragraph?.dispose(); - this.#paragraph = undefined; - this.#preparedFrom = undefined; - this.#preparedText = undefined; - } - - #prepare(state: WorkerParagraphLayoutInput): ParagraphLayout { - const fallbackIndexes = new Map(); - const fallbacks = new Map(); - const maximumDepth = Math.max(state.fonts.length, ...state.spans.map((span) => span.fonts?.length ?? 1)); - for (let pass = 0; pass < maximumDepth; pass += 1) { - const paragraph: EngineParagraph = this.#retain(state, fallbacks); - // Fallback substitution asks which clusters shaped to `.notdef`, which shaping already answered. Laying the - // paragraph out to discover it would break lines and position every glyph for a result that is discarded, and - // would make font selection depend on where the text happened to wrap. - const probe = paragraph.shaped(); - // Only clusters of the paragraph's own text can carry a fallback selection. A cluster past the end belongs to - // overflow measurement, and substituting a font for one would author a span outside the text. - const clusters = [...new Set(probe.clusters)] - .filter((value) => value < state.text.length) - .sort((left, right) => left - right); - let changed = false; - for (let glyph = 0; glyph < probe.glyphIds.length; glyph += 1) { - if (probe.glyphIds[glyph] !== 0) continue; - const cluster = probe.clusters[glyph]!; - if (cluster >= state.text.length) continue; - const fonts = fontHandlesAt(state, cluster); - const next = (fallbackIndexes.get(cluster) ?? 0) + 1; - if (next >= fonts.length) continue; - fallbackIndexes.set(cluster, next); - fallbacks.set(cluster, fonts[next]!); - const nextCluster = clusters.find((value) => value > cluster); - if (nextCluster !== undefined && !fallbacks.has(nextCluster)) { - // A sentinel restores authored selection after this shaped cluster. - fallbacks.set(nextCluster, fontHandlesAt(state, nextCluster)[0]!); - } - changed = true; - } - if (!changed) return paragraph.layout(layoutConstraints(state.contentBox)); - } - return this.#retain(state, fallbacks).layout(layoutConstraints(state.contentBox)); - } - - #retain(state: WorkerParagraphLayoutInput, fallbacks: ReadonlyMap): EngineParagraph { - const input = { - text: state.text, - font: state.fonts[0]!, - spans: shapingSpans(state, fallbacks), - style: state.style, - }; - const existing = this.#paragraph; - if (existing === undefined) { - const created = this.#engine.create(input); - this.#paragraph = created; - return created; - } - existing.update(input); - return existing; - } -} - -/** - * Identifies everything preparation depends on, excluding the text, which is compared directly. Spans, fonts, and style - * are bounded by the authored markup rather than by the glyph count, so serializing them stays cheap at any scale. - */ -function preparationKey(state: WorkerParagraphLayoutInput): string { - return JSON.stringify({ fonts: state.fonts, spans: state.spans, style: state.style }); -} - -/** @internal */ -export function prepareParagraphLayout(shaper: RuntimeShaper, state: WorkerParagraphLayoutInput): ParagraphLayout { - const session = new ParagraphLayoutSession(shaper); - try { - return session.layout(state); - } finally { - session.dispose(); - } -} - -function fontHandlesAt(state: WorkerParagraphLayoutInput, cluster: number): readonly FontHandle[] { - let selection = state.fonts; - for (const span of state.spans) { - if (span.start <= cluster && cluster < span.end && span.fonts !== undefined) selection = span.fonts; - } - return selection; -} - -/** - * Every span property in one flat record so a single cascade resolves shaping - * and paint under one merge rule. `outline` and `shadow` stay whole values - * because neither is meaningful without all of its parts. - */ -interface StatedSpanProperties extends ParagraphStyle, GlyphPaintInput { - readonly font?: FontSelection; - readonly renderVariant?: Variant; -} - -type ParagraphCascade = readonly SpanCascadeSegment< - StatedSpanProperties ->[]; - -/** - * A snapshot is replaced whenever a paragraph property changes, so keying the - * cascade on it resolves the spans once per revision for both the shaping layer - * and glyph-instance packing. - */ -const paragraphCascades = new WeakMap(); - -function paragraphCascade( - state: ParagraphSnapshot, -): ParagraphCascade { - const cached = paragraphCascades.get(state); - if (cached !== undefined) return cached as ParagraphCascade; - const resolved = resolveSpanCascade( - state.spans.map((span) => ({ - start: span.start, - end: span.end, - properties: statedProperties>( - span.font === undefined ? undefined : { font: span.font }, - span.style, - span.paint, - span.renderVariant === undefined ? undefined : { renderVariant: span.renderVariant }, - ), - })), - state.text.length, - 'paragraph span', - ); - paragraphCascades.set(state, resolved); - return resolved; -} - -function shapingStyleOf( - properties: StatedSpanProperties, -): ParagraphStyle | undefined { - const style = statedProperties({ - fontSize: properties.fontSize, - lineHeight: properties.lineHeight, - letterSpacing: properties.letterSpacing, - language: properties.language, - direction: properties.direction, - features: properties.features, - }); - return Object.keys(style).length === 0 ? undefined : style; -} - -function paintOf( - root: GlyphPaintInput, - properties: StatedSpanProperties, -): GlyphPaintInput | undefined { - const stated = statedProperties({ - color: properties.color, - opacity: properties.opacity, - outline: properties.outline, - shadow: properties.shadow, - }); - return Object.keys(stated).length === 0 ? undefined : { ...root, ...stated }; -} - -/** - * Paint and render variant resolve once per revision from the same cascade the - * shaping layer consumed, so packing indexes a per-glyph result instead of - * rescanning the span array for every glyph. - */ -function resolveGlyphAttribution( - state: ParagraphSnapshot, - layout: ParagraphLayout, - batchRenderVariant: Variant | undefined, -): GlyphAttribution { - const cascade = paragraphCascade(state); - const rootPaint = resolvePaint(state.paint); - const rootVariant = state.renderVariant ?? batchRenderVariant; - // An empty cascade states nothing, so every glyph inherits the paragraph root. - if (cascade.length === 0) return { kind: 'uniform', paint: rootPaint, variant: rootVariant }; - const paints = cascade.map((segment) => { - const paint = paintOf(state.paint, segment.properties); - return paint === undefined ? rootPaint : resolvePaint(paint); - }); - const variants = cascade.map((segment) => segment.properties.renderVariant ?? rootVariant); - // One segment covering the text from its start attributes every cluster alike. - if (cascade.length === 1 && cascade[0]!.start === 0) { - return { kind: 'uniform', paint: paints[0]!, variant: variants[0] }; - } - const starts = Uint32Array.from(cascade, (segment) => segment.start); - const glyphCount = layout.glyphIds.length; - const glyphPaints = new Array(glyphCount); - const glyphVariants = new Array(glyphCount); - for (let index = 0; index < glyphCount; index += 1) { - const segment = segmentIndexAt(starts, layout.clusters[index]!); - glyphPaints[index] = segment === -1 ? rootPaint : paints[segment]!; - glyphVariants[index] = segment === -1 ? rootVariant : variants[segment]; - } - return { kind: 'per-glyph', paints: glyphPaints, variants: glyphVariants }; -} - -function segmentIndexAt(starts: Uint32Array, offset: number): number { - let low = 0; - let high = starts.length - 1; - let found = -1; - while (low <= high) { - const middle = (low + high) >>> 1; - if (starts[middle]! <= offset) { - found = middle; - low = middle + 1; - } else { - high = middle - 1; - } - } - return found; -} -function layoutConstraints(box: ParagraphContentBox): import('./paragraph.js').ParagraphConstraints { - const axis = ( - value: ParagraphAxisConstraint | undefined, - ): import('./paragraph.js').ParagraphAxisConstraint | undefined => - value?.mode === 'exact' ? { mode: 'exactly', size: value.size } : value; - return { - ...(box.width === undefined ? {} : { width: axis(box.width)! }), - ...(box.height === undefined ? {} : { height: axis(box.height)! }), - ...(box.maxLines === undefined ? {} : { maxLines: box.maxLines }), - ...(box.wrap === undefined ? {} : { wrap: box.wrap }), - ...(box.align === undefined ? {} : { align: box.align }), - ...(box.overflow === undefined ? {} : { overflow: box.overflow }), - }; -} - -interface TechniquePackingOperations { - select( - input: RasterGlyphInput['data']>, - ): RasterGlyphSelection> | undefined; - createStorage(capacity: number): GlyphBatchStorageOf; - writeStorage( - storage: GlyphBatchStorageOf, - range: GlyphRange, - input: { - readonly data: LoadedFont['data']; - readonly binding: RasterBindingOf; - readonly glyphs: readonly RasterGlyphInput['data']>[]; - }, - ): void; -} - -function packingOperations( - technique: Technique, -): TechniquePackingOperations { - return technique as unknown as TechniquePackingOperations; -} -function normalizeCapacity(value: GlyphBufferCapacity): GlyphBufferCapacity { - if (!Number.isSafeInteger(value.size) || value.size <= 0) - throw new RangeError('glyph capacity size must be a positive safe integer'); - if (value.policy !== 'grow' && value.policy !== 'chunk' && value.policy !== 'fixed') - throw new TypeError('glyph capacity policy is invalid'); - return Object.freeze({ size: value.size, policy: value.policy }); -} -function grownCapacity(size: number, required: number): number { - let value = size; - while (value < required) value *= 2; - return value; -} -function positive(value: number, name: string): number { - if (!Number.isFinite(value) || value <= 0) throw new RangeError(`${name} must be positive and finite`); - return value; -} -function finite(value: number, name: string): number { - if (!Number.isFinite(value)) throw new RangeError(`${name} must be finite`); - return value; -} -function resolvePaint(input: GlyphPaintInput): ResolvedPaint { - const opacity = input.opacity ?? 1; - return Object.freeze({ - color: color(input.color ?? '#ffffff', opacity), - ...(input.outline === undefined - ? {} - : { - outline: Object.freeze({ - color: color(input.outline.color, opacity), - width: positive(input.outline.width, 'outline width'), - }), - }), - ...(input.shadow === undefined - ? {} - : { shadow: Object.freeze({ color: color(input.shadow.color, opacity), offset: input.shadow.offset }) }), - }); -} -function color( - value: GlyphPaintInput['color'] extends infer _ ? import('./formatted-text.js').ColorInput : never, - opacity: number, -): readonly [number, number, number, number] { - if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1) throw new RangeError('opacity must be in [0, 1]'); - if (typeof value !== 'string') return [value[0], value[1], value[2], value[3] * opacity]; - const match = /^#([0-9a-f]{6}|[0-9a-f]{8})$/iu.exec(value); - if (match === null) throw new TypeError('colors must be #rrggbb, #rrggbbaa, or linear RGBA'); - const hex = match[1]!; - const channel = (at: number) => { - const srgb = Number.parseInt(hex.slice(at, at + 2), 16) / 255; - return srgb <= 0.04045 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4; - }; - return [ - channel(0), - channel(2), - channel(4), - (hex.length === 8 ? Number.parseInt(hex.slice(6), 16) / 255 : 1) * opacity, - ]; -} diff --git a/packages/text/src/paragraph.ts b/packages/text/src/paragraph.ts deleted file mode 100644 index 98c954d1..00000000 --- a/packages/text/src/paragraph.ts +++ /dev/null @@ -1,2047 +0,0 @@ -import type { FontHandle } from './identity.js'; -import type { ParagraphLayout, ParagraphMeasurement } from './layout.js'; -import type { FontFeature, ResolvedFontFeature } from './font-feature.js'; -import type { RegisteredFont } from './font.js'; -import type { BidiAnalysisViews, ReshapeRange, RuntimeShaper, ShapeBatchRequest, ShapedBatchViews } from './shaper.js'; -import { analyzeUnicodeText, type UnicodeTextAnalysis } from './internal/unicode.js'; -import { resolveSpanCascade, type SpanCascadeEntry } from './internal/span-cascade.js'; - -/** - * A layout-system-neutral axis constraint. - * - * These modes describe the common constraint vocabulary used by flex, grid, - * retained UI, and application-owned layout systems. - */ -export type ParagraphAxisConstraint = - | { readonly mode: 'unconstrained' } - | { readonly mode: 'at-most'; readonly size: number } - | { readonly mode: 'exactly'; readonly size: number }; - -export interface ParagraphStyle { - readonly fontSize?: number; - readonly lineHeight?: number; - readonly letterSpacing?: number; - readonly language?: string; - readonly direction?: 'auto' | 'ltr' | 'rtl'; - readonly features?: readonly FontFeature[]; -} - -export interface ParagraphSpan extends ParagraphStyle { - readonly start: number; - readonly end: number; - readonly font?: FontHandle; -} - -export interface ParagraphInput { - readonly text: string; - readonly font: FontHandle; - readonly spans?: readonly ParagraphSpan[]; - readonly style?: ParagraphStyle; -} - -export interface ParagraphConstraints { - /** Defaults to `{ mode: 'unconstrained' }`. */ - readonly width?: ParagraphAxisConstraint; - /** Defaults to `{ mode: 'unconstrained' }`. */ - readonly height?: ParagraphAxisConstraint; - readonly maxLines?: number; - readonly wrap?: 'none' | 'word' | 'character'; - readonly align?: 'start' | 'center' | 'end' | 'justify'; - readonly overflow?: 'visible' | 'clip' | 'ellipsis'; -} - -/** - * A prepared paragraph has no asynchronous methods. Font and shaper - * dependencies must be loaded before it is exposed to a synchronous host - * layout system. - */ -export interface Paragraph { - /** Resolve box metrics without materializing positioned glyph arrays. */ - measure(constraints?: ParagraphConstraints): ParagraphMeasurement; - /** Resolve the final box and materialize positioned glyph output. */ - layout(constraints?: ParagraphConstraints): ParagraphLayout; - /** - * Shaped glyph identity, before line breaking and positioning. A caller resolving font fallback needs to know which - * clusters shaped to `.notdef` and nothing else, and that answer must not depend on where lines happen to break. - */ - shaped(): ShapedGlyphIdentity; - update(input: ParagraphInput): void; - dispose(): void; -} - -/** Glyph identity in shaping order, covering every run of the paragraph. */ -export interface ShapedGlyphIdentity { - readonly glyphIds: Uint16Array; - readonly clusters: Uint32Array; -} - -export interface ParagraphEngine { - create(input: ParagraphInput): Paragraph; -} - -export interface ParagraphEngineOptions { - readonly shaper: RuntimeShaper; -} - -interface ResolvedStyle { - readonly font: FontHandle; - readonly fontSize: number; - readonly lineHeight?: number; - readonly letterSpacing: number; - readonly language?: string; - readonly direction: 'auto' | 'ltr' | 'rtl'; - readonly bidiOverride?: 'ltr' | 'rtl'; - readonly features: readonly ResolvedFontFeature[]; -} - -interface StyleSegment { - readonly start: number; - readonly end: number; - readonly style: ResolvedStyle; -} - -/** The shaping properties one span states, before the cascade merges them. */ -interface StatedSpanStyle { - readonly font?: FontHandle; - readonly fontSize?: number; - readonly lineHeight?: number; - readonly letterSpacing?: number; - readonly language?: string; - readonly direction?: 'auto' | 'ltr' | 'rtl'; - readonly features?: readonly ResolvedFontFeature[]; -} - -interface PreparedRun extends StyleSegment { - readonly script: string; - readonly direction: 'ltr' | 'rtl'; - readonly bidiLevel: number; -} - -interface OwnedBidiAnalysis { - readonly levels: Uint8Array; - readonly classes: Uint8Array; - readonly paragraphStarts: Uint32Array; - readonly paragraphEnds: Uint32Array; - readonly paragraphLevels: Uint8Array; -} - -interface OwnedShape { - readonly fontHandles: Uint32Array; - readonly runFontSlots: Uint16Array; - readonly runGlyphStarts: Uint32Array; - readonly runGlyphCounts: Uint32Array; - readonly glyphIds: Uint16Array; - readonly clusters: Uint32Array; - readonly xAdvances: Int32Array; - readonly yAdvances: Int32Array; - readonly xOffsets: Int32Array; - readonly yOffsets: Int32Array; - readonly glyphFlags: Uint16Array; -} - -/** - * Every extended grapheme cluster of the paragraph, as parallel arrays rather than one object per cluster. A paragraph - * holds tens of thousands of clusters and every update rebuilt all of them, which made short-lived cluster objects the - * largest single source of garbage on the layout path. - * - * The backing buffers are retained across updates by {@link measureClusters} and only ever grow, so a steady-state - * animation reuses them. `count` is the live length; the views are sliced to it, and the buffers behind them may be - * larger. - */ -interface MeasuredClusters { - readonly count: number; - readonly starts: Uint32Array; - readonly ends: Uint32Array; - /** - * Double precision. Line breaking accumulates a line advance from these one cluster at a time and the result is - * compared against the width limit, so rounding each cluster to single precision would move where lines break. - */ - readonly advances: Float64Array; - /** {@link CLUSTER_SAFE_BEFORE}, {@link CLUSTER_REQUIRED_BREAK}, and {@link CLUSTER_HARD_BREAK}. */ - readonly flags: Uint8Array; - /** Index into {@link PreparedParagraph.styles}, so a cluster carries its style without holding a reference. */ - readonly styleIndexes: Uint32Array; -} - -interface LineMetrics { - readonly height: number; - readonly baseline: number; -} - -interface LinePlan extends LineMetrics { - readonly clusterStart: number; - readonly clusterEnd: number; - readonly textStart: number; - readonly textEnd: number; - readonly advance: number; - readonly hardBreak: boolean; - readonly ellipsis?: EllipsisPlan; -} - -interface EllipsisPlan { - readonly sourceRun: number; - readonly shapeRun: number; - readonly textStart: number; - readonly textEnd: number; - readonly cluster: number; - readonly advance: number; - readonly level: number; -} - -interface PreparedEllipsis { - readonly sourceRun: number; - readonly shapeRun: number; - readonly textStart: number; - readonly textEnd: number; - readonly advance: number; -} - -interface PreparedParagraph { - readonly input: ParagraphInput; - readonly unicode: UnicodeTextAnalysis; - readonly bidi: OwnedBidiAnalysis; - readonly styles: readonly StyleSegment[]; - readonly runs: readonly PreparedRun[]; - readonly request: ShapeBatchRequest; - readonly shape: OwnedShape; - readonly ellipses: readonly PreparedEllipsis[]; - readonly clusters: MeasuredClusters; - /** - * Cluster index for every text offset, so resolving an offset to a cluster is a load rather than a binary search. - * Positioning resolves two offsets at every cluster boundary of every glyph, which made that search the hottest leaf - * in a browser profile of the layout path. - */ - readonly clusterIndexAt: Uint32Array; - readonly letterSpacingPrefix: Float64Array; - readonly spacePrefix: Uint32Array; -} - -interface NormalizedConstraints { - readonly width: ParagraphAxisConstraint; - readonly height: ParagraphAxisConstraint; - readonly maxLines?: number; - readonly wrap: 'none' | 'word' | 'character'; - readonly align: 'start' | 'center' | 'end' | 'justify'; - readonly overflow: 'visible' | 'clip' | 'ellipsis'; -} - -interface MeasuredPlan { - readonly measurement: ParagraphMeasurement; - readonly lines: readonly LinePlan[]; -} - -/** - * A fragment under construction. {@link collectLineFragments} appends fragments in logical order, then fills the - * shaping flags and the reshape decision in place once the line's first and last fragments are known, and reorders the - * line's own range in place. Published fragments are read-only. - */ -interface LineFragmentDraft { - line: number; - run: number; - start: number; - end: number; - flags: number; - level: number; - ellipsis?: EllipsisPlan; - reshape: boolean; -} - -type LineFragment = Readonly; - -interface PositionedGeometry { - readonly fontHandles: Uint32Array; - readonly glyphFontSlots: Uint16Array; - readonly glyphIds: Uint16Array; - readonly clusters: Uint32Array; - readonly glyphFontSizes: Float32Array; - readonly x: Float32Array; - readonly y: Float32Array; - readonly glyphFlags: Uint16Array; - readonly lineTextStarts: Uint32Array; - readonly lineTextEnds: Uint32Array; - readonly lineGlyphStarts: Uint32Array; - readonly lineGlyphCounts: Uint32Array; - readonly lineBaselines: Float32Array; - readonly lineAdvances: Float32Array; -} - -interface PreparedPositioning { - readonly fragments: readonly LineFragment[]; - readonly reshaped?: OwnedShape; -} - -const DEFAULT_FONT_SIZE = 16; -const MAX_PARAGRAPH_CACHE_ENTRIES = 32; -const PRODUCE_UNSAFE_TO_CONCAT = 0x40; -const BEGINNING_OF_TEXT = 0x01; -const END_OF_TEXT = 0x02; -const GLYPH_UNSAFE_TO_BREAK = 0x01; -/** The cluster starts at a shaping boundary that shaping did not mark unsafe to break. */ -const CLUSTER_SAFE_BEFORE = 0x01; -/** Unicode line breaking requires a break after the cluster. */ -const CLUSTER_REQUIRED_BREAK = 0x02; -/** The cluster is a hard line separator rather than drawable text. */ -const CLUSTER_HARD_BREAK = 0x04; -/** A text offset that shaping treated as a run or cluster boundary. */ -const OFFSET_SHAPED_BOUNDARY = 0x01; -/** A text offset that shaping marked unsafe to break at. */ -const OFFSET_UNSAFE_TO_BREAK = 0x02; -const BIDI_BN = 9; -const BIDI_B = 10; -const BIDI_S = 11; -const BIDI_WS = 12; -const BIDI_LRE = 14; -const BIDI_LRO = 15; -const BIDI_RLE = 16; -const BIDI_RLO = 17; -const BIDI_PDF = 18; -const BIDI_LRI = 19; -const BIDI_RLI = 20; -const BIDI_FSI = 21; -const BIDI_PDI = 22; - -export function createParagraphEngine(options: ParagraphEngineOptions): ParagraphEngine { - if (options?.shaper === undefined) throw new TypeError('paragraph engine requires a shaper'); - return new ParagraphEngineImpl(options.shaper); -} - -class ParagraphEngineImpl implements ParagraphEngine { - readonly #shaper: RuntimeShaper; - - constructor(shaper: RuntimeShaper) { - this.#shaper = shaper; - } - - create(input: ParagraphInput): Paragraph { - return new ParagraphImpl(this.#shaper, input); - } -} - -class ParagraphImpl implements Paragraph { - readonly #shaper: RuntimeShaper; - readonly #measurements = new Map(); - readonly #linePlans = new Map(); - readonly #positioning = new Map(); - readonly #positionedLines = new Map(); - readonly #layouts = new Map(); - #prepared: PreparedParagraph; - #disposed = false; - - constructor(shaper: RuntimeShaper, input: ParagraphInput) { - this.#shaper = shaper; - this.#prepared = prepareParagraph(shaper, input); - } - - measure(constraints?: ParagraphConstraints): ParagraphMeasurement { - this.#assertActive(); - const normalized = normalizeConstraints(constraints); - return this.#measurePlan(normalized).measurement; - } - - layout(constraints?: ParagraphConstraints): ParagraphLayout { - this.#assertActive(); - const normalized = normalizeConstraints(constraints); - const key = constraintKey(normalized); - let layout = getRecent(this.#layouts, key); - if (layout !== undefined) return layout; - const measured = this.#measurePlan(normalized); - const positioningKey = positioningLinesKey(measured.lines); - const lineKey = geometryLinesKey(positioningKey, normalized.align, measured.measurement.width); - let geometry = getRecent(this.#positionedLines, lineKey); - if (geometry === undefined) { - let positioning = getRecent(this.#positioning, positioningKey); - if (positioning === undefined) { - positioning = preparePositioning(this.#shaper, this.#prepared, measured.lines); - retainRecent(this.#positioning, positioningKey, positioning); - } - geometry = positionPrepared( - this.#shaper, - this.#prepared, - measured.lines, - positioning, - normalized, - measured.measurement.width, - ); - retainRecent(this.#positionedLines, lineKey, geometry); - } - layout = Object.freeze({ - ...measurementForGeometry(normalized, measured.measurement, geometry), - ...geometry, - }); - retainRecent(this.#layouts, key, layout); - return layout; - } - - shaped(): ShapedGlyphIdentity { - this.#assertActive(); - const { shape, runs } = this.#prepared; - // The shaping request appends one ellipsis run per source run, clustered past the end of the text, so a caller - // inspecting glyph identity must not see them: they are how overflow is measured, not glyphs of this paragraph. - // Those runs are requested after every source run, so the first of them bounds the paragraph's own glyphs. - const end = - runs.length < shape.runGlyphStarts.length ? (shape.runGlyphStarts[runs.length] ?? 0) : shape.glyphIds.length; - return { glyphIds: shape.glyphIds.subarray(0, end), clusters: shape.clusters.subarray(0, end) }; - } - - update(input: ParagraphInput): void { - this.#assertActive(); - this.#prepared = prepareParagraph(this.#shaper, input, this.#prepared); - this.#measurements.clear(); - this.#linePlans.clear(); - this.#positioning.clear(); - this.#positionedLines.clear(); - this.#layouts.clear(); - } - - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - this.#measurements.clear(); - this.#linePlans.clear(); - this.#positioning.clear(); - this.#positionedLines.clear(); - this.#layouts.clear(); - } - - #measurePlan(constraints: NormalizedConstraints): MeasuredPlan { - const key = constraintKey(constraints); - let plan = getRecent(this.#measurements, key); - if (plan === undefined) { - const lineKey = linePlanConstraintKey(constraints); - let lines = getRecent(this.#linePlans, lineKey); - if (lines === undefined) { - lines = planLines(this.#shaper, this.#prepared, constraints); - retainRecent(this.#linePlans, lineKey, lines); - } - plan = measurePrepared(this.#prepared, constraints, lines); - retainRecent(this.#measurements, key, plan); - } - return plan; - } - - #assertActive(): void { - if (this.#disposed) throw new Error('paragraph has been disposed'); - } -} - -function getRecent(cache: Map, key: Key): Value | undefined { - const value = cache.get(key); - if (value === undefined) return undefined; - cache.delete(key); - cache.set(key, value); - return value; -} - -function retainRecent(cache: Map, key: Key, value: Value): void { - cache.delete(key); - cache.set(key, value); - if (cache.size <= MAX_PARAGRAPH_CACHE_ENTRIES) return; - const oldest = cache.keys().next(); - if (!oldest.done) cache.delete(oldest.value); -} - -function prepareParagraph( - shaper: RuntimeShaper, - input: ParagraphInput, - previous?: PreparedParagraph, -): PreparedParagraph { - const ownedInput = copyInput(input); - // Grapheme boundaries, line break opportunities, script items, and bidi levels are decided by the text and its base - // direction and by nothing else, so a resize, a colour change, or a letter-spacing change all recompute a result - // identical to the retained one. Both are owned, immutable products, so reusing them is a pointer copy. - const sameText = - previous !== undefined && - previous.input.text === ownedInput.text && - (previous.input.style?.direction ?? 'auto') === (ownedInput.style?.direction ?? 'auto'); - const unicode = sameText ? previous.unicode : analyzeUnicodeText(ownedInput.text); - const styles = resolveStyles(shaper, ownedInput, unicode.graphemeBoundaries); - const bidi = sameText - ? previous.bidi - : ownBidi(shaper.analyzeBidi(utf16(ownedInput.text), ownedInput.style?.direction ?? 'auto')); - const runs = prepareRuns(ownedInput.text, styles, unicode, bidi); - const shapedRequest = shapeRequest(ownedInput.text, runs); - const request = shapedRequest.request; - // Shaping is deterministic in its request, and the request carries no font size, line height, or letter spacing — - // those scale the shaped advances afterward. An animated resize therefore rebuilds an identical request, so reusing - // the retained shape skips the whole shaping pass while every measurement below still recomputes at the new size. - // A shape is plain owned typed arrays that nothing releases, so retaining one across preparations is safe. - const reused = previous !== undefined && sameShapeRequest(previous.request, request) ? previous.shape : undefined; - const shape = reused ?? ownShape(request.runs.length === 0 ? emptyShape() : shaper.shapeBatch(request)); - const ellipses = measureEllipses(shaper, runs, shape, shapedRequest.ellipses); - const clusters = measureClusters(shaper, ownedInput.text, unicode, styles, runs, shape, previous); - const clusterIndexes = indexClusters(ownedInput.text, styles, clusters, previous); - return { - input: ownedInput, - unicode, - bidi, - styles, - runs, - request, - shape, - ellipses, - clusters, - ...clusterIndexes, - }; -} - -function copyInput(input: ParagraphInput): ParagraphInput { - if (!isNonArrayObject(input)) throw new TypeError('paragraph input must be an object'); - if (typeof input.text !== 'string') throw new TypeError('paragraph text must be a string'); - if (input.style !== undefined && !isNonArrayObject(input.style)) { - throw new TypeError('paragraph style must be an object'); - } - if (input.spans !== undefined && !Array.isArray(input.spans)) { - throw new TypeError('paragraph spans must be an array'); - } - return { - text: input.text, - font: input.font, - ...(input.style === undefined ? {} : { style: copyStyle(input.style, 'paragraph style') }), - ...(input.spans === undefined - ? {} - : { - spans: input.spans.map((span) => { - if (!isNonArrayObject(span)) throw new TypeError('paragraph span must be an object'); - return { - ...copyStyle(span, 'paragraph span'), - start: span.start, - end: span.end, - ...(span.font === undefined ? {} : { font: span.font }), - }; - }), - }), - }; -} - -function copyStyle(style: ParagraphStyle, name: string): ParagraphStyle { - if (!isNonArrayObject(style)) throw new TypeError(`${name} must be an object`); - if (style.features !== undefined && !Array.isArray(style.features)) { - throw new TypeError(`${name} features must be an array`); - } - if ( - style.direction !== undefined && - style.direction !== 'auto' && - style.direction !== 'ltr' && - style.direction !== 'rtl' - ) { - throw new RangeError(`${name} direction must be auto, ltr, or rtl`); - } - return { - ...(style.fontSize === undefined ? {} : { fontSize: style.fontSize }), - ...(style.lineHeight === undefined ? {} : { lineHeight: style.lineHeight }), - ...(style.letterSpacing === undefined ? {} : { letterSpacing: style.letterSpacing }), - ...(style.language === undefined ? {} : { language: style.language }), - ...(style.direction === undefined ? {} : { direction: style.direction }), - ...(style.features === undefined - ? {} - : { - features: style.features.map((feature) => { - if (!isNonArrayObject(feature)) throw new TypeError(`${name} feature must be an object`); - return { ...feature }; - }), - }), - }; -} - -/** - * Shaping resolution consumes the one span cascade before run segmentation, so - * a span's font, size, language, direction, or features re-segment shaping and - * change advances rather than only scaling already-shaped glyphs. - */ -function resolveStyles( - shaper: RuntimeShaper, - input: ParagraphInput, - graphemeBoundaries: Uint32Array, -): readonly StyleSegment[] { - const legalBoundaries = new Set(graphemeBoundaries); - const entries: SpanCascadeEntry[] = []; - for (const span of input.spans ?? []) { - assertTextRange(span.start, span.end, input.text.length, 'paragraph span'); - if (!legalBoundaries.has(span.start) || !legalBoundaries.has(span.end)) { - throw new RangeError('paragraph span boundaries must be extended-grapheme boundaries'); - } - entries.push({ start: span.start, end: span.end, properties: resolveSpanStyle(shaper, span) }); - } - const root = resolveStyle(shaper, input.font, input.style ?? {}, 0, input.text.length); - if (input.text.length === 0) return [{ start: 0, end: 0, style: root }]; - const segments: StyleSegment[] = []; - for (const segment of resolveSpanCascade(entries, input.text.length, 'paragraph span')) { - const style = styleOver(root, segment.properties); - const previous = segments.at(-1); - if (previous !== undefined && previous.end === segment.start && equalStyles(previous.style, style)) { - segments[segments.length - 1] = { ...previous, end: segment.end }; - } else { - segments.push({ start: segment.start, end: segment.end, style }); - } - } - return segments; -} - -function resolveSpanStyle(shaper: RuntimeShaper, span: ParagraphSpan): StatedSpanStyle { - if (span.font !== undefined) shaper.registerFont(requireFont(shaper, span.font)); - const lineHeight = span.lineHeight === undefined ? undefined : finitePositive(span.lineHeight, 'lineHeight'); - const direction = span.direction; - const language = span.language === undefined ? undefined : normalizeLanguage(span.language); - return { - ...(span.font === undefined ? {} : { font: span.font }), - ...(span.fontSize === undefined ? {} : { fontSize: finitePositive(span.fontSize, 'fontSize') }), - ...(lineHeight === undefined ? {} : { lineHeight }), - ...(span.letterSpacing === undefined ? {} : { letterSpacing: finite(span.letterSpacing, 'letterSpacing') }), - ...(language === undefined ? {} : { language }), - ...(direction === undefined ? {} : { direction }), - ...(span.features === undefined ? {} : { features: resolveFeatures(span.features, span.start, span.end) }), - }; -} - -/** The paragraph is the outermost scope, so an unstated property inherits from it. */ -function styleOver(root: ResolvedStyle, stated: StatedSpanStyle): ResolvedStyle { - const lineHeight = stated.lineHeight ?? root.lineHeight; - const language = stated.language ?? root.language; - const direction = stated.direction ?? root.direction; - return { - font: stated.font ?? root.font, - fontSize: stated.fontSize ?? root.fontSize, - ...(lineHeight === undefined ? {} : { lineHeight }), - letterSpacing: stated.letterSpacing ?? root.letterSpacing, - ...(language === undefined ? {} : { language }), - direction, - ...(stated.direction === undefined || direction === 'auto' ? {} : { bidiOverride: direction }), - features: stated.features ?? root.features, - }; -} - -function resolveStyle( - shaper: RuntimeShaper, - fontHandle: FontHandle, - style: ParagraphStyle, - start: number, - end: number, -): ResolvedStyle { - const font = requireFont(shaper, fontHandle); - shaper.registerFont(font); - const fontSize = finitePositive(style.fontSize ?? DEFAULT_FONT_SIZE, 'fontSize'); - const lineHeight = style.lineHeight === undefined ? undefined : finitePositive(style.lineHeight, 'lineHeight'); - const letterSpacing = finite(style.letterSpacing ?? 0, 'letterSpacing'); - const language = normalizeLanguage(style.language); - const direction = style.direction ?? 'auto'; - return { - font: fontHandle, - fontSize, - ...(lineHeight === undefined ? {} : { lineHeight }), - letterSpacing, - ...(language === undefined ? {} : { language }), - direction, - features: resolveFeatures(style.features ?? [], start, end), - }; -} - -function resolveFeatures( - features: readonly FontFeature[], - containingStart: number, - containingEnd: number, -): readonly ResolvedFontFeature[] { - return features.flatMap((feature) => { - const start = feature.start ?? containingStart; - const end = feature.end ?? containingEnd; - // A feature that states no range covers whatever it is applied to, so an empty containing range makes it - // vacuous rather than invalid. Rejecting it would fail an ordinary feature-styled field before its first - // character is typed. An explicitly empty range is still a caller error. - if (feature.start === undefined && feature.end === undefined && start === end) return []; - assertTextRange(start, end, containingEnd, `feature ${feature.tag}`); - if (start < containingStart) throw new RangeError(`feature ${feature.tag} starts before its style range`); - const value = feature.value ?? 1; - if (!Number.isSafeInteger(value) || value < 0 || value > 0xffff_ffff) { - throw new RangeError(`feature ${feature.tag} value must be a uint32`); - } - return [{ tag: feature.tag, value, start, end }]; - }); -} - -function prepareRuns( - text: string, - styles: readonly StyleSegment[], - unicode: UnicodeTextAnalysis, - bidi: OwnedBidiAnalysis, -): readonly PreparedRun[] { - const runs: PreparedRun[] = []; - const bidiItems = bidiRuns(bidi.levels); - let styleIndex = 0; - let scriptIndex = 0; - let bidiIndex = 0; - while (styleIndex < styles.length && scriptIndex < unicode.scriptItems.length && bidiIndex < bidiItems.length) { - const style = styles[styleIndex]; - const script = unicode.scriptItems[scriptIndex]; - const bidiRun = bidiItems[bidiIndex]; - if (style === undefined || script === undefined || bidiRun === undefined) break; - const start = Math.max(style.start, script.start, bidiRun.start); - const end = Math.min(style.end, script.end, bidiRun.end); - if (start < end) { - const direction = style.style.bidiOverride ?? directionForLevel(bidiRun.level); - const bidiLevel = - style.style.bidiOverride === undefined ? bidiRun.level : forceLevelDirection(bidiRun.level, direction); - for (const fragment of drawableFragments(text, start, end)) { - appendPreparedRun(runs, { - ...fragment, - style: style.style, - script: script.script, - direction, - bidiLevel, - }); - } - } - const boundary = Math.min(style.end, script.end, bidiRun.end); - if (style.end === boundary) styleIndex += 1; - if (script.end === boundary) scriptIndex += 1; - if (bidiRun.end === boundary) bidiIndex += 1; - } - if (runs.length === 0) { - const fallback = styles[0]; - if (fallback !== undefined) { - const level = bidi.levels[0] ?? bidi.paragraphLevels[0] ?? 0; - runs.push({ - start: fallback.start, - end: fallback.start, - style: fallback.style, - script: 'Zyyy', - direction: fallback.style.bidiOverride ?? directionForLevel(level), - bidiLevel: level, - }); - } - } - return runs; -} - -function appendPreparedRun(runs: PreparedRun[], run: PreparedRun): void { - const previous = runs.at(-1); - if ( - previous !== undefined && - previous.end === run.start && - previous.script === run.script && - previous.direction === run.direction && - previous.bidiLevel === run.bidiLevel && - equalStyles(previous.style, run.style) - ) { - runs[runs.length - 1] = { ...previous, end: run.end }; - } else { - runs.push(run); - } -} - -function bidiRuns(levels: Uint8Array): readonly { - readonly start: number; - readonly end: number; - readonly level: number; -}[] { - const runs = []; - let start = 0; - while (start < levels.length) { - const level = levels[start]; - if (level === undefined) break; - let end = start + 1; - while (end < levels.length && levels[end] === level) end += 1; - runs.push({ start, end, level }); - start = end; - } - return runs; -} - -function directionForLevel(level: number): 'ltr' | 'rtl' { - return (level & 1) === 0 ? 'ltr' : 'rtl'; -} - -function forceLevelDirection(level: number, direction: 'ltr' | 'rtl'): number { - return directionForLevel(level) === direction ? level : level + 1; -} - -function shapeRequest( - text: string, - runs: readonly PreparedRun[], -): { - readonly request: ShapeBatchRequest; - readonly ellipses: readonly Omit[]; -} { - const features: ResolvedFontFeature[] = []; - const shapeRuns = runs.map((run) => { - const selected = run.style.features.filter((feature) => feature.start < run.end && feature.end > run.start); - const featureStart = features.length; - features.push(...selected); - return { - font: run.style.font, - textStart: run.start, - textEnd: run.end, - direction: run.direction, - script: run.script, - ...(run.style.language === undefined ? {} : { language: run.style.language }), - clusterLevel: 0 as const, - flags: PRODUCE_UNSAFE_TO_CONCAT, - featureStart, - featureCount: selected.length, - }; - }); - const ellipses = runs.map((run, sourceRun) => { - const textStart = text.length + sourceRun; - const shapeRun = shapeRuns.length; - shapeRuns.push({ - font: run.style.font, - textStart, - textEnd: textStart + 1, - direction: run.direction, - script: run.script, - ...(run.style.language === undefined ? {} : { language: run.style.language }), - clusterLevel: 0 as const, - flags: PRODUCE_UNSAFE_TO_CONCAT, - featureStart: features.length, - featureCount: 0, - }); - return { sourceRun, shapeRun, textStart, textEnd: textStart + 1 }; - }); - return { - request: { textUtf16: utf16(`${text}${'…'.repeat(runs.length)}`), runs: shapeRuns, features }, - ellipses, - }; -} - -function measureEllipses( - shaper: RuntimeShaper, - runs: readonly PreparedRun[], - shape: OwnedShape, - ellipses: readonly Omit[], -): readonly PreparedEllipsis[] { - return ellipses.map((ellipsis) => { - const run = runs[ellipsis.sourceRun]; - const glyphStart = shape.runGlyphStarts[ellipsis.shapeRun]; - const glyphCount = shape.runGlyphCounts[ellipsis.shapeRun]; - if (run === undefined || glyphStart === undefined || glyphCount === undefined) { - throw new Error('shaper returned an incomplete ellipsis run'); - } - const font = requireFont(shaper, run.style.font); - const scale = run.style.fontSize / font.metrics.unitsPerEm; - let advance = 0; - for (let glyph = glyphStart; glyph < glyphStart + glyphCount; glyph += 1) { - advance += Math.abs(shape.xAdvances[glyph] ?? 0) * scale; - } - return { ...ellipsis, advance }; - }); -} - -function measureClusters( - shaper: RuntimeShaper, - text: string, - unicode: UnicodeTextAnalysis, - styles: readonly StyleSegment[], - runs: readonly PreparedRun[], - shape: OwnedShape, - previous?: PreparedParagraph, -): MeasuredClusters { - // Shaping results are gathered by text offset rather than into a map and two sets keyed by offset. Every glyph of the - // paragraph touched all three, so the hash entries were themselves a per-glyph allocation. Advances still accumulate - // in the shaped order and in double precision, so the totals are unchanged. - const offsets = text.length + 1; - const offsetAdvances = new Float64Array(offsets); - const offsetFlags = new Uint8Array(offsets); - for (let runIndex = 0; runIndex < runs.length; runIndex += 1) { - const run = runs[runIndex]; - const glyphStart = shape.runGlyphStarts[runIndex]; - const glyphCount = shape.runGlyphCounts[runIndex]; - if (run === undefined || glyphStart === undefined || glyphCount === undefined) { - throw new Error('shaper returned an incomplete run table'); - } - const font = requireFont(shaper, run.style.font); - const scale = run.style.fontSize / font.metrics.unitsPerEm; - offsetFlags[run.start] = (offsetFlags[run.start] ?? 0) | OFFSET_SHAPED_BOUNDARY; - offsetFlags[run.end] = (offsetFlags[run.end] ?? 0) | OFFSET_SHAPED_BOUNDARY; - for (let glyph = glyphStart; glyph < glyphStart + glyphCount; glyph += 1) { - const cluster = shape.clusters[glyph]; - const advance = shape.xAdvances[glyph]; - const flags = shape.glyphFlags[glyph]; - if (cluster === undefined || advance === undefined || flags === undefined) { - throw new Error('shaper returned an incomplete glyph table'); - } - offsetAdvances[cluster] = (offsetAdvances[cluster] ?? 0) + Math.abs(advance) * scale; - offsetFlags[cluster] = - (offsetFlags[cluster] ?? 0) | - OFFSET_SHAPED_BOUNDARY | - ((flags & GLYPH_UNSAFE_TO_BREAK) !== 0 ? OFFSET_UNSAFE_TO_BREAK : 0); - } - } - const requiredBreaks = new Uint8Array(offsets); - for (const entry of unicode.lineBreaks) requiredBreaks[entry.position] = entry.required ? 1 : 0; - - const count = Math.max(0, unicode.graphemeBoundaries.length - 1); - const retained = previous?.clusters; - const starts = reuseTypedArray(retained?.starts, count, (capacity) => new Uint32Array(capacity).subarray(0, count)); - const ends = reuseTypedArray(retained?.ends, count, (capacity) => new Uint32Array(capacity).subarray(0, count)); - const clusterAdvances = reuseTypedArray(retained?.advances, count, (capacity) => - new Float64Array(capacity).subarray(0, count), - ); - const flags = reuseTypedArray(retained?.flags, count, (capacity) => new Uint8Array(capacity).subarray(0, count)); - const styleIndexes = reuseTypedArray(retained?.styleIndexes, count, (capacity) => - new Uint32Array(capacity).subarray(0, count), - ); - let styleIndex = 0; - for (let index = 0; index < count; index += 1) { - const start = unicode.graphemeBoundaries[index] ?? 0; - const end = unicode.graphemeBoundaries[index + 1] ?? 0; - while ((styles[styleIndex]?.end ?? Number.POSITIVE_INFINITY) <= start) styleIndex += 1; - const styleSegment = styles[styleIndex]; - if (styleSegment === undefined || styleSegment.start > start) { - throw new Error(`paragraph offset ${start} has no resolved style`); - } - const hardBreak = isHardBreak(text, start); - const boundary = offsetFlags[start] ?? 0; - starts[index] = start; - ends[index] = end; - clusterAdvances[index] = (offsetAdvances[start] ?? 0) + (hardBreak ? 0 : styleSegment.style.letterSpacing); - styleIndexes[index] = styleIndex; - flags[index] = - ((boundary & (OFFSET_SHAPED_BOUNDARY | OFFSET_UNSAFE_TO_BREAK)) === OFFSET_SHAPED_BOUNDARY - ? CLUSTER_SAFE_BEFORE - : 0) | - (requiredBreaks[end] === 1 ? CLUSTER_REQUIRED_BREAK : 0) | - (hardBreak ? CLUSTER_HARD_BREAK : 0); - } - return { count, starts, ends, advances: clusterAdvances, flags, styleIndexes }; -} - -/** The style a cluster resolved to during measurement. */ -function clusterStyle(prepared: PreparedParagraph, index: number): ResolvedStyle { - const style = prepared.styles[prepared.clusters.styleIndexes[index] ?? 0]?.style; - if (style === undefined) throw new Error(`cluster ${index} has no resolved style`); - return style; -} - -/** Smallest retained index capacity. Ordinary paragraphs never pay a growth step on their first frames. */ -const MINIMUM_CLUSTER_INDEX_CAPACITY = 512; - -/** - * Reuses a retained buffer's memory when it already holds `length` elements, and otherwise grows to a high watermark - * that later preparations reuse. The returned view carries the live length, so binary searches over it stay correct - * while the backing allocation outlives any single preparation. - */ -function reuseTypedArray( - previous: Array | undefined, - length: number, - construct: (capacity: number) => Array, -): Array { - const capacity = previous === undefined ? 0 : previous.buffer.byteLength / previous.BYTES_PER_ELEMENT; - if (previous !== undefined && capacity >= length) { - const view = new (previous.constructor as new (buffer: ArrayBufferLike, offset: number, length: number) => Array)( - previous.buffer, - 0, - length, - ); - view.fill(0); - return view; - } - return construct(Math.max(length, MINIMUM_CLUSTER_INDEX_CAPACITY, capacity * 2)); -} - -function indexClusters( - text: string, - styles: readonly StyleSegment[], - clusters: MeasuredClusters, - previous?: PreparedParagraph, -): Pick { - const { count, starts, flags, styleIndexes } = clusters; - const letterSpacingPrefix = reuseTypedArray(previous?.letterSpacingPrefix, count + 1, (capacity) => - new Float64Array(capacity).subarray(0, count + 1), - ); - const spacePrefix = reuseTypedArray(previous?.spacePrefix, count + 1, (capacity) => - new Uint32Array(capacity).subarray(0, count + 1), - ); - const clusterIndexAt = reuseTypedArray(previous?.clusterIndexAt, text.length + 1, (capacity) => - new Uint32Array(capacity).subarray(0, text.length + 1), - ); - for (let index = 0; index < count; index += 1) { - const start = starts[index] ?? 0; - const letterSpacing = - ((flags[index] ?? 0) & CLUSTER_HARD_BREAK) !== 0 - ? 0 - : (styles[styleIndexes[index] ?? 0]?.style.letterSpacing ?? 0); - letterSpacingPrefix[index + 1] = (letterSpacingPrefix[index] ?? 0) + letterSpacing; - spacePrefix[index + 1] = (spacePrefix[index] ?? 0) + (text.charCodeAt(start) === 0x20 ? 1 : 0); - } - // The same answer a lower-bound search over the cluster starts gives, resolved once for every offset in one pass. - let cluster = 0; - for (let offset = 0; offset <= text.length; offset += 1) { - while (cluster < count && (starts[cluster] ?? 0) < offset) cluster += 1; - clusterIndexAt[offset] = cluster; - } - return { clusterIndexAt, letterSpacingPrefix, spacePrefix }; -} - -function planLines( - shaper: RuntimeShaper, - prepared: PreparedParagraph, - constraints: NormalizedConstraints, -): readonly LinePlan[] { - const widthLimit = constraints.width.mode === 'unconstrained' ? Number.POSITIVE_INFINITY : constraints.width.size; - const { count, starts, ends, flags } = prepared.clusters; - const allowed = new Set(); - if (constraints.wrap === 'character') { - for (let index = 0; index < count; index += 1) { - const nextIsSafe = index + 1 === count || ((flags[index + 1] ?? 0) & CLUSTER_SAFE_BEFORE) !== 0; - if (nextIsSafe) allowed.add(ends[index] ?? 0); - } - } else if (constraints.wrap === 'word') { - const shapingBoundaries = new Set(); - for (let index = 0; index < count; index += 1) { - if (((flags[index] ?? 0) & CLUSTER_SAFE_BEFORE) !== 0) shapingBoundaries.add(starts[index] ?? 0); - } - shapingBoundaries.add(prepared.input.text.length); - for (const entry of prepared.unicode.lineBreaks) { - if (shapingBoundaries.has(entry.position)) allowed.add(entry.position); - } - } - - return breakLines(shaper, prepared, allowed, widthLimit, constraints.wrap); -} - -function measurePrepared( - prepared: PreparedParagraph, - constraints: NormalizedConstraints, - allLines: readonly LinePlan[], -): MeasuredPlan { - const lines = visibleLines(prepared, constraints, allLines); - const naturalContentWidth = allLines.reduce((maximum, line) => Math.max(maximum, line.advance), 0); - const contentHeight = allLines.reduce((sum, line) => sum + line.height, 0); - const displayAdvances = lines.map((line, index) => measuredLineAdvance(prepared, constraints, lines, line, index)); - const visibleWidth = displayAdvances.reduce((maximum, advance) => Math.max(maximum, advance), 0); - const contentWidth = Math.max(naturalContentWidth, visibleWidth); - const visibleHeight = lines.reduce((sum, line) => sum + line.height, 0); - const width = resolveAxis(constraints.width, visibleWidth); - const height = resolveAxis(constraints.height, visibleHeight); - let blockOffset = 0; - const baselines = lines.map((line) => { - const baseline = blockOffset + line.baseline; - blockOffset += line.height; - return baseline; - }); - const measurement = Object.freeze({ - width, - height, - contentWidth, - contentHeight, - firstBaseline: baselines[0] ?? 0, - lastBaseline: baselines.at(-1) ?? 0, - overflowed: lines.length < allLines.length || contentWidth > width || contentHeight > height, - }); - return { measurement, lines }; -} - -function measuredLineAdvance( - prepared: PreparedParagraph, - constraints: NormalizedConstraints, - lines: readonly LinePlan[], - line: LinePlan, - index: number, -): number { - if ( - constraints.align !== 'justify' || - constraints.width.mode !== 'exactly' || - line.hardBreak || - index >= lines.length - 1 || - justificationSpaces(prepared, line, line.textStart, line.textEnd) === 0 - ) { - return line.advance; - } - return Math.max(line.advance, constraints.width.size); -} - -function visibleLines( - prepared: PreparedParagraph, - constraints: NormalizedConstraints, - allLines: readonly LinePlan[], -): readonly LinePlan[] { - let count = constraints.maxLines === undefined ? allLines.length : Math.min(allLines.length, constraints.maxLines); - if (constraints.overflow === 'ellipsis' && constraints.height.mode !== 'unconstrained') { - let height = 0; - let fitting = 0; - for (const line of allLines) { - if (height + line.height > constraints.height.size) break; - height += line.height; - fitting += 1; - } - count = Math.min(count, fitting); - } - const lines = allLines.slice(0, count); - if (constraints.overflow !== 'ellipsis' || lines.length === 0) return lines; - const last = lines.at(-1); - if (last === undefined) return lines; - const widthLimit = constraints.width.mode === 'unconstrained' ? Number.POSITIVE_INFINITY : constraints.width.size; - const truncated = count < allLines.length; - if (!truncated && last.advance <= widthLimit) return lines; - return [...lines.slice(0, -1), ellipsizeLine(prepared, last, widthLimit)]; -} - -function ellipsizeLine(prepared: PreparedParagraph, line: LinePlan, widthLimit: number): LinePlan { - const { count, starts, advances, flags } = prepared.clusters; - const startAt = (index: number): number => (index < count ? (starts[index] ?? 0) : line.textStart); - let clusterEnd = line.clusterEnd; - let advance = line.advance; - while (clusterEnd > line.clusterStart && ((flags[clusterEnd - 1] ?? 0) & CLUSTER_HARD_BREAK) !== 0) { - clusterEnd -= 1; - } - let selected = ellipsisAt(prepared, line.textEnd); - while (clusterEnd > line.clusterStart && Number.isFinite(widthLimit) && advance + selected.advance > widthLimit) { - clusterEnd -= 1; - if (clusterEnd < count) advance -= advances[clusterEnd] ?? 0; - selected = ellipsisAt(prepared, startAt(clusterEnd)); - } - const textEnd = startAt(clusterEnd); - const levelOffset = Math.max(line.textStart, textEnd - 1); - const level = prepared.bidi.levels[levelOffset] ?? paragraphLevelAt(prepared.bidi, textEnd); - return { - ...line, - clusterEnd, - textEnd, - advance: Math.max(0, advance) + selected.advance, - hardBreak: false, - ellipsis: { - ...selected, - cluster: textEnd, - level, - }, - }; -} - -function ellipsisAt(prepared: PreparedParagraph, offset: number): PreparedEllipsis { - const sourceRun = prepared.runs.findIndex((run) => run.start < offset && offset <= run.end); - const fallbackRun = prepared.runs.findIndex((run) => run.start <= offset && offset < run.end); - const run = sourceRun >= 0 ? sourceRun : fallbackRun; - const ellipsis = prepared.ellipses.find((entry) => entry.sourceRun === run) ?? prepared.ellipses[0]; - if (ellipsis === undefined) throw new Error('paragraph has no ellipsis shaping run'); - return ellipsis; -} - -function breakLines( - shaper: RuntimeShaper, - prepared: PreparedParagraph, - allowed: ReadonlySet, - widthLimit: number, - wrap: 'none' | 'word' | 'character', -): readonly LinePlan[] { - const { count, starts, ends, advances: clusterAdvances, flags } = prepared.clusters; - if (count === 0) return []; - const lines: LinePlan[] = []; - let lineStart = 0; - while (lineStart < count) { - let advance = 0; - let lastAllowed = -1; - let lastAllowedAdvance = 0; - let lastSafe = -1; - let lastSafeAdvance = 0; - let lineEnd = count; - let lineAdvance = 0; - for (let index = lineStart; index < count; index += 1) { - const clusterFlags = flags[index] ?? 0; - if (index > lineStart && (clusterFlags & CLUSTER_SAFE_BEFORE) !== 0) { - lastSafe = index; - lastSafeAdvance = advance; - } - const requiredBreak = (clusterFlags & CLUSTER_REQUIRED_BREAK) !== 0; - const nextAdvance = advance + (clusterAdvances[index] ?? 0); - if (wrap !== 'none' && Number.isFinite(widthLimit) && nextAdvance > widthLimit && index > lineStart) { - if (lastAllowed > lineStart) { - lineEnd = lastAllowed; - lineAdvance = lastAllowedAdvance; - } else if (lastSafe > lineStart) { - lineEnd = lastSafe; - lineAdvance = lastSafeAdvance; - } else { - advance = nextAdvance; - if (requiredBreak || index === count - 1) { - lineEnd = index + 1; - lineAdvance = advance; - break; - } - continue; - } - break; - } - advance = nextAdvance; - if (requiredBreak) { - lineEnd = index + 1; - lineAdvance = advance; - break; - } - if (allowed.has(ends[index] ?? 0)) { - lastAllowed = index + 1; - lastAllowedAdvance = advance; - } - if (index === count - 1) lineAdvance = advance; - } - if (lineEnd <= lineStart) { - lineEnd = lineStart + 1; - lineAdvance = clusterAdvances[lineStart] ?? 0; - } - const lastHardBreak = ((flags[lineEnd - 1] ?? 0) & CLUSTER_HARD_BREAK) !== 0; - const metrics = metricsForLine(shaper, prepared, lineStart, lineEnd, prepared.styles[0]?.style); - lines.push({ - clusterStart: lineStart, - clusterEnd: lineEnd, - textStart: starts[lineStart] ?? 0, - textEnd: (lastHardBreak ? starts[lineEnd - 1] : ends[lineEnd - 1]) ?? 0, - advance: lineAdvance, - hardBreak: lastHardBreak, - ...metrics, - }); - lineStart = lineEnd; - } - if (((flags[count - 1] ?? 0) & CLUSTER_HARD_BREAK) !== 0) { - const metrics = metricsForLine(shaper, prepared, count, count, prepared.styles[0]?.style); - lines.push({ - clusterStart: count, - clusterEnd: count, - textStart: prepared.input.text.length, - textEnd: prepared.input.text.length, - advance: 0, - hardBreak: false, - ...metrics, - }); - } - return lines; -} - -/** - * Line box metrics over the drawable clusters of `[lineStart, lineEnd)`. Both extents are maxima over the contributing - * styles, so the clusters are visited in place: the order is irrelevant and a repeated style contributes nothing new, - * which lets the common single-style line resolve its font and scale once. - */ -function metricsForLine( - shaper: RuntimeShaper, - prepared: PreparedParagraph, - lineStart: number, - lineEnd: number, - fallback?: ResolvedStyle, -): LineMetrics { - const { flags, styleIndexes } = prepared.clusters; - let above = 0; - let below = 0; - let contributed = false; - let lastStyleIndex = -1; - for (let index = lineStart; index < lineEnd; index += 1) { - if (((flags[index] ?? 0) & CLUSTER_HARD_BREAK) !== 0) continue; - const styleIndex = styleIndexes[index] ?? 0; - if (contributed && styleIndex === lastStyleIndex) continue; - lastStyleIndex = styleIndex; - contributed = true; - const extents = styleLineExtents(shaper, clusterStyle(prepared, index)); - above = Math.max(above, extents.above); - below = Math.max(below, extents.below); - } - if (!contributed && fallback !== undefined) { - const extents = styleLineExtents(shaper, fallback); - above = Math.max(above, extents.above); - below = Math.max(below, extents.below); - } - return { height: above + below, baseline: above }; -} - -function styleLineExtents( - shaper: RuntimeShaper, - style: ResolvedStyle, -): { readonly above: number; readonly below: number } { - const font = requireFont(shaper, style.font); - const scale = style.fontSize / font.metrics.unitsPerEm; - const ascent = font.metrics.ascender * scale; - const descent = -font.metrics.descender * scale; - const natural = (font.metrics.ascender - font.metrics.descender + font.metrics.lineGap) * scale; - const height = style.lineHeight === undefined ? natural : style.fontSize * style.lineHeight; - const leading = Math.max(0, height - ascent - descent); - return { above: ascent + leading / 2, below: descent + leading / 2 }; -} - -function normalizeConstraints(constraints: ParagraphConstraints = {}): NormalizedConstraints { - if (!isNonArrayObject(constraints)) throw new TypeError('paragraph constraints must be an object'); - const width = normalizeAxis(constraints.width, 'width'); - const height = normalizeAxis(constraints.height, 'height'); - const maxLines = constraints.maxLines; - if (maxLines !== undefined && (!Number.isSafeInteger(maxLines) || maxLines <= 0)) { - throw new RangeError('maxLines must be a positive safe integer'); - } - const wrap = constraints.wrap ?? 'word'; - if (wrap !== 'none' && wrap !== 'word' && wrap !== 'character') { - throw new RangeError('wrap must be none, word, or character'); - } - const align = constraints.align ?? 'start'; - if (align !== 'start' && align !== 'center' && align !== 'end' && align !== 'justify') { - throw new RangeError('align must be start, center, end, or justify'); - } - const overflow = constraints.overflow ?? 'visible'; - if (overflow !== 'visible' && overflow !== 'clip' && overflow !== 'ellipsis') { - throw new RangeError('overflow must be visible, clip, or ellipsis'); - } - return { - width, - height, - ...(maxLines === undefined ? {} : { maxLines }), - wrap, - align, - overflow, - }; -} - -function normalizeAxis(constraint: ParagraphAxisConstraint | undefined, name: string): ParagraphAxisConstraint { - if (constraint === undefined) return { mode: 'unconstrained' }; - if (!isNonArrayObject(constraint)) throw new TypeError(`${name} constraint must be an object`); - if (constraint.mode === 'unconstrained') return { mode: 'unconstrained' }; - if (constraint.mode !== 'at-most' && constraint.mode !== 'exactly') { - throw new RangeError(`${name} mode must be unconstrained, at-most, or exactly`); - } - return { mode: constraint.mode, size: finiteNonnegative(constraint.size, `${name} size`) }; -} - -function constraintKey(constraints: NormalizedConstraints): string { - return JSON.stringify(constraints); -} - -function linePlanConstraintKey(constraints: NormalizedConstraints): string { - return JSON.stringify({ - width: constraints.width, - wrap: constraints.wrap, - }); -} - -function geometryLinesKey(positioningKey: string, align: NormalizedConstraints['align'], boxWidth: number): string { - return JSON.stringify({ - positioningKey, - align, - boxWidth, - }); -} - -function positioningLinesKey(lines: readonly LinePlan[]): string { - return JSON.stringify( - lines.map((line) => ({ - clusterStart: line.clusterStart, - clusterEnd: line.clusterEnd, - textStart: line.textStart, - textEnd: line.textEnd, - hardBreak: line.hardBreak, - ...(line.ellipsis === undefined - ? {} - : { - ellipsis: { - sourceRun: line.ellipsis.sourceRun, - shapeRun: line.ellipsis.shapeRun, - cluster: line.ellipsis.cluster, - level: line.ellipsis.level, - }, - }), - })), - ); -} - -function positionPrepared( - shaper: RuntimeShaper, - prepared: PreparedParagraph, - lines: readonly LinePlan[], - positioning: PreparedPositioning, - constraints: NormalizedConstraints, - boxWidth: number, -): PositionedGeometry { - if (lines.length === 0) return emptyGeometry(); - const { fragments, reshaped } = positioning; - const reshapeRunByFragment = new Map(); - let reshapeRun = 0; - for (const [fragmentIndex, fragment] of fragments.entries()) { - if (!fragment.reshape) continue; - reshapeRunByFragment.set(fragmentIndex, reshapeRun); - reshapeRun += 1; - } - - const fontHandles: number[] = []; - const fontSlots = new Map(); - // Every output glyph comes from one source glyph, so the shaped runs bound the output. The arrays are written in - // place and sliced to the final count, which removes both the per-glyph push and the copy that `TypedArray.from` - // made out of every accumulator. - let capacity = prepared.shape.glyphIds.length + (reshaped?.glyphIds.length ?? 0); - let glyphFontSlots = new Uint16Array(capacity); - let glyphIds = new Uint16Array(capacity); - let clusters = new Uint32Array(capacity); - let glyphFontSizes = new Float32Array(capacity); - // Both axes accumulate in double precision and narrow once, when the geometry is handed out. Alignment and - // justification already read `x` back and add to it, and single precision would round at the store and again at the - // adjustment and drift from the golden layout. The same becomes true of `y` the moment vertical alignment or a - // vertical writing mode lands, so the rule is the axis, not today's caller. - let x = new Float64Array(capacity); - let y = new Float64Array(capacity); - let glyphFlags = new Uint16Array(capacity); - const justifying = constraints.align === 'justify'; - let justificationCounts = justifying ? new Uint32Array(capacity) : undefined; - const lineTextStarts = new Uint32Array(lines.length); - const lineTextEnds = new Uint32Array(lines.length); - const lineGlyphStarts = new Uint32Array(lines.length); - const lineGlyphCounts = new Uint32Array(lines.length); - const lineBaselines = new Float32Array(lines.length); - const lineAdvances = new Float32Array(lines.length); - let count = 0; - let blockOffset = 0; - let fragmentIndex = 0; - - const reserve = (needed: number): void => { - if (needed <= capacity) return; - let next = Math.max(capacity * 2, 64); - while (next < needed) next *= 2; - capacity = next; - glyphFontSlots = grownTypedArray(glyphFontSlots, next); - glyphIds = grownTypedArray(glyphIds, next); - clusters = grownTypedArray(clusters, next); - glyphFontSizes = grownTypedArray(glyphFontSizes, next); - x = grownTypedArray(x, next); - y = grownTypedArray(y, next); - glyphFlags = grownTypedArray(glyphFlags, next); - if (justificationCounts !== undefined) justificationCounts = grownTypedArray(justificationCounts, next); - }; - - for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { - const line = lines[lineIndex]!; - const lineGlyphStart = count; - let passedSpaces = 0; - let cursor = 0; - const baseline = blockOffset + line.baseline; - while (fragments[fragmentIndex]?.line === lineIndex) { - const fragment = fragments[fragmentIndex]; - if (fragment === undefined) break; - const run = prepared.runs[fragment.run]; - if (run === undefined) throw new Error('line fragment references a missing shaping run'); - const reshapedRun = reshapeRunByFragment.get(fragmentIndex); - const source = reshapedRun === undefined ? prepared.shape : reshaped; - const sourceRun = fragment.ellipsis?.shapeRun ?? reshapedRun ?? fragment.run; - if (source === undefined) throw new Error('boundary reshape result is missing'); - const glyphStart = source.runGlyphStarts[sourceRun]; - const glyphCount = source.runGlyphCounts[sourceRun]; - if (glyphStart === undefined || glyphCount === undefined) { - throw new Error('shaper returned an incomplete positioned run'); - } - let slot = fontSlots.get(run.style.font); - if (slot === undefined) { - slot = fontHandles.length; - fontHandles.push(run.style.font); - fontSlots.set(run.style.font, slot); - } - const font = requireFont(shaper, run.style.font); - const scale = run.style.fontSize / font.metrics.unitsPerEm; - const selectedStart = fragment.ellipsis?.textStart ?? fragment.start; - const selectedEnd = fragment.ellipsis?.textEnd ?? fragment.end; - const selected = glyphRange(source, glyphStart, glyphCount, selectedStart, selectedEnd); - reserve(count + (selected.end - selected.start)); - let clusterBoundary = run.direction === 'ltr' ? fragment.start : fragment.end; - for (let glyph = selected.start; glyph < selected.end; glyph += 1) { - const cluster = source.clusters[glyph]; - const glyphId = source.glyphIds[glyph]; - const xAdvance = source.xAdvances[glyph]; - const xOffset = source.xOffsets[glyph]; - const yOffset = source.yOffsets[glyph]; - const flags = source.glyphFlags[glyph]; - if ( - cluster === undefined || - glyphId === undefined || - xAdvance === undefined || - xOffset === undefined || - yOffset === undefined || - flags === undefined - ) { - throw new Error('shaper returned an incomplete positioned glyph'); - } - glyphFontSlots[count] = slot; - glyphIds[count] = glyphId; - clusters[count] = fragment.ellipsis?.cluster ?? cluster; - glyphFontSizes[count] = run.style.fontSize; - if (justificationCounts !== undefined) justificationCounts[count] = passedSpaces; - x[count] = cursor + xOffset * scale; - y[count] = baseline - yOffset * scale; - glyphFlags[count] = flags; - count += 1; - cursor += Math.abs(xAdvance) * scale; - const nextCluster = glyph + 1 < selected.end ? source.clusters[glyph + 1] : selectedEnd; - if (nextCluster !== cluster && fragment.ellipsis === undefined) { - const rangeStart = run.direction === 'ltr' ? cluster : Math.min(cluster, clusterBoundary); - const rangeEnd = run.direction === 'ltr' ? (nextCluster ?? fragment.end) : Math.max(cluster, clusterBoundary); - cursor += spacingBetween(prepared, rangeStart, rangeEnd); - passedSpaces += justificationSpaces(prepared, line, rangeStart, rangeEnd); - clusterBoundary = cluster; - } - } - fragmentIndex += 1; - } - const available = Math.max(0, boxWidth - cursor); - if (justifying && !line.hardBreak && lineIndex < lines.length - 1 && passedSpaces > 0) { - const perSpace = available / passedSpaces; - for (let glyph = lineGlyphStart; glyph < count; glyph += 1) { - x[glyph] = (x[glyph] ?? 0) + (justificationCounts?.[glyph] ?? 0) * perSpace; - } - cursor += available; - } else { - const paragraphDirection = directionForLevel(paragraphLevelAt(prepared.bidi, line.textStart)); - const offset = alignmentOffset(constraints.align, paragraphDirection, available); - if (offset !== 0) { - for (let glyph = lineGlyphStart; glyph < count; glyph += 1) { - x[glyph] = (x[glyph] ?? 0) + offset; - } - } - } - lineTextStarts[lineIndex] = line.textStart; - lineTextEnds[lineIndex] = line.textEnd; - lineGlyphStarts[lineIndex] = lineGlyphStart; - lineGlyphCounts[lineIndex] = count - lineGlyphStart; - lineBaselines[lineIndex] = baseline; - lineAdvances[lineIndex] = cursor; - blockOffset += line.height; - } - - return { - fontHandles: Uint32Array.from(fontHandles), - glyphFontSlots: glyphFontSlots.subarray(0, count), - glyphIds: glyphIds.subarray(0, count), - clusters: clusters.subarray(0, count), - glyphFontSizes: glyphFontSizes.subarray(0, count), - x: new Float32Array(x.subarray(0, count)), - y: new Float32Array(y.subarray(0, count)), - glyphFlags: glyphFlags.subarray(0, count), - lineTextStarts, - lineTextEnds, - lineGlyphStarts, - lineGlyphCounts, - lineBaselines, - lineAdvances, - }; -} - -function grownTypedArray( - array: T, - capacity: number, -): T { - const next = new (array.constructor as new (length: number) => T)(capacity); - next.set(array as unknown as ArrayLike & ArrayBufferView as never); - return next; -} - -function preparePositioning( - shaper: RuntimeShaper, - prepared: PreparedParagraph, - lines: readonly LinePlan[], -): PreparedPositioning { - const fragments = collectLineFragments(prepared, lines); - const ranges: ReshapeRange[] = []; - for (const fragment of fragments) { - if (!fragment.reshape) continue; - const run = prepared.runs[fragment.run]; - if (run === undefined) throw new Error('line fragment references a missing shaping run'); - ranges.push({ - run: fragment.run, - itemStart: fragment.start, - itemEnd: fragment.end, - contextStart: run.start, - contextEnd: run.end, - flags: fragment.flags, - }); - } - const reshaped = ranges.length === 0 ? undefined : ownShape(shaper.reshapeRanges({ ...prepared.request, ranges })); - return { fragments, ...(reshaped === undefined ? {} : { reshaped }) }; -} - -function alignmentOffset(align: NormalizedConstraints['align'], direction: 'ltr' | 'rtl', available: number): number { - if (align === 'center') return available / 2; - if (align === 'end') return direction === 'ltr' ? available : 0; - if (align === 'start') return direction === 'rtl' ? available : 0; - return direction === 'rtl' ? available : 0; -} - -function justificationSpaces(prepared: PreparedParagraph, line: LinePlan, start: number, end: number): number { - let trimmedEnd = line.textEnd; - while (trimmedEnd > line.textStart && prepared.input.text.charCodeAt(trimmedEnd - 1) === 0x20) { - trimmedEnd -= 1; - } - return clusterRangeSum(prepared, prepared.spacePrefix, start, Math.min(end, trimmedEnd)); -} - -function collectLineFragments(prepared: PreparedParagraph, lines: readonly LinePlan[]): readonly LineFragment[] { - const fragments: LineFragmentDraft[] = []; - // One scratch buffer for the whole paragraph rather than a fresh copy of every line's levels. The reordered levels - // of a line are read only while that line's fragments are built, so the lines share it. - const levels = new Uint8Array(prepared.input.text.length); - for (const [lineIndex, line] of lines.entries()) { - const levelCount = reorderedLineLevels(prepared.bidi, line.textStart, line.textEnd, levels); - const logicalStart = fragments.length; - for (const [runIndex, run] of prepared.runs.entries()) { - const start = Math.max(line.textStart, run.start); - const end = Math.min(line.textEnd, run.end); - if (start >= end) continue; - let fragmentStart = start; - while (fragmentStart < end) { - const level = fragmentLevel(run, levels, levelCount, fragmentStart - line.textStart); - let fragmentEnd = fragmentStart + 1; - while (fragmentEnd < end) { - if (fragmentLevel(run, levels, levelCount, fragmentEnd - line.textStart) !== level) break; - fragmentEnd += 1; - } - fragments.push({ - line: lineIndex, - run: runIndex, - start: fragmentStart, - end: fragmentEnd, - level, - flags: 0, - reshape: false, - }); - fragmentStart = fragmentEnd; - } - } - const logicalEnd = fragments.length; - for (let index = logicalStart; index < logicalEnd; index += 1) { - const fragment = fragments[index]; - if (fragment === undefined) continue; - const first = index === logicalStart; - const last = index === logicalEnd - 1; - const run = prepared.runs[fragment.run]; - if (run === undefined) throw new Error('line fragment references a missing shaping run'); - fragment.flags = PRODUCE_UNSAFE_TO_CONCAT | (first ? BEGINNING_OF_TEXT : 0) | (last ? END_OF_TEXT : 0); - // A reshape can only change the answer when it shapes with LESS context than the paragraph shape already used. - // Boundary reshaping requests the whole run as context, which is exactly the context that produced the retained - // shape, so the shaper returns the glyphs it already returned. The buffer's beginning- and end-of-text flags do - // not rescue it either: they describe the buffer edge, and the surrounding text shipped as context overrides - // them. Measured byte-identical over 640 ranges and 20,280 glyphs across Latin word wrap, Arabic word wrap, and - // Arabic character wrap narrow enough to break inside joined words, and the whole alignment, clipping, - // max-lines, ellipsis, and justification contract lays out identically with it disabled. - // - // Narrowing the context is a real future need — a truncated line whose last letter should take its final form, - // or a line shaped as an isolated unit — and `ReshapeRange` stays for it. Set this where that context is - // narrowed, not on every unsafe boundary. - fragment.reshape = false; - } - if (line.ellipsis !== undefined) { - fragments.push({ - line: lineIndex, - run: line.ellipsis.sourceRun, - start: line.ellipsis.cluster, - end: line.ellipsis.cluster, - level: line.ellipsis.level, - flags: PRODUCE_UNSAFE_TO_CONCAT, - reshape: false, - ellipsis: line.ellipsis, - }); - } - reorderFragments(fragments, logicalStart, fragments.length); - } - return fragments; -} - -/** The bidi level a fragment resolves to, honouring a span that overrode the run's direction. */ -function fragmentLevel(run: PreparedRun, levels: Uint8Array, levelCount: number, localOffset: number): number { - const resolved = (localOffset < levelCount ? levels[localOffset] : undefined) ?? run.bidiLevel; - return run.style.bidiOverride === undefined ? resolved : forceLevelDirection(resolved, run.direction); -} - -/** - * Writes the line's levels, with the trailing and separator resets of UAX #9 rule L1 applied, into the first - * `end - start` entries of `levels` and returns how many it wrote. - */ -function reorderedLineLevels(bidi: OwnedBidiAnalysis, start: number, end: number, levels: Uint8Array): number { - const count = Math.max(0, Math.min(end, bidi.levels.length) - start); - for (let index = 0; index < count; index += 1) levels[index] = bidi.levels[start + index] ?? 0; - const classes = bidi.classes; - const paragraphLevel = paragraphLevelAt(bidi, start); - let resetFrom: number | undefined = 0; - let resetTo: number | undefined; - let previousLevel = paragraphLevel; - for (let index = 0; index < count; index += 1) { - const bidiClass = classes[start + index]; - if (bidiClass === BIDI_B || bidiClass === BIDI_S) { - resetTo = index + 1; - resetFrom ??= index; - } else if ( - bidiClass === BIDI_WS || - bidiClass === BIDI_FSI || - bidiClass === BIDI_LRI || - bidiClass === BIDI_RLI || - bidiClass === BIDI_PDI - ) { - resetFrom ??= index; - } else if ( - bidiClass === BIDI_RLE || - bidiClass === BIDI_LRE || - bidiClass === BIDI_RLO || - bidiClass === BIDI_LRO || - bidiClass === BIDI_PDF || - bidiClass === BIDI_BN - ) { - resetFrom ??= index; - levels[index] = previousLevel; - } else { - resetFrom = undefined; - } - if (resetFrom !== undefined && resetTo !== undefined) { - levels.fill(paragraphLevel, resetFrom, resetTo); - resetFrom = undefined; - resetTo = undefined; - } - previousLevel = levels[index] ?? paragraphLevel; - } - if (resetFrom !== undefined) levels.fill(paragraphLevel, resetFrom, count); - return count; -} - -function paragraphLevelAt(bidi: OwnedBidiAnalysis, offset: number): number { - for (let index = 0; index < bidi.paragraphStarts.length; index += 1) { - const start = bidi.paragraphStarts[index]; - const end = bidi.paragraphEnds[index]; - if (start !== undefined && end !== undefined && start <= offset && offset < end) { - return bidi.paragraphLevels[index] ?? 0; - } - } - return bidi.paragraphLevels.at(-1) ?? 0; -} - -/** Reorders `visual[rangeStart, rangeEnd)` from logical into visual order in place, by UAX #9 rule L2. */ -function reorderFragments( - visual: Fragment[], - rangeStart: number, - rangeEnd: number, -): void { - let maximum = 0; - let lowestOdd = Number.POSITIVE_INFINITY; - for (let index = rangeStart; index < rangeEnd; index += 1) { - const level = visual[index]?.level ?? 0; - maximum = Math.max(maximum, level); - if ((level & 1) === 1) lowestOdd = Math.min(lowestOdd, level); - } - if (!Number.isFinite(lowestOdd)) return; - for (let level = maximum; level >= lowestOdd; level -= 1) { - let start = rangeStart; - while (start < rangeEnd) { - while (start < rangeEnd && (visual[start]?.level ?? -1) < level) start += 1; - let end = start; - while (end < rangeEnd && (visual[end]?.level ?? -1) >= level) end += 1; - reverse(visual, start, end); - start = end; - } - } -} - -function reverse(values: Value[], start: number, end: number): void { - for (let left = start, right = end - 1; left < right; left += 1, right -= 1) { - const value = values[left]; - if (value === undefined) break; - values[left] = values[right] as Value; - values[right] = value; - } -} - -/** - * The selected glyphs of a run are always one contiguous ascending span, so the selection is two indices. Materializing - * it as an array allocated one entry per glyph to describe a range that two integers already describe. - */ -function glyphRange( - shape: OwnedShape, - glyphStart: number, - glyphCount: number, - textStart: number, - textEnd: number, -): { readonly start: number; readonly end: number } { - if (glyphCount === 0 || textEnd <= textStart) return EMPTY_GLYPH_RANGE; - const firstCluster = shape.clusters[glyphStart]; - const lastCluster = shape.clusters[glyphStart + glyphCount - 1]; - if (firstCluster === undefined || lastCluster === undefined) { - throw new Error('shaper returned an incomplete glyph cluster table'); - } - const ascending = firstCluster <= lastCluster; - const selectedStart = ascending - ? glyphLowerBound(shape.clusters, glyphStart, glyphCount, textStart) - : glyphBelow(shape.clusters, glyphStart, glyphCount, textEnd); - const selectedEnd = ascending - ? glyphLowerBound(shape.clusters, glyphStart, glyphCount, textEnd) - : glyphBelow(shape.clusters, glyphStart, glyphCount, textStart); - return { start: selectedStart, end: selectedEnd }; -} - -const EMPTY_GLYPH_RANGE = { start: 0, end: 0 } as const; - -function glyphLowerBound(clusters: Uint32Array, glyphStart: number, glyphCount: number, target: number): number { - let low = glyphStart; - let high = glyphStart + glyphCount; - while (low < high) { - const middle = low + Math.floor((high - low) / 2); - if ((clusters[middle] ?? 0) < target) low = middle + 1; - else high = middle; - } - return low; -} - -function glyphBelow(clusters: Uint32Array, glyphStart: number, glyphCount: number, target: number): number { - let low = glyphStart; - let high = glyphStart + glyphCount; - while (low < high) { - const middle = low + Math.floor((high - low) / 2); - if ((clusters[middle] ?? 0) >= target) low = middle + 1; - else high = middle; - } - return low; -} - -function spacingBetween(prepared: PreparedParagraph, start: number, end: number): number { - return clusterRangeSum(prepared, prepared.letterSpacingPrefix, start, end); -} - -function clusterRangeSum( - prepared: PreparedParagraph, - prefix: Uint32Array | Float64Array, - start: number, - end: number, -): number { - if (end <= start) return 0; - // An offset past the table addresses no cluster, and answering `0` would invert the prefix difference rather than - // return nothing. Shaped clusters cross the Wasm boundary, so a malformed one must degrade, not silently mis-space. - const index = prepared.clusterIndexAt; - const last = index.length - 1; - const first = index[Math.min(start, last)] ?? 0; - const afterLast = index[Math.min(end, last)] ?? 0; - return (prefix[afterLast] ?? 0) - (prefix[first] ?? 0); -} - -function measurementForGeometry( - constraints: NormalizedConstraints, - measured: ParagraphMeasurement, - geometry: PositionedGeometry, -): ParagraphMeasurement { - const contentWidth = maxArray(geometry.lineAdvances); - const requiredContentWidth = Math.max(contentWidth, measured.contentWidth); - const width = resolveAxis(constraints.width, requiredContentWidth); - const height = measured.height; - return { - width, - height, - contentWidth: requiredContentWidth, - contentHeight: measured.contentHeight, - firstBaseline: geometry.lineBaselines[0] ?? 0, - lastBaseline: geometry.lineBaselines.at(-1) ?? 0, - overflowed: measured.overflowed || requiredContentWidth > width || measured.contentHeight > height, - }; -} - -function maxArray(values: Float32Array): number { - let maximum = 0; - for (const value of values) maximum = Math.max(maximum, value); - return maximum; -} - -function emptyGeometry(): PositionedGeometry { - return { - fontHandles: new Uint32Array(), - glyphFontSlots: new Uint16Array(), - glyphIds: new Uint16Array(), - clusters: new Uint32Array(), - glyphFontSizes: new Float32Array(), - x: new Float32Array(), - y: new Float32Array(), - glyphFlags: new Uint16Array(), - lineTextStarts: new Uint32Array(), - lineTextEnds: new Uint32Array(), - lineGlyphStarts: new Uint32Array(), - lineGlyphCounts: new Uint32Array(), - lineBaselines: new Float32Array(), - lineAdvances: new Float32Array(), - }; -} - -function resolveAxis(constraint: ParagraphAxisConstraint, content: number): number { - if (constraint.mode === 'unconstrained') return content; - if (constraint.mode === 'at-most') return Math.min(content, constraint.size); - return constraint.size; -} - -function requireFont(shaper: RuntimeShaper, handle: FontHandle): RegisteredFont { - const font = shaper.registry.getByHandle(handle); - if (font === undefined) throw new RangeError(`font handle ${handle} is not active in the registry`); - return font; -} - -function drawableFragments( - text: string, - start: number, - end: number, -): readonly { readonly start: number; readonly end: number }[] { - const fragments = []; - let fragmentStart = start; - let offset = start; - while (offset < end) { - const codePoint = text.codePointAt(offset); - if (codePoint === undefined) break; - const length = codePoint > 0xffff ? 2 : 1; - if (isHardBreakCodePoint(codePoint)) { - if (fragmentStart < offset) fragments.push({ start: fragmentStart, end: offset }); - offset += length; - if (codePoint === 0x0d && text.charCodeAt(offset) === 0x0a) offset += 1; - fragmentStart = offset; - } else { - offset += length; - } - } - if (fragmentStart < end) fragments.push({ start: fragmentStart, end }); - return fragments; -} - -function isHardBreak(text: string, offset: number): boolean { - const codePoint = text.codePointAt(offset); - return codePoint !== undefined && isHardBreakCodePoint(codePoint); -} - -function isHardBreakCodePoint(codePoint: number): boolean { - return ( - codePoint === 0x0a || - codePoint === 0x0b || - codePoint === 0x0c || - codePoint === 0x0d || - codePoint === 0x85 || - codePoint === 0x2028 || - codePoint === 0x2029 - ); -} - -function equalStyles(left: ResolvedStyle, right: ResolvedStyle): boolean { - return ( - left.font === right.font && - left.fontSize === right.fontSize && - left.lineHeight === right.lineHeight && - left.letterSpacing === right.letterSpacing && - left.language === right.language && - left.direction === right.direction && - left.bidiOverride === right.bidiOverride && - equalFeatures(left.features, right.features) - ); -} - -function equalFeatures(left: readonly ResolvedFontFeature[], right: readonly ResolvedFontFeature[]): boolean { - return ( - left.length === right.length && - left.every((feature, index) => { - const other = right[index]; - return ( - other !== undefined && - feature.tag === other.tag && - feature.value === other.value && - feature.start === other.start && - feature.end === other.end - ); - }) - ); -} - -/** - * Whether two shape requests would produce identical shaped output. Compares exactly what the shaper reads, so a - * paragraph whose size, line height, or letter spacing changed rebuilds an equal request and reuses its shape. - */ -function sameShapeRequest(left: ShapeBatchRequest, right: ShapeBatchRequest): boolean { - if (left.textUtf16.length !== right.textUtf16.length) return false; - for (let index = 0; index < left.textUtf16.length; index += 1) { - if (left.textUtf16[index] !== right.textUtf16[index]) return false; - } - if (left.runs.length !== right.runs.length) return false; - for (let index = 0; index < left.runs.length; index += 1) { - const a = left.runs[index]!; - const b = right.runs[index]!; - if ( - a.font !== b.font || - a.textStart !== b.textStart || - a.textEnd !== b.textEnd || - a.direction !== b.direction || - a.script !== b.script || - a.language !== b.language || - a.clusterLevel !== b.clusterLevel || - a.flags !== b.flags || - a.featureStart !== b.featureStart || - a.featureCount !== b.featureCount - ) { - return false; - } - } - if (left.features.length !== right.features.length) return false; - for (let index = 0; index < left.features.length; index += 1) { - const a = left.features[index]!; - const b = right.features[index]!; - if (a.tag !== b.tag || a.value !== b.value || a.start !== b.start || a.end !== b.end) return false; - } - return true; -} - -function ownShape(shape: ShapedBatchViews): OwnedShape { - return { - fontHandles: shape.fontHandles.slice(), - runFontSlots: shape.runFontSlots.slice(), - runGlyphStarts: shape.runGlyphStarts.slice(), - runGlyphCounts: shape.runGlyphCounts.slice(), - glyphIds: shape.glyphIds.slice(), - clusters: shape.clusters.slice(), - xAdvances: shape.xAdvances.slice(), - yAdvances: shape.yAdvances.slice(), - xOffsets: shape.xOffsets.slice(), - yOffsets: shape.yOffsets.slice(), - glyphFlags: shape.glyphFlags.slice(), - }; -} - -function ownBidi(bidi: BidiAnalysisViews): OwnedBidiAnalysis { - return { - levels: bidi.levels.slice(), - classes: bidi.classes.slice(), - paragraphStarts: bidi.paragraphStarts.slice(), - paragraphEnds: bidi.paragraphEnds.slice(), - paragraphLevels: bidi.paragraphLevels.slice(), - }; -} - -function utf16(text: string): Uint16Array { - const result = new Uint16Array(text.length); - for (let index = 0; index < text.length; index += 1) result[index] = text.charCodeAt(index); - return result; -} - -function emptyShape(): OwnedShape { - return { - fontHandles: new Uint32Array(), - runFontSlots: new Uint16Array(), - runGlyphStarts: new Uint32Array(), - runGlyphCounts: new Uint32Array(), - glyphIds: new Uint16Array(), - clusters: new Uint32Array(), - xAdvances: new Int32Array(), - yAdvances: new Int32Array(), - xOffsets: new Int32Array(), - yOffsets: new Int32Array(), - glyphFlags: new Uint16Array(), - }; -} - -function assertTextRange(start: number, end: number, textLength: number, name: string): void { - if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || start >= end || end > textLength) { - throw new RangeError(`${name} must be a non-empty UTF-16 range inside the paragraph`); - } -} - -function finite(value: number, name: string): number { - if (!Number.isFinite(value)) throw new RangeError(`${name} must be finite`); - return value; -} - -function isNonArrayObject(value: T): value is T & object { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function finitePositive(value: number, name: string): number { - if (!Number.isFinite(value) || value <= 0) throw new RangeError(`${name} must be finite and positive`); - return value; -} - -function finiteNonnegative(value: number, name: string): number { - if (!Number.isFinite(value) || value < 0) throw new RangeError(`${name} must be finite and nonnegative`); - return value; -} - -function normalizeLanguage(language: string | undefined): string | undefined { - if (language === undefined) return undefined; - if (typeof language !== 'string') throw new TypeError('language must be a string'); - const normalized = language.trim().toLowerCase(); - if (normalized.length === 0) throw new RangeError('language must not be empty'); - return normalized; -} diff --git a/packages/text/src/r3f.ts b/packages/text/src/r3f.ts index 5b61f0f7..a9988f79 100644 --- a/packages/text/src/r3f.ts +++ b/packages/text/src/r3f.ts @@ -14,10 +14,9 @@ import { type Ref, } from 'react'; -import type { GlyphPaintInput, ParagraphSpan } from './formatted-text.js'; +import type { GlyphPaintInput } from './formatted-text.js'; import type { FontSelection, LoadedFont } from './loaded-font.js'; -import type { ParagraphContentBox } from './paragraph-batch.js'; -import type { ParagraphStyle } from './paragraph.js'; +import type { ParagraphContentBox, ParagraphStyle } from './text-properties.js'; import type { AnyRasterTechnique } from './raster-technique.js'; import type { LoadedFontRequest } from './text-runtime.js'; import { @@ -26,49 +25,50 @@ import { TextGroup as ThreeTextGroup, type StandaloneTextProperties, type TextGroupOptions, - type ThreeRenderVariant, + type TextSpan, + type ThreeTextMaterial, } from './three.js'; type Object3DProps = Omit; -export type R3fTextChild = +export type R3fTextChild = | string | number | null | false - | ReactElement> - | readonly R3fTextChild[]; + | ReactElement> + | readonly R3fTextChild[]; -export type R3fTextProps = Object3DProps & { +export type R3fTextProps = Object3DProps & { readonly font?: FontSelection; - readonly children?: R3fTextChild; + readonly children?: R3fTextChild; readonly contentBox?: ParagraphContentBox; readonly style?: ParagraphStyle; readonly paint?: GlyphPaintInput; readonly rasterPixelRatio?: number; - readonly renderVariant?: Variant; - readonly capacity?: StandaloneTextProperties['capacity']; + readonly material?: ThreeTextMaterial; + readonly capacity?: StandaloneTextProperties['capacity']; readonly onError?: ((error: unknown) => void) | undefined; - readonly ref?: Ref>; + readonly ref?: Ref>; }; -export type R3fTextGroupProps = Object3DProps & - TextGroupOptions & { +export type R3fTextGroupProps = Object3DProps & + TextGroupOptions & { readonly children?: ReactNode; readonly onError?: ((error: unknown) => void) | undefined; - readonly ref?: Ref>; + readonly ref?: Ref; }; -interface FlattenedText { +interface FlattenedText { readonly text: string; - readonly spans: readonly ParagraphSpan[]; + readonly spans: readonly TextSpan[]; } -interface InlineProperties { +interface InlineProperties { readonly font?: FontSelection; readonly style?: ParagraphStyle; readonly paint?: GlyphPaintInput; - readonly renderVariant?: Variant; + readonly material?: ThreeTextMaterial; } interface UseFont { @@ -82,21 +82,19 @@ const fontPromises = new Map>>(); const techniqueIds = new WeakMap(); let nextTechniqueId = 1; -export function Text( - input: R3fTextProps, -): ReactElement | null { +export function Text(input: R3fTextProps): ReactElement | null { const { ref: forwardedRef, ...properties } = input; - const flattened = useMemo(() => flattenText(properties.children), [properties.children]); + const flattened = useMemo(() => flattenText(properties.children), [properties.children]); const desired = textProperties(properties, flattened); const appliedRef = useRef(undefined); const capacityRef = useRef(properties.capacity); - const [store] = useState(() => createObjectStore>()); + const [store] = useState(() => createObjectStore>()); const object = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); const invalidate = useThree((state) => state.invalidate); const onErrorRef = useRef(properties.onError); const createObject = useEffectEvent(() => { if (desired.font === undefined) throw new TypeError('an outer R3F Text requires a font'); - const created = new ThreeText(desired as StandaloneTextProperties); + const created = new ThreeText(desired as StandaloneTextProperties); created.onError = (error: unknown) => onErrorRef.current?.(error); appliedRef.current = desired; return created; @@ -117,7 +115,7 @@ export function Text); + object.set(update as StandaloneTextProperties); appliedRef.current = desired; } if (capacity !== undefined && !sameCapacity(capacity, capacityRef.current)) object.setCapacity(capacity); @@ -136,20 +134,18 @@ export function Text( - input: R3fTextGroupProps, -): ReactElement | null { +export function TextGroup(input: R3fTextGroupProps): ReactElement | null { const { ref: forwardedRef, ...properties } = input; - const [store] = useState(() => createObjectStore>()); + const [store] = useState(() => createObjectStore()); const object = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); const invalidate = useThree((state) => state.invalidate); const onErrorRef = useRef(properties.onError); const createObject = useEffectEvent(() => { - const created = new ThreeTextGroup({ - technique: properties.technique, + const created = new ThreeTextGroup({ ...(properties.capacity === undefined ? {} : { capacity: properties.capacity }), + ...(properties.compositing === undefined ? {} : { compositing: properties.compositing }), ...(properties.renderOrder === undefined ? {} : { renderOrder: properties.renderOrder }), - ...(properties.renderVariant === undefined ? {} : { renderVariant: properties.renderVariant }), + ...(properties.material === undefined ? {} : { material: properties.material }), }); created.onError = (error: unknown) => onErrorRef.current?.(error); return created; @@ -168,13 +164,11 @@ export function TextGroup { if (object === undefined) return; - if (properties.technique !== object.technique) - throw new TypeError('changing an R3F TextGroup technique requires a new keyed component'); if (properties.capacity !== undefined && !sameCapacity(properties.capacity, object)) object.setCapacity(properties.capacity); - object.setRenderVariant(properties.renderVariant); + object.setMaterial(properties.material); invalidate(); - }, [invalidate, object, properties.capacity, properties.renderVariant, properties.technique]); + }, [invalidate, object, properties.capacity, properties.material]); useLayoutEffect(() => { onErrorRef.current = properties.onError; @@ -260,14 +254,14 @@ function fontRequestKey(request: LoadedFon return JSON.stringify([input, techniqueId, request.raster.options ?? null]); } -function flattenText( - children: R3fTextChild | undefined, -): FlattenedText { +function flattenText( + children: R3fTextChild | undefined, +): FlattenedText { const chunks: string[] = []; - const spans: ParagraphSpan[] = []; + const spans: TextSpan[] = []; let length = 0; - const append = (child: R3fTextChild, inherited: InlineProperties): void => { + const append = (child: R3fTextChild, inherited: InlineProperties): void => { if (child === null || child === false) return; if (typeof child === 'string' || typeof child === 'number') { const value = String(child); @@ -279,7 +273,7 @@ function flattenText( for (const nested of child) append(nested, inherited); return; } - if (!isValidElement>(child) || child.type !== Text) + if (!isValidElement>(child) || child.type !== Text) throw new TypeError('R3F Text children must be text, numbers, arrays, or nested Text elements'); const inline = inlineProperties(child.props, inherited); const start = length; @@ -293,10 +287,10 @@ function flattenText( return Object.freeze({ text: chunks.join(''), spans: Object.freeze(spans) }); } -function inlineProperties( - properties: R3fTextProps, - inherited: InlineProperties, -): InlineProperties { +function inlineProperties( + properties: R3fTextProps, + inherited: InlineProperties, +): InlineProperties { return Object.freeze({ ...((properties.font ?? inherited.font) === undefined ? {} : { font: properties.font ?? inherited.font }), ...(properties.style === undefined && inherited.style === undefined @@ -305,16 +299,16 @@ function inlineProperties( ...(properties.paint === undefined && inherited.paint === undefined ? {} : { paint: Object.freeze({ ...inherited.paint, ...properties.paint }) }), - ...((properties.renderVariant ?? inherited.renderVariant) === undefined + ...((properties.material ?? inherited.material) === undefined ? {} - : { renderVariant: properties.renderVariant ?? inherited.renderVariant }), + : { material: properties.material ?? inherited.material }), }); } -function textProperties( - properties: R3fTextProps, - flattened: FlattenedText, -): Partial> & { readonly text: string } { +function textProperties( + properties: R3fTextProps, + flattened: FlattenedText, +): Partial> & { readonly text: string } { return Object.freeze({ ...(properties.font === undefined ? {} : { font: properties.font }), text: flattened.text, @@ -323,14 +317,12 @@ function textProperties( ...(properties.style === undefined ? {} : { style: properties.style }), ...(properties.paint === undefined ? {} : { paint: properties.paint }), ...(properties.rasterPixelRatio === undefined ? {} : { rasterPixelRatio: properties.rasterPixelRatio }), - ...(properties.renderVariant === undefined ? {} : { renderVariant: properties.renderVariant }), + ...(properties.material === undefined ? {} : { material: properties.material }), ...(properties.capacity === undefined ? {} : { capacity: properties.capacity }), }); } -function objectProperties( - properties: R3fTextProps, -): Object3DProps { +function objectProperties(properties: R3fTextProps): Object3DProps { const object = { ...properties } as Record; for (const key of [ 'font', @@ -339,7 +331,7 @@ function objectProperties( 'style', 'paint', 'rasterPixelRatio', - 'renderVariant', + 'material', 'capacity', 'onError', 'ref', @@ -348,11 +340,9 @@ function objectProperties( return object as Object3DProps; } -function groupObjectProperties( - properties: R3fTextGroupProps, -): Object3DProps { +function groupObjectProperties(properties: R3fTextGroupProps): Object3DProps { const object = { ...properties } as Record; - for (const key of ['technique', 'capacity', 'renderVariant', 'children', 'onError', 'ref']) delete object[key]; + for (const key of ['capacity', 'compositing', 'material', 'children', 'onError', 'ref']) delete object[key]; return object as Object3DProps; } @@ -370,16 +360,16 @@ function sameCapacity( return current?.size === capacity.size && current.policy === capacity.policy; } -function sameDesiredText( - left: (Partial> & { readonly text: string }) | undefined, - right: Partial> & { readonly text: string }, +function sameDesiredText( + left: (Partial> & { readonly text: string }) | undefined, + right: Partial> & { readonly text: string }, ): boolean { if ( left === undefined || left.font !== right.font || left.text !== right.text || left.rasterPixelRatio !== right.rasterPixelRatio || - left.renderVariant !== right.renderVariant || + left.material !== right.material || !sameSnapshot(left.contentBox, right.contentBox) || !sameSnapshot(left.style, right.style) || !sameSnapshot(left.paint, right.paint) @@ -395,7 +385,7 @@ function sameDesiredText( span.start === other.start && span.end === other.end && span.font === other.font && - span.renderVariant === other.renderVariant && + span.material === other.material && sameSnapshot(span.style, other.style) && sameSnapshot(span.paint, other.paint) ); diff --git a/packages/text/src/raster-runtime.ts b/packages/text/src/raster-runtime.ts deleted file mode 100644 index f21c0f76..00000000 --- a/packages/text/src/raster-runtime.ts +++ /dev/null @@ -1,355 +0,0 @@ -import type { RasterBakeArtifact } from './bake.js'; -import type { RegisteredFont } from './font.js'; -import type { RasterKey } from './identity.js'; -import { FontLoadError, registeredFontRegistry } from './loader.js'; -import { canonicalJson, deriveRasterKey } from './internal/raster-identity.js'; -import { getRegisteredFontData } from './internal/registered-font.js'; -import type { - AnyRasterModule, - LoadedRaster, - RasterKindOf, - RasterLoadOptions, - RasterRequest, - RuntimeRasterBakerModule, -} from './raster.js'; - -type LoadedAnyRaster = LoadedRaster; - -interface CachedRaster { - readonly promise: Promise; - readonly controller: AbortController; - readonly descriptorKey: string; - value: LoadedAnyRaster | undefined; - consumers: number; - settled: boolean; -} - -interface ObservedRegistry { - readonly fonts: Set; - readonly unsubscribe: () => void; -} - -/** - * Resolves, validates, decodes, and caches raster resources per font generation. - * Failed loads are never retained, and font disposal releases decoded resources. - */ -export class RasterRuntime { - readonly #fonts = new Map>>(); - readonly #registries = new Map, ObservedRegistry>(); - #disposed = false; - - load( - font: RegisteredFont, - request: RasterRequest, - options: RasterLoadOptions = {}, - ): Promise> { - this.#assertActive(); - options.signal?.throwIfAborted(); - return this.#load(font, request, options); - } - - /** @internal Return a current decoded resource without crossing a Promise boundary. */ - _peek( - font: RegisteredFont, - request: RasterRequest, - ): LoadedRaster | undefined { - this.#assertActive(); - const rasters = this.#fonts.get(font)?.get(request.module); - if (rasters === undefined) return undefined; - const descriptorKey = rasterDescriptorKey(request.module, request.options); - for (const [rasterKey, cached] of rasters) { - if (cached.descriptorKey !== descriptorKey || cached.value === undefined) continue; - if (isCurrentRaster(font, rasterKey as RasterKey, cached.value.artifact)) { - return cached.value as LoadedRaster; - } - rasters.delete(rasterKey); - request.module.dispose(cached.value.resource); - return undefined; - } - return undefined; - } - - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - for (const { unsubscribe } of this.#registries.values()) unsubscribe(); - this.#registries.clear(); - for (const font of this.#fonts.keys()) this.#disposeFontResources(font); - } - - async #load( - font: RegisteredFont, - request: RasterRequest, - options: RasterLoadOptions, - ): Promise> { - const module = request.module; - const descriptor = module.descriptor(request.options); - const descriptorKey = rasterDescriptorKey(module, request.options, descriptor); - const rasterKey = await deriveRasterKey({ - descriptor, - extension: module.extension, - kind: module.kind, - version: module.version, - }); - this.#assertActive(); - options.signal?.throwIfAborted(); - - this.#observeRegistry(font); - let modules = this.#fonts.get(font); - if (modules === undefined) { - modules = new Map(); - this.#fonts.set(font, modules); - } - let rasters = modules.get(module); - if (rasters === undefined) { - rasters = new Map(); - modules.set(module, rasters); - } - const existing = rasters.get(rasterKey); - if (existing !== undefined) { - if (existing.controller.signal.aborted) { - if (rasters.get(rasterKey) === existing) rasters.delete(rasterKey); - return this.#load(font, request, options); - } - const loaded = await consumeCachedRaster(existing, options.signal); - if (this.#fonts.get(font)?.get(module)?.get(rasterKey) !== existing) { - throw rasterLoadInvalidated(); - } - if (isCurrentRaster(font, rasterKey, loaded.artifact)) return loaded; - rasters.delete(rasterKey); - module.dispose(loaded.resource); - return this.#load(font, request, options); - } - - const controller = new AbortController(); - const loadOptions: RasterLoadOptions = { ...options, signal: controller.signal }; - let cached!: CachedRaster; - const promise = this.#loadUncached(font, request, rasterKey, loadOptions) - .then((loaded) => { - if ( - this.#disposed || - controller.signal.aborted || - this.#fonts.get(font)?.get(module)?.get(rasterKey) !== cached || - !isCurrentRaster(font, rasterKey, loaded.artifact) - ) { - module.dispose(loaded.resource); - throw rasterLoadInvalidated(); - } - return loaded; - }) - .then( - (loaded) => { - cached.settled = true; - cached.value = loaded; - return loaded; - }, - (error: unknown) => { - cached.settled = true; - if (rasters?.get(rasterKey) === cached) rasters.delete(rasterKey); - throw error; - }, - ); - cached = { promise, controller, descriptorKey, value: undefined, consumers: 0, settled: false }; - rasters.set(rasterKey, cached); - return consumeCachedRaster(cached, options.signal); - } - - async #loadUncached( - font: RegisteredFont, - request: RasterRequest, - rasterKey: RasterKey, - options: RasterLoadOptions, - ): Promise> { - const module = request.module; - const kind = module.kind as RasterKindOf; - let artifact: LoadedRaster['artifact']; - try { - artifact = await font.loadRaster({ rasterKey, kind }, options); - } catch (error) { - if (!isRasterMiss(error)) throw error; - artifact = await this.#runtimeBake(font, request, rasterKey, options.signal); - } - options.signal?.throwIfAborted(); - const resource = await module.decode(font, artifact, options.signal); - // The cache publication guard owns this resource once decode returns, including - // disposal when a module ignores cancellation and completes after invalidation. - return { module, artifact, resource }; - } - - async #runtimeBake( - font: RegisteredFont, - request: RasterRequest, - rasterKey: RasterKey, - signal: AbortSignal | undefined, - ) { - const loadBaker = request.module.runtimeBaker; - if (loadBaker === undefined) { - throw new FontLoadError('RASTER_NOT_FOUND', `${request.module.kind} has no baked artifact or runtime baker`); - } - const registeredData = getRegisteredFontData(font); - const source = registeredData.sourceBytes; - if (source === undefined) { - throw new FontLoadError( - 'RASTER_SOURCE_UNAVAILABLE', - `${request.module.kind} runtime generation requires retained source bytes`, - ); - } - signal?.throwIfAborted(); - const imported = await loadBaker(); - const baker = 'default' in imported ? imported.default : imported; - assertMatchingBaker(request.module, baker); - const baked = await baker.bake({ - source: source.slice(), - font, - fontFaceIndex: registeredData.fontFaceIndex, - rasterKey, - options: request.options, - ...(signal === undefined ? {} : { signal }), - }); - assertMatchingArtifact(request.module, rasterKey, baked); - const rasterArtifacts = baked.artifacts.filter((artifact) => artifact.role === 'raster'); - if (rasterArtifacts.length !== 1) { - throw new FontLoadError( - 'INVALID_RASTER_ASSET', - 'runtime raster generation must return one authoritative raster artifact', - ); - } - const rasterArtifact = rasterArtifacts[0]; - if (rasterArtifact === undefined) throw new Error('unreachable raster artifact state'); - const registered = await registeredFontRegistry(font)._attachGeneratedRaster(font, rasterArtifact.bytes, { - rasterKey, - kind: baked.kind, - extension: baked.extension, - version: baked.version, - }); - if (registered.kind !== request.module.kind) { - registered.dispose(); - throw new FontLoadError( - 'RASTER_INCOMPATIBLE', - 'registered runtime raster kind does not match the selected module', - ); - } - return registered as LoadedRaster['artifact']; - } - - #observeRegistry(font: RegisteredFont): void { - const registry = registeredFontRegistry(font); - const observed = this.#registries.get(registry); - if (observed !== undefined) { - observed.fonts.add(font); - return; - } - this.#registries.set(registry, { - fonts: new Set([font]), - unsubscribe: registry._onFontDispose((disposed) => this.#disposeFont(disposed, registry)), - }); - } - - #disposeFont(font: RegisteredFont, registry: ReturnType): void { - this.#disposeFontResources(font); - const observed = this.#registries.get(registry); - if (observed === undefined) return; - observed.fonts.delete(font); - if (observed.fonts.size === 0) { - observed.unsubscribe(); - this.#registries.delete(registry); - } - } - - #disposeFontResources(font: RegisteredFont): void { - const modules = this.#fonts.get(font); - if (modules === undefined) return; - this.#fonts.delete(font); - for (const [module, rasters] of modules) { - for (const { promise, controller } of rasters.values()) { - controller.abort(rasterLoadInvalidated()); - void promise.then( - ({ resource }) => module.dispose(resource), - () => undefined, - ); - } - } - } - - #assertActive(): void { - if (this.#disposed) throw new Error('raster runtime is disposed'); - } -} - -function rasterDescriptorKey( - module: AnyRasterModule, - options: unknown, - descriptor = module.descriptor(options), -): string { - return canonicalJson({ descriptor, extension: module.extension, kind: module.kind, version: module.version }); -} - -function isCurrentRaster(font: RegisteredFont, rasterKey: RasterKey, raster: LoadedAnyRaster['artifact']): boolean { - try { - return font.getRaster(rasterKey) === raster; - } catch { - return false; - } -} - -function rasterLoadInvalidated(): DOMException { - return new DOMException('The raster load was invalidated', 'AbortError'); -} - -function isRasterMiss(error: unknown): boolean { - return error instanceof FontLoadError && (error.code === 'RASTER_NOT_FOUND' || error.code === 'RASTER_FETCH'); -} - -function assertMatchingBaker(module: AnyRasterModule, baker: RuntimeRasterBakerModule): void { - if (baker.kind !== module.kind) { - throw new FontLoadError('RASTER_INCOMPATIBLE', 'runtime raster baker kind does not match module'); - } -} - -function assertMatchingArtifact(module: AnyRasterModule, rasterKey: RasterKey, artifact: RasterBakeArtifact): void { - if ( - artifact.rasterKey !== rasterKey || - artifact.kind !== module.kind || - artifact.extension !== module.extension || - artifact.version !== module.version - ) { - throw new FontLoadError('RASTER_INCOMPATIBLE', 'runtime raster artifact does not match the selected module'); - } -} - -function consumeCachedRaster( - cached: CachedRaster, - signal: AbortSignal | undefined, -): Promise> { - signal?.throwIfAborted(); - cached.consumers += 1; - return new Promise>((resolve, reject) => { - let active = true; - const release = (): void => { - if (!active) return; - active = false; - signal?.removeEventListener('abort', aborted); - cached.consumers -= 1; - if (cached.consumers === 0 && !cached.settled) { - cached.controller.abort(signal?.reason ?? rasterLoadInvalidated()); - } - }; - const aborted = (): void => { - release(); - reject(signal?.reason ?? rasterLoadInvalidated()); - }; - signal?.addEventListener('abort', aborted, { once: true }); - void (cached.promise as Promise>).then( - (value) => { - if (!active) return; - release(); - resolve(value); - }, - (error: unknown) => { - if (!active) return; - release(); - reject(error); - }, - ); - }); -} diff --git a/packages/text/src/raster-technique.ts b/packages/text/src/raster-technique.ts index f5584d74..b0d2b211 100644 --- a/packages/text/src/raster-technique.ts +++ b/packages/text/src/raster-technique.ts @@ -1,13 +1,5 @@ import type { RegisteredFont } from './font.js'; -import type { GlyphPaint, ResolvedPaint } from './paint.js'; -import type { - AnyRasterModule, - JsonValue, - RasterModuleOptionsOf, - RasterOptionsArgument, - RegisteredRaster, - RuntimeRasterBakerLoader, -} from './raster.js'; +import type { JsonValue, RasterOptionsArgument, RegisteredRaster, RuntimeRasterBakerLoader } from './raster.js'; declare const rasterTechniqueIdBrand: unique symbol; declare const rasterResourceIdBrand: unique symbol; @@ -19,26 +11,10 @@ export type RasterTechniqueId = string & { readonly [rasterTechniqueIdBrand]: tr /** Stable technique-authored identity for one physical raster resource. */ export type RasterResourceId = string & { readonly [rasterResourceIdBrand]: true }; -/** One logical glyph-instance range. */ -export interface GlyphRange { - readonly start: number; - readonly count: number; -} - -/** Every public canonical storage field is an independently addressable typed-array view. */ -export type GlyphBatchStorageShape = { - readonly [Field in keyof Storage]: ArrayBufferView; -}; - -/** Type-erased canonical storage at heterogeneous runtime boundaries. */ -export type GlyphBatchStorage = Readonly>>; - -interface RasterTechniqueTypeMap { +interface RasterTechniqueTypeMap { readonly options: Options; readonly descriptor: Descriptor; readonly data: Data; - readonly binding: Binding; - readonly storage: Storage; } /** Common identity retained when concrete technique-associated types are intentionally erased. */ @@ -47,50 +23,18 @@ export interface AnyRasterTechnique { readonly kind: string; readonly extension: string; readonly version: number; - readonly [rasterTechniqueTypes]?: RasterTechniqueTypeMap; -} - -/** - * Valid only for the duration of the `select` or `writeStorage` call that - * receives it. Core pools these objects across updates, so a technique that - * needs a field beyond the call must copy the value rather than retain the - * input. - */ -export interface RasterGlyphInput { - readonly data: Data; - readonly glyphId: number; - readonly fontSize: number; - /** Paragraph-local displayed origin after any caller-authored glyph override. */ - readonly originX: number; - /** Paragraph-local displayed origin after any caller-authored glyph override. */ - readonly originY: number; - readonly rasterPixelRatio: number; - readonly paint: ResolvedPaint; -} - -export interface RasterGlyphWriteInput { - readonly data: Data; - /** The selection already used by core to form this physical glyph batch. */ - readonly binding: Binding; - readonly glyphs: readonly RasterGlyphInput[]; -} - -export interface RasterGlyphSelection { - readonly resource: RasterResourceId; - readonly pipelineVariant: number; - readonly binding: Binding; + readonly [rasterTechniqueTypes]?: RasterTechniqueTypeMap; } +/** Renderer-neutral raster identity, decoding, and ownership contract. */ export interface RasterTechnique< Id extends RasterTechniqueId, Kind extends string, Options, Descriptor extends JsonValue, Data, - Binding, - Storage extends GlyphBatchStorageShape, > extends AnyRasterTechnique { - readonly [rasterTechniqueTypes]?: RasterTechniqueTypeMap; + readonly [rasterTechniqueTypes]?: RasterTechniqueTypeMap; readonly id: Id; readonly kind: Kind; @@ -100,55 +44,44 @@ export interface RasterTechnique< descriptor(options: RasterOptionsArgument): Descriptor; decode(font: RegisteredFont, raster: RegisteredRaster, signal?: AbortSignal): Promise; - /** Return undefined when a shaped glyph intentionally has no renderable raster instance. */ - select(input: RasterGlyphInput): RasterGlyphSelection | undefined; - createStorage(capacity: number): Storage; - writeStorage(storage: Storage, range: GlyphRange, input: RasterGlyphWriteInput): void; - validatePaint?(paint: GlyphPaint): void; dispose(data: Data): void; } export type RasterTechniqueTypesOf = - Technique extends RasterTechnique< - infer _Id, - infer _Kind, - infer Options, - infer Descriptor, - infer Data, - infer Binding, - infer Storage - > - ? RasterTechniqueTypeMap - : RasterTechniqueTypeMap; - -export type RasterTechniqueOptionsOf = - RasterTechniqueTypesOf['options']; - -/** Options inferred from either the target-v1 technique or the merged-v0 raster module. */ -export type RasterOptionsOf = Raster extends AnyRasterTechnique - ? RasterTechniqueOptionsOf - : Raster extends AnyRasterModule - ? RasterModuleOptionsOf - : never; + Technique extends RasterTechnique + ? RasterTechniqueTypeMap + : RasterTechniqueTypeMap; + +export type RasterOptionsOf = RasterTechniqueTypesOf['options']; + +export type RasterTechniqueOptionsOf = RasterOptionsOf; + +export type RasterTechniqueRequest = { + readonly technique: Technique; +} & ([RasterOptionsOf] extends [never] + ? { readonly options?: never } + : undefined extends RasterOptionsOf + ? { readonly options?: RasterOptionsOf } + : { readonly options: RasterOptionsOf }); + +export type RasterTechniqueInput = [RasterOptionsOf] extends [never] + ? Technique | RasterTechniqueRequest + : undefined extends RasterOptionsOf + ? Technique | RasterTechniqueRequest + : RasterTechniqueRequest; export type RasterTechniqueDescriptorOf = RasterTechniqueTypesOf['descriptor']; export type RasterDataOf = RasterTechniqueTypesOf['data']; -export type RasterBindingOf = RasterTechniqueTypesOf['binding']; - -export type GlyphBatchStorageOf = RasterTechniqueTypesOf['storage']; - type RasterTechniqueDefinition< Id extends string, Kind extends string, Options, Descriptor extends JsonValue, Data, - Binding, - Storage extends GlyphBatchStorageShape, -> = Omit, 'id'> & { +> = Omit, 'id'> & { readonly id: Id; }; @@ -159,13 +92,11 @@ export function defineRasterTechnique< Options, Descriptor extends JsonValue, Data, - Binding, - Storage extends GlyphBatchStorageShape, >( - technique: RasterTechniqueDefinition, -): RasterTechnique { + technique: RasterTechniqueDefinition, +): RasterTechnique { assertIdentifier(technique.id, 'raster technique ID'); - return technique as RasterTechnique; + return technique as RasterTechnique; } /** Brand a stable resource identity produced by a portable technique. */ diff --git a/packages/text/src/raster.ts b/packages/text/src/raster.ts index 6d58004d..53b88c0a 100644 --- a/packages/text/src/raster.ts +++ b/packages/text/src/raster.ts @@ -1,8 +1,6 @@ import type { RegisteredFont } from './font.js'; -import type { ParagraphLayout } from './layout.js'; -import type { FontHandle, FontSlot, RasterHandle, RasterKey, Sha256Hex } from './identity.js'; +import type { FontHandle, RasterHandle, RasterKey, Sha256Hex } from './identity.js'; import type { BakeProgressListener, RasterBakeArtifact } from './bake.js'; -import type { GlyphPaint } from './paint.js'; export type RasterKind = string; @@ -10,8 +8,6 @@ export type JsonValue = null | boolean | number | string | readonly JsonValue[] export type RasterOptionsArgument = [Options] extends [never] ? undefined : Options; -type RasterOptionsOptional = [Options] extends [never] ? true : undefined extends Options ? true : false; - export type StaticNumberTuple = number extends Values[number] ? never : Values; @@ -111,131 +107,4 @@ export type RuntimeRasterBakerLoader = () => Promi RuntimeRasterBakerModule | { readonly default: RuntimeRasterBakerModule } >; -export interface RasterModule { - readonly kind: Kind; - readonly extension: string; - readonly version: number; - readonly runtimeBaker?: RuntimeRasterBakerLoader; - - descriptor(options: RasterOptionsArgument): JsonValue; - - decode(font: RegisteredFont, raster: RegisteredRaster, signal?: AbortSignal): Promise; - - prepare(layout: ParagraphLayout, resource: Resource, fontSlot: FontSlot, signal?: AbortSignal): void | Promise; - /** - * Stage one complete renderer-owned batch generation. The implementation must not mutate - * `previous` before commit and must release partial allocations before throwing. Each stage - * owns its unpublished state independently: aborting one candidate must not affect another - * candidate staged concurrently against the same `previous` batch. - */ - stageBatch( - previous: DrawBatch | undefined, - layout: ParagraphLayout, - resource: Resource, - fontSlot: FontSlot, - paint: GlyphPaint, - rasterPixelRatio: number, - ): RasterBatchStage; - validatePaint?(paint: GlyphPaint): void; - dispose(resource: Resource): void; -} - -// Deliberately erase every module parameter at heterogeneous token/cache boundaries. -// Keeping `Kind` as `string` makes the mutable-in-position module surface invariant, -// so a concrete `RasterModule<'bitmap', ...>` no longer satisfies this erasure. -export type AnyRasterModule = RasterModule; - -export type RasterKindOf = - Raster extends RasterModule ? Kind : Raster['kind']; - -export type RasterResourceOf = - Module extends RasterModule ? Resource : never; - -export type RasterBatchOf = - Module extends RasterModule ? DrawBatch : never; - -export type RasterModuleOptionsOf = - Module extends RasterModule ? Options : never; - -type RasterRequestBase = { - readonly module: Module; -}; - -export type RasterRequest = RasterRequestBase & - ([RasterModuleOptionsOf] extends [never] - ? { readonly options?: never } - : undefined extends RasterModuleOptionsOf - ? { readonly options?: RasterModuleOptionsOf } - : { readonly options: RasterModuleOptionsOf }); - -export type RasterInput = - RasterOptionsOptional> extends true - ? Module | RasterRequest - : RasterRequest; - -export type AnyRasterInput = - | AnyRasterModule - | { - readonly module: AnyRasterModule; - readonly options?: unknown; - }; - -export interface LoadedRaster { - readonly module: Module; - readonly artifact: RegisteredRaster>; - readonly resource: RasterResourceOf; -} - -/** Renderer-neutral ownership surface implemented by every raster-owned batch. */ -export interface RasterDrawBatch { - /** Release all renderer resources owned by this batch. Safe to call repeatedly. */ - dispose(): void; -} - -/** Renderer adapter batch that publishes one host-owned scene object. */ -export interface RasterObjectDrawBatch extends RasterDrawBatch { - readonly object: SceneObject; - /** Synchronously and infallibly apply the owning object's order while preserving draw-local ordering. */ - setRenderOrderBase(base: number): void; -} - -/** - * One unpublished raster generation. The target may retain `previous` or replace it, but staging - * must not mutate committed state. Commit is synchronous and infallible; abort is idempotent and - * becomes a no-op after commit transfers batch ownership to the caller. - */ -export interface RasterBatchStage { - readonly batch: DrawBatch; - /** Publish the fully validated stage. Safe to call repeatedly. */ - commit(): void; - /** Release an unpublished stage without touching the live batch. Safe to call repeatedly. */ - abort(): void; -} - -/** Build the required one-shot ownership state machine around a package-owned staged update. */ -export function defineRasterBatchStage( - batch: DrawBatch, - publish: () => void, - release: () => void, -): RasterBatchStage { - let state: 'staged' | 'committed' | 'aborted' = 'staged'; - return { - batch, - commit() { - if (state !== 'staged') return; - state = 'committed'; - publish(); - }, - abort() { - if (state !== 'staged') return; - state = 'aborted'; - release(); - }, - }; -} - -export function defineRaster( - module: RasterModule, -): RasterModule { - return module; -} +export type RasterKindOf = Raster['kind']; diff --git a/packages/text/src/raster/bitmap-technique.ts b/packages/text/src/raster/bitmap-technique.ts index a0a0a9c1..d5285a70 100644 --- a/packages/text/src/raster/bitmap-technique.ts +++ b/packages/text/src/raster/bitmap-technique.ts @@ -11,7 +11,6 @@ import { } from '../internal/bitmap-contract.js'; import { nearestBitmapStrikeIndex } from '../internal/bitmap-strike.js'; import { - ABSENT_GLYPH_PAGE, DENSE_GLYPH_RECORD_STRIDE, decodeEmbeddedLosslessAtlasPage, jsonArray, @@ -22,15 +21,11 @@ import { type RasterAtlasPage, } from '../internal/raster-atlas.js'; import { decodeRasterCoverage } from '../internal/raster-coverage-artifact.js'; -import type { GlyphPaint, ResolvedPaint } from '../paint.js'; -import { RasterCoverageError, type RasterCoverage } from '../raster-coverage.js'; +import type { RasterCoverage } from '../raster-coverage.js'; import type { RegisteredRaster } from '../raster.js'; import { defineRasterResourceId, defineRasterTechnique, - type GlyphRange, - type RasterGlyphInput, - type RasterGlyphWriteInput, type RasterResourceId, type RasterTechnique, type RasterTechniqueId, @@ -51,7 +46,6 @@ export { } from '../internal/bitmap-contract.js'; const RECORD_STRIDE = DENSE_GLYPH_RECORD_STRIDE; -const ABSENT_PAGE = ABSENT_GLYPH_PAGE; const MAX_RUNTIME_TEXTURE_BYTES = 256 * 1024 * 1024; export interface BitmapTechniqueOptions { @@ -64,20 +58,11 @@ export interface BitmapPageData extends RasterAtlasPage { readonly resource: RasterResourceId; } -export interface BitmapBinding { - readonly strike: number; - readonly page: number; - readonly ppem: number; - readonly width: number; - readonly height: number; -} - export interface BitmapStrikeData { readonly ppem: number; readonly planeUnitsPerEm: number; readonly records: Uint8Array; readonly pages: readonly BitmapPageData[]; - readonly bindings: readonly BitmapBinding[]; } export interface BitmapData { @@ -98,23 +83,13 @@ export function selectBitmapStrikePpem( return strikes[nearestBitmapStrikeIndex(strikes, cssFontSize, rasterPixelRatio)]!.ppem; } -export interface BitmapGlyphBatchStorage { - readonly origins: Float32Array; - readonly sizes: Float32Array; - readonly uvOrigins: Float32Array; - readonly uvSizes: Float32Array; - readonly colors: Float32Array; -} - -/** Renderer-neutral Bitmap decoding, strike/page selection, and canonical instance packing. */ +/** Renderer-neutral Bitmap identity, decoding, and ownership. */ export const bitmap: RasterTechnique< RasterTechniqueId & 'pmndrs.bitmap', typeof BITMAP_KIND, BitmapTechniqueOptions, BitmapDescriptorV0, - BitmapData, - BitmapBinding, - BitmapGlyphBatchStorage + BitmapData > = defineRasterTechnique({ id: 'pmndrs.bitmap', kind: BITMAP_KIND, @@ -130,43 +105,6 @@ export const bitmap: RasterTechnique< signal?.throwIfAborted(); return data; }, - select(input: RasterGlyphInput) { - const { data, glyphId } = input; - assertGlyphId(data, glyphId); - assertCoverage(data, glyphId); - if (!Number.isFinite(input.fontSize) || input.fontSize <= 0) { - throw new TypeError('bitmap glyph font sizes must be positive finite values'); - } - if (!Number.isFinite(input.rasterPixelRatio) || input.rasterPixelRatio <= 0) { - throw new TypeError('bitmap rasterPixelRatio must be a positive finite value'); - } - const strikeIndex = nearestBitmapStrikeIndex(data.strikes, input.fontSize, input.rasterPixelRatio); - const strike = data.strikes[strikeIndex]!; - const pageIndex = recordView(strike).getUint16(glyphId * RECORD_STRIDE + 16, true); - if (pageIndex === ABSENT_PAGE) return undefined; - const page = strike.pages[pageIndex]; - const binding = strike.bindings[pageIndex]; - if (page === undefined || binding === undefined) throw new TypeError('bitmap glyph references a missing page'); - return { resource: page.resource, pipelineVariant: 0, binding }; - }, - createStorage(capacity: number): BitmapGlyphBatchStorage { - assertCapacity(capacity); - return { - origins: new Float32Array(capacity * 2), - sizes: new Float32Array(capacity * 2), - uvOrigins: new Float32Array(capacity * 2), - uvSizes: new Float32Array(capacity * 2), - colors: new Float32Array(capacity * 4), - }; - }, - writeStorage( - storage: BitmapGlyphBatchStorage, - range: GlyphRange, - input: RasterGlyphWriteInput, - ): void { - writeBitmapStorage(storage, range, input); - }, - validatePaint: assertBitmapPaint, dispose() {}, }); @@ -252,108 +190,7 @@ async function decodeBitmapData(font: RegisteredFont, raster: RegisteredRaster): }, ); validateDenseGlyphRecords(records, pages, 'bitmap'); - const bindings = pages.map((page, pageIndex) => - Object.freeze({ strike: strikeIndex, page: pageIndex, ppem, width: page.width, height: page.height }), - ); - strikes.push({ ppem, planeUnitsPerEm, records, pages, bindings }); + strikes.push({ ppem, planeUnitsPerEm, records, pages }); } return { strikes, ...(coverage === undefined ? {} : { coverage: coverage.bits }) }; } - -function writeBitmapStorage( - storage: BitmapGlyphBatchStorage, - range: GlyphRange, - input: RasterGlyphWriteInput, -): void { - assertWriteRange(storage, range, input.glyphs.length); - const strike = input.data.strikes[input.binding.strike]; - if (strike?.bindings[input.binding.page] !== input.binding) { - throw new TypeError('bitmap write binding does not belong to its data'); - } - const records = recordView(strike); - for (let index = 0; index < input.glyphs.length; index += 1) { - const glyph = input.glyphs[index]!; - assertGlyphId(input.data, glyph.glyphId); - assertCoverage(input.data, glyph.glyphId); - if (!Number.isFinite(glyph.fontSize) || glyph.fontSize <= 0) { - throw new TypeError('bitmap glyph font sizes must be positive finite values'); - } - if (!Number.isFinite(glyph.originX) || !Number.isFinite(glyph.originY)) { - throw new TypeError('bitmap glyph origins must be finite values'); - } - assertResolvedPaint(glyph.paint); - const record = glyph.glyphId * RECORD_STRIDE; - const pageIndex = records.getUint16(record + 16, true); - if (pageIndex !== input.binding.page) throw new TypeError('bitmap glyph does not belong to the selected page'); - const scale = glyph.fontSize / strike.planeUnitsPerEm; - const planeLeft = records.getInt16(record, true); - const planeBottom = records.getInt16(record + 2, true); - const planeRight = records.getInt16(record + 4, true); - const planeTop = records.getInt16(record + 6, true); - const atlasLeft = records.getUint16(record + 8, true); - const atlasTop = records.getUint16(record + 10, true); - const atlasRight = records.getUint16(record + 12, true); - const atlasBottom = records.getUint16(record + 14, true); - const instance = range.start + index; - const vectorOffset = instance * 2; - storage.origins[vectorOffset] = glyph.originX + planeLeft * scale; - storage.origins[vectorOffset + 1] = glyph.originY - planeTop * scale; - storage.sizes[vectorOffset] = (planeRight - planeLeft) * scale; - storage.sizes[vectorOffset + 1] = (planeTop - planeBottom) * scale; - storage.uvOrigins[vectorOffset] = atlasLeft / input.binding.width; - storage.uvOrigins[vectorOffset + 1] = atlasTop / input.binding.height; - storage.uvSizes[vectorOffset] = (atlasRight - atlasLeft) / input.binding.width; - storage.uvSizes[vectorOffset + 1] = (atlasBottom - atlasTop) / input.binding.height; - storage.colors.set(glyph.paint.color, instance * 4); - } -} - -function recordView(strike: BitmapStrikeData): DataView { - return new DataView(strike.records.buffer, strike.records.byteOffset, strike.records.byteLength); -} - -function assertGlyphId(data: BitmapData, glyphId: number): void { - const recordCount = data.strikes[0]!.records.byteLength / RECORD_STRIDE; - if (!Number.isSafeInteger(glyphId) || glyphId < 0 || glyphId >= recordCount) { - throw new TypeError('bitmap glyph is outside the registered font'); - } -} - -function assertCoverage(data: BitmapData, glyphId: number): void { - if (data.coverage !== undefined && (data.coverage[glyphId >> 3]! & (1 << (glyphId & 7))) === 0) { - throw new RasterCoverageError(BITMAP_KIND, [glyphId]); - } -} - -function assertBitmapPaint(paint: GlyphPaint): void { - for (const entry of paint.palette) assertResolvedPaint(entry); -} - -function assertResolvedPaint(paint: ResolvedPaint): void { - if (paint.outline !== undefined || paint.shadow !== undefined) { - throw new TypeError('bitmap raster does not support outline or shadow paint'); - } - if (paint.color.length !== 4 || paint.color.some((value) => !Number.isFinite(value) || value < 0 || value > 1)) { - throw new TypeError('bitmap color must contain four finite linear values in [0, 1]'); - } -} - -function assertWriteRange(storage: BitmapGlyphBatchStorage, range: GlyphRange, glyphCount: number): void { - const capacity = storage.colors.length / 4; - if ( - !Number.isSafeInteger(range.start) || - !Number.isSafeInteger(range.count) || - range.start < 0 || - range.count < 0 || - range.count !== glyphCount || - range.start > capacity - range.count - ) { - throw new RangeError('bitmap storage write range is outside its capacity'); - } -} - -function assertCapacity(capacity: number): void { - if (!Number.isSafeInteger(capacity) || capacity < 0) { - throw new RangeError('bitmap storage capacity must be a non-negative safe integer'); - } -} diff --git a/packages/text/src/raster/msdf.ts b/packages/text/src/raster/msdf.ts index 8df664df..8a4b10e8 100644 --- a/packages/text/src/raster/msdf.ts +++ b/packages/text/src/raster/msdf.ts @@ -19,7 +19,6 @@ import { type MsdfOptions, } from '../internal/msdf-contract.js'; import { - ABSENT_GLYPH_PAGE, DENSE_GLYPH_RECORD_STRIDE, decodeEmbeddedLosslessAtlasPage, jsonArray, @@ -29,15 +28,10 @@ import { type RasterAtlasPage, } from '../internal/raster-atlas.js'; import { decodeRasterCoverage } from '../internal/raster-coverage-artifact.js'; -import type { GlyphPaint, LinearRgba, ResolvedPaint } from '../paint.js'; -import { RasterCoverageError } from '../raster-coverage.js'; import type { JsonValue, RegisteredRaster } from '../raster.js'; import { defineRasterResourceId, defineRasterTechnique, - type GlyphRange, - type RasterGlyphInput, - type RasterGlyphWriteInput, type RasterResourceId, type RasterTechnique, type RasterTechniqueId, @@ -64,7 +58,6 @@ export { export { DENSE_GLYPH_RECORD_STRIDE as MSDF_GLYPH_RECORD_STRIDE } from '../internal/raster-atlas.js'; const RECORD_STRIDE = DENSE_GLYPH_RECORD_STRIDE; -const ABSENT_PAGE = ABSENT_GLYPH_PAGE; const MAX_RUNTIME_TEXTURE_BYTES = 256 * 1024 * 1024; export interface MsdfPageData extends RasterAtlasPage { @@ -88,29 +81,13 @@ export interface MsdfData { readonly pages: readonly MsdfPageData[]; } -export interface MsdfGlyphBatchStorage { - readonly origins: Float32Array; - readonly sizes: Float32Array; - readonly uvOrigins: Float32Array; - readonly uvSizes: Float32Array; - readonly uvBounds: Float32Array; - readonly shadowOffsets: Float32Array; - readonly fillColors: Float32Array; - readonly outlineColors: Float32Array; - readonly outlineWidths: Float32Array; - readonly shadowColors: Float32Array; - readonly pageIndices: Uint16Array; -} - -/** Renderer-neutral MSDF decoding, physical selection, and canonical instance packing. */ +/** Renderer-neutral MSDF identity, decoding, and ownership. */ export const msdf: RasterTechnique< RasterTechniqueId & 'pmndrs.msdf', typeof MSDF_KIND, MsdfOptions | undefined, MsdfDescriptorV0, - MsdfData, - MsdfBinding, - MsdfGlyphBatchStorage + MsdfData > = defineRasterTechnique({ id: 'pmndrs.msdf', kind: MSDF_KIND, @@ -126,39 +103,6 @@ export const msdf: RasterTechnique< signal?.throwIfAborted(); return data; }, - select(input: RasterGlyphInput) { - const { data, glyphId } = input; - assertGlyphId(data, glyphId); - assertCoverage(data, glyphId); - const pageIndex = recordView(data).getUint16(glyphId * RECORD_STRIDE + 16, true); - if (pageIndex === ABSENT_PAGE) return undefined; - if (data.pages[pageIndex] === undefined) throw new TypeError('MSDF glyph references a missing page'); - return { resource: data.resource, pipelineVariant: 0, binding: data.binding }; - }, - createStorage(capacity: number): MsdfGlyphBatchStorage { - assertCapacity(capacity); - return { - origins: new Float32Array(capacity * 2), - sizes: new Float32Array(capacity * 2), - uvOrigins: new Float32Array(capacity * 2), - uvSizes: new Float32Array(capacity * 2), - uvBounds: new Float32Array(capacity * 4), - shadowOffsets: new Float32Array(capacity * 2), - fillColors: new Float32Array(capacity * 4), - outlineColors: new Float32Array(capacity * 4), - outlineWidths: new Float32Array(capacity), - shadowColors: new Float32Array(capacity * 4), - pageIndices: new Uint16Array(capacity), - }; - }, - writeStorage( - storage: MsdfGlyphBatchStorage, - range: GlyphRange, - input: RasterGlyphWriteInput, - ): void { - writeMsdfStorage(storage, range, input); - }, - validatePaint: assertMsdfPaint, dispose() {}, }); @@ -243,133 +187,6 @@ async function decodeMsdfData(font: RegisteredFont, raster: RegisteredRaster): P }; } -function writeMsdfStorage( - storage: MsdfGlyphBatchStorage, - range: GlyphRange, - input: RasterGlyphWriteInput, -): void { - assertWriteRange(storage, range, input.glyphs.length); - if (input.binding !== input.data.binding) throw new TypeError('MSDF write binding does not belong to its data'); - const records = recordView(input.data); - for (let index = 0; index < input.glyphs.length; index += 1) { - writeMsdfGlyph(storage, range.start + index, input.data, records, input.glyphs[index]!); - } -} - -function writeMsdfGlyph( - storage: MsdfGlyphBatchStorage, - instance: number, - data: MsdfData, - records: DataView, - glyph: RasterGlyphInput, -): void { - assertGlyphId(data, glyph.glyphId); - assertCoverage(data, glyph.glyphId); - if (!Number.isFinite(glyph.fontSize) || glyph.fontSize <= 0) { - throw new TypeError('MSDF glyph font sizes must be positive finite values'); - } - if (!Number.isFinite(glyph.originX) || !Number.isFinite(glyph.originY)) { - throw new TypeError('MSDF glyph origins must be finite values'); - } - assertResolvedPaint(glyph.paint); - const record = glyph.glyphId * RECORD_STRIDE; - const planeLeft = records.getInt16(record, true); - const planeBottom = records.getInt16(record + 2, true); - const planeRight = records.getInt16(record + 4, true); - const planeTop = records.getInt16(record + 6, true); - const atlasLeft = records.getUint16(record + 8, true); - const atlasTop = records.getUint16(record + 10, true); - const atlasRight = records.getUint16(record + 12, true); - const atlasBottom = records.getUint16(record + 14, true); - const pageIndex = records.getUint16(record + 16, true); - if (pageIndex === ABSENT_PAGE || data.pages[pageIndex] === undefined) { - throw new TypeError('MSDF storage write requires a selected renderable glyph'); - } - const scale = glyph.fontSize / data.planeUnitsPerEm; - const baseOriginX = glyph.originX + planeLeft * scale; - const baseOriginY = glyph.originY - planeTop * scale; - const baseWidth = (planeRight - planeLeft) * scale; - const baseHeight = (planeTop - planeBottom) * scale; - const shadowX = glyph.paint.shadow?.offset[0] ?? 0; - const shadowY = glyph.paint.shadow?.offset[1] ?? 0; - const originX = baseOriginX + Math.min(0, shadowX); - const originY = baseOriginY + Math.min(0, shadowY); - const width = baseWidth + Math.abs(shadowX); - const height = baseHeight + Math.abs(shadowY); - const baseUvX = atlasLeft / data.binding.width; - const baseUvY = atlasTop / data.binding.height; - const baseUvWidth = (atlasRight - atlasLeft) / data.binding.width; - const baseUvHeight = (atlasBottom - atlasTop) / data.binding.height; - const uvPerUnitX = baseUvWidth / baseWidth; - const uvPerUnitY = baseUvHeight / baseHeight; - const vectorOffset = instance * 2; - storage.origins[vectorOffset] = originX; - storage.origins[vectorOffset + 1] = originY; - storage.sizes[vectorOffset] = width; - storage.sizes[vectorOffset + 1] = height; - storage.uvOrigins[vectorOffset] = baseUvX + (originX - baseOriginX) * uvPerUnitX; - storage.uvOrigins[vectorOffset + 1] = baseUvY + (originY - baseOriginY) * uvPerUnitY; - storage.uvSizes[vectorOffset] = width * uvPerUnitX; - storage.uvSizes[vectorOffset + 1] = height * uvPerUnitY; - storage.shadowOffsets[vectorOffset] = shadowX * uvPerUnitX; - storage.shadowOffsets[vectorOffset + 1] = shadowY * uvPerUnitY; - const boundsOffset = instance * 4; - storage.uvBounds[boundsOffset] = baseUvX; - storage.uvBounds[boundsOffset + 1] = baseUvY; - storage.uvBounds[boundsOffset + 2] = baseUvX + baseUvWidth; - storage.uvBounds[boundsOffset + 3] = baseUvY + baseUvHeight; - storage.fillColors.set(glyph.paint.color, boundsOffset); - storage.outlineColors.set(glyph.paint.outline?.color ?? TRANSPARENT, boundsOffset); - storage.shadowColors.set(glyph.paint.shadow?.color ?? TRANSPARENT, boundsOffset); - storage.outlineWidths[instance] = resolveOutlineDistance(data, glyph.fontSize, glyph.paint.outline?.width ?? 0); - storage.pageIndices[instance] = pageIndex; -} - -function recordView(data: MsdfData): DataView { - return new DataView(data.records.buffer, data.records.byteOffset, data.records.byteLength); -} - -function assertGlyphId(data: MsdfData, glyphId: number): void { - if (!Number.isSafeInteger(glyphId) || glyphId < 0 || glyphId >= data.records.byteLength / RECORD_STRIDE) { - throw new TypeError('MSDF glyph is outside the registered font'); - } -} - -function assertCoverage(data: MsdfData, glyphId: number): void { - if (data.coverage !== undefined && (data.coverage[glyphId >> 3]! & (1 << (glyphId & 7))) === 0) { - throw new RasterCoverageError(MSDF_KIND, [glyphId]); - } -} - -function resolveOutlineDistance(data: MsdfData, fontSize: number, outlineWidth: number): number { - const atlasPixels = outlineWidth / (fontSize / data.planeUnitsPerEm); - const maximum = data.pixelRange / 2; - if (atlasPixels > maximum) { - throw new RangeError(`MSDF outline width exceeds the ${maximum}-atlas-pixel field limit`); - } - return atlasPixels / data.pixelRange; -} - -function assertWriteRange(storage: MsdfGlyphBatchStorage, range: GlyphRange, glyphCount: number): void { - const capacity = storage.pageIndices.length; - if ( - !Number.isSafeInteger(range.start) || - !Number.isSafeInteger(range.count) || - range.start < 0 || - range.count < 0 || - range.count !== glyphCount || - range.start > capacity - range.count - ) { - throw new RangeError('MSDF storage write range is outside its capacity'); - } -} - -function assertCapacity(capacity: number): void { - if (!Number.isSafeInteger(capacity) || capacity < 0) { - throw new RangeError('MSDF storage capacity must be a non-negative safe integer'); - } -} - function configuredInteger(value: unknown, label: string, maximum: number): number { if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1 || value > maximum) { throw new TypeError(`${label} must be an integer in 1..=${maximum}`); @@ -386,31 +203,3 @@ function validateMsdfPageDirectory(value: JsonValue, pageIndex: number): void { throw new TypeError('MSDF V0 pages accept only the lossless rgba8unorm baseline'); } } - -function assertMsdfPaint(paint: GlyphPaint): void { - for (const entry of paint.palette) assertResolvedPaint(entry); -} - -function assertResolvedPaint(paint: ResolvedPaint): void { - assertLinearColor(paint.color, 'MSDF fill'); - if (paint.outline !== undefined) { - assertLinearColor(paint.outline.color, 'MSDF outline'); - if (!Number.isFinite(paint.outline.width) || paint.outline.width < 0) { - throw new TypeError('MSDF outline width must be a non-negative finite value'); - } - } - if (paint.shadow !== undefined) { - assertLinearColor(paint.shadow.color, 'MSDF shadow'); - if (paint.shadow.offset.some((value) => !Number.isFinite(value))) { - throw new TypeError('MSDF shadow offsets must be finite values'); - } - } -} - -function assertLinearColor(color: readonly number[], label: string): void { - if (color.length !== 4 || color.some((value) => !Number.isFinite(value) || value < 0 || value > 1)) { - throw new TypeError(`${label} color must contain four finite linear values in [0, 1]`); - } -} - -const TRANSPARENT: LinearRgba = [0, 0, 0, 0]; diff --git a/packages/text/src/raster/slug-technique.ts b/packages/text/src/raster/slug-technique.ts index a7ba7e72..1c935e74 100644 --- a/packages/text/src/raster/slug-technique.ts +++ b/packages/text/src/raster/slug-technique.ts @@ -19,14 +19,10 @@ import { slugDescriptor, type SlugDescriptorV0, } from '../internal/slug-contract.js'; -import type { GlyphPaint, ResolvedPaint } from '../paint.js'; import type { JsonValue, RasterResourceSource, RegisteredRaster } from '../raster.js'; import { defineRasterResourceId, defineRasterTechnique, - type GlyphRange, - type RasterGlyphInput, - type RasterGlyphWriteInput, type RasterResourceId, type RasterTechnique, type RasterTechniqueId, @@ -64,48 +60,19 @@ export interface SlugPageData { readonly referenceBytes: Uint8Array; } -export interface SlugBinding { - readonly page: number; - readonly curveWidth: number; - readonly curveHeight: number; - readonly headerWidth: number; - readonly headerHeight: number; - readonly referenceWidth: number; - readonly referenceHeight: number; -} - export interface SlugData { readonly planeUnitsPerEm: number; readonly records: Uint8Array; readonly pages: readonly SlugPageData[]; - readonly bindings: readonly SlugBinding[]; } -export interface SlugGlyphBatchStorage { - readonly origins: Float32Array; - readonly sizes: Float32Array; - readonly emOrigins: Float32Array; - readonly emSizes: Float32Array; - readonly inverseScales: Float32Array; - readonly bandTransforms: Float32Array; - readonly colors: Float32Array; - readonly curveBases: Uint32Array; - readonly horizontalHeaderBases: Uint32Array; - readonly verticalHeaderBases: Uint32Array; - readonly referenceBases: Uint32Array; - readonly horizontalBandCounts: Uint32Array; - readonly verticalBandCounts: Uint32Array; -} - -/** Renderer-neutral Slug decoding, page selection, and canonical analytic instance packing. */ +/** Renderer-neutral Slug identity, decoding, and ownership. */ export const slug: RasterTechnique< RasterTechniqueId & 'pmndrs.slug', typeof SLUG_KIND, undefined, SlugDescriptorV0, - SlugData, - SlugBinding, - SlugGlyphBatchStorage + SlugData > = defineRasterTechnique({ id: 'pmndrs.slug', kind: SLUG_KIND, @@ -121,41 +88,6 @@ export const slug: RasterTechnique< signal?.throwIfAborted(); return data; }, - select(input: RasterGlyphInput) { - assertGlyphId(input.data, input.glyphId); - const pageIndex = recordView(input.data).getUint16(input.glyphId * SLUG_GLYPH_RECORD_STRIDE + 8, true); - if (pageIndex === ABSENT_PAGE) return undefined; - const page = input.data.pages[pageIndex]; - const binding = input.data.bindings[pageIndex]; - if (page === undefined || binding === undefined) throw new TypeError('Slug glyph references a missing page'); - return { resource: page.resource, pipelineVariant: 0, binding }; - }, - createStorage(capacity: number): SlugGlyphBatchStorage { - assertCapacity(capacity); - return { - origins: new Float32Array(capacity * 2), - sizes: new Float32Array(capacity * 2), - emOrigins: new Float32Array(capacity * 2), - emSizes: new Float32Array(capacity * 2), - inverseScales: new Float32Array(capacity), - bandTransforms: new Float32Array(capacity * 4), - colors: new Float32Array(capacity * 4), - curveBases: new Uint32Array(capacity), - horizontalHeaderBases: new Uint32Array(capacity), - verticalHeaderBases: new Uint32Array(capacity), - referenceBases: new Uint32Array(capacity), - horizontalBandCounts: new Uint32Array(capacity), - verticalBandCounts: new Uint32Array(capacity), - }; - }, - writeStorage( - storage: SlugGlyphBatchStorage, - range: GlyphRange, - input: RasterGlyphWriteInput, - ): void { - writeSlugStorage(storage, range, input); - }, - validatePaint: assertSlugPaint, dispose() {}, }); @@ -199,18 +131,7 @@ async function decodeSlugData(font: RegisteredFont, raster: RegisteredRaster, si ); } validateSlugRecordTable(records, pages, font.glyphCount); - const bindings = pages.map((page, index) => - Object.freeze({ - page: index, - curveWidth: page.curveWidth, - curveHeight: page.curveHeight, - headerWidth: page.headerWidth, - headerHeight: page.headerHeight, - referenceWidth: page.referenceWidth, - referenceHeight: page.referenceHeight, - }), - ); - return { planeUnitsPerEm: SLUG_PLANE_UNITS_PER_EM, records, pages, bindings }; + return { planeUnitsPerEm: SLUG_PLANE_UNITS_PER_EM, records, pages }; } async function decodeSlugPage( @@ -293,64 +214,6 @@ async function decodeSlugPage( }; } -function writeSlugStorage( - storage: SlugGlyphBatchStorage, - range: GlyphRange, - input: RasterGlyphWriteInput, -): void { - assertWriteRange(storage, range, input.glyphs.length); - if (input.data.bindings[input.binding.page] !== input.binding) { - throw new TypeError('Slug write binding does not belong to its data'); - } - const records = recordView(input.data); - for (let index = 0; index < input.glyphs.length; index += 1) { - const glyph = input.glyphs[index]!; - assertGlyphId(input.data, glyph.glyphId); - if (!Number.isFinite(glyph.fontSize) || glyph.fontSize <= 0) { - throw new TypeError('Slug glyph font sizes must be positive finite values'); - } - if (!Number.isFinite(glyph.originX) || !Number.isFinite(glyph.originY)) { - throw new TypeError('Slug glyph origins must be finite values'); - } - assertResolvedPaint(glyph.paint); - const record = glyph.glyphId * SLUG_GLYPH_RECORD_STRIDE; - const pageIndex = records.getUint16(record + 8, true); - if (pageIndex !== input.binding.page) throw new TypeError('Slug glyph does not belong to the selected page'); - const left = records.getInt16(record, true); - const bottom = records.getInt16(record + 2, true); - const right = records.getInt16(record + 4, true); - const top = records.getInt16(record + 6, true); - const horizontalBands = records.getUint16(record + 10, true); - const verticalBands = records.getUint16(record + 12, true); - const normalizedLeft = left / input.data.planeUnitsPerEm; - const normalizedBottom = bottom / input.data.planeUnitsPerEm; - const normalizedTop = top / input.data.planeUnitsPerEm; - const normalizedWidth = (right - left) / input.data.planeUnitsPerEm; - const normalizedHeight = (top - bottom) / input.data.planeUnitsPerEm; - const scale = glyph.fontSize / input.data.planeUnitsPerEm; - const instance = range.start + index; - setVector2(storage.origins, instance, glyph.originX + left * scale, glyph.originY - top * scale); - setVector2(storage.sizes, instance, (right - left) * scale, (top - bottom) * scale); - setVector2(storage.emOrigins, instance, normalizedLeft, normalizedTop); - setVector2(storage.emSizes, instance, normalizedWidth, normalizedHeight); - storage.inverseScales[instance] = 1 / glyph.fontSize; - const transformOffset = instance * 4; - const bandScaleX = verticalBands / normalizedWidth; - const bandScaleY = horizontalBands / normalizedHeight; - storage.bandTransforms.set( - [bandScaleX, bandScaleY, -normalizedLeft * bandScaleX, -normalizedBottom * bandScaleY], - transformOffset, - ); - storage.colors.set(glyph.paint.color, transformOffset); - storage.curveBases[instance] = records.getUint32(record + 16, true); - storage.horizontalHeaderBases[instance] = records.getUint32(record + 24, true); - storage.verticalHeaderBases[instance] = records.getUint32(record + 28, true); - storage.referenceBases[instance] = records.getUint32(record + 32, true); - storage.horizontalBandCounts[instance] = horizontalBands; - storage.verticalBandCounts[instance] = verticalBands; - } -} - async function rasterResourceBytes( raster: RegisteredRaster, value: JsonValue | undefined, @@ -429,34 +292,6 @@ function validateSlugRecordTable(records: Uint8Array, pages: readonly SlugPageDa } } -function recordView(data: SlugData): DataView { - return new DataView(data.records.buffer, data.records.byteOffset, data.records.byteLength); -} - -function assertGlyphId(data: SlugData, glyphId: number): void { - if (!Number.isSafeInteger(glyphId) || glyphId < 0 || glyphId >= data.records.byteLength / SLUG_GLYPH_RECORD_STRIDE) { - throw new TypeError('Slug glyph is outside the registered font'); - } -} - -function assertSlugPaint(paint: GlyphPaint): void { - for (const entry of paint.palette) assertResolvedPaint(entry); -} - -function assertResolvedPaint(paint: ResolvedPaint): void { - if (paint.outline !== undefined || paint.shadow !== undefined) { - throw new TypeError('Slug V0 supports fill paint only'); - } - if (paint.color.length !== 4 || paint.color.some((value) => !Number.isFinite(value) || value < 0 || value > 1)) { - throw new TypeError('Slug fill color must contain four finite linear values in [0, 1]'); - } -} - -function setVector2(target: Float32Array, index: number, x: number, y: number): void { - target[index * 2] = x; - target[index * 2 + 1] = y; -} - function absentRecordIsCanonical(records: Uint8Array, offset: number): boolean { for (let byte = 0; byte < SLUG_GLYPH_RECORD_STRIDE; byte += 1) { if (byte === 8 || byte === 9) continue; @@ -512,23 +347,3 @@ function checkedBytes(left: number, right: number): number { } return total; } - -function assertWriteRange(storage: SlugGlyphBatchStorage, range: GlyphRange, glyphCount: number): void { - const capacity = storage.inverseScales.length; - if ( - !Number.isSafeInteger(range.start) || - !Number.isSafeInteger(range.count) || - range.start < 0 || - range.count < 0 || - range.count !== glyphCount || - range.start > capacity - range.count - ) { - throw new RangeError('Slug storage write range is outside its capacity'); - } -} - -function assertCapacity(capacity: number): void { - if (!Number.isSafeInteger(capacity) || capacity < 0) { - throw new RangeError('Slug storage capacity must be a non-negative safe integer'); - } -} diff --git a/packages/text/src/shaper.ts b/packages/text/src/shaper.ts index 91e6c368..28f31e63 100644 --- a/packages/text/src/shaper.ts +++ b/packages/text/src/shaper.ts @@ -1,9 +1,8 @@ -import type { FontMetrics, RegisteredFont } from './font.js'; +import type { RegisteredFont } from './font.js'; import { textShaperAbi } from './generated/text-shaper-abi.js'; -import type { FontHandle, FontKey, Sha256Hex } from './identity.js'; +import type { FontHandle } from './identity.js'; import { getRegisteredFontData } from './internal/registered-font.js'; import { FontRegistry } from './loader.js'; -import type { ResolvedFontFeature } from './font-feature.js'; export type TextShaperWasmSource = BufferSource | WebAssembly.Module; @@ -19,190 +18,19 @@ export interface RuntimeShaperMemoryReport { readonly wasmMemoryBytes: number; } -export interface ShapeRunRequest { - readonly font: FontHandle; - readonly textStart: number; - readonly textEnd: number; - readonly direction: 'ltr' | 'rtl'; - readonly script: string; - readonly language?: string; - readonly clusterLevel: 0 | 1 | 2 | 3; - readonly flags: number; - readonly featureStart: number; - readonly featureCount: number; -} - -export interface ShapeBatchRequest { - readonly textUtf16: Uint16Array; - readonly runs: readonly ShapeRunRequest[]; - readonly features: readonly ResolvedFontFeature[]; -} - -export interface ReshapeRange { - readonly run: number; - readonly itemStart: number; - readonly itemEnd: number; - readonly contextStart: number; - readonly contextEnd: number; - readonly flags: number; -} - -export interface ReshapeBatchRequest extends ShapeBatchRequest { - readonly ranges: readonly ReshapeRange[]; -} - -export interface ShapedBatchViews { - readonly fontHandles: Uint32Array; - readonly runFontSlots: Uint16Array; - readonly runGlyphStarts: Uint32Array; - readonly runGlyphCounts: Uint32Array; - readonly glyphIds: Uint16Array; - readonly clusters: Uint32Array; - readonly xAdvances: Int32Array; - readonly yAdvances: Int32Array; - readonly xOffsets: Int32Array; - readonly yOffsets: Int32Array; - readonly glyphFlags: Uint16Array; -} - -export type BidiDirection = 'auto' | 'ltr' | 'rtl'; - -/** Borrowed direct-memory UAX #9 output, indexed by UTF-16 code unit. */ -export interface BidiAnalysisViews { - readonly levels: Uint8Array; - readonly classes: Uint8Array; - readonly paragraphStarts: Uint32Array; - readonly paragraphEnds: Uint32Array; - readonly paragraphLevels: Uint8Array; -} - export interface RuntimeShaper { readonly registry: FontRegistry; registerFont(font: RegisteredFont): void; disposeFont(font: RegisteredFont): void; - analyzeBidi(textUtf16: Uint16Array, direction?: BidiDirection): BidiAnalysisViews; - shapeBatch(request: ShapeBatchRequest): ShapedBatchViews; - reshapeRanges(request: ReshapeBatchRequest): ShapedBatchViews; memoryReport(): RuntimeShaperMemoryReport; dispose(): void; } -/** @internal Worker-side shaping registration that bypasses renderer and loader ownership. */ -export interface RuntimeShaperFontData { - readonly key: FontKey; - readonly handle: FontHandle; - readonly shapingHash: Sha256Hex; - readonly glyphCount: number; - readonly metrics: FontMetrics; - readonly fontFaceIndex: number; - readonly sourceHash: string; - readonly unicodeVersion: string; - readonly shapingSfnt: Uint8Array; - readonly glyphExtents: Uint8Array; - readonly glyphExtentsAvailability: Uint8Array; -} - -/** @internal */ -export function registerRuntimeShaperFontData(shaper: RuntimeShaper, data: RuntimeShaperFontData): void { +/** @internal Shared direct-memory access for the retained text-engine host. */ +export function runtimeShaperEngineExports(shaper: RuntimeShaper): ShaperExports { if (!(shaper instanceof RuntimeShaperImpl)) throw new TypeError('runtime shaper was not created by this package'); - shaper._registerFontData(data); -} - -interface LayoutBase { - readonly size: number; - readonly alignment: number; - readonly [field: string]: number; -} - -interface ShapeRequestLayout extends LayoutBase { - readonly textOffset: number; - readonly textLength: number; - readonly runsOffset: number; - readonly runCount: number; - readonly featuresOffset: number; - readonly featureCount: number; - readonly languagesOffset: number; - readonly languagesLength: number; -} - -interface ReshapeRequestLayout extends LayoutBase { - readonly rangesOffset: number; - readonly rangeCount: number; -} - -interface BidiRequestLayout extends LayoutBase { - readonly textOffset: number; - readonly textLength: number; - readonly direction: number; -} - -interface FeatureLayout extends LayoutBase { - readonly tag: number; - readonly value: number; - readonly start: number; - readonly end: number; -} - -interface RunLayout extends LayoutBase { - readonly fontHandle: number; - readonly textStart: number; - readonly textEnd: number; - readonly script: number; - readonly languageOffset: number; - readonly featureStart: number; - readonly featureCount: number; - readonly direction: number; - readonly clusterLevel: number; - readonly flags: number; -} - -interface ReshapeRangeLayout extends LayoutBase { - readonly run: number; - readonly itemStart: number; - readonly itemEnd: number; - readonly contextStart: number; - readonly contextEnd: number; - readonly flags: number; -} - -interface ResultLayout extends LayoutBase { - readonly byteLength: number; - readonly fontHandlesOffset: number; - readonly fontHandleCount: number; - readonly runFontSlotsOffset: number; - readonly runGlyphStartsOffset: number; - readonly runGlyphCountsOffset: number; - readonly runCount: number; - readonly glyphIdsOffset: number; - readonly clustersOffset: number; - readonly xAdvancesOffset: number; - readonly yAdvancesOffset: number; - readonly xOffsetsOffset: number; - readonly yOffsetsOffset: number; - readonly glyphFlagsOffset: number; - readonly glyphCount: number; -} - -interface BidiResultLayout extends LayoutBase { - readonly byteLength: number; - readonly levelsOffset: number; - readonly classesOffset: number; - readonly textLength: number; - readonly paragraphStartsOffset: number; - readonly paragraphEndsOffset: number; - readonly paragraphLevelsOffset: number; - readonly paragraphCount: number; -} - -interface ShaperAbiLayouts { - readonly shapeRequest: ShapeRequestLayout; - readonly reshapeRequest: ReshapeRequestLayout; - readonly bidiRequest: BidiRequestLayout; - readonly feature: FeatureLayout; - readonly run: RunLayout; - readonly reshapeRange: ReshapeRangeLayout; - readonly result: ResultLayout; - readonly bidiResult: BidiResultLayout; + shaper._assertEngineAccess(); + return shaper._engineExports(); } interface ShaperExports { @@ -222,22 +50,38 @@ interface ShaperExports { readonly fontCount: () => number; readonly retainedFontBytes: () => number; readonly planCount: () => number; - readonly shapeBatch: (pointer: number, length: number) => number; - readonly reshapeRanges: (pointer: number, length: number) => number; - readonly analyzeBidi: (pointer: number, length: number) => number; - readonly resultPointer: () => number; - readonly resultLength: () => number; + readonly registerFontBinding: ( + bindingHandle: number, + shapingFontHandle: number, + pointer: number, + length: number, + ) => number; + readonly registerFontStack: (handle: number, pointer: number, count: number) => number; + readonly disposeFontStack: (handle: number) => number; + readonly registerPolicy: (handle: number, pointer: number, length: number) => number; + readonly disposePolicy: (handle: number) => number; + readonly createSession: ( + handle: number, + requestCapacity: number, + resultCapacity: number, + textCapacity: number, + ) => number; + readonly reserveSession: ( + handle: number, + requestCapacity: number, + resultCapacity: number, + textCapacity: number, + ) => number; + readonly disposeSession: (handle: number) => number; + readonly requestPointer: (handle: number) => number; + readonly requestCapacity: (handle: number) => number; + readonly textUpdate: (handle: number, pointer: number, length: number) => number; } interface ShaperModule { readonly exports: ShaperExports; - readonly layouts: ShaperAbiLayouts; } -const encoder = new TextEncoder(); -const noLanguage = 0xffff_ffff; -const bufferFlagsMask = 0xff; - export async function createRuntimeShaper(options: RuntimeShaperOptions = {}): Promise { const source = options.wasm ?? (await fetchDefaultWasm()); const module = source instanceof WebAssembly.Module ? source : await WebAssembly.compile(source); @@ -249,7 +93,6 @@ export async function createRuntimeShaper(options: RuntimeShaperOptions = {}): P class RuntimeShaperImpl implements RuntimeShaper { readonly registry: FontRegistry; readonly #exports: ShaperExports; - readonly #layouts: ShaperAbiLayouts; readonly #registered = new Map(); readonly #unsubscribe: () => void; #disposed = false; @@ -257,7 +100,6 @@ class RuntimeShaperImpl implements RuntimeShaper { constructor(registry: FontRegistry, module: ShaperModule) { this.registry = registry; this.#exports = module.exports; - this.#layouts = module.layouts; this.#unsubscribe = registry._onFontDispose((font) => this.#disposeHandle(font.handle)); } @@ -268,35 +110,25 @@ class RuntimeShaperImpl implements RuntimeShaper { } if (this.#registered.get(font.handle) === font) return; const data = getRegisteredFontData(font); - this.#registerFontBytes({ - key: font.key, - handle: font.handle, - shapingHash: font.shapingHash, - glyphCount: font.glyphCount, - metrics: font.metrics, - ...data, - }); + this.#registerFontBytes(font.handle, data.shapingSfnt, data.glyphExtents, data.glyphExtentsAvailability); this.#registered.set(font.handle, font); } - /** @internal */ - _registerFontData(data: RuntimeShaperFontData): void { - this.#assertActive(); - if (this.#registered.has(data.handle)) return; - this.#registerFontBytes(data); - this.#registered.set(data.handle, undefined); - } - - #registerFontBytes(data: RuntimeShaperFontData): void { + #registerFontBytes( + handle: FontHandle, + shapingSfnt: Uint8Array, + glyphExtents: Uint8Array, + glyphExtentsAvailability: Uint8Array, + ): void { let sfnt: { readonly pointer: number; readonly length: number } | undefined; let extents: { readonly pointer: number; readonly length: number } | undefined; let availability: { readonly pointer: number; readonly length: number } | undefined; try { - sfnt = copyIntoWasm(this.#exports, data.shapingSfnt); - extents = copyIntoWasm(this.#exports, data.glyphExtents); - availability = copyIntoWasm(this.#exports, data.glyphExtentsAvailability); + sfnt = copyIntoWasm(this.#exports, shapingSfnt); + extents = copyIntoWasm(this.#exports, glyphExtents); + availability = copyIntoWasm(this.#exports, glyphExtentsAvailability); const status = this.#exports.registerFont( - data.handle, + handle, sfnt.pointer, sfnt.length, extents.pointer, @@ -319,31 +151,6 @@ class RuntimeShaperImpl implements RuntimeShaper { if (this.#registered.get(font.handle) === font) this.#disposeHandle(font.handle); } - analyzeBidi(textUtf16: Uint16Array, direction: BidiDirection = 'auto'): BidiAnalysisViews { - this.#assertActive(); - const bytes = packBidiRequest(this.#layouts.bidiRequest, textUtf16, direction); - const allocation = copyIntoWasm(this.#exports, bytes); - try { - const status = this.#exports.analyzeBidi(allocation.pointer, allocation.length); - if (status !== 0) throw shaperStatusError(status, 'analyze bidi text'); - return readBidiResultViews(this.#exports, this.#layouts.bidiResult); - } finally { - this.#exports.deallocate(allocation.pointer, allocation.length); - } - } - - shapeBatch(request: ShapeBatchRequest): ShapedBatchViews { - this.#assertActive(); - this.#assertFontsRegistered(request.runs); - return this.#call(request, undefined); - } - - reshapeRanges(request: ReshapeBatchRequest): ShapedBatchViews { - this.#assertActive(); - this.#assertFontsRegistered(request.runs); - return this.#call(request, request.ranges); - } - memoryReport(): RuntimeShaperMemoryReport { this.#assertActive(); return { @@ -361,29 +168,14 @@ class RuntimeShaperImpl implements RuntimeShaper { this.#disposed = true; } - #call(request: ShapeBatchRequest, ranges: readonly ReshapeRange[] | undefined): ShapedBatchViews { - const bytes = packRequest(this.#layouts, request, ranges); - const allocation = copyIntoWasm(this.#exports, bytes); - try { - const status = - ranges === undefined - ? this.#exports.shapeBatch(allocation.pointer, allocation.length) - : this.#exports.reshapeRanges(allocation.pointer, allocation.length); - if (status !== 0) { - throw shaperStatusError(status, ranges === undefined ? 'shape batch' : 'reshape ranges'); - } - return readResultViews(this.#exports, this.#layouts.result); - } finally { - this.#exports.deallocate(allocation.pointer, allocation.length); - } + /** @internal */ + _engineExports(): ShaperExports { + return this.#exports; } - #assertFontsRegistered(runs: readonly ShapeRunRequest[]): void { - for (const run of runs) { - if (!this.#registered.has(run.font)) { - throw new TypeError(`font handle ${run.font} is not registered with this shaper`); - } - } + /** @internal */ + _assertEngineAccess(): void { + this.#assertActive(); } #disposeHandle(handle: FontHandle): void { @@ -409,6 +201,9 @@ function readModule(instance: WebAssembly.Instance): ShaperModule { const memory = instance.exports.memory; if (!(memory instanceof WebAssembly.Memory)) throw new TypeError('text shaper is missing memory'); const functions = textShaperAbi.functions; + const initialize = exportedFunction(instance, functions.initialize); + const status = initialize(); + if (status !== 0) throw shaperStatusError(status, 'initialize'); return { exports: { memory, @@ -419,228 +214,21 @@ function readModule(instance: WebAssembly.Instance): ShaperModule { fontCount: exportedFunction(instance, functions.fontCount), retainedFontBytes: exportedFunction(instance, functions.retainedFontBytes), planCount: exportedFunction(instance, functions.planCount), - shapeBatch: exportedFunction(instance, functions.shapeBatch), - reshapeRanges: exportedFunction(instance, functions.reshapeRanges), - analyzeBidi: exportedFunction(instance, functions.analyzeBidi), - resultPointer: exportedFunction(instance, functions.resultPointer), - resultLength: exportedFunction(instance, functions.resultLength), + registerFontBinding: exportedFunction(instance, functions.registerFontBinding), + registerFontStack: exportedFunction(instance, functions.registerFontStack), + disposeFontStack: exportedFunction(instance, functions.disposeFontStack), + registerPolicy: exportedFunction(instance, functions.registerPolicy), + disposePolicy: exportedFunction(instance, functions.disposePolicy), + createSession: exportedFunction(instance, functions.createSession), + reserveSession: exportedFunction(instance, functions.reserveSession), + disposeSession: exportedFunction(instance, functions.disposeSession), + requestPointer: exportedFunction(instance, functions.requestPointer), + requestCapacity: exportedFunction(instance, functions.requestCapacity), + textUpdate: exportedFunction(instance, functions.textUpdate), }, - layouts: textShaperAbi.layouts, - }; -} - -function packBidiRequest(layout: BidiRequestLayout, textUtf16: Uint16Array, direction: BidiDirection): Uint8Array { - if (!(textUtf16 instanceof Uint16Array)) { - throw new TypeError('bidi textUtf16 must be a Uint16Array'); - } - const directions: Readonly> = { auto: 0, ltr: 1, rtl: 2 }; - const directionCode = directions[direction]; - if (directionCode === undefined) throw new RangeError('bidi direction must be auto, ltr, or rtl'); - uint32(textUtf16.length, 'bidi text UTF-16 length'); - const textOffset = align(layout.size, 2); - const bytes = new Uint8Array(checkedAdd(textOffset, checkedMultiply(textUtf16.length, 2, 'bidi text bytes'))); - const view = new DataView(bytes.buffer); - writeUint32(view, layout.textOffset, textOffset); - writeUint32(view, layout.textLength, textUtf16.length); - view.setUint8(layout.direction, directionCode); - for (let index = 0; index < textUtf16.length; index += 1) { - view.setUint16(textOffset + index * 2, textUtf16[index]!, true); - } - return bytes; -} - -function packRequest( - layouts: ShaperAbiLayouts, - request: ShapeBatchRequest, - ranges: readonly ReshapeRange[] | undefined, -): Uint8Array { - if (!(request.textUtf16 instanceof Uint16Array)) { - throw new TypeError('shape textUtf16 must be a Uint16Array'); - } - if (request.runs.length === 0) throw new RangeError('shape batch must contain a run'); - if (ranges !== undefined && ranges.length === 0) { - throw new RangeError('reshape batch must contain a range'); - } - uint32(request.textUtf16.length, 'text UTF-16 length'); - uint32(request.runs.length, 'run count'); - uint32(request.features.length, 'feature count'); - if (ranges !== undefined) uint32(ranges.length, 'reshape range count'); - - const languages = new Map(); - const languageBytes: number[] = []; - for (const run of request.runs) { - if (run.language === undefined || languages.has(run.language)) continue; - const bytes = encoder.encode(run.language); - if (bytes.length === 0 || bytes.length > 0xffff) { - throw new RangeError('shape language must encode to 1..65535 UTF-8 bytes'); - } - const offset = languageBytes.length; - uint32(offset, 'language offset'); - languages.set(run.language, offset); - languageBytes.push(bytes.length & 0xff, bytes.length >>> 8, ...bytes); - } - - const header = ranges === undefined ? layouts.shapeRequest : layouts.reshapeRequest; - let length = header.size; - const textOffset = align(length, 2); - length = checkedAdd(textOffset, checkedMultiply(request.textUtf16.length, 2, 'text bytes')); - const runsOffset = align(length, 4); - length = checkedAdd(runsOffset, checkedMultiply(request.runs.length, layouts.run.size, 'run bytes')); - const featuresOffset = align(length, 4); - length = checkedAdd(featuresOffset, checkedMultiply(request.features.length, layouts.feature.size, 'feature bytes')); - const languagesOffset = length; - length = checkedAdd(length, languageBytes.length); - const rangesOffset = ranges === undefined ? 0 : align(length, 4); - if (ranges !== undefined) { - length = checkedAdd(rangesOffset, checkedMultiply(ranges.length, layouts.reshapeRange.size, 'reshape range bytes')); - } - const bytes = new Uint8Array(length); - const view = new DataView(bytes.buffer); - writeUint32(view, layouts.shapeRequest.textOffset, textOffset); - writeUint32(view, layouts.shapeRequest.textLength, request.textUtf16.length); - writeUint32(view, layouts.shapeRequest.runsOffset, runsOffset); - writeUint32(view, layouts.shapeRequest.runCount, request.runs.length); - writeUint32(view, layouts.shapeRequest.featuresOffset, featuresOffset); - writeUint32(view, layouts.shapeRequest.featureCount, request.features.length); - writeUint32(view, layouts.shapeRequest.languagesOffset, languagesOffset); - writeUint32(view, layouts.shapeRequest.languagesLength, languageBytes.length); - if (ranges !== undefined) { - writeUint32(view, layouts.reshapeRequest.rangesOffset, rangesOffset); - writeUint32(view, layouts.reshapeRequest.rangeCount, ranges.length); - } - for (let index = 0; index < request.textUtf16.length; index++) { - view.setUint16(textOffset + index * 2, request.textUtf16[index]!, true); - } - request.features.forEach((feature, index) => { - const offset = featuresOffset + index * layouts.feature.size; - writeUint32(view, offset + layouts.feature.tag, tag(feature.tag, 'feature')); - writeUint32(view, offset + layouts.feature.value, uint32(feature.value, 'feature value')); - writeUint32(view, offset + layouts.feature.start, uint32(feature.start, 'feature start')); - writeUint32(view, offset + layouts.feature.end, uint32(feature.end, 'feature end')); - if (feature.start > feature.end || feature.end > request.textUtf16.length) { - throw new RangeError('feature range is outside textUtf16'); - } - }); - request.runs.forEach((run, index) => { - const offset = runsOffset + index * layouts.run.size; - const featureStart = uint32(run.featureStart, 'run feature start'); - const featureCount = uint16(run.featureCount, 'run feature count'); - if (featureStart + featureCount > request.features.length) { - throw new RangeError('run feature range is outside features'); - } - writeUint32(view, offset + layouts.run.fontHandle, uint32(run.font, 'font handle')); - writeUint32(view, offset + layouts.run.textStart, uint32(run.textStart, 'run text start')); - writeUint32(view, offset + layouts.run.textEnd, uint32(run.textEnd, 'run text end')); - if (run.textStart > run.textEnd || run.textEnd > request.textUtf16.length) { - throw new RangeError('run range is outside textUtf16'); - } - if (run.direction !== 'ltr' && run.direction !== 'rtl') { - throw new RangeError('run direction must be ltr or rtl'); - } - if (!Number.isInteger(run.clusterLevel) || run.clusterLevel < 0 || run.clusterLevel > 3) { - throw new RangeError('run cluster level must be an integer from 0 through 3'); - } - writeUint32(view, offset + layouts.run.script, tag(run.script, 'script')); - writeUint32( - view, - offset + layouts.run.languageOffset, - run.language === undefined ? noLanguage : languages.get(run.language)!, - ); - writeUint32(view, offset + layouts.run.featureStart, featureStart); - view.setUint16(offset + layouts.run.featureCount, featureCount, true); - view.setUint8(offset + layouts.run.direction, run.direction === 'ltr' ? 0 : 1); - view.setUint8(offset + layouts.run.clusterLevel, run.clusterLevel); - writeUint32(view, offset + layouts.run.flags, flags(run.flags, 'run flags')); - }); - bytes.set(languageBytes, languagesOffset); - ranges?.forEach((range, index) => { - const offset = rangesOffset + index * layouts.reshapeRange.size; - writeUint32(view, offset + layouts.reshapeRange.run, uint32(range.run, 'range run')); - writeUint32(view, offset + layouts.reshapeRange.itemStart, uint32(range.itemStart, 'range item start')); - writeUint32(view, offset + layouts.reshapeRange.itemEnd, uint32(range.itemEnd, 'range item end')); - writeUint32(view, offset + layouts.reshapeRange.contextStart, uint32(range.contextStart, 'range context start')); - writeUint32(view, offset + layouts.reshapeRange.contextEnd, uint32(range.contextEnd, 'range context end')); - writeUint32(view, offset + layouts.reshapeRange.flags, flags(range.flags, 'range flags')); - const run = request.runs[range.run]; - if ( - run === undefined || - range.contextStart > range.itemStart || - range.itemStart > range.itemEnd || - range.itemEnd > range.contextEnd || - range.contextStart < run.textStart || - range.contextEnd > run.textEnd - ) { - throw new RangeError('reshape range is outside its run context'); - } - }); - return bytes; -} - -function readResultViews(exports: ShaperExports, layout: ResultLayout): ShapedBatchViews { - const pointer = exports.resultPointer(); - const length = exports.resultLength(); - if (length < layout.size) throw new TypeError('text shaper returned a truncated result header'); - const bytes = checkedMemoryView(exports.memory, pointer, length); - const header = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - if (readUint32(header, layout.byteLength) !== length) { - throw new TypeError('text shaper result length does not match its header'); - } - const fontHandleCount = readUint32(header, layout.fontHandleCount); - const runCount = readUint32(header, layout.runCount); - const glyphCount = readUint32(header, layout.glyphCount); - return { - fontHandles: resultArray(Uint32Array, bytes, readUint32(header, layout.fontHandlesOffset), fontHandleCount), - runFontSlots: resultArray(Uint16Array, bytes, readUint32(header, layout.runFontSlotsOffset), runCount), - runGlyphStarts: resultArray(Uint32Array, bytes, readUint32(header, layout.runGlyphStartsOffset), runCount), - runGlyphCounts: resultArray(Uint32Array, bytes, readUint32(header, layout.runGlyphCountsOffset), runCount), - glyphIds: resultArray(Uint16Array, bytes, readUint32(header, layout.glyphIdsOffset), glyphCount), - clusters: resultArray(Uint32Array, bytes, readUint32(header, layout.clustersOffset), glyphCount), - xAdvances: resultArray(Int32Array, bytes, readUint32(header, layout.xAdvancesOffset), glyphCount), - yAdvances: resultArray(Int32Array, bytes, readUint32(header, layout.yAdvancesOffset), glyphCount), - xOffsets: resultArray(Int32Array, bytes, readUint32(header, layout.xOffsetsOffset), glyphCount), - yOffsets: resultArray(Int32Array, bytes, readUint32(header, layout.yOffsetsOffset), glyphCount), - glyphFlags: resultArray(Uint16Array, bytes, readUint32(header, layout.glyphFlagsOffset), glyphCount), - }; -} - -function readBidiResultViews(exports: ShaperExports, layout: BidiResultLayout): BidiAnalysisViews { - const pointer = exports.resultPointer(); - const length = exports.resultLength(); - if (length < layout.size) throw new TypeError('text shaper returned a truncated bidi header'); - const bytes = checkedMemoryView(exports.memory, pointer, length); - const header = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - if (readUint32(header, layout.byteLength) !== length) { - throw new TypeError('text shaper bidi result length does not match its header'); - } - const textLength = readUint32(header, layout.textLength); - const paragraphCount = readUint32(header, layout.paragraphCount); - return { - levels: resultArray(Uint8Array, bytes, readUint32(header, layout.levelsOffset), textLength), - classes: resultArray(Uint8Array, bytes, readUint32(header, layout.classesOffset), textLength), - paragraphStarts: resultArray(Uint32Array, bytes, readUint32(header, layout.paragraphStartsOffset), paragraphCount), - paragraphEnds: resultArray(Uint32Array, bytes, readUint32(header, layout.paragraphEndsOffset), paragraphCount), - paragraphLevels: resultArray(Uint8Array, bytes, readUint32(header, layout.paragraphLevelsOffset), paragraphCount), }; } -type ResultArrayConstructor = { - readonly BYTES_PER_ELEMENT: number; - new (buffer: ArrayBuffer, byteOffset: number, length: number): ArrayType; -}; - -function resultArray( - Constructor: ResultArrayConstructor, - result: Uint8Array, - offset: number, - count: number, -): ArrayType { - const length = checkedMultiply(count, Constructor.BYTES_PER_ELEMENT, 'result array bytes'); - if (offset % Constructor.BYTES_PER_ELEMENT !== 0 || offset + length > result.byteLength) { - throw new TypeError('text shaper returned an invalid result-array range'); - } - return new Constructor(result.buffer, result.byteOffset + offset, count); -} - function exportedFunction(instance: WebAssembly.Instance, name: string): (...args: number[]) => number { const value = instance.exports[name]; if (typeof value !== 'function') throw new TypeError(`text shaper is missing export ${name}`); @@ -667,32 +255,6 @@ function checkedMemoryView(memory: WebAssembly.Memory, pointer: number, length: return new Uint8Array(memory.buffer as ArrayBuffer, pointer, length); } -function tag(value: string, label: string): number { - if (value.length !== 4) throw new RangeError(`${label} tag must contain exactly four bytes`); - let packed = 0; - for (let index = 0; index < 4; index++) { - const byte = value.charCodeAt(index); - if (byte < 0x20 || byte > 0x7e) { - throw new RangeError(`${label} tag must contain printable ASCII bytes`); - } - packed = (packed << 8) | byte; - } - return packed >>> 0; -} - -function flags(value: number, label: string): number { - const packed = uint32(value, label); - if ((packed & ~bufferFlagsMask) !== 0) throw new RangeError(`${label} contains unknown bits`); - return packed; -} - -function uint16(value: number, label: string): number { - if (!Number.isInteger(value) || value < 0 || value > 0xffff) { - throw new RangeError(`${label} must be an unsigned 16-bit integer`); - } - return value; -} - function uint32(value: number, label: string): number { if (!Number.isInteger(value) || value < 0 || value > 0xffff_ffff) { throw new RangeError(`${label} must be an unsigned 32-bit integer`); @@ -700,34 +262,6 @@ function uint32(value: number, label: string): number { return value; } -function align(value: number, alignment: number): number { - return checkedAdd(value, (alignment - (value % alignment)) % alignment); -} - -function checkedMultiply(left: number, right: number, label: string): number { - const value = left * right; - if (!Number.isSafeInteger(value) || value > 0xffff_ffff) { - throw new RangeError(`${label} exceeds the V0 address space`); - } - return value; -} - -function checkedAdd(left: number, right: number): number { - const value = left + right; - if (!Number.isSafeInteger(value) || value > 0xffff_ffff) { - throw new RangeError('shape request exceeds the V0 address space'); - } - return value; -} - -function writeUint32(view: DataView, offset: number, value: number): void { - view.setUint32(offset, value, true); -} - -function readUint32(view: DataView, offset: number): number { - return view.getUint32(offset, true); -} - function shaperStatusError(status: number, action: string): Error { const labels: Record = { 1: 'invalid font handle', @@ -737,6 +271,8 @@ function shaperStatusError(status: number, action: string): Error { 5: 'font handle is not registered', 6: 'invalid batch request', 7: 'result exceeds the V0 address space', + 13: 'font stack handle is not registered', + 14: 'font is retained by a registered font stack', }; return new Error(`text shaper could not ${action}: ${labels[status] ?? `status ${status}`}`); } diff --git a/packages/text/src/text-preparation-worker.ts b/packages/text/src/text-preparation-worker.ts deleted file mode 100644 index 3ffc127f..00000000 --- a/packages/text/src/text-preparation-worker.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { prepareParagraphLayout } from './paragraph-batch.js'; -import { FontRegistry } from './loader.js'; -import { createRuntimeShaper } from './shaper.js'; -import type { - TextPreparationFailureV1, - TextPreparationRequestV1, - TextPreparationSuccessV1, - TextPreparationWorkerMessageV1, -} from './internal/text-preparation-worker-protocol.js'; - -const scope = globalThis as unknown as DedicatedWorkerGlobalScope; -const cancelled = new Set(); -const registry = new FontRegistry(); -const shaperPromise = createRuntimeShaper({ registry }); - -scope.addEventListener('message', (event: MessageEvent) => { - const message = event.data; - if (message.type === 'pmndrs-text-cancel-v1') { - cancelled.add(message.id); - return; - } - if (message.type === 'pmndrs-text-prepare-v1') void prepare(message); -}); - -async function prepare(request: TextPreparationRequestV1): Promise { - try { - const shaper = await shaperPromise; - for (const font of request.fonts) shaper.registerFont(registry._registerShapingFont(font)); - if (cancelled.delete(request.id)) return; - const layouts: TextPreparationSuccessV1['layouts'][number][] = []; - let stagedGlyphs = 0; - for (let index = 0; index < request.paragraphs.length; index += 1) { - if (cancelled.delete(request.id)) return; - const paragraph = request.paragraphs[index]!; - const layout = prepareParagraphLayout(shaper, paragraph.input); - stagedGlyphs += layout.glyphIds.length; - layouts.push({ batch: paragraph.batch, paragraph: paragraph.paragraph, layout }); - scope.postMessage({ - type: 'pmndrs-text-progress-v1', - id: request.id, - preparedParagraphs: index + 1, - totalParagraphs: request.paragraphs.length, - stagedGlyphs, - }); - } - const response: TextPreparationSuccessV1 = { type: 'pmndrs-text-success-v1', id: request.id, layouts }; - scope.postMessage(response, transferLayouts(layouts)); - } catch (cause) { - const error = cause instanceof Error ? cause : new Error(String(cause)); - const response: TextPreparationFailureV1 = { - type: 'pmndrs-text-failure-v1', - id: request.id, - error: { name: error.name, message: error.message, ...(error.stack === undefined ? {} : { stack: error.stack }) }, - }; - scope.postMessage(response); - } finally { - cancelled.delete(request.id); - } -} - -function transferLayouts(layouts: TextPreparationSuccessV1['layouts']): Transferable[] { - const buffers = new Set(); - for (const { layout } of layouts) { - for (const value of Object.values(layout)) if (ArrayBuffer.isView(value)) buffers.add(value.buffer as ArrayBuffer); - } - return [...buffers]; -} diff --git a/packages/text/src/text-properties.ts b/packages/text/src/text-properties.ts new file mode 100644 index 00000000..ca3561a8 --- /dev/null +++ b/packages/text/src/text-properties.ts @@ -0,0 +1,49 @@ +import type { FontFeature } from './font-feature.js'; +import type { FormattedText, GlyphPaintInput, ParagraphSpan } from './formatted-text.js'; +import type { FontSelection } from './loaded-font.js'; +import type { AnyRasterTechnique } from './raster-technique.js'; + +export interface GlyphBufferCapacity { + readonly size: number; + readonly policy: 'grow' | 'chunk' | 'fixed'; +} + +/** A layout-system-neutral axis constraint. */ +export type ParagraphAxisConstraint = + | { readonly mode: 'unconstrained' } + | { readonly mode: 'at-most'; readonly size: number } + | { readonly mode: 'exact'; readonly size: number }; + +export interface ParagraphContentBox { + readonly width?: ParagraphAxisConstraint; + readonly height?: ParagraphAxisConstraint; + readonly maxLines?: number; + readonly wrap?: 'none' | 'word' | 'character'; + readonly align?: 'start' | 'center' | 'end' | 'justify'; + readonly overflow?: 'visible' | 'clip' | 'ellipsis'; +} + +export interface ParagraphStyle { + readonly fontSize?: number; + readonly lineHeight?: number; + readonly letterSpacing?: number; + readonly language?: string; + readonly direction?: 'auto' | 'ltr' | 'rtl'; + readonly features?: readonly FontFeature[]; +} + +export interface ParagraphBaseProperties { + readonly font: FontSelection; + readonly contentBox?: ParagraphContentBox; + readonly style?: ParagraphStyle; + readonly paint?: GlyphPaintInput; + readonly rasterPixelRatio?: number; + readonly order?: number; +} + +export type ParagraphContentProperties = + | Readonly<{ text: string; spans?: readonly ParagraphSpan[] }> + | Readonly<{ text: FormattedText; spans?: never }>; + +export type ParagraphProperties = ParagraphBaseProperties & + ParagraphContentProperties; diff --git a/packages/text/src/text-runtime.ts b/packages/text/src/text-runtime.ts index 119a1510..bb524319 100644 --- a/packages/text/src/text-runtime.ts +++ b/packages/text/src/text-runtime.ts @@ -10,7 +10,13 @@ import { } from './loader.js'; import { canonicalJson, deriveRasterKey } from './internal/raster-identity.js'; import { getRegisteredFontData } from './internal/registered-font.js'; -import type { AnyRasterTechnique, RasterDataOf, RasterOptionsOf, RasterTechniqueTypesOf } from './raster-technique.js'; +import type { + AnyRasterTechnique, + RasterDataOf, + RasterOptionsOf, + RasterTechniqueRequest, + RasterTechniqueTypesOf, +} from './raster-technique.js'; import type { RasterKindOf, RasterOptionsArgument, @@ -20,98 +26,23 @@ import type { RuntimeRasterBakerModule, } from './raster.js'; import { createRuntimeShaper, type RuntimeShaper } from './shaper.js'; -import { - createParagraphBatch, - type ParagraphBatch, - type ParagraphBatchController, - type ParagraphBatchOptions, - type ParagraphBatchPreparation, - type ParagraphId, - type PreparedParagraphBatchCandidate, - type PreparedParagraphBatchRevision, - type TextPreparationError, -} from './paragraph-batch.js'; -import { - isTextPreparationWorkerResultV1, - type TextPreparationRequestV1, - type TextPreparationSuccessV1, -} from './internal/text-preparation-worker-protocol.js'; - -export interface TextPreparationWorker { - postMessage(message: unknown, transfer?: readonly Transferable[]): void; - addEventListener(type: 'message', listener: (event: MessageEvent) => void): void; - removeEventListener(type: 'message', listener: (event: MessageEvent) => void): void; - addEventListener(type: 'error', listener: (event: ErrorEvent) => void): void; - removeEventListener(type: 'error', listener: (event: ErrorEvent) => void): void; - terminate(): void; -} - -export function createTextPreparationWorker(): TextPreparationWorker { - if (typeof Worker === 'undefined') throw new TypeError('this environment does not provide module Workers'); - return new Worker(new URL('./text-preparation-worker.js', import.meta.url), { type: 'module' }); -} export interface TextRuntimeOptions { readonly registry?: FontRegistry; - readonly shaper?: RuntimeShaper; - readonly async?: Readonly<{ - readonly worker?: TextPreparationWorker; - readonly createWorker?: () => TextPreparationWorker; - }>; + readonly wasm?: BufferSource | WebAssembly.Module; } export type LoadedFontInput = | { readonly baked: string | URL } | { readonly source: string | URL; readonly runtimeBake: RuntimeFontBake }; -type TechniqueRasterRequest = { - readonly technique: Technique; -} & ([RasterOptionsOf] extends [never] - ? { readonly options?: never } - : undefined extends RasterOptionsOf - ? { readonly options?: RasterOptionsOf } - : { readonly options: RasterOptionsOf }); - export interface LoadedFontRequest { readonly input: LoadedFontInput; - readonly raster: TechniqueRasterRequest; -} - -export interface TextRuntimeRevision { - readonly revision: number; - readonly paragraphBatches: readonly PreparedParagraphBatchRevision[]; -} - -export interface AsyncTextUpdateOptions { - readonly signal?: AbortSignal; - readonly priority?: 'background' | 'normal' | 'urgent'; - readonly onProgress?: (progress: TextUpdateProgress) => void; -} - -export interface TextUpdateProgress { - readonly revision: number; - readonly preparedParagraphs: number; - readonly totalParagraphs: number; - readonly stagedGlyphs: number; + readonly raster: RasterTechniqueRequest; } -export type TextUpdateOutcome = - | { readonly status: 'published'; readonly value: TextRuntimeRevision } - | { readonly status: 'superseded'; readonly revision: number; readonly byRevision: number } - | { readonly status: 'aborted'; readonly revision: number; readonly reason?: unknown }; - -export type TextUpdateResult = - | { readonly ok: true; readonly value: TextUpdateOutcome } - | { readonly ok: false; readonly error: TextPreparationError }; - -export type TextUpdateCallback = (result: TextUpdateResult) => void; - export interface TextRuntime { readonly registry: FontRegistry; - readonly shaper: RuntimeShaper; - readonly current: TextRuntimeRevision; - readonly hasPendingChanges: boolean; - readonly isPreparing: boolean; readonly disposed: boolean; loadFont( @@ -119,15 +50,6 @@ export interface TextRuntime { options?: { readonly signal?: AbortSignal }, ): Promise>; - createParagraphBatch( - options: ParagraphBatchOptions, - ): ParagraphBatch; - - update(): TextRuntimeRevision; - updateAsync(options?: AsyncTextUpdateOptions): Promise; - updateAsync(callback: TextUpdateCallback): void; - updateAsync(options: AsyncTextUpdateOptions, callback: TextUpdateCallback): void; - subscribe(listener: (revision: TextRuntimeRevision) => void): () => void; dispose(): void; } @@ -136,94 +58,38 @@ interface PendingTechniqueLoad { readonly promise: Promise>; } -interface PendingTextUpdate { - readonly revision: number; - readonly snapshots: readonly ParagraphBatchPreparation[]; - readonly options: AsyncTextUpdateOptions; - readonly complete: (result: TextUpdateResult) => void; - readonly abort: () => void; - settled: boolean; - preparedParagraphs: number; - stagedGlyphs: number; -} - -type WorkerLayouts = ReadonlyMap>; - -interface PendingWorkerPreparation { - readonly operation: PendingTextUpdate; - readonly resolve: (layouts: WorkerLayouts) => void; - readonly reject: (error: unknown) => void; -} - export async function createTextRuntime(options: TextRuntimeOptions = {}): Promise { - if (options.async?.worker !== undefined && options.async.createWorker !== undefined) { - throw new TypeError('text runtime async options accept worker or createWorker, not both'); - } - const registry = options.registry ?? options.shaper?.registry ?? new FontRegistry(); - if (options.shaper !== undefined && options.shaper.registry !== registry) { - throw new TypeError('text runtime registry and shaper must share one ownership domain'); - } - const shaper = options.shaper ?? (await createRuntimeShaper({ registry })); + const registry = options.registry ?? new FontRegistry(); + const shaper = await createRuntimeShaper({ registry, ...(options.wasm === undefined ? {} : { wasm: options.wasm }) }); try { - return new TextRuntimeImpl(registry, shaper, options.async?.worker, options.async?.createWorker); + return new TextRuntimeImpl(registry, shaper); } catch (error) { - if (options.shaper === undefined) shaper.dispose(); - options.async?.worker?.terminate(); + shaper.dispose(); throw error; } } +/** @internal Rust-engine access for renderer integrations owned by this package. */ +export function textRuntimeShaper(runtime: TextRuntime): RuntimeShaper { + if (!(runtime instanceof TextRuntimeImpl)) throw new TypeError('text runtime was not created by this package'); + return runtime._shaper(); +} + class TextRuntimeImpl implements TextRuntime { readonly registry: FontRegistry; - readonly shaper: RuntimeShaper; + readonly #shaper: RuntimeShaper; readonly #defaultLoader: FontLoader; readonly #sourceLoaders = new Map(); readonly #loaded = new Map>>>(); readonly #pending = new Map>>(); - readonly #listeners = new Set<(revision: TextRuntimeRevision) => void>(); - readonly #paragraphBatches = new Set(); - readonly #pendingUpdates = new Map(); - readonly #pendingWorkerPreparations = new Map(); - readonly #workerFontHandles = new Set(); - readonly #createWorker: (() => TextPreparationWorker) | undefined; - #worker: TextPreparationWorker | undefined; - #workerMessageListener: ((event: MessageEvent) => void) | undefined; - #workerErrorListener: ((event: ErrorEvent) => void) | undefined; - #current: TextRuntimeRevision = Object.freeze({ revision: 0, paragraphBatches: Object.freeze([]) }); - #updateRequestRevision = 0; #disposed = false; - constructor( - registry: FontRegistry, - shaper: RuntimeShaper, - worker: TextPreparationWorker | undefined, - createWorker: (() => TextPreparationWorker) | undefined, - ) { + constructor(registry: FontRegistry, shaper: RuntimeShaper) { this.registry = registry; - this.shaper = shaper; - this.#createWorker = createWorker; - this.#worker = worker; - if (worker !== undefined) this.#listenToWorker(worker); + this.#shaper = shaper; this.#defaultLoader = new FontLoader({ registry }); } - get runtime(): TextRuntime { - return this; - } - - get current(): TextRuntimeRevision { - return this.#current; - } - - get hasPendingChanges(): boolean { - for (const batch of this.#paragraphBatches) if (batch.dirty) return true; - return false; - } - - get isPreparing(): boolean { - return this.#pendingUpdates.size !== 0; - } - get disposed(): boolean { return this.#disposed; } @@ -237,7 +103,7 @@ class TextRuntimeImpl implements TextRuntime { const font = await this.#loadRegisteredFont(request.input, options.signal); this.#assertActive(); options.signal?.throwIfAborted(); - this.shaper.registerFont(font); + this.#shaper.registerFont(font); const descriptor = techniqueOperations(request.raster.technique).descriptor( request.raster.options as RasterOptionsArgument>, ); @@ -269,372 +135,22 @@ class TextRuntimeImpl implements TextRuntime { return consumePending(promise, options.signal); } - createParagraphBatch( - options: ParagraphBatchOptions, - ): ParagraphBatch { - this.#assertActive(); - const controller = createParagraphBatch(this, options); - this.#paragraphBatches.add(controller); - return controller.publicBatch as ParagraphBatch; - } - - dirty(): void { - this.#assertActive(); - } - - remove(batch: ParagraphBatchController): void { - this.#paragraphBatches.delete(batch); - } - - update(): TextRuntimeRevision { - this.#assertActive(); - const requestRevision = ++this.#updateRequestRevision; - this.#supersedePending(requestRevision); - const dirty = [...this.#paragraphBatches].filter((batch) => batch.dirty); - if (dirty.length === 0) return this.#current; - const snapshots = this.#capture(dirty); - const candidates: PreparedParagraphBatchCandidate[] = []; - try { - for (const snapshot of snapshots) candidates.push(snapshot.controller.prepare(snapshot)); - return this.#publish(candidates); - } catch (error) { - this.#discard(candidates); - throw error; - } finally { - this.#release(snapshots); - } - } - - updateAsync(): Promise; - updateAsync(callback: TextUpdateCallback): void; - updateAsync(options: AsyncTextUpdateOptions): Promise; - updateAsync(options: AsyncTextUpdateOptions, callback: TextUpdateCallback): void; - updateAsync( - optionsOrCallback: AsyncTextUpdateOptions | TextUpdateCallback = {}, - callback?: TextUpdateCallback, - ): Promise | void { - this.#assertActive(); - const options = typeof optionsOrCallback === 'function' ? {} : optionsOrCallback; - const listener = typeof optionsOrCallback === 'function' ? optionsOrCallback : callback; - if (listener !== undefined) { - this.#startAsyncUpdate(options, listener); - return; - } - return new Promise((resolve, reject) => { - this.#startAsyncUpdate(options, (result) => { - if (result.ok) resolve(result.value); - else reject(result.error); - }); - }); - } - - #startAsyncUpdate(options: AsyncTextUpdateOptions, complete: (result: TextUpdateResult) => void): void { - if ( - options.priority !== undefined && - options.priority !== 'background' && - options.priority !== 'normal' && - options.priority !== 'urgent' - ) - throw new TypeError('text update priority is invalid'); - const revision = ++this.#updateRequestRevision; - this.#supersedePending(revision); - if (options.signal?.aborted === true) { - queueMicrotask(() => - complete({ ok: true, value: { status: 'aborted', revision, reason: options.signal?.reason } }), - ); - return; - } - let snapshots: readonly ParagraphBatchPreparation[]; - try { - snapshots = this.#capture([...this.#paragraphBatches].filter((batch) => batch.dirty)); - } catch (cause) { - const error = preparationError(cause); - queueMicrotask(() => complete({ ok: false, error })); - return; - } - let operation!: PendingTextUpdate; - const abort = (): void => { - this.#settle(operation, { - ok: true, - value: { status: 'aborted', revision, reason: options.signal?.reason }, - }); - }; - operation = { - revision, - snapshots, - options, - complete, - abort, - settled: false, - preparedParagraphs: 0, - stagedGlyphs: 0, - }; - this.#pendingUpdates.set(revision, operation); - options.signal?.addEventListener('abort', abort, { once: true }); - queueMicrotask(() => void this.#runAsyncUpdate(operation)); - } - - async #runAsyncUpdate(operation: PendingTextUpdate): Promise { - if (operation.settled) return; - const totalParagraphs = operation.snapshots.reduce((total, snapshot) => total + snapshot.paragraphs.length, 0); - let preparedParagraphs = 0; - let stagedGlyphs = 0; - const candidates: PreparedParagraphBatchCandidate[] = []; - try { - this.#reportProgress(operation, preparedParagraphs, totalParagraphs, stagedGlyphs); - if (operation.settled) return; - const layoutsFromWorker = await this.#prepareLayoutsInWorker(operation); - if (operation.settled) return; - for (let index = 0; index < operation.snapshots.length; index += 1) { - const snapshot = operation.snapshots[index]!; - const candidate = snapshot.controller.prepare(snapshot, layoutsFromWorker?.get(index)); - candidates.push(candidate); - preparedParagraphs += snapshot.paragraphs.length; - stagedGlyphs += candidate.revision.glyphRuns.reduce((total, run) => total + run.count, 0); - this.#reportProgress(operation, preparedParagraphs, totalParagraphs, stagedGlyphs); - if (operation.settled) { - this.#discard(candidates); - return; - } - } - if (operation.revision !== this.#updateRequestRevision) { - this.#discard(candidates); - this.#settle(operation, { - ok: true, - value: { - status: 'superseded', - revision: operation.revision, - byRevision: this.#updateRequestRevision, - }, - }); - return; - } - const value = candidates.length === 0 ? this.#current : this.#publish(candidates); - this.#settle(operation, { ok: true, value: { status: 'published', value } }); - } catch (cause) { - this.#discard(candidates); - if (!operation.settled) this.#settle(operation, { ok: false, error: preparationError(cause) }); - } - } - - #prepareLayoutsInWorker(operation: PendingTextUpdate): Promise { - const worker = this.#ensureWorker(); - if (worker === undefined) return Promise.resolve(undefined); - const paragraphs: TextPreparationRequestV1['paragraphs'][number][] = []; - for (let batch = 0; batch < operation.snapshots.length; batch += 1) { - for (const layout of operation.snapshots[batch]!.layouts) { - if (layout.input !== undefined) paragraphs.push({ batch, paragraph: layout.paragraph, input: layout.input }); - } - } - if (paragraphs.length === 0) return Promise.resolve(new Map()); - const fonts: TextPreparationRequestV1['fonts'][number][] = []; - for (const snapshot of operation.snapshots) { - for (const loaded of snapshot.leases) { - const handle = loaded.font.handle; - if (this.#workerFontHandles.has(handle)) continue; - this.#workerFontHandles.add(handle); - const data = getRegisteredFontData(loaded.font); - fonts.push({ - key: loaded.font.key, - handle, - shapingHash: loaded.font.shapingHash, - glyphCount: loaded.font.glyphCount, - metrics: loaded.font.metrics, - fontFaceIndex: data.fontFaceIndex, - sourceHash: data.sourceHash, - unicodeVersion: data.unicodeVersion, - shapingSfnt: data.shapingSfnt, - glyphExtents: data.glyphExtents, - glyphExtentsAvailability: data.glyphExtentsAvailability, - }); - } - } - return new Promise((resolve, reject) => { - this.#pendingWorkerPreparations.set(operation.revision, { operation, resolve, reject }); - const request: TextPreparationRequestV1 = { - type: 'pmndrs-text-prepare-v1', - id: operation.revision, - fonts, - paragraphs, - }; - try { - worker.postMessage(request); - } catch (error) { - this.#pendingWorkerPreparations.delete(operation.revision); - for (const font of fonts) this.#workerFontHandles.delete(font.handle); - reject(error); - } - }); - } - - #receiveWorker(value: unknown): void { - if (!isTextPreparationWorkerResultV1(value)) { - this.#failWorker(new TypeError('text preparation Worker returned an invalid message')); - return; - } - const pending = this.#pendingWorkerPreparations.get(value.id); - if (pending === undefined) return; - if (value.type === 'pmndrs-text-progress-v1') { - try { - this.#reportProgress( - pending.operation, - value.preparedParagraphs, - pending.operation.snapshots.reduce((total, snapshot) => total + snapshot.paragraphs.length, 0), - value.stagedGlyphs, - ); - } catch (error) { - this.#pendingWorkerPreparations.delete(value.id); - pending.reject(error); - } - return; - } - this.#pendingWorkerPreparations.delete(value.id); - if (value.type === 'pmndrs-text-failure-v1') { - const error = new Error(value.error.message); - error.name = value.error.name; - if (value.error.stack !== undefined) error.stack = value.error.stack; - pending.reject(error); - return; - } - try { - pending.resolve(workerLayouts(value, pending.operation.snapshots)); - } catch (error) { - pending.reject(error); - } - } - - #failWorker(error: unknown): void { - const worker = this.#worker; - if (this.#workerMessageListener !== undefined) worker?.removeEventListener('message', this.#workerMessageListener); - if (this.#workerErrorListener !== undefined) worker?.removeEventListener('error', this.#workerErrorListener); - worker?.terminate(); - this.#worker = undefined; - this.#workerMessageListener = undefined; - this.#workerErrorListener = undefined; - this.#workerFontHandles.clear(); - for (const pending of this.#pendingWorkerPreparations.values()) pending.reject(error); - this.#pendingWorkerPreparations.clear(); - } - - #reportProgress( - operation: PendingTextUpdate, - preparedParagraphs: number, - totalParagraphs: number, - stagedGlyphs: number, - ): void { - operation.preparedParagraphs = Math.max(operation.preparedParagraphs, preparedParagraphs); - operation.stagedGlyphs = Math.max(operation.stagedGlyphs, stagedGlyphs); - operation.options.onProgress?.({ - revision: operation.revision, - preparedParagraphs: operation.preparedParagraphs, - totalParagraphs, - stagedGlyphs: operation.stagedGlyphs, - }); - } - - #ensureWorker(): TextPreparationWorker | undefined { - if (this.#worker !== undefined || this.#createWorker === undefined) return this.#worker; - const worker = this.#createWorker(); - this.#worker = worker; - this.#listenToWorker(worker); - return worker; - } - - #listenToWorker(worker: TextPreparationWorker): void { - this.#workerMessageListener = (event) => this.#receiveWorker(event.data); - this.#workerErrorListener = (event) => - this.#failWorker(event.error ?? new Error(event.message || 'text preparation Worker failed')); - worker.addEventListener('message', this.#workerMessageListener); - worker.addEventListener('error', this.#workerErrorListener); - } - - #cancelWorkerPreparation(operation: PendingTextUpdate): void { - const pending = this.#pendingWorkerPreparations.get(operation.revision); - if (pending === undefined) return; - this.#pendingWorkerPreparations.delete(operation.revision); - this.#worker?.postMessage({ type: 'pmndrs-text-cancel-v1', id: operation.revision }); - pending.reject(new DOMException('Text preparation was cancelled', 'AbortError')); - } - - #capture(batches: readonly ParagraphBatchController[]): readonly ParagraphBatchPreparation[] { - const snapshots: ParagraphBatchPreparation[] = []; - try { - for (const batch of batches) snapshots.push(batch.capture()); - return snapshots; - } catch (error) { - this.#release(snapshots); - throw error; - } - } - - #release(snapshots: readonly ParagraphBatchPreparation[]): void { - for (const snapshot of snapshots) snapshot.controller.release(snapshot); - } - - #discard(candidates: readonly PreparedParagraphBatchCandidate[]): void { - for (const candidate of candidates) candidate.snapshot.controller.discard(candidate); - } - - #publish(candidates: readonly PreparedParagraphBatchCandidate[]): TextRuntimeRevision { - for (const candidate of candidates) candidate.snapshot.controller.publish(candidate); - this.#current = Object.freeze({ - revision: this.#current.revision + 1, - paragraphBatches: Object.freeze([...this.#paragraphBatches].map((batch) => batch.publicBatch.current)), - }); - for (const listener of this.#listeners) listener(this.#current); - return this.#current; - } - - #supersedePending(byRevision: number): void { - for (const operation of [...this.#pendingUpdates.values()]) - this.#settle(operation, { - ok: true, - value: { status: 'superseded', revision: operation.revision, byRevision }, - }); - } - - #settle(operation: PendingTextUpdate, result: TextUpdateResult): void { - if (operation.settled) return; - operation.settled = true; - this.#pendingUpdates.delete(operation.revision); - operation.options.signal?.removeEventListener('abort', operation.abort); - this.#cancelWorkerPreparation(operation); - this.#release(operation.snapshots); - operation.complete(result); - } - - subscribe(listener: (revision: TextRuntimeRevision) => void): () => void { - this.#assertActive(); - this.#listeners.add(listener); - return () => this.#listeners.delete(listener); - } - dispose(): void { if (this.#disposed) return; this.#disposed = true; - this.#updateRequestRevision += 1; - for (const operation of [...this.#pendingUpdates.values()]) - this.#settle(operation, { - ok: true, - value: { status: 'aborted', revision: operation.revision, reason: new Error('text runtime was disposed') }, - }); for (const techniques of this.#pending.values()) { for (const loads of techniques.values()) { for (const pending of loads.values()) pending.controller.abort(); } } this.#pending.clear(); - for (const batch of [...this.#paragraphBatches]) batch.publicBatch.dispose(); - this.#paragraphBatches.clear(); for (const techniques of [...this.#loaded.values()]) { for (const fonts of [...techniques.values()]) { for (const font of [...fonts.values()]) disposeLoadedFontFromRuntime(font); } } this.#loaded.clear(); - this.#failWorker(new Error('text runtime was disposed')); - this.shaper.dispose(); - this.#listeners.clear(); + this.#shaper.dispose(); } async #loadRegisteredFont(input: LoadedFontInput, signal: AbortSignal | undefined): Promise { @@ -794,42 +310,12 @@ class TextRuntimeImpl implements TextRuntime { #assertActive(): void { if (this.#disposed) throw new Error('text runtime has been disposed'); } -} -function preparationError(cause: unknown): TextPreparationError { - if ( - typeof cause === 'object' && - cause !== null && - 'kind' in cause && - (cause.kind === 'capacity-exceeded' || cause.kind === 'preparation-failed') - ) - return cause as TextPreparationError; - return Object.freeze({ kind: 'preparation-failed', cause }); -} - -function workerLayouts( - value: TextPreparationSuccessV1, - snapshots: readonly ParagraphBatchPreparation[], -): WorkerLayouts { - const expected = new Set(); - for (let batch = 0; batch < snapshots.length; batch += 1) - for (const layout of snapshots[batch]!.layouts) - if (layout.input !== undefined) expected.add(`${String(batch)}:${String(layout.paragraph)}`); - const batches = new Map>(); - for (const entry of value.layouts) { - const key = `${String(entry.batch)}:${String(entry.paragraph)}`; - if (!expected.delete(key)) throw new TypeError('text preparation Worker returned an unexpected paragraph'); - let paragraphs = batches.get(entry.batch); - if (paragraphs === undefined) { - paragraphs = new Map(); - batches.set(entry.batch, paragraphs); - } - const paragraph = entry.paragraph as ParagraphId; - if (paragraphs.has(paragraph)) throw new TypeError('text preparation Worker returned a duplicate paragraph'); - paragraphs.set(paragraph, entry.layout); + /** @internal */ + _shaper(): RuntimeShaper { + this.#assertActive(); + return this.#shaper; } - if (expected.size !== 0) throw new TypeError('text preparation Worker omitted a paragraph'); - return batches; } async function decodeTechnique( diff --git a/packages/text/src/three.ts b/packages/text/src/three.ts index 3db3c1b2..ebb17b24 100644 --- a/packages/text/src/three.ts +++ b/packages/text/src/three.ts @@ -9,9 +9,7 @@ export type { UnboundSpanTag, } from './formatted-text.js'; export type { FontSelection, FontStack, LoadedFont } from './loaded-font.js'; -export type { GlyphBufferCapacity, GlyphOriginUpdate, GlyphSnapshot, ParagraphContentBox } from './paragraph-batch.js'; -export type { ParagraphLayout } from './layout.js'; -export type { ParagraphStyle } from './paragraph.js'; +export type { GlyphBufferCapacity, ParagraphContentBox, ParagraphStyle } from './text-properties.js'; export { bitmapShader } from './three/bitmap-shader.js'; export type { ThreeBitmapInstanceNodes, @@ -19,14 +17,17 @@ export type { ThreeBitmapShaderResources, } from './three/bitmap-shader.js'; export { FontLoader } from './three/font-loader.js'; +export { defineTextMaterial } from './three/material.js'; +export type { ThreeTextMaterial, ThreeTextMaterialContext } from './three/material.js'; +export { registerThreeRasterPlanProgram, threePolicyAbi } from './three/plan-program-registry.js'; +export type { + ThreePlanProgramBuffer, + ThreePlanProgramFontCompiler, + ThreePlanProgramMaterialContext, + ThreeRasterPlanProgram, +} from './three/plan-program-registry.js'; export { msdfShader } from './three/msdf-shader.js'; export type { ThreeMsdfInstanceNodes, ThreeMsdfShaderOutput, ThreeMsdfShaderResources } from './three/msdf-shader.js'; -export { registerThreeRasterProgram } from './three/program-registry.js'; -export type { - ThreeRasterProgram, - ThreeRasterTargetAccounting, - ThreeRasterTargetOwner, -} from './three/program-registry.js'; export type { ThreeFontLoaderOptions as FontLoaderOptions, ThreeLoadedFontRequest as LoadedFontRequest, @@ -42,9 +43,10 @@ export type { export { Text, TextGroup } from './three/text.js'; export type { StandaloneTextProperties, + TextGlyphOriginSnapshot, + TextGlyphOriginUpdate, TextGroupOptions, TextProperties, TextSpan, TextUpdate, - ThreeRenderVariant, } from './three/text.js'; diff --git a/packages/text/src/three/bitmap-shader.ts b/packages/text/src/three/bitmap-shader.ts index 22352f77..425dc1da 100644 --- a/packages/text/src/three/bitmap-shader.ts +++ b/packages/text/src/three/bitmap-shader.ts @@ -42,7 +42,7 @@ export interface ThreeBitmapShaderOutput { } /** - * Builds the canonical Bitmap node graph. This is the exact graph `ThreeBitmapTarget` renders, so a program that + * Builds the canonical Bitmap node graph. This is the exact graph the command-buffer executor renders, so a program that * composes over the returned nodes inherits the technique's coverage sampling and pixel snapping instead of * reimplementing them. * diff --git a/packages/text/src/three/bitmap-target.ts b/packages/text/src/three/bitmap-target.ts deleted file mode 100644 index cb1ebcd9..00000000 --- a/packages/text/src/three/bitmap-target.ts +++ /dev/null @@ -1,262 +0,0 @@ -import * as TSL from 'three/tsl'; -import * as THREE from 'three/webgpu'; - -import type { - GlyphBatchKey, - ParagraphId, - PreparedGlyphBatch, - PreparedParagraphBatchRevision, -} from '../paragraph-batch.js'; -import type { ParagraphBatchTarget, ParagraphBatchTargetUpdate } from '../paragraph-batch-attachment.js'; -import { bitmap, type BitmapPageData } from '../raster/bitmap-technique.js'; -import { bitmapShader } from './bitmap-shader.js'; -import { - instanceStorageBytes, - invalidatePboTexture, - retainedRunIdentities, - RetainedThreeGpuBytes, - RetainedThreeTargetRevision, - type RetainedThreeTargetResource, -} from './retained-target.js'; - -export interface ThreeBitmapTargetOwner { - objectForParagraph(paragraph: ParagraphId): THREE.Object3D; - readonly renderOrderBase: number; -} - -interface BitmapTargetResource extends RetainedThreeTargetResource { - readonly key: GlyphBatchKey; - readonly capacity: number; - readonly gpuBytes: number; - readonly material: THREE.MeshBasicNodeMaterial; - readonly attributes: readonly THREE.StorageInstancedBufferAttribute[]; - update(batch: PreparedGlyphBatch): void; - geometry(count: number): THREE.InstancedBufferGeometry; - dispose(): void; -} - -export class ThreeBitmapTargetRevision extends RetainedThreeTargetRevision {} - -export class ThreeBitmapTarget implements ParagraphBatchTarget< - typeof bitmap, - Variant, - ThreeBitmapTargetRevision -> { - readonly technique: typeof bitmap = bitmap; - readonly #owner: ThreeBitmapTargetOwner; - readonly #textures = new Map(); - readonly #gpuBytes = new RetainedThreeGpuBytes(); - #disposed = false; - - constructor(owner: ThreeBitmapTargetOwner) { - this.#owner = owner; - } - - get gpuBytes(): number { - return this.#gpuBytes.total; - } - - stage( - previous: ThreeBitmapTargetRevision | undefined, - next: PreparedParagraphBatchRevision, - ): ParagraphBatchTargetUpdate { - if (this.#disposed) throw new Error('Three bitmap target has been disposed'); - if (previous?.canReuse(next) === true) { - let finished = false; - return { - status: 'ready', - stage: { - sourceRevision: next.revision, - commit: () => { - if (finished) throw new Error('Three bitmap stage is no longer active'); - finished = true; - const state = previous.transfer(next, this.#owner.renderOrderBase); - return this.#gpuBytes.retain( - new ThreeBitmapTargetRevision(next.revision, state.draws, state.resources, state.runIdentities), - ); - }, - abort: () => { - finished = true; - }, - }, - }; - } - const resources = new Map(); - const draws: THREE.Mesh[] = []; - try { - for (const batch of next.glyphBatches) resources.set(batch.key, this.#createResource(batch)); - 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('bitmap 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); - } - let finished = false; - return { - status: 'ready' as const, - stage: { - sourceRevision: next.revision, - commit: () => { - if (finished) throw new Error('Three bitmap stage is no longer active'); - finished = true; - for (let index = 0; index < draws.length; index += 1) { - this.#owner.objectForParagraph(next.glyphRuns[index]!.paragraph).add(draws[index]!); - } - return this.#gpuBytes.retain( - new ThreeBitmapTargetRevision(next.revision, draws, resources, retainedRunIdentities(next)), - ); - }, - abort: () => { - if (finished) return; - finished = true; - disposeStaged(draws, resources.values()); - }, - }, - }; - } catch (error) { - disposeStaged(draws, resources.values()); - throw error; - } - } - - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - for (const texture of this.#textures.values()) texture.dispose(); - this.#textures.clear(); - this.#gpuBytes.release(); - } - - #createResource(batch: PreparedGlyphBatch): BitmapTargetResource { - const page = batch.font.data.strikes[batch.binding.strike]?.pages[batch.binding.page]; - if (page === undefined) throw new TypeError('bitmap binding references a missing decoded page'); - const texture = this.#texture(page); - return createBitmapTargetResource(batch, texture); - } - - #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); - this.#gpuBytes.addShared(page.bytes.byteLength); - return texture; - } -} - -function createBitmapTargetResource( - batch: PreparedGlyphBatch, - texture: THREE.DataTexture, -): BitmapTargetResource { - 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 attributes = [origins, sizes, uvOrigins, uvSizes, colors] as const; - 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: texture }, - ); - 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; - material.opacityNode = shader.opacity; - - return { - key: batch.key, - capacity: batch.capacity, - gpuBytes: instanceStorageBytes(attributes), - material, - attributes, - update(next) { - updateStorageAttribute(origins, next.storage.origins, 2, next.dirtyRanges); - updateStorageAttribute(sizes, next.storage.sizes, 2, next.dirtyRanges); - updateStorageAttribute(uvOrigins, next.storage.uvOrigins, 2, next.dirtyRanges); - updateStorageAttribute(uvSizes, next.storage.uvSizes, 2, next.dirtyRanges); - updateStorageAttribute(colors, next.storage.colors, 4, next.dirtyRanges); - }, - geometry(count) { - const geometry = unitQuad(); - geometry.instanceCount = count; - geometry.setAttribute('_pmndrsTextOrigins', origins); - geometry.setAttribute('_pmndrsTextSizes', sizes); - geometry.setAttribute('_pmndrsTextUvOrigins', uvOrigins); - geometry.setAttribute('_pmndrsTextUvSizes', uvSizes); - geometry.setAttribute('_pmndrsTextColors', colors); - return geometry; - }, - dispose() { - material.dispose(); - }, - }; -} - -function updateStorageAttribute( - attribute: THREE.StorageInstancedBufferAttribute, - source: Float32Array, - itemSize: number, - ranges: readonly { readonly start: number; readonly count: number }[], -): void { - if (ranges.length === 0) return; - const target = attribute.array as Float32Array; - attribute.clearUpdateRanges(); - for (const range of ranges) { - const start = range.start * itemSize; - const count = range.count * itemSize; - target.set(source.subarray(start, start + count), start); - attribute.addUpdateRange(start, count); - } - attribute.needsUpdate = true; - invalidatePboTexture(attribute); -} - -function storageAttribute(array: Float32Array, itemSize: number): THREE.StorageInstancedBufferAttribute { - const copy = new Float32Array(array); - const attribute = new THREE.StorageInstancedBufferAttribute(copy, itemSize); - attribute.setUsage(THREE.DynamicDrawUsage); - attribute.needsUpdate = true; - return attribute; -} - -function unitQuad(): THREE.InstancedBufferGeometry { - 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)); - return geometry; -} - -function disposeStaged(draws: readonly THREE.Mesh[], resources: Iterable): void { - for (const draw of draws) draw.geometry.dispose(); - for (const resource of resources) resource.dispose(); -} diff --git a/packages/text/src/three/bitmap.ts b/packages/text/src/three/bitmap.ts index 072f4384..3aba616d 100644 --- a/packages/text/src/three/bitmap.ts +++ b/packages/text/src/three/bitmap.ts @@ -1,9 +1 @@ -import { bitmap } from '../raster/bitmap-technique.js'; -import { ThreeBitmapTarget, type ThreeBitmapTargetOwner } from './bitmap-target.js'; -import { registerThreeRasterProgram } from './program-registry.js'; - -// Pairing the technique with its program here is what keeps them separable: importing this subpath wires exactly one -// technique, so an application that never names MSDF or Slug does not carry their shaders, decoders, or targets. -registerThreeRasterProgram(bitmap, (owner: ThreeBitmapTargetOwner) => new ThreeBitmapTarget(owner)); - export * from '../raster/bitmap-technique.js'; diff --git a/packages/text/src/three/engine-plan-target.ts b/packages/text/src/three/engine-plan-target.ts new file mode 100644 index 00000000..5f4de5f7 --- /dev/null +++ b/packages/text/src/three/engine-plan-target.ts @@ -0,0 +1,1334 @@ +import * as TSL from 'three/tsl'; +import * as THREE from 'three/webgpu'; + +import { textShaperAbi } from '../generated/text-shaper-abi.js'; +import type { TextEnginePublication } from '../internal/text-engine-host.js'; +import { FIRST_PARTY_STABLE_GLYPH_BUFFER_ID, FIRST_PARTY_TRANSFORM_BUFFER_ID } from '../internal/render-policy-wire.js'; +import { TextEngineRenderPlanView, type RenderPlanTable } from '../internal/render-plan-view.js'; +import { bitmap, type BitmapPageData } from '../raster/bitmap-technique.js'; +import { msdf, type MsdfData } from '../raster/msdf.js'; +import { slug, type SlugPageData } from '../raster/slug-technique.js'; +import { bitmapShader } from './bitmap-shader.js'; +import type { ThreeTextEngineCoordinator, ThreeTextEngineResource } from './engine-runtime.js'; +import { msdfShader } from './msdf-shader.js'; +import { slugShader, type ThreeSlugPageResources } from './slug-shader.js'; +import type { ThreeTextMaterialContext } from './material.js'; +import type { ThreePlanProgramBuffer } from './plan-program-registry.js'; + +type ScalarArray = Float32Array | Uint32Array | Uint16Array; + +interface RetainedBuffer { + readonly id: number; + readonly generation: number; + readonly policyBufferId: number; + readonly scalarType: number; + readonly vectorWidth: number; + readonly capacityRecords: number; + readonly array: ScalarArray; + readonly attribute: THREE.StorageInstancedBufferAttribute; +} + +interface RetainedResource { + readonly id: number; + readonly generation: number; + readonly techniqueId: number; + readonly referenceId: number; +} + +interface RetainedSlugPage extends ThreeSlugPageResources { + readonly byteLength: number; + dispose(): void; +} + +interface MaterialRealization { + readonly material: THREE.NodeMaterial; + readonly resourceId: number; + readonly resourceGeneration: number; + readonly buffers: readonly Readonly<{ id: number; generation: number }>[]; + readonly indexedTransform: boolean; +} + +interface OriginSegment { + readonly origins: RetainedBuffer; + readonly stableIds: RetainedBuffer; + readonly order: RetainedBuffer | undefined; + readonly start: number; + readonly count: number; +} + +interface OriginRecord { + readonly buffer: RetainedBuffer; + readonly index: number; + targetX: number; + targetY: number; +} + +type TransformRealization = + | Readonly<{ kind: 'direct'; transformId: number }> + | Readonly<{ kind: 'indexed'; indices: RetainedBuffer }>; + +interface RecordAddressing { + readonly order: RetainedBuffer | undefined; +} + +export interface ThreeTextEnginePlanOwner { + readonly drawRoot: THREE.Object3D; + objectForTransform(transformId: number): THREE.Object3D; + transformIds(): Iterable; + readonly renderOrderBase: number; +} + +/** Applies retained Rust command-buffer deltas to Three storage attributes and draw objects. */ +export class ThreeTextRenderPlanExecutor { + readonly #coordinator: ThreeTextEngineCoordinator; + readonly #owner: ThreeTextEnginePlanOwner; + readonly #view = new TextEngineRenderPlanView(); + readonly #buffers = new Map(); + readonly #resources = new Map(); + readonly #bitmapTextures = new Map(); + readonly #msdfAtlases = new Map(); + readonly #slugPages = new Map(); + readonly #materials = new Map(); + readonly #ownedMaterials = new WeakSet(); + readonly #activeTransformIndices = new Set(); + readonly #originRecords = new Map(); + readonly #rootInverse = new THREE.Matrix4(); + readonly #relativeTransform = new THREE.Matrix4(); + #transformAttribute = transformAttribute(1); + #transformGeneration = 1; + #draws: THREE.Mesh[] = []; + #drawKeys: string[] = []; + #originSegments: OriginSegment[] = []; + #disposed = false; + + constructor(coordinator: ThreeTextEngineCoordinator, owner: ThreeTextEnginePlanOwner) { + this.#coordinator = coordinator; + this.#owner = owner; + } + + get draws(): readonly THREE.Mesh[] { + return this.#draws; + } + + get gpuBytes(): number { + let bytes = 0; + for (const buffer of this.#buffers.values()) bytes += buffer.array.byteLength; + bytes += this.#transformAttribute.array.byteLength; + for (const texture of this.#bitmapTextures.values()) { + const data = texture.image.data as ArrayBufferView | undefined; + bytes += data?.byteLength ?? 0; + } + for (const atlas of this.#msdfAtlases.values()) { + const data = atlas.image.data as ArrayBufferView | undefined; + bytes += data?.byteLength ?? 0; + } + for (const page of this.#slugPages.values()) bytes += page.byteLength; + return bytes; + } + + apply(publication: TextEnginePublication): void { + if (this.#disposed) throw new Error('Three text-engine plan target has been disposed'); + this.#coordinator.applyPlan(() => { + this.#restoreOriginTargets(); + const plan = this.#view.bind(publication); + const resources = plan.table('resources'); + const buffers = plan.table('buffers'); + const patches = plan.table('patches'); + const primitives = plan.table('primitives'); + const draws = plan.table('draws'); + const retirements = plan.table('retirements'); + if (resources.count !== 0) this.#readResources(plan, resources); + if (buffers.count !== 0) this.#readBuffers(plan, buffers); + this.#applyPatches(plan, patches); + if ( + resources.count !== 0 || + buffers.count !== 0 || + primitives.count !== 0 || + draws.count !== 0 || + retirements.count !== 0 + ) { + this.#replaceDraws(plan, draws, primitives, buffers, resources); + } + this.syncTransforms(); + this.#applyRetirements(plan, retirements); + this.#originRecords.clear(); + }); + } + + snapshotGlyphOrigins( + stableIds: Uint32Array, + fallbackX: Float32Array, + fallbackY: Float32Array, + ): Readonly<{ shapedX: Float32Array; shapedY: Float32Array; displayedX: Float32Array; displayedY: Float32Array }> { + if (stableIds.length !== fallbackX.length || stableIds.length !== fallbackY.length) { + throw new RangeError('glyph origin snapshot arrays must be parallel'); + } + this.#ensureOriginRecords(); + const shapedX = fallbackX.slice(); + const shapedY = fallbackY.slice(); + const displayedX = fallbackX.slice(); + const displayedY = fallbackY.slice(); + for (let index = 0; index < stableIds.length; index += 1) { + const record = this.#originRecords.get(stableIds[index]!); + if (record === undefined || !(record.buffer.array instanceof Float32Array)) continue; + const offset = record.index * record.buffer.vectorWidth; + shapedX[index] = record.targetX; + shapedY[index] = record.targetY; + displayedX[index] = record.buffer.array[offset]!; + displayedY[index] = record.buffer.array[offset + 1]!; + } + return { shapedX, shapedY, displayedX, displayedY }; + } + + setGlyphOriginOverrides(stableIds: Uint32Array, x: Float32Array, y: Float32Array): void { + if (stableIds.length !== x.length || stableIds.length !== y.length) { + throw new RangeError('glyph origin override arrays must be parallel'); + } + this.#ensureOriginRecords(); + const touched = new Map(); + for (let index = 0; index < stableIds.length; index += 1) { + const record = this.#originRecords.get(stableIds[index]!); + if (record === undefined || !(record.buffer.array instanceof Float32Array)) continue; + const offset = record.index * record.buffer.vectorWidth; + record.buffer.array[offset] = x[index]!; + record.buffer.array[offset + 1] = y[index]!; + const range = touched.get(record.buffer); + touched.set(record.buffer, [ + Math.min(range?.[0] ?? offset, offset), + Math.max(range?.[1] ?? offset + 2, offset + 2), + ]); + } + markOriginRanges(touched); + } + + clearGlyphOriginOverrides(stableIds: Uint32Array): void { + this.#ensureOriginRecords(); + const touched = new Map(); + for (const stableId of stableIds) { + const record = this.#originRecords.get(stableId); + if (record === undefined || !(record.buffer.array instanceof Float32Array)) continue; + const offset = record.index * record.buffer.vectorWidth; + record.buffer.array[offset] = record.targetX; + record.buffer.array[offset + 1] = record.targetY; + const range = touched.get(record.buffer); + touched.set(record.buffer, [ + Math.min(range?.[0] ?? offset, offset), + Math.max(range?.[1] ?? offset + 2, offset + 2), + ]); + } + markOriginRanges(touched); + } + + /** Upload changed scene transforms without crossing into Wasm or invalidating text layout. */ + syncTransforms(): number { + for (const [index, draw] of this.#draws.entries()) { + draw.renderOrder = this.#owner.renderOrderBase + index; + } + const hasDirectTransforms = this.#draws.some((draw) => directTransformId(draw) !== 0); + if (this.#activeTransformIndices.size === 0 && !hasDirectTransforms) return 0; + this.#owner.drawRoot.updateWorldMatrix(true, false); + this.#rootInverse.copy(this.#owner.drawRoot.matrixWorld).invert(); + const target = this.#transformAttribute.array as Float32Array; + const changedTransforms = new Set(); + let indexedChanged = 0; + for (const index of this.#activeTransformIndices) { + const object = this.#owner.objectForTransform(index); + object.updateWorldMatrix(true, false); + this.#relativeTransform.multiplyMatrices(this.#rootInverse, object.matrixWorld); + const offset = index * 16; + if (object.visible) { + if (matrixEquals(target, offset, this.#relativeTransform.elements)) continue; + target.set(this.#relativeTransform.elements, offset); + } else { + if (zeroMatrixEquals(target, offset)) continue; + target.fill(0, offset, offset + 16); + } + this.#transformAttribute.addUpdateRange(index * 16, 16); + changedTransforms.add(index); + indexedChanged += 1; + } + for (const draw of this.#draws) { + const transformId = directTransformId(draw); + if (transformId === 0) continue; + const object = this.#owner.objectForTransform(transformId); + let drawChanged = false; + if (draw.visible !== object.visible) { + draw.visible = object.visible; + drawChanged = true; + } + object.updateWorldMatrix(true, false); + this.#relativeTransform.multiplyMatrices(this.#rootInverse, object.matrixWorld); + if (!draw.matrix.equals(this.#relativeTransform)) { + draw.matrix.copy(this.#relativeTransform); + draw.matrixWorldNeedsUpdate = true; + drawChanged = true; + } + if (drawChanged) changedTransforms.add(transformId); + } + if (changedTransforms.size === 0) return 0; + if (indexedChanged !== 0) { + this.#transformAttribute.needsUpdate = true; + invalidatePboTexture(this.#transformAttribute); + } + return changedTransforms.size; + } + + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + this.#disposeDraws(); + for (const realization of this.#materials.values()) realization.material.dispose(); + for (const texture of this.#bitmapTextures.values()) texture.dispose(); + for (const atlas of this.#msdfAtlases.values()) atlas.dispose(); + for (const page of this.#slugPages.values()) page.dispose(); + this.#materials.clear(); + this.#bitmapTextures.clear(); + this.#msdfAtlases.clear(); + this.#slugPages.clear(); + this.#buffers.clear(); + this.#resources.clear(); + this.#activeTransformIndices.clear(); + this.#originRecords.clear(); + this.#originSegments = []; + } + + #readResources(plan: TextEngineRenderPlanView, table: RenderPlanTable): void { + const layout = textShaperAbi.layouts.engineResource; + for (let index = 0; index < table.count; index += 1) { + const record = plan.record(table, index); + const resource: RetainedResource = { + id: plan.u32(record + layout.id), + generation: plan.u32(record + layout.generation), + techniqueId: plan.u32(record + layout.techniqueId), + referenceId: plan.u32(record + layout.referenceId), + }; + this.#coordinator.resolveResource(resource.referenceId); + this.#resources.set(resource.id, resource); + } + } + + #readBuffers(plan: TextEngineRenderPlanView, table: RenderPlanTable): void { + const layout = textShaperAbi.layouts.engineBuffer; + for (let index = 0; index < table.count; index += 1) { + const record = plan.record(table, index); + const id = plan.u32(record + layout.id); + const generation = plan.u32(record + layout.generation); + const scalarType = plan.u8(record + layout.scalarType); + const vectorWidth = plan.u8(record + layout.vectorWidth); + const capacityRecords = plan.u32(record + layout.capacityRecords); + const byteLength = plan.u32(record + layout.byteLength); + const existing = this.#buffers.get(id); + if ( + existing?.generation === generation && + existing.scalarType === scalarType && + existing.vectorWidth === vectorWidth && + existing.array.byteLength === byteLength + ) { + continue; + } + const array = scalarArray(scalarType, byteLength); + if (array.length !== capacityRecords * vectorWidth) { + throw new Error('first-party Three policy requires tightly packed physical buffers'); + } + const attribute = new THREE.StorageInstancedBufferAttribute(array, vectorWidth); + attribute.setUsage(THREE.DynamicDrawUsage); + attribute.needsUpdate = true; + this.#buffers.set(id, { + id, + generation, + policyBufferId: plan.u16(record + layout.policyBufferId), + scalarType, + vectorWidth, + capacityRecords, + array, + attribute, + }); + } + } + + #applyPatches(plan: TextEngineRenderPlanView, table: RenderPlanTable): void { + const layout = textShaperAbi.layouts.enginePatch; + const opcodes = textShaperAbi.engine.patchOpcodes; + for (let index = 0; index < table.count; index += 1) { + const record = plan.record(table, index); + const opcode = plan.u16(record + layout.opcode); + const buffer = this.#buffer(plan.u32(record + layout.bufferId), plan.u32(record + layout.bufferGeneration)); + const destinationOffset = plan.u32(record + layout.destinationOffset); + const byteLength = plan.u32(record + layout.byteLength); + if (opcode === opcodes.allocateOrResize || opcode === opcodes.retire) continue; + const destination = new Uint8Array(buffer.array.buffer, buffer.array.byteOffset, buffer.array.byteLength); + if (destinationOffset + byteLength > destination.byteLength) + throw new RangeError('buffer patch exceeds allocation'); + if (opcode === opcodes.write) { + destination.set(plan.bytes(plan.u32(record + layout.payloadOffset), byteLength), destinationOffset); + } else if (opcode === opcodes.fill) { + const fill = plan.u32(record + layout.fillValue); + const view = new DataView(destination.buffer, destination.byteOffset + destinationOffset, byteLength); + if (byteLength % 4 !== 0) throw new RangeError('fill patch is not u32 aligned'); + for (let offset = 0; offset < byteLength; offset += 4) view.setUint32(offset, fill, true); + } else if (opcode === opcodes.copy) { + const source = this.#buffers.get(plan.u32(record + layout.sourceBufferId)); + if (source === undefined) throw new Error('copy patch references an unknown source buffer'); + const sourceOffset = plan.u32(record + layout.sourceOffset); + const sourceBytes = new Uint8Array(source.array.buffer, source.array.byteOffset, source.array.byteLength); + if (source.array.buffer === buffer.array.buffer && source.array.byteOffset === buffer.array.byteOffset) { + destination.copyWithin(destinationOffset, sourceOffset, sourceOffset + byteLength); + } else { + destination.set(sourceBytes.subarray(sourceOffset, sourceOffset + byteLength), destinationOffset); + } + } else { + throw new Error(`unsupported text-engine patch opcode ${opcode}`); + } + markUpdated(buffer, destinationOffset, byteLength); + } + } + + #replaceDraws( + plan: TextEngineRenderPlanView, + draws: RenderPlanTable, + primitives: RenderPlanTable, + buffers: RenderPlanTable, + resources: RenderPlanTable, + ): void { + const drawLayout = textShaperAbi.layouts.engineDraw; + const primitiveLayout = textShaperAbi.layouts.enginePrimitive; + const bufferLayout = textShaperAbi.layouts.engineBuffer; + const resourceLayout = textShaperAbi.layouts.engineResource; + const next: THREE.Mesh[] = []; + const nextKeys: string[] = []; + const nextOriginSegments: OriginSegment[] = []; + const previous = new Map(); + for (let index = 0; index < this.#draws.length; index += 1) { + const key = this.#drawKeys[index]!; + const matches = previous.get(key) ?? []; + matches.push(this.#draws[index]!); + previous.set(key, matches); + } + const reused = new Set(); + const transformIndices = this.#collectTransformIndices(plan, draws); + this.#ensureTransformCapacity(transformIndices); + try { + for (let index = 0; index < draws.count; index += 1) { + const draw = plan.record(draws, index); + if (plan.u32(draw + drawLayout.primitiveCount) !== 1) { + throw new Error('first-party Three plan target requires one primitive span per draw'); + } + const primitiveIndex = plan.u32(draw + drawLayout.primitiveStart); + const primitive = plan.record(primitives, primitiveIndex); + if (plan.u16(primitive + primitiveLayout.kind) !== textShaperAbi.engine.primitiveKinds.glyph) { + throw new Error('first-party Three plan target does not yet realize non-glyph primitives'); + } + const drawBufferStart = plan.u32(draw + drawLayout.bufferStart); + const drawBufferCount = plan.u32(draw + drawLayout.bufferCount); + const byPolicyId = new Map(); + for (let bufferIndex = drawBufferStart; bufferIndex < drawBufferStart + drawBufferCount; bufferIndex += 1) { + const record = plan.record(buffers, bufferIndex); + const buffer = this.#buffer(plan.u32(record + bufferLayout.id), plan.u32(record + bufferLayout.generation)); + byPolicyId.set(buffer.policyBufferId, buffer); + } + const resourceRecord = plan.record(resources, plan.u32(draw + drawLayout.resourceStart)); + const resource = this.#resources.get(plan.u32(resourceRecord + resourceLayout.id)); + if (resource === undefined) throw new Error('draw references an unknown retained resource'); + const materialId = plan.u32(draw + drawLayout.materialId); + const transformId = plan.u32(draw + drawLayout.transformId); + const recordIndex = plan.u32(primitive + primitiveLayout.recordIndex); + const recordCount = plan.u16(primitive + primitiveLayout.recordCount); + const addressing = recordAddressing(plan, draw, primitive, byPolicyId); + const transform = this.#transformRealization(byPolicyId, transformId); + const material = this.#material(resource, byPolicyId, materialId, transform, addressing); + const origins = byPolicyId.get(1); + const stableIds = byPolicyId.get(FIRST_PARTY_STABLE_GLYPH_BUFFER_ID); + if (origins !== undefined && stableIds !== undefined) { + if (!(origins.array instanceof Float32Array) || !(stableIds.array instanceof Uint32Array)) { + throw new TypeError('glyph-origin augmentation buffers have invalid scalar types'); + } + nextOriginSegments.push({ + origins, + stableIds, + order: addressing.order, + start: recordIndex, + count: recordCount, + }); + } + const key = drawRealizationKey( + plan.u32(draw + drawLayout.programId), + resource, + materialId, + byPolicyId, + plan.u32(draw + drawLayout.clipId), + plan.u32(draw + drawLayout.depthKey), + transform, + this.#transformGeneration, + ); + const reusable = previous.get(key)?.shift(); + if (reusable !== undefined) { + if (!(reusable.geometry instanceof THREE.InstancedBufferGeometry)) { + throw new TypeError('retained text draw lost its instanced geometry'); + } + reusable.geometry.instanceCount = recordCount; + reusable.userData.pmndrsTextRunStart = recordIndex; + reusable.userData.pmndrsTextTransformId = transformId; + reusable.matrixAutoUpdate = transform.kind !== 'direct'; + reusable.renderOrder = this.#owner.renderOrderBase + index; + if (reusable.parent !== this.#owner.drawRoot) this.#owner.drawRoot.add(reusable); + reused.add(reusable); + next.push(reusable); + nextKeys.push(key); + continue; + } + const geometry = unitQuad(); + geometry.instanceCount = recordCount; + for (const buffer of byPolicyId.values()) { + geometry.setAttribute(`_pmndrsText_${buffer.policyBufferId}`, buffer.attribute); + } + if (transform.kind === 'indexed') geometry.setAttribute('_pmndrsTextTransforms', this.#transformAttribute); + const mesh = new THREE.Mesh(geometry, material); + mesh.userData.pmndrsTextRunStart = recordIndex; + mesh.userData.pmndrsTextTransformId = transformId; + mesh.matrixAutoUpdate = transform.kind !== 'direct'; + mesh.frustumCulled = false; + mesh.renderOrder = this.#owner.renderOrderBase + index; + this.#owner.drawRoot.add(mesh); + next.push(mesh); + nextKeys.push(key); + } + } catch (error) { + for (const mesh of next) { + if (reused.has(mesh)) continue; + mesh.removeFromParent(); + mesh.geometry.dispose(); + } + throw error; + } + this.#disposeDraws(reused); + this.#draws = next; + this.#drawKeys = nextKeys; + this.#originSegments = nextOriginSegments; + this.#activeTransformIndices.clear(); + for (const transformIndex of transformIndices) this.#activeTransformIndices.add(transformIndex); + } + + #restoreOriginTargets(): void { + const touched = new Map(); + for (const record of this.#originRecords.values()) { + if (!(record.buffer.array instanceof Float32Array)) continue; + const offset = record.index * record.buffer.vectorWidth; + if (record.buffer.array[offset] === record.targetX && record.buffer.array[offset + 1] === record.targetY) + continue; + record.buffer.array[offset] = record.targetX; + record.buffer.array[offset + 1] = record.targetY; + const range = touched.get(record.buffer); + touched.set(record.buffer, [ + Math.min(range?.[0] ?? offset, offset), + Math.max(range?.[1] ?? offset + 2, offset + 2), + ]); + } + markOriginRanges(touched); + } + + #ensureOriginRecords(): void { + if (this.#originRecords.size !== 0) return; + for (const segment of this.#originSegments) { + if (!(segment.origins.array instanceof Float32Array) || !(segment.stableIds.array instanceof Uint32Array)) + continue; + for (let index = segment.start; index < segment.start + segment.count; index += 1) { + const recordIndex = physicalRecordIndex(segment.order, index); + const stableId = segment.stableIds.array[recordIndex]; + if (stableId === undefined || stableId === 0) + throw new Error('origin augmentation references an invalid glyph'); + const offset = recordIndex * segment.origins.vectorWidth; + if (this.#originRecords.has(stableId)) throw new Error('origin augmentation repeats a stable glyph identity'); + this.#originRecords.set(stableId, { + buffer: segment.origins, + index: recordIndex, + targetX: segment.origins.array[offset]!, + targetY: segment.origins.array[offset + 1]!, + }); + } + } + } + + #transformRealization(buffers: ReadonlyMap, transformId: number): TransformRealization { + if (transformId !== 0) return { kind: 'direct', transformId }; + const indices = buffers.get(FIRST_PARTY_TRANSFORM_BUFFER_ID); + if (indices === undefined || !(indices.array instanceof Uint32Array)) { + throw new Error('indexed Three draw is missing its u32 transform-index buffer'); + } + return { kind: 'indexed', indices }; + } + + #collectTransformIndices(plan: TextEngineRenderPlanView, draws: RenderPlanTable): Set { + const drawLayout = textShaperAbi.layouts.engineDraw; + for (let drawIndex = 0; drawIndex < draws.count; drawIndex += 1) { + const draw = plan.record(draws, drawIndex); + if (plan.u32(draw + drawLayout.transformId) === 0) return new Set(this.#owner.transformIds()); + } + return new Set(); + } + + #ensureTransformCapacity(indices: ReadonlySet): void { + let maximum = 0; + for (const index of indices) maximum = Math.max(maximum, index); + const requiredRecords = (maximum + 1) * 4; + if (this.#transformAttribute.count >= requiredRecords) return; + let capacity = this.#transformAttribute.count; + while (capacity < requiredRecords) capacity *= 2; + this.#transformAttribute = transformAttribute(capacity / 4); + this.#transformGeneration += 1; + this.#disposeMaterials((realization) => realization.indexedTransform); + } + + #bitmapMaterial( + resource: RetainedResource, + buffers: ReadonlyMap, + materialId: number, + transform: TransformRealization, + addressing: RecordAddressing, + ): THREE.NodeMaterial { + const resolved = this.#coordinator.resolveResource(resource.referenceId); + if (resolved.technique !== bitmap.id) { + throw new Error('this Three plan target checkpoint realizes Bitmap draws only'); + } + const page = bitmapPage(resolved); + const required = [1, 2, 3, 4, 5].map((id) => { + const buffer = buffers.get(id); + if (buffer === undefined) throw new Error(`Bitmap draw is missing policy buffer ${id}`); + return buffer; + }); + const key = `${resource.id}:${resource.generation}:${materialId}:${required + .map((buffer) => `${buffer.id}:${buffer.generation}`) + .join(',')}:${transformProgramKey(transform, this.#transformGeneration)}:${addressingProgramKey(addressing)}`; + const cached = this.#materials.get(key); + if (cached !== undefined) return cached.material; + const texture = this.#bitmapTexture(resource.referenceId, page); + const runStart = TSL.uniform(0, 'uint').onObjectUpdate( + ({ object }) => (object?.userData.pmndrsTextRunStart as number | undefined) ?? 0, + ); + const instance = physicalInstance(TSL.instanceIndex.add(runStart), addressing); + const shader = bitmapShader( + { + origin: TSL.storage(required[0]!.attribute, 'vec2', required[0]!.attribute.count) + .setPBO(true) + .element(instance), + size: TSL.storage(required[1]!.attribute, 'vec2', required[1]!.attribute.count).setPBO(true).element(instance), + uvOrigin: TSL.storage(required[2]!.attribute, 'vec2', required[2]!.attribute.count) + .setPBO(true) + .element(instance), + uvSize: TSL.storage(required[3]!.attribute, 'vec2', required[3]!.attribute.count) + .setPBO(true) + .element(instance), + color: TSL.storage(required[4]!.attribute, 'vec4', required[4]!.attribute.count).setPBO(true).element(instance), + }, + { page: texture }, + ); + const position = + transform.kind === 'indexed' + ? indexedTransformPosition(shader.position, transform.indices.attribute, this.#transformAttribute, instance) + : shader.position; + const material = this.#createMaterial(materialId, { + technique: bitmap.id, + shader, + position, + createDefaultMaterial: () => bitmapMaterial(shader, position), + }); + this.#retainMaterial(key, material, resource, materialBuffers(required, transform, addressing), transform.kind); + return material; + } + + #material( + resource: RetainedResource, + buffers: ReadonlyMap, + materialId: number, + transform: TransformRealization, + addressing: RecordAddressing, + ): THREE.NodeMaterial { + const resolved = this.#coordinator.resolveResource(resource.referenceId); + if (resolved.technique === bitmap.id) + return this.#bitmapMaterial(resource, buffers, materialId, transform, addressing); + if (resolved.technique === msdf.id) return this.#msdfMaterial(resource, buffers, materialId, transform, addressing); + if (resolved.technique === slug.id) return this.#slugMaterial(resource, buffers, materialId, transform, addressing); + if ('program' in resolved) + return this.#planProgramMaterial(resource, resolved, buffers, materialId, transform, addressing); + throw new Error('this Three plan target does not recognize the draw technique'); + } + + #planProgramMaterial( + resource: RetainedResource, + resolved: Extract, + buffers: ReadonlyMap, + materialId: number, + transform: TransformRealization, + addressing: RecordAddressing, + ): THREE.NodeMaterial { + const required = [...buffers.values()]; + const key = `external:${resource.id}:${resource.generation}:${materialId}:${required + .map((buffer) => `${buffer.policyBufferId}:${buffer.id}:${buffer.generation}`) + .join(',')}:${transformProgramKey(transform, this.#transformGeneration)}:${addressingProgramKey(addressing)}`; + const cached = this.#materials.get(key); + if (cached !== undefined) return cached.material; + const runStart = TSL.uniform(0, 'uint').onObjectUpdate( + ({ object }) => (object?.userData.pmndrsTextRunStart as number | undefined) ?? 0, + ); + const instance = physicalInstance(TSL.instanceIndex.add(runStart), addressing); + const publicBuffers = new Map( + [...buffers] + .filter(([id]) => id !== textShaperAbi.engine.internalBufferBindings.order) + .map(([id, buffer]) => [ + id, + { + scalarType: buffer.scalarType, + vectorWidth: buffer.vectorWidth, + attribute: buffer.attribute, + }, + ]), + ); + const material = this.#ownMaterial( + resolved.program.createMaterial({ + resource: resolved.resource, + buffers: publicBuffers, + instance, + materialId, + material: this.#coordinator.resolveMaterial(materialId), + transformPosition: (position) => + transform.kind === 'indexed' + ? indexedTransformPosition(position, transform.indices.attribute, this.#transformAttribute, instance) + : position, + }), + ); + this.#retainMaterial(key, material, resource, materialBuffers(required, transform, addressing), transform.kind); + return material; + } + + #msdfMaterial( + resource: RetainedResource, + buffers: ReadonlyMap, + materialId: number, + transform: TransformRealization, + addressing: RecordAddressing, + ): THREE.NodeMaterial { + const data = msdfData(this.#coordinator.resolveResource(resource.referenceId)); + const required = [1, 2, 3, 4, 5, 6, 7].map((id) => { + const buffer = buffers.get(id); + if (buffer === undefined) throw new Error(`MSDF draw is missing policy buffer ${id}`); + return buffer; + }); + const key = `msdf:${resource.id}:${resource.generation}:${materialId}:${required + .map((buffer) => `${buffer.id}:${buffer.generation}`) + .join(',')}:${transformProgramKey(transform, this.#transformGeneration)}:${addressingProgramKey(addressing)}`; + const cached = this.#materials.get(key); + if (cached !== undefined) return cached.material; + const runStart = TSL.uniform(0, 'uint').onObjectUpdate( + ({ object }) => (object?.userData.pmndrsTextRunStart as number | undefined) ?? 0, + ); + const instance = physicalInstance(TSL.instanceIndex.add(runStart), addressing); + const fields = required.map((buffer) => + TSL.storage(buffer.attribute, 'vec4', buffer.attribute.count).setPBO(true).element(instance), + ); + const shader = msdfShader( + { + origin: fields[0]!.xy, + size: fields[0]!.zw, + uvOrigin: fields[1]!.xy, + uvSize: fields[1]!.zw, + uvBounds: fields[2]!, + fillColor: fields[3]!, + outlineColor: fields[4]!, + shadowColor: fields[5]!, + shadowOffset: fields[6]!.xy, + outlineWidth: fields[6]!.z, + pageIndex: fields[6]!.w, + }, + { + atlas: this.#msdfAtlas(resource.referenceId, data), + atlasWidth: data.binding.width, + atlasHeight: data.binding.height, + pixelRange: data.pixelRange, + }, + ); + const position = + transform.kind === 'indexed' + ? indexedTransformPosition(shader.position, transform.indices.attribute, this.#transformAttribute, instance) + : shader.position; + const material = this.#createMaterial(materialId, { + technique: msdf.id, + shader, + position, + createDefaultMaterial: () => coverageMaterial(shader, position), + }); + this.#retainMaterial(key, material, resource, materialBuffers(required, transform, addressing), transform.kind); + return material; + } + + #bitmapTexture(referenceId: number, page: BitmapPageData): THREE.DataTexture { + let texture = this.#bitmapTextures.get(referenceId); + 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.#bitmapTextures.set(referenceId, texture); + return texture; + } + + #msdfAtlas(referenceId: number, data: MsdfData): THREE.DataArrayTexture { + let atlas = this.#msdfAtlases.get(referenceId); + if (atlas !== undefined) return atlas; + const bytes = new Uint8Array(data.binding.width * data.binding.height * data.binding.layers * 4); + for (let layer = 0; layer < data.pages.length; layer += 1) { + const page = data.pages[layer]!; + for (let row = 0; row < page.height; row += 1) { + const source = row * page.width * 4; + const target = (layer * data.binding.height + row) * data.binding.width * 4; + bytes.set(page.bytes.subarray(source, source + page.width * 4), target); + } + } + atlas = new THREE.DataArrayTexture(bytes, data.binding.width, data.binding.height, data.binding.layers); + atlas.format = THREE.RGBAFormat; + atlas.type = THREE.UnsignedByteType; + atlas.colorSpace = THREE.NoColorSpace; + atlas.magFilter = THREE.LinearFilter; + atlas.minFilter = THREE.LinearFilter; + atlas.generateMipmaps = false; + atlas.needsUpdate = true; + this.#msdfAtlases.set(referenceId, atlas); + return atlas; + } + + #slugMaterial( + resource: RetainedResource, + buffers: ReadonlyMap, + materialId: number, + transformRealization: TransformRealization, + addressing: RecordAddressing, + ): THREE.NodeMaterial { + const page = slugPage(this.#coordinator.resolveResource(resource.referenceId)); + const required = [1, 2, 3, 4, 5, 6, 7].map((id) => { + const buffer = buffers.get(id); + if (buffer === undefined) throw new Error(`Slug draw is missing policy buffer ${id}`); + return buffer; + }); + const key = `slug:${resource.id}:${resource.generation}:${materialId}:${required + .map((buffer) => `${buffer.id}:${buffer.generation}`) + .join( + ',', + )}:${transformProgramKey(transformRealization, this.#transformGeneration)}:${addressingProgramKey(addressing)}`; + const cached = this.#materials.get(key); + if (cached !== undefined) return cached.material; + const runStart = TSL.uniform(0, 'uint').onObjectUpdate( + ({ object }) => (object?.userData.pmndrsTextRunStart as number | undefined) ?? 0, + ); + const instance = physicalInstance(TSL.instanceIndex.add(runStart), addressing); + const floatFields = required + .slice(0, 5) + .map((buffer) => TSL.storage(buffer.attribute, 'vec4', buffer.attribute.count).setPBO(true).element(instance)); + const addresses = TSL.storage(required[5]!.attribute, 'uvec4', required[5]!.attribute.count) + .setPBO(true) + .element(instance); + const counts = TSL.storage(required[6]!.attribute, 'uvec4', required[6]!.attribute.count) + .setPBO(true) + .element(instance); + const indexedTransform = + transformRealization.kind === 'indexed' + ? indexedTransformNodes(transformRealization.indices.attribute, this.#transformAttribute, instance) + : undefined; + const modelViewProjection = + indexedTransform === undefined + ? TSL.cameraProjectionMatrix.mul(TSL.modelViewMatrix) + : TSL.cameraProjectionMatrix.mul(TSL.modelViewMatrix).mul(indexedTransform.matrix); + const viewport = TSL.uniform(new THREE.Vector2(1, 1)).onRenderUpdate(({ renderer }, self) => + renderer?.getDrawingBufferSize(self.value), + ); + const shader = slugShader( + { + origin: floatFields[0]!.xy, + size: floatFields[0]!.zw, + emOrigin: floatFields[1]!.xy, + emSize: floatFields[1]!.zw, + bandTransform: floatFields[2]!, + color: floatFields[3]!, + inverseScale: floatFields[4]!.x, + curveBaseTexel: addresses.x, + horizontalHeaderBase: addresses.y, + verticalHeaderBase: addresses.z, + referenceBase: addresses.w, + horizontalBandCount: counts.x, + verticalBandCount: counts.y, + }, + { + page: this.#slugPage(resource.referenceId, page), + viewport, + modelViewProjection, + }, + ); + const position = indexedTransform?.position(shader.position) ?? shader.position; + const material = this.#createMaterial(materialId, { + technique: slug.id, + shader, + position, + createDefaultMaterial: () => coverageMaterial(shader, position), + }); + this.#retainMaterial( + key, + material, + resource, + materialBuffers(required, transformRealization, addressing), + transformRealization.kind, + ); + return material; + } + + #slugPage(referenceId: number, data: SlugPageData): RetainedSlugPage { + let page = this.#slugPages.get(referenceId); + if (page !== undefined) return page; + const curves = ownedUint16(data.curveBytes); + const headers = ownedUint32(data.headerBytes); + const references = packReferencePairs(ownedUint16(data.referenceBytes), data.referenceWidth); + const curveTexture = dataTexture(curves, data.curveWidth, data.curveHeight, THREE.RGBAFormat, THREE.HalfFloatType); + const headerTexture = dataTexture( + headers, + data.headerWidth, + data.headerHeight, + THREE.RedIntegerFormat, + THREE.UnsignedIntType, + ); + const referenceTexture = dataTexture( + references.data, + references.width, + references.height, + THREE.RedIntegerFormat, + THREE.UnsignedIntType, + ); + page = { + curveTexture, + curveWidth: data.curveWidth, + headerTexture, + headerWidth: data.headerWidth, + referenceTexture, + referenceWidth: references.width, + byteLength: curves.byteLength + headers.byteLength + references.data.byteLength, + dispose() { + curveTexture.dispose(); + headerTexture.dispose(); + referenceTexture.dispose(); + }, + }; + this.#slugPages.set(referenceId, page); + return page; + } + + #createMaterial(materialId: number, context: ThreeTextMaterialContext): THREE.NodeMaterial { + const definition = this.#coordinator.resolveMaterial(materialId); + return this.#ownMaterial(definition?.create(context) ?? context.createDefaultMaterial()); + } + + #ownMaterial(material: THREE.NodeMaterial): THREE.NodeMaterial { + if (material?.isNodeMaterial !== true) + throw new TypeError('text material factory must return a Three NodeMaterial'); + if (this.#ownedMaterials.has(material)) { + throw new TypeError('text material factory must return a fresh unowned NodeMaterial'); + } + this.#ownedMaterials.add(material); + return material; + } + + #retainMaterial( + key: string, + material: THREE.NodeMaterial, + resource: RetainedResource, + buffers: readonly RetainedBuffer[], + transformKind: TransformRealization['kind'], + ): void { + this.#materials.set(key, { + material, + resourceId: resource.id, + resourceGeneration: resource.generation, + buffers: buffers.map(({ id, generation }) => ({ id, generation })), + indexedTransform: transformKind === 'indexed', + }); + } + + #disposeMaterials(predicate: (realization: MaterialRealization) => boolean): void { + for (const [key, realization] of this.#materials) { + if (!predicate(realization)) continue; + realization.material.dispose(); + this.#materials.delete(key); + } + } + + #disposeResource(referenceId: number): void { + if ([...this.#resources.values()].some((resource) => resource.referenceId === referenceId)) return; + const bitmapTexture = this.#bitmapTextures.get(referenceId); + bitmapTexture?.dispose(); + this.#bitmapTextures.delete(referenceId); + const msdfAtlas = this.#msdfAtlases.get(referenceId); + msdfAtlas?.dispose(); + this.#msdfAtlases.delete(referenceId); + const retainedSlugPage = this.#slugPages.get(referenceId); + retainedSlugPage?.dispose(); + this.#slugPages.delete(referenceId); + } + + #applyRetirements(plan: TextEngineRenderPlanView, table: RenderPlanTable): void { + const layout = textShaperAbi.layouts.engineRetirement; + const kinds = textShaperAbi.engine.retirementKinds; + for (let index = 0; index < table.count; index += 1) { + const record = plan.record(table, index); + const kind = plan.u16(record + layout.kind); + const id = plan.u32(record + layout.id); + const generation = plan.u32(record + layout.generation); + if (kind === kinds.buffer) { + this.#disposeMaterials((realization) => + realization.buffers.some((buffer) => buffer.id === id && buffer.generation === generation), + ); + if (this.#buffers.get(id)?.generation === generation) this.#buffers.delete(id); + continue; + } + if (kind === kinds.resource) { + const resource = this.#resources.get(id); + if (resource?.generation !== generation) continue; + this.#disposeMaterials( + (realization) => + realization.resourceId === resource.id && realization.resourceGeneration === resource.generation, + ); + this.#resources.delete(id); + this.#disposeResource(resource.referenceId); + } + } + } + + #buffer(id: number, generation: number): RetainedBuffer { + const buffer = this.#buffers.get(id); + if (buffer === undefined || buffer.generation !== generation) { + throw new Error(`text-engine buffer ${id}:${generation} is not retained`); + } + return buffer; + } + + #disposeDraws(retained: ReadonlySet = new Set()): void { + for (const draw of this.#draws) { + if (retained.has(draw)) continue; + draw.removeFromParent(); + draw.geometry.dispose(); + } + this.#draws = []; + this.#drawKeys = []; + } +} + +function drawRealizationKey( + programId: number, + resource: RetainedResource, + materialId: number, + buffers: ReadonlyMap, + clipId: number, + depthKey: number, + transform: TransformRealization, + transformGeneration: number, +): string { + const bufferKey = [...buffers] + .sort(([left], [right]) => left - right) + .map(([policyId, buffer]) => `${policyId}:${buffer.id}:${buffer.generation}`) + .join(','); + const transformKey = + transform.kind === 'direct' + ? `direct:${transform.transformId}` + : transformProgramKey(transform, transformGeneration); + return `${programId}:${resource.id}:${resource.generation}:${materialId}:${clipId}:${depthKey}:${transformKey}:${bufferKey}`; +} + +function recordAddressing( + plan: TextEngineRenderPlanView, + draw: number, + primitive: number, + buffers: ReadonlyMap, +): RecordAddressing { + const drawLayout = textShaperAbi.layouts.engineDraw; + const primitiveLayout = textShaperAbi.layouts.enginePrimitive; + const indirectBufferId = plan.u32(draw + drawLayout.indirectBufferId); + const order = buffers.get(textShaperAbi.engine.internalBufferBindings.order); + if (indirectBufferId === 0) { + if (order !== undefined) throw new Error('ordered-direct draw unexpectedly contains an indirection buffer'); + return { order: undefined }; + } + if ( + order === undefined || + order.id !== indirectBufferId || + !(order.array instanceof Uint32Array) || + order.vectorWidth !== 1 + ) { + throw new Error('stable-indirect draw is missing its scalar u32 order buffer'); + } + const indirectOffset = plan.u32(draw + drawLayout.indirectOffset); + const recordIndex = plan.u32(primitive + primitiveLayout.recordIndex); + if ( + indirectOffset % Uint32Array.BYTES_PER_ELEMENT !== 0 || + indirectOffset / Uint32Array.BYTES_PER_ELEMENT !== recordIndex || + plan.u32(primitive + primitiveLayout.bufferId) !== indirectBufferId + ) { + throw new Error('stable-indirect draw and primitive disagree about logical record addressing'); + } + return { order }; +} + +function physicalInstance(logical: THREE.Node<'uint'>, addressing: RecordAddressing): THREE.Node<'uint'> { + const order = addressing.order; + return order === undefined + ? logical + : TSL.storage(order.attribute, 'uint', order.attribute.count).setPBO(true).element(logical); +} + +function physicalRecordIndex(order: RetainedBuffer | undefined, logical: number): number { + if (order === undefined) return logical; + if (!(order.array instanceof Uint32Array) || order.vectorWidth !== 1) { + throw new TypeError('stable-indirect order buffer must contain scalar u32 records'); + } + const physical = order.array[logical]; + if (physical === undefined) throw new RangeError('stable-indirect logical record exceeds its order buffer'); + return physical; +} + +function addressingProgramKey(addressing: RecordAddressing): string { + const order = addressing.order; + return order === undefined ? 'ordered' : `stable:${order.id}:${order.generation}`; +} + +function materialBuffers( + required: readonly RetainedBuffer[], + transform: TransformRealization, + addressing: RecordAddressing, +): readonly RetainedBuffer[] { + const buffers = new Map(required.map((buffer) => [buffer.id, buffer])); + if (transform.kind === 'indexed') buffers.set(transform.indices.id, transform.indices); + if (addressing.order !== undefined) buffers.set(addressing.order.id, addressing.order); + return [...buffers.values()]; +} + +function transformProgramKey(transform: TransformRealization, generation: number): string { + return transform.kind === 'direct' + ? 'direct' + : `indexed:${transform.indices.id}:${transform.indices.generation}:table:${generation}`; +} + +function directTransformId(draw: THREE.Mesh): number { + return (draw.userData.pmndrsTextTransformId as number | undefined) ?? 0; +} + +function bitmapMaterial( + shader: Readonly<{ + clipPosition: THREE.Node<'vec4'>; + color: THREE.Node<'vec3'>; + opacity: THREE.Node<'float'>; + }>, + position: THREE.Node<'vec3'>, +): THREE.MeshBasicNodeMaterial { + const material = baseTextMaterial(); + material.positionNode = position; + material.vertexNode = shader.clipPosition; + material.colorNode = shader.color; + material.opacityNode = shader.opacity; + return material; +} + +function coverageMaterial( + shader: Readonly<{ color: THREE.Node<'vec3'>; opacity: THREE.Node<'float'> }>, + position: THREE.Node<'vec3'>, +): THREE.MeshBasicNodeMaterial { + const material = baseTextMaterial(); + material.positionNode = position; + material.colorNode = shader.color; + material.opacityNode = shader.opacity; + return material; +} + +function baseTextMaterial(): THREE.MeshBasicNodeMaterial { + return new THREE.MeshBasicNodeMaterial({ + blending: THREE.NormalBlending, + depthTest: false, + depthWrite: false, + side: THREE.DoubleSide, + transparent: true, + }); +} + +function bitmapPage(resource: ThreeTextEngineResource): BitmapPageData { + if (resource.technique !== bitmap.id || !('page' in resource)) { + throw new Error('this Three plan target checkpoint realizes Bitmap draws only'); + } + return resource.page as BitmapPageData; +} + +function msdfData(resource: ThreeTextEngineResource): MsdfData { + if (resource.technique !== msdf.id || !('data' in resource)) { + throw new Error('Three MSDF draw references an incompatible resource'); + } + return resource.data; +} + +function slugPage(resource: ThreeTextEngineResource): SlugPageData { + if (resource.technique !== slug.id || !('page' in resource)) { + throw new Error('Three Slug draw references an incompatible resource'); + } + return resource.page as SlugPageData; +} + +function scalarArray(scalarType: number, byteLength: number): ScalarArray { + const scalar = textShaperAbi.policy.scalarTypes; + if (scalarType === scalar.f32) return new Float32Array(byteLength / 4); + if (scalarType === scalar.u32) return new Uint32Array(byteLength / 4); + if (scalarType === scalar.u16) return new Uint16Array(byteLength / 2); + throw new Error(`unsupported text-engine scalar type ${scalarType}`); +} + +function transformAttribute(transformCapacity: number): THREE.StorageInstancedBufferAttribute { + const attribute = new THREE.StorageInstancedBufferAttribute(new Float32Array(transformCapacity * 16), 4); + attribute.setUsage(THREE.DynamicDrawUsage); + return attribute; +} + +function matrixEquals(target: Float32Array, offset: number, matrix: readonly number[]): boolean { + for (let index = 0; index < 16; index += 1) { + if (target[offset + index] !== Math.fround(matrix[index]!)) return false; + } + return true; +} + +function zeroMatrixEquals(target: Float32Array, offset: number): boolean { + for (let index = 0; index < 16; index += 1) { + if (target[offset + index] !== 0) return false; + } + return true; +} + +function indexedTransformPosition( + position: THREE.Node<'vec3'>, + indexAttribute: THREE.StorageInstancedBufferAttribute, + transforms: THREE.StorageInstancedBufferAttribute, + instance: THREE.Node<'uint'>, +): THREE.Node<'vec3'> { + return indexedTransformNodes(indexAttribute, transforms, instance).position(position); +} + +function indexedTransformNodes( + indexAttribute: THREE.StorageInstancedBufferAttribute, + transforms: THREE.StorageInstancedBufferAttribute, + instance: THREE.Node<'uint'>, +): { + readonly matrix: THREE.Node<'mat4'>; + readonly position: (position: THREE.Node<'vec3'>) => THREE.Node<'vec3'>; +} { + const transformIndex = TSL.storage(indexAttribute, 'uint', indexAttribute.count).setPBO(true).element(instance); + const firstColumn = transformIndex.mul(4); + const table = TSL.storage(transforms, 'vec4', transforms.count).setPBO(true); + const column0 = table.element(firstColumn); + const column1 = table.element(firstColumn.add(1)); + const column2 = table.element(firstColumn.add(2)); + const column3 = table.element(firstColumn.add(3)); + return { + matrix: TSL.mat4(column0, column1, column2, column3), + position(position) { + const local = TSL.vec4(position, 1); + return column0.mul(local.x).add(column1.mul(local.y)).add(column2.mul(local.z)).add(column3.mul(local.w)).xyz; + }, + }; +} + +function dataTexture( + data: Uint16Array | Uint32Array, + width: number, + height: number, + format: THREE.PixelFormat, + type: THREE.TextureDataType, +): THREE.DataTexture { + const texture = new THREE.DataTexture(data, width, height, format, type); + texture.colorSpace = THREE.NoColorSpace; + texture.flipY = false; + texture.generateMipmaps = false; + texture.minFilter = THREE.NearestFilter; + texture.magFilter = THREE.NearestFilter; + texture.needsUpdate = true; + return texture; +} + +function ownedUint16(bytes: Uint8Array): Uint16Array { + const copy = bytes.slice(); + return new Uint16Array(copy.buffer, copy.byteOffset, copy.byteLength / 2); +} + +function ownedUint32(bytes: Uint8Array): Uint32Array { + const copy = bytes.slice(); + return new Uint32Array(copy.buffer, copy.byteOffset, copy.byteLength / 4); +} + +function packReferencePairs( + references: Uint16Array, + preferredWidth: number, +): { readonly data: Uint32Array; readonly width: number; readonly height: number } { + const texelCount = Math.ceil(references.length / 2); + const width = Math.min(preferredWidth, texelCount); + const height = Math.ceil(texelCount / width); + const data = new Uint32Array(width * height); + for (let index = 0; index < references.length; index += 1) { + data[index >> 1] = (data[index >> 1] ?? 0) | (references[index]! << ((index & 1) * 16)); + } + return { data, width, height }; +} + +function markUpdated(buffer: RetainedBuffer, byteOffset: number, byteLength: number): void { + const scalarBytes = buffer.array.BYTES_PER_ELEMENT; + if (byteOffset % scalarBytes !== 0 || byteLength % scalarBytes !== 0) { + throw new RangeError('buffer patch is not scalar aligned'); + } + syncDetachedUploadRange(buffer, byteOffset, byteLength); + mergeUpdateRange(buffer.attribute, byteOffset / scalarBytes, byteLength / scalarBytes); + buffer.attribute.needsUpdate = true; + invalidatePboTexture(buffer.attribute); +} + +function syncDetachedUploadRange(buffer: RetainedBuffer, byteOffset: number, byteLength: number): void { + const upload = buffer.attribute.array; + if (upload === buffer.array) return; + if (upload.constructor !== buffer.array.constructor || upload.byteLength < buffer.array.byteLength) { + throw new TypeError('Three replaced a text-plan upload array with an incompatible view'); + } + const source = new Uint8Array(buffer.array.buffer, buffer.array.byteOffset + byteOffset, byteLength); + const destination = new Uint8Array(upload.buffer, upload.byteOffset + byteOffset, byteLength); + destination.set(source); +} + +function mergeUpdateRange(attribute: THREE.BufferAttribute, start: number, count: number): void { + let mergedStart = start; + let mergedEnd = start + count; + for (let index = attribute.updateRanges.length - 1; index >= 0; index -= 1) { + const range = attribute.updateRanges[index]!; + const rangeEnd = range.start + range.count; + if (rangeEnd < mergedStart || mergedEnd < range.start) continue; + mergedStart = Math.min(mergedStart, range.start); + mergedEnd = Math.max(mergedEnd, rangeEnd); + attribute.updateRanges.splice(index, 1); + } + attribute.addUpdateRange(mergedStart, mergedEnd - mergedStart); +} + +function markOriginRanges(ranges: ReadonlyMap): void { + for (const [buffer, [start, end]] of ranges) { + markUpdated(buffer, start * buffer.array.BYTES_PER_ELEMENT, (end - start) * buffer.array.BYTES_PER_ELEMENT); + } +} + +function invalidatePboTexture(attribute: THREE.StorageInstancedBufferAttribute): void { + const pbo = (attribute as THREE.StorageInstancedBufferAttribute & { pbo?: THREE.DataTexture }).pbo; + if (pbo !== undefined) pbo.needsUpdate = true; +} + +function unitQuad(): THREE.InstancedBufferGeometry { + 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)); + return geometry; +} diff --git a/packages/text/src/three/engine-runtime.ts b/packages/text/src/three/engine-runtime.ts new file mode 100644 index 00000000..2f2ea1fb --- /dev/null +++ b/packages/text/src/three/engine-runtime.ts @@ -0,0 +1,304 @@ +import { observeLoadedFontDispose, type LoadedFont } from '../loaded-font.js'; +import { bitmap, type BitmapData, type BitmapPageData } from '../raster/bitmap-technique.js'; +import { msdf, type MsdfData } from '../raster/msdf.js'; +import { slug, type SlugData, type SlugPageData } from '../raster/slug-technique.js'; +import type { AnyRasterTechnique } from '../raster-technique.js'; +import { textRuntimeShaper, type TextRuntime } from '../text-runtime.js'; +import { firstPartyFontBindingBytes } from '../internal/font-binding-wire.js'; +import { firstPartyThreeRenderPolicyBytes, type ThreeTransformMode } from '../internal/render-policy-wire.js'; +import { TextEngineHost, type TextEngineSession, type TextEngineSessionOptions } from '../internal/text-engine-host.js'; +import type { ThreeTextMaterial } from './material.js'; +import { compiledThreeRasterPlanPrograms, type CompiledThreeRasterPlanProgram } from './plan-program-registry.js'; + +const POLICY_HANDLE = 1; +const MAX_U32 = 0xffff_ffff; +const coordinators = new WeakMap(); + +export interface ThreeTextEngineStackLease { + readonly handle: number; + release(): void; +} + +export interface ThreeTextMaterialLease { + readonly id: number; + release(): void; +} + +interface RetainedStack { + readonly handle: number; + references: number; +} + +interface RetainedMaterial { + readonly id: number; + readonly material: ThreeTextMaterial; + references: number; +} + +interface RetainedResourceOwners { + readonly owners: Map, ThreeTextEngineResource>; +} + +export type ThreeTextEngineResource = + | Readonly<{ technique: typeof bitmap.id; page: BitmapPageData }> + | Readonly<{ technique: typeof msdf.id; data: MsdfData }> + | Readonly<{ technique: typeof slug.id; page: SlugPageData }> + | Readonly<{ technique: string; resource: unknown; program: CompiledThreeRasterPlanProgram }>; + +export interface ThreeTextEngineCoordinatorOptions { + /** Renderer-policy choice; indexed is the first-party high-throughput default. */ + readonly transformMode?: ThreeTransformMode; +} + +/** Three-owned cold registrations shared by every text batch using one renderer-neutral runtime. */ +export class ThreeTextEngineCoordinator { + readonly host: TextEngineHost; + readonly #bindingHandles = new WeakMap, number>(); + readonly #resources = new Map(); + readonly #fontResourceReferences = new Map, Set>(); + readonly #fontDisposeObservers = new Map, () => void>(); + readonly #stacks = new Map(); + readonly #materialHandles = new WeakMap(); + readonly #materials = new Map(); + readonly #planPrograms: ReadonlyMap; + #nextBindingHandle = 1; + #nextStackHandle = 1; + #nextSessionHandle = 1; + #nextMaterialHandle = 1; + #applyingPlan = false; + #disposed = false; + + constructor( + shaper: ConstructorParameters[0], + options: ThreeTextEngineCoordinatorOptions = {}, + ) { + this.host = new TextEngineHost(shaper); + const planPrograms = compiledThreeRasterPlanPrograms(this.host.wireIdentities); + this.#planPrograms = new Map(planPrograms.map((program) => [program.technique.id, program])); + this.host.registerPolicy( + POLICY_HANDLE, + firstPartyThreeRenderPolicyBytes( + this.host.wireIdentities, + options.transformMode, + planPrograms.map((program) => program.policy), + ), + ); + } + + get policyHandle(): number { + return POLICY_HANDLE; + } + + assertFrameUpdateAllowed(): void { + if (this.#applyingPlan) throw new Error('text updates and queries cannot reenter Three render-plan application'); + } + + applyPlan(apply: () => Result): Result { + if (this.#applyingPlan) throw new Error('Three render-plan application cannot be reentered'); + this.#applyingPlan = true; + try { + return apply(); + } finally { + this.#applyingPlan = false; + } + } + + acquireFontStack( + fonts: readonly [LoadedFont, ...LoadedFont[]], + ): ThreeTextEngineStackLease { + this.#assertActive(); + const bindingHandles = fonts.map((font) => this.#bindingHandle(font)); + const key = bindingHandles.join(','); + let retained = this.#stacks.get(key); + if (retained === undefined) { + retained = { handle: this.#allocateStackHandle(), references: 0 }; + this.host.registerFontStack(retained.handle, bindingHandles); + this.#stacks.set(key, retained); + } + retained.references += 1; + let released = false; + return { + handle: retained.handle, + release: () => { + if (released) return; + released = true; + retained.references -= 1; + if (retained.references !== 0) return; + this.#stacks.delete(key); + this.host.disposeFontStack(retained.handle); + }, + }; + } + + createSession(options: Omit): TextEngineSession { + this.#assertActive(); + return this.host.createSession({ ...options, handle: this.#allocateSessionHandle() }); + } + + acquireMaterial(material: ThreeTextMaterial): ThreeTextMaterialLease { + this.#assertActive(); + let retained = this.#materialHandles.get(material); + if (retained === undefined || retained.references === 0) { + retained = { id: this.#allocateMaterialHandle(), material, references: 0 }; + this.#materialHandles.set(material, retained); + this.#materials.set(retained.id, retained); + } + retained.references += 1; + let released = false; + return { + id: retained.id, + release: () => { + if (released) return; + released = true; + retained.references -= 1; + if (retained.references === 0) this.#materials.delete(retained.id); + }, + }; + } + + resolveMaterial(materialId: number): ThreeTextMaterial | undefined { + if (materialId === 0) return undefined; + const retained = this.#materials.get(materialId); + if (retained === undefined) throw new Error(`Three text command buffer references unknown material ${materialId}`); + return retained.material; + } + + resolveResource(referenceId: number): ThreeTextEngineResource { + const resource = this.#resources.get(referenceId)?.owners.values().next().value; + if (resource === undefined) throw new Error(`Three text command buffer references unknown resource ${referenceId}`); + return resource; + } + + dispose(): void { + if (this.#disposed) return; + this.host.dispose(); + for (const stopObserving of this.#fontDisposeObservers.values()) stopObserving(); + this.#fontDisposeObservers.clear(); + this.#fontResourceReferences.clear(); + this.#resources.clear(); + this.#stacks.clear(); + this.#materials.clear(); + this.#disposed = true; + } + + #bindingHandle(font: LoadedFont): number { + if (font.disposed) throw new TypeError('cannot register a disposed loaded font with the Three text engine'); + const existing = this.#bindingHandles.get(font); + if (existing !== undefined) return existing; + this.#observeFont(font); + const handle = this.#allocateBindingHandle(); + const program = this.#planPrograms.get(font.technique.id); + if (program === undefined) { + this.#registerResources(font); + this.host.registerFontBinding( + handle, + font.font.handle, + firstPartyFontBindingBytes(font, this.host.wireIdentities), + ); + } else { + const compiled = program.compileFont(font, this.host.wireIdentities); + for (const [key, resource] of compiled.resources) { + this.#retainResource(font, key, { technique: font.technique.id, resource, program }); + } + this.host.registerFontBinding(handle, font.font.handle, compiled.binding); + } + this.#bindingHandles.set(font, handle); + return handle; + } + + #registerResources(font: LoadedFont): void { + if (font.technique.id === bitmap.id) { + const data = font.data as BitmapData; + for (const strike of data.strikes) { + for (const page of strike.pages) this.#retainResource(font, page.resource, { technique: bitmap.id, page }); + } + return; + } + if (font.technique.id === msdf.id) { + const data = font.data as MsdfData; + this.#retainResource(font, data.resource, { technique: msdf.id, data }); + return; + } + if (font.technique.id === slug.id) { + const data = font.data as SlugData; + for (const page of data.pages) this.#retainResource(font, page.resource, { technique: slug.id, page }); + return; + } + throw new TypeError(`no first-party Three resource resolver is registered for "${font.technique.id}"`); + } + + #retainResource(font: LoadedFont, key: string, resource: ThreeTextEngineResource): void { + const referenceId = this.host.wireIdentities.resolve(key); + let retained = this.#resources.get(referenceId); + const existing = retained?.owners.values().next().value; + if (existing !== undefined && existing.technique !== resource.technique) { + throw new TypeError(`Three text resource ${referenceId} is registered for incompatible techniques`); + } + if (retained === undefined) { + retained = { owners: new Map() }; + this.#resources.set(referenceId, retained); + } + retained.owners.set(font, resource); + let references = this.#fontResourceReferences.get(font); + if (references === undefined) { + references = new Set(); + this.#fontResourceReferences.set(font, references); + } + references.add(referenceId); + } + + #observeFont(font: LoadedFont): void { + if (this.#fontDisposeObservers.has(font)) return; + const stopObserving = observeLoadedFontDispose(font, () => this.#releaseFontResources(font)); + this.#fontDisposeObservers.set(font, stopObserving); + } + + #releaseFontResources(font: LoadedFont): void { + for (const referenceId of this.#fontResourceReferences.get(font) ?? []) { + const retained = this.#resources.get(referenceId); + retained?.owners.delete(font); + if (retained?.owners.size === 0) this.#resources.delete(referenceId); + } + this.#fontResourceReferences.delete(font); + this.#fontDisposeObservers.delete(font); + this.#bindingHandles.delete(font); + } + + #allocateBindingHandle(): number { + return allocateHandle(this.#nextBindingHandle, (next) => (this.#nextBindingHandle = next), 'font binding'); + } + + #allocateStackHandle(): number { + return allocateHandle(this.#nextStackHandle, (next) => (this.#nextStackHandle = next), 'font stack'); + } + + #allocateSessionHandle(): number { + return allocateHandle(this.#nextSessionHandle, (next) => (this.#nextSessionHandle = next), 'text session'); + } + + #allocateMaterialHandle(): number { + return allocateHandle(this.#nextMaterialHandle, (next) => (this.#nextMaterialHandle = next), 'material'); + } + + #assertActive(): void { + if (this.#disposed) throw new Error('Three text engine coordinator is disposed'); + } +} + +/** Resolve the lazy Three-owned coordinator without pulling renderer policies into the core runtime graph. */ +export function threeTextEngineCoordinator(runtime: TextRuntime): ThreeTextEngineCoordinator { + let coordinator = coordinators.get(runtime); + if (coordinator === undefined) { + coordinator = new ThreeTextEngineCoordinator(textRuntimeShaper(runtime)); + coordinators.set(runtime, coordinator); + } + return coordinator; +} + +function allocateHandle(current: number, setNext: (next: number) => void, label: string): number { + if (!Number.isSafeInteger(current) || current <= 0 || current > MAX_U32) { + throw new RangeError(`${label} handles are exhausted`); + } + setNext(current === MAX_U32 ? MAX_U32 + 1 : current + 1); + return current; +} diff --git a/packages/text/src/three/font-loader.ts b/packages/text/src/three/font-loader.ts index 9fcc1c4f..3fb6d043 100644 --- a/packages/text/src/three/font-loader.ts +++ b/packages/text/src/three/font-loader.ts @@ -3,17 +3,11 @@ import * as THREE from 'three/webgpu'; import type { LoadedFont } from '../loaded-font.js'; import { observeLoadedFontDispose } from '../loaded-font.js'; import type { AnyRasterTechnique } from '../raster-technique.js'; -import { - createTextRuntime, - type LoadedFontRequest, - type TextPreparationWorker, - type TextRuntime, -} from '../text-runtime.js'; +import { createTextRuntime, type LoadedFontRequest, type TextRuntime } from '../text-runtime.js'; import type { FontRegistry, RuntimeFontBake } from '../loader.js'; export interface ThreeFontLoaderOptions { readonly runtimeBake?: RuntimeFontBake; - readonly createWorker?: () => TextPreparationWorker; /** * Registers loaded fonts in a registry the application already owns. Without one the runtime creates its own, and a * caller holding registry-scoped state cannot reach the fonts this loader produces. @@ -114,7 +108,6 @@ export class FontLoader extends THREE.Loader, Loa domain = { manager: this.manager, runtime: createTextRuntime({ - ...(this.#options.createWorker === undefined ? {} : { async: { createWorker: this.#options.createWorker } }), ...(this.#options.registry === undefined ? {} : { registry: this.#options.registry }), }), fonts: new Set(), diff --git a/packages/text/src/three/material.ts b/packages/text/src/three/material.ts new file mode 100644 index 00000000..c26330b4 --- /dev/null +++ b/packages/text/src/three/material.ts @@ -0,0 +1,36 @@ +import type { Node, NodeMaterial } from 'three/webgpu'; + +import type { ThreeBitmapShaderOutput } from './bitmap-shader.js'; +import type { ThreeMsdfShaderOutput } from './msdf-shader.js'; +import type { ThreeSlugShaderOutput } from './slug-shader.js'; + +export type ThreeTextMaterialContext = + | Readonly<{ + technique: 'pmndrs.bitmap'; + shader: ThreeBitmapShaderOutput; + /** Final renderer-local position including policy-selected transform indirection. */ + position: Node<'vec3'>; + createDefaultMaterial(): NodeMaterial; + }> + | Readonly<{ + technique: 'pmndrs.msdf'; + shader: ThreeMsdfShaderOutput; + position: Node<'vec3'>; + createDefaultMaterial(): NodeMaterial; + }> + | Readonly<{ + technique: 'pmndrs.slug'; + shader: ThreeSlugShaderOutput; + position: Node<'vec3'>; + createDefaultMaterial(): NodeMaterial; + }>; + +export interface ThreeTextMaterial { + create(context: ThreeTextMaterialContext): NodeMaterial; +} + +/** Define one renderer-owned material factory carried through Rust as a numeric `materialId`. */ +export function defineTextMaterial(create: ThreeTextMaterial['create']): ThreeTextMaterial { + if (typeof create !== 'function') throw new TypeError('text material create must be a function'); + return Object.freeze({ create }); +} diff --git a/packages/text/src/three/msdf-shader.ts b/packages/text/src/three/msdf-shader.ts index 13efc5ac..77f1ee4d 100644 --- a/packages/text/src/three/msdf-shader.ts +++ b/packages/text/src/three/msdf-shader.ts @@ -58,7 +58,7 @@ export interface ThreeMsdfShaderOutput { } /** - * Builds the canonical MSDF node graph. This is the exact graph `ThreeMsdfTarget` renders, so a program that composes + * Builds the canonical MSDF node graph. This is the exact graph the command-buffer executor renders, so a program that composes * over the returned nodes inherits the technique's median distance decode, screen-space range, and layer compositing. * * The graph reads `positionLocal` and `uv()` from the technique's unit quad: both must span `[0, 1]` with the origin at diff --git a/packages/text/src/three/msdf-target.ts b/packages/text/src/three/msdf-target.ts deleted file mode 100644 index b5ca0756..00000000 --- a/packages/text/src/three/msdf-target.ts +++ /dev/null @@ -1,327 +0,0 @@ -import * as TSL from 'three/tsl'; -import * as THREE from 'three/webgpu'; - -import type { - GlyphBatchKey, - ParagraphId, - PreparedGlyphBatch, - PreparedParagraphBatchRevision, -} from '../paragraph-batch.js'; -import type { ParagraphBatchTarget, ParagraphBatchTargetUpdate } from '../paragraph-batch-attachment.js'; -import { msdf, type MsdfData } from '../raster/msdf.js'; -import { msdfShader } from './msdf-shader.js'; -import { - instanceStorageBytes, - invalidatePboTexture, - retainedRunIdentities, - RetainedThreeGpuBytes, - RetainedThreeTargetRevision, - type RetainedThreeTargetResource, -} from './retained-target.js'; - -export interface ThreeMsdfTargetOwner { - objectForParagraph(paragraph: ParagraphId): THREE.Object3D; - readonly renderOrderBase: number; -} - -interface MsdfTargetResource extends RetainedThreeTargetResource { - readonly key: GlyphBatchKey; - readonly capacity: number; - readonly gpuBytes: number; - readonly material: THREE.MeshBasicNodeMaterial; - update(batch: PreparedGlyphBatch): void; - geometry(count: number): THREE.InstancedBufferGeometry; - dispose(): void; -} - -export class ThreeMsdfTargetRevision extends RetainedThreeTargetRevision {} - -export class ThreeMsdfTarget implements ParagraphBatchTarget { - readonly technique: typeof msdf = msdf; - readonly #owner: ThreeMsdfTargetOwner; - readonly #atlases = new Map(); - readonly #gpuBytes = new RetainedThreeGpuBytes(); - #disposed = false; - - constructor(owner: ThreeMsdfTargetOwner) { - this.#owner = owner; - } - - get gpuBytes(): number { - return this.#gpuBytes.total; - } - - stage( - previous: ThreeMsdfTargetRevision | undefined, - next: PreparedParagraphBatchRevision, - ): ParagraphBatchTargetUpdate { - if (this.#disposed) throw new Error('Three MSDF target has been disposed'); - if (previous?.canReuse(next) === true) { - let finished = false; - return { - status: 'ready', - stage: { - sourceRevision: next.revision, - commit: () => { - if (finished) throw new Error('Three MSDF stage is no longer active'); - finished = true; - const state = previous.transfer(next, this.#owner.renderOrderBase); - return this.#gpuBytes.retain( - new ThreeMsdfTargetRevision(next.revision, state.draws, state.resources, state.runIdentities), - ); - }, - abort: () => { - finished = true; - }, - }, - }; - } - const resources = new Map(); - const draws: THREE.Mesh[] = []; - try { - for (const batch of next.glyphBatches) - resources.set(batch.key, createMsdfTargetResource(batch, this.#atlas(batch.font.data))); - 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('MSDF 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); - } - let finished = false; - return { - status: 'ready', - stage: { - sourceRevision: next.revision, - commit: () => { - if (finished) throw new Error('Three MSDF stage is no longer active'); - finished = true; - for (let index = 0; index < draws.length; index += 1) - this.#owner.objectForParagraph(next.glyphRuns[index]!.paragraph).add(draws[index]!); - return this.#gpuBytes.retain( - new ThreeMsdfTargetRevision(next.revision, draws, resources, retainedRunIdentities(next)), - ); - }, - abort: () => { - if (finished) return; - finished = true; - disposeStaged(draws, resources.values()); - }, - }, - }; - } catch (error) { - disposeStaged(draws, resources.values()); - throw error; - } - } - - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - for (const atlas of this.#atlases.values()) atlas.dispose(); - this.#atlases.clear(); - this.#gpuBytes.release(); - } - - #atlas(data: MsdfData): THREE.DataArrayTexture { - let atlas = this.#atlases.get(data.resource); - if (atlas !== undefined) return atlas; - const bytes = new Uint8Array(data.binding.width * data.binding.height * data.binding.layers * 4); - for (let layer = 0; layer < data.pages.length; layer += 1) { - const page = data.pages[layer]!; - for (let row = 0; row < page.height; row += 1) { - const source = row * page.width * 4; - const target = (layer * data.binding.height + row) * data.binding.width * 4; - bytes.set(page.bytes.subarray(source, source + page.width * 4), target); - } - } - atlas = new THREE.DataArrayTexture(bytes, data.binding.width, data.binding.height, data.binding.layers); - atlas.format = THREE.RGBAFormat; - atlas.type = THREE.UnsignedByteType; - atlas.colorSpace = THREE.NoColorSpace; - atlas.magFilter = THREE.LinearFilter; - atlas.minFilter = THREE.LinearFilter; - atlas.generateMipmaps = false; - atlas.needsUpdate = true; - this.#atlases.set(data.resource, atlas); - this.#gpuBytes.addShared(bytes.byteLength); - return atlas; - } -} - -function createMsdfTargetResource( - batch: PreparedGlyphBatch, - atlas: THREE.DataArrayTexture, -): MsdfTargetResource { - const geometryValues = new Float32Array(batch.capacity * 4); - const uvValues = new Float32Array(batch.capacity * 4); - const boundsValues = new Float32Array(batch.capacity * 4); - const fillValues = new Float32Array(batch.capacity * 4); - const outlineValues = new Float32Array(batch.capacity * 4); - const shadowValues = new Float32Array(batch.capacity * 4); - const effectsValues = new Float32Array(batch.capacity * 4); - const arrays = { - geometry: geometryValues, - uv: uvValues, - bounds: boundsValues, - fill: fillValues, - outline: outlineValues, - shadow: shadowValues, - effects: effectsValues, - }; - writeMsdfStorage(batch, arrays, 0, batch.instanceCount); - const attributes = { - geometry: floatStorage(geometryValues, 4), - uv: floatStorage(uvValues, 4), - bounds: floatStorage(boundsValues, 4), - fill: floatStorage(fillValues, 4), - outline: floatStorage(outlineValues, 4), - shadow: floatStorage(shadowValues, 4), - effects: floatStorage(effectsValues, 4), - }; - const runStart = TSL.uniform(0, 'uint').onObjectUpdate( - ({ object }) => (object?.userData.pmndrsTextRunStart as number | undefined) ?? 0, - ); - const instance = TSL.instanceIndex.add(runStart); - const geometry = TSL.storage(attributes.geometry, 'vec4', attributes.geometry.count).setPBO(true).element(instance); - const uvData = TSL.storage(attributes.uv, 'vec4', attributes.uv.count).setPBO(true).element(instance); - const uvBounds = TSL.storage(attributes.bounds, 'vec4', attributes.bounds.count).setPBO(true).element(instance); - const fillColor = TSL.storage(attributes.fill, 'vec4', attributes.fill.count).setPBO(true).element(instance); - const outlineColor = TSL.storage(attributes.outline, 'vec4', attributes.outline.count).setPBO(true).element(instance); - const shadowColor = TSL.storage(attributes.shadow, 'vec4', attributes.shadow.count).setPBO(true).element(instance); - const effects = TSL.storage(attributes.effects, 'vec4', attributes.effects.count).setPBO(true).element(instance); - const shader = msdfShader( - { - origin: geometry.xy, - size: geometry.zw, - uvOrigin: uvData.xy, - uvSize: uvData.zw, - uvBounds, - fillColor, - outlineColor, - shadowColor, - shadowOffset: effects.xy, - outlineWidth: effects.z, - pageIndex: effects.w, - }, - { - atlas, - atlasWidth: batch.binding.width, - atlasHeight: batch.binding.height, - pixelRange: batch.font.data.pixelRange, - }, - ); - const material = new THREE.MeshBasicNodeMaterial({ - depthTest: false, - depthWrite: false, - side: THREE.DoubleSide, - transparent: true, - }); - material.positionNode = shader.position; - material.colorNode = shader.color; - material.opacityNode = shader.opacity; - const allAttributes = Object.entries(attributes); - return { - key: batch.key, - capacity: batch.capacity, - gpuBytes: instanceStorageBytes(Object.values(attributes)), - material, - update(next) { - for (const range of next.dirtyRanges) writeMsdfStorage(next, arrays, range.start, range.count); - markStorageRanges(attributes.geometry, arrays.geometry, next.dirtyRanges); - markStorageRanges(attributes.uv, arrays.uv, next.dirtyRanges); - markStorageRanges(attributes.bounds, arrays.bounds, next.dirtyRanges); - markStorageRanges(attributes.fill, arrays.fill, next.dirtyRanges); - markStorageRanges(attributes.outline, arrays.outline, next.dirtyRanges); - markStorageRanges(attributes.shadow, arrays.shadow, next.dirtyRanges); - markStorageRanges(attributes.effects, arrays.effects, next.dirtyRanges); - }, - geometry(count) { - const result = unitQuad(); - result.instanceCount = count; - for (const [name, attribute] of allAttributes) result.setAttribute(`_pmndrsText_${name}`, attribute); - return result; - }, - dispose() { - material.dispose(); - }, - }; -} - -interface MsdfTargetArrays { - readonly geometry: Float32Array; - readonly uv: Float32Array; - readonly bounds: Float32Array; - readonly fill: Float32Array; - readonly outline: Float32Array; - readonly shadow: Float32Array; - readonly effects: Float32Array; -} - -function writeMsdfStorage( - batch: PreparedGlyphBatch, - arrays: MsdfTargetArrays, - start: number, - count: number, -): void { - const storage = batch.storage; - for (let index = start; index < start + count; index += 1) { - const pair = index * 2; - const vector = index * 4; - arrays.geometry.set(storage.origins.subarray(pair, pair + 2), vector); - arrays.geometry.set(storage.sizes.subarray(pair, pair + 2), vector + 2); - arrays.uv.set(storage.uvOrigins.subarray(pair, pair + 2), vector); - arrays.uv.set(storage.uvSizes.subarray(pair, pair + 2), vector + 2); - arrays.bounds.set(storage.uvBounds.subarray(vector, vector + 4), vector); - arrays.fill.set(storage.fillColors.subarray(vector, vector + 4), vector); - arrays.outline.set(storage.outlineColors.subarray(vector, vector + 4), vector); - arrays.shadow.set(storage.shadowColors.subarray(vector, vector + 4), vector); - arrays.effects[vector] = storage.shadowOffsets[pair]!; - arrays.effects[vector + 1] = storage.shadowOffsets[pair + 1]!; - arrays.effects[vector + 2] = storage.outlineWidths[index]!; - arrays.effects[vector + 3] = storage.pageIndices[index]!; - } -} - -function markStorageRanges( - attribute: THREE.StorageInstancedBufferAttribute, - source: Float32Array, - ranges: readonly { readonly start: number; readonly count: number }[], -): void { - if (ranges.length === 0) return; - const target = attribute.array as Float32Array; - attribute.clearUpdateRanges(); - for (const range of ranges) { - const start = range.start * 4; - const count = range.count * 4; - target.set(source.subarray(start, start + count), start); - attribute.addUpdateRange(start, count); - } - attribute.needsUpdate = true; - invalidatePboTexture(attribute); -} - -function floatStorage(array: Float32Array, itemSize: number): THREE.StorageInstancedBufferAttribute { - const attribute = new THREE.StorageInstancedBufferAttribute(new Float32Array(array), itemSize); - attribute.setUsage(THREE.DynamicDrawUsage); - attribute.needsUpdate = true; - return attribute; -} - -function unitQuad(): THREE.InstancedBufferGeometry { - 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)); - return geometry; -} - -function disposeStaged(draws: readonly THREE.Mesh[], resources: Iterable): void { - for (const draw of draws) draw.geometry.dispose(); - for (const resource of resources) resource.dispose(); -} diff --git a/packages/text/src/three/msdf.ts b/packages/text/src/three/msdf.ts index 013f94b1..74051134 100644 --- a/packages/text/src/three/msdf.ts +++ b/packages/text/src/three/msdf.ts @@ -1,7 +1 @@ -import { msdf } from '../raster/msdf.js'; -import { ThreeMsdfTarget, type ThreeMsdfTargetOwner } from './msdf-target.js'; -import { registerThreeRasterProgram } from './program-registry.js'; - -registerThreeRasterProgram(msdf, (owner: ThreeMsdfTargetOwner) => new ThreeMsdfTarget(owner)); - export * from '../raster/msdf.js'; diff --git a/packages/text/src/three/plan-program-registry.ts b/packages/text/src/three/plan-program-registry.ts new file mode 100644 index 00000000..990c8fac --- /dev/null +++ b/packages/text/src/three/plan-program-registry.ts @@ -0,0 +1,148 @@ +import type { Node, NodeMaterial, StorageInstancedBufferAttribute } from 'three/webgpu'; + +import { textShaperAbi } from '../generated/text-shaper-abi.js'; +import type { FontBindingDescriptor, FontBindingFieldTable } from '../internal/font-binding-wire.js'; +import { compileFontBinding, emptyFontBindingTable, fontBindingResources } from '../internal/font-binding-wire.js'; +import type { PolicyProgram } from '../internal/render-policy-wire.js'; +import { RenderWireIdentityRegistry } from '../internal/render-policy-wire.js'; +import type { LoadedFont } from '../loaded-font.js'; +import type { AnyRasterTechnique, RasterResourceId } from '../raster-technique.js'; +import type { ThreeTextMaterial } from './material.js'; + +export interface ThreePlanProgramBuffer { + readonly scalarType: number; + readonly vectorWidth: number; + readonly attribute: StorageInstancedBufferAttribute; +} + +export interface ThreePlanProgramMaterialContext { + readonly resource: Resource; + readonly buffers: ReadonlyMap; + readonly instance: Node<'uint'>; + readonly materialId: number; + readonly material: ThreeTextMaterial | undefined; + transformPosition(position: Node<'vec3'>): Node<'vec3'>; +} + +export interface ThreePlanProgramFontCompiler { + readonly font: LoadedFont; + readonly techniqueId: number; + readonly identities: RenderWireIdentityRegistry; + readonly emptyTable: (rows: number) => FontBindingFieldTable; + resources(keys: readonly RasterResourceId[]): ReturnType; + compile(descriptor: FontBindingDescriptor): Uint8Array; + retain(key: RasterResourceId, resource: Resource): void; +} + +export interface ThreeRasterPlanProgram { + readonly technique: Technique; + /** Static validated policy bytecode descriptor. IDs are supplied by the renderer registry. */ + readonly policy: Omit; + /** Cold font registration; never runs during frame shaping, layout, packing, or draw submission. */ + compileFont(compiler: ThreePlanProgramFontCompiler): void; + /** + * Renderer realization invoked only when a compatible retained material is absent. + * The callback receives borrowed plan-backed attributes and must not synchronously update or query text. + */ + createMaterial(context: ThreePlanProgramMaterialContext): NodeMaterial; +} + +export interface CompiledThreeRasterPlanProgram { + readonly technique: AnyRasterTechnique; + readonly techniqueId: number; + readonly programId: number; + readonly policy: PolicyProgram; + compileFont( + font: LoadedFont, + identities: RenderWireIdentityRegistry, + ): Readonly<{ binding: Uint8Array; resources: ReadonlyMap }>; + createMaterial(context: ThreePlanProgramMaterialContext): NodeMaterial; +} + +const programs = new Map>(); + +/** + * Register declarative Rust packing policy plus cold font/resource and renderer realization code. + * Registration must happen before the runtime-scoped Three coordinator is first created. + */ +export function registerThreeRasterPlanProgram( + program: ThreeRasterPlanProgram, +): void { + const erased = program as unknown as ThreeRasterPlanProgram; + const existing = programs.get(program.technique.id); + if (existing !== undefined && existing !== erased) { + throw new TypeError(`a different Three raster plan program is already registered for "${program.technique.id}"`); + } + programs.set(program.technique.id, erased); +} + +/** @internal Compile the cold registry snapshot into one Rust policy and exact font compilers. */ +export function compiledThreeRasterPlanPrograms( + identities: RenderWireIdentityRegistry, +): readonly CompiledThreeRasterPlanProgram[] { + return [...programs.values()] + .sort((left, right) => left.technique.id.localeCompare(right.technique.id)) + .map((program) => compileProgram(program, identities)); +} + +export interface ThreePolicyAbi { + readonly opcodes: typeof textShaperAbi.policy.opcodes; + readonly scalarTypes: typeof textShaperAbi.policy.scalarTypes; + readonly bufferUsage: typeof textShaperAbi.policy.bufferUsage; + readonly allocationStrategies: typeof textShaperAbi.policy.allocationStrategies; + readonly batchFields: typeof textShaperAbi.policy.batchFields; + readonly semanticF32Fields: typeof textShaperAbi.engine.semanticF32Fields; + readonly semanticU32Fields: typeof textShaperAbi.engine.semanticU32Fields; + readonly transformBufferId: 15; +} + +export const threePolicyAbi: ThreePolicyAbi = Object.freeze({ + opcodes: textShaperAbi.policy.opcodes, + scalarTypes: textShaperAbi.policy.scalarTypes, + bufferUsage: textShaperAbi.policy.bufferUsage, + allocationStrategies: textShaperAbi.policy.allocationStrategies, + batchFields: textShaperAbi.policy.batchFields, + semanticF32Fields: textShaperAbi.engine.semanticF32Fields, + semanticU32Fields: textShaperAbi.engine.semanticU32Fields, + transformBufferId: 15, +}); + +function compileProgram( + program: ThreeRasterPlanProgram, + identities: RenderWireIdentityRegistry, +): CompiledThreeRasterPlanProgram { + const techniqueId = identities.resolve(program.technique.id); + const programId = identities.resolve(`${program.technique.id}/three-plan-program`); + return { + technique: program.technique, + techniqueId, + programId, + policy: { ...program.policy, techniqueId, programId }, + compileFont(font, bindingIdentities) { + if (font.technique.id !== program.technique.id) { + throw new TypeError('Three raster plan program received an incompatible loaded font'); + } + let binding: Uint8Array | undefined; + const resources = new Map(); + program.compileFont({ + font, + techniqueId, + identities: bindingIdentities, + emptyTable: emptyFontBindingTable, + resources: (keys) => fontBindingResources(keys, bindingIdentities), + compile(descriptor) { + if (binding !== undefined) throw new Error('Three raster plan font compiler produced more than one binding'); + binding = compileFontBinding(descriptor); + return binding; + }, + retain(key, resource) { + if (resources.has(key)) throw new TypeError(`Three raster plan font retained duplicate resource "${key}"`); + resources.set(key, resource); + }, + }); + if (binding === undefined) throw new Error('Three raster plan font compiler produced no binding'); + return { binding, resources }; + }, + createMaterial: (context) => program.createMaterial(context), + }; +} diff --git a/packages/text/src/three/program-registry.ts b/packages/text/src/three/program-registry.ts deleted file mode 100644 index 9def05f7..00000000 --- a/packages/text/src/three/program-registry.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type * as THREE from 'three/webgpu'; - -import type { ParagraphBatchTarget, ParagraphBatchTargetRevision } from '../paragraph-batch-attachment.js'; -import type { ParagraphId } from '../paragraph-batch.js'; -import type { AnyRasterTechnique, RasterTechniqueId } from '../raster-technique.js'; - -/** - * The scene-side state a Three raster program needs to place its draws. Every first-party target and every third-party - * program receives exactly this view; a program never reaches the owning `Text` or `TextGroup`. - */ -export interface ThreeRasterTargetOwner { - objectForParagraph(paragraph: ParagraphId): THREE.Object3D; - readonly renderOrderBase: number; -} - -/** - * Optional GPU accounting a Three target may report: the bytes of the engine resources it currently retains. Only the - * target knows its realized allocation, so a portable technique never reports one; a third-party program may omit it. - */ -export interface ThreeRasterTargetAccounting { - readonly gpuBytes?: number; -} - -/** - * Builds the Three target that realizes one technique's prepared glyph batches as engine resources and draws. Core - * owns partitioning, packing, and ordering; a program owns shaders, pipelines, and final draw compilation. - */ -export type ThreeRasterProgram = ( - owner: ThreeRasterTargetOwner, -) => ParagraphBatchTarget & ThreeRasterTargetAccounting; - -const programs = new Map(); - -/** - * Registers the Three program for a raster technique, keyed by the technique's stable identifier rather than its object - * identity. Identifier keying lets an application wrap a technique — to instrument its runtime baker, for example — - * without losing the ability to render it. - * - * The technique is inferred, so a program may type its prepared batches, storage, and binding concretely; the registry - * itself is heterogeneous and holds the erased form, having already proven the pairing at this call. - */ -export function registerThreeRasterProgram( - technique: Technique, - program: ThreeRasterProgram, -): void { - const erased = program as ThreeRasterProgram; - const existing = programs.get(technique.id); - if (existing !== undefined && existing !== erased) { - throw new TypeError(`a different Three raster program is already registered for "${technique.id}"`); - } - programs.set(technique.id, erased); -} - -/** Resolves the registered Three program for a technique, or `undefined` when no program has been registered. */ -export function threeRasterProgram(technique: AnyRasterTechnique): ThreeRasterProgram | undefined { - return programs.get(technique.id); -} diff --git a/packages/text/src/three/retained-target.ts b/packages/text/src/three/retained-target.ts deleted file mode 100644 index 32186750..00000000 --- a/packages/text/src/three/retained-target.ts +++ /dev/null @@ -1,156 +0,0 @@ -import * as THREE from 'three/webgpu'; - -import type { - GlyphBatchKey, - ParagraphId, - PreparedGlyphBatch, - PreparedParagraphBatchRevision, -} from '../paragraph-batch.js'; -import type { ParagraphBatchTargetRevision } from '../paragraph-batch-attachment.js'; -import type { AnyRasterTechnique } from '../raster-technique.js'; - -export interface RetainedThreeTargetResource { - readonly key: GlyphBatchKey; - readonly capacity: number; - readonly gpuBytes: number; - update(batch: PreparedGlyphBatch): void; - dispose(): void; -} - -export interface RetainedThreeRunIdentity { - readonly paragraph: ParagraphId; - readonly batch: GlyphBatchKey; -} - -export interface TransferredThreeTargetState< - Technique extends AnyRasterTechnique, - Resource extends RetainedThreeTargetResource, -> { - readonly draws: readonly THREE.Mesh[]; - readonly resources: ReadonlyMap; - readonly runIdentities: readonly RetainedThreeRunIdentity[]; -} - -export class RetainedThreeTargetRevision< - Technique extends AnyRasterTechnique, - Resource extends RetainedThreeTargetResource, -> implements ParagraphBatchTargetRevision { - readonly sourceRevision: number; - readonly draws: readonly THREE.Mesh[]; - readonly #resources: ReadonlyMap; - readonly #runIdentities: readonly RetainedThreeRunIdentity[]; - #transferred = false; - #disposed = false; - - constructor( - sourceRevision: number, - draws: readonly THREE.Mesh[], - resources: ReadonlyMap, - runIdentities: readonly RetainedThreeRunIdentity[], - ) { - this.sourceRevision = sourceRevision; - this.draws = draws; - this.#resources = resources; - this.#runIdentities = runIdentities; - } - - get gpuBytes(): number { - if (this.#disposed || this.#transferred) return 0; - let bytes = 0; - for (const resource of this.#resources.values()) bytes += resource.gpuBytes; - return bytes; - } - - setRenderOrderBase(base: number): void { - for (let index = 0; index < this.draws.length; index += 1) this.draws[index]!.renderOrder = base + index; - } - - canReuse(next: PreparedParagraphBatchRevision): boolean { - if (this.#disposed || this.#transferred || next.glyphBatches.length !== this.#resources.size) return false; - for (const batch of next.glyphBatches) { - const resource = this.#resources.get(batch.key); - if (resource === undefined || resource.capacity !== batch.capacity) return false; - } - if (next.glyphRuns.length !== this.#runIdentities.length) return false; - return next.glyphRuns.every((run, index) => { - const identity = this.#runIdentities[index]; - return identity?.paragraph === run.paragraph && identity.batch === run.batch; - }); - } - - transfer( - next: PreparedParagraphBatchRevision, - renderOrderBase: number, - ): TransferredThreeTargetState { - if (!this.canReuse(next)) throw new Error('Three target revision is not compatible for reuse'); - for (const batch of next.glyphBatches) this.#resources.get(batch.key)!.update(batch); - for (let index = 0; index < next.glyphRuns.length; index += 1) { - const run = next.glyphRuns[index]!; - const draw = this.draws[index]!; - draw.userData.pmndrsTextRunStart = run.start; - (draw.geometry as THREE.InstancedBufferGeometry).instanceCount = run.count; - draw.renderOrder = renderOrderBase + index; - } - this.#transferred = true; - return { draws: this.draws, resources: this.#resources, runIdentities: this.#runIdentities }; - } - - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - if (this.#transferred) return; - for (const draw of this.draws) { - draw.removeFromParent(); - draw.geometry.dispose(); - } - for (const resource of this.#resources.values()) resource.dispose(); - } -} - -/** - * GPU residency of one retained Three target: the resources it shares across batches plus the instance buffers its - * committed revision owns. A revision that has transferred its resources to a successor reports nothing, so a warm - * commit never counts the same buffers twice. - */ -export class RetainedThreeGpuBytes< - Technique extends AnyRasterTechnique, - Resource extends RetainedThreeTargetResource, -> { - #shared = 0; - #revision: RetainedThreeTargetRevision | undefined; - - get total(): number { - return this.#shared + (this.#revision?.gpuBytes ?? 0); - } - - addShared(bytes: number): void { - this.#shared += bytes; - } - - retain>(revision: Revision): Revision { - this.#revision = revision; - return revision; - } - - release(): void { - this.#shared = 0; - this.#revision = undefined; - } -} - -export function instanceStorageBytes(attributes: Iterable): number { - let bytes = 0; - for (const attribute of attributes) bytes += attribute.array.byteLength; - return bytes; -} - -export function retainedRunIdentities( - revision: PreparedParagraphBatchRevision, -): readonly RetainedThreeRunIdentity[] { - return revision.glyphRuns.map((run) => ({ paragraph: run.paragraph, batch: run.batch })); -} - -export function invalidatePboTexture(attribute: THREE.StorageInstancedBufferAttribute): void { - const pbo = (attribute as THREE.StorageInstancedBufferAttribute & { pbo?: THREE.DataTexture }).pbo; - if (pbo !== undefined) pbo.needsUpdate = true; -} diff --git a/packages/text/src/three/slug-shader.ts b/packages/text/src/three/slug-shader.ts index 45c02fb9..26fb4b7c 100644 --- a/packages/text/src/three/slug-shader.ts +++ b/packages/text/src/three/slug-shader.ts @@ -1,7 +1,7 @@ import * as TSL from 'three/tsl'; import type { DataTexture, Node } from 'three/webgpu'; -import { slugDilate, slugRender, type SlugRenderOptions } from '../internal/slug-shaders/index.js'; +import { slugDilate, slugDilateMatrix, slugRender, type SlugRenderOptions } from '../internal/slug-shaders/index.js'; /** * One glyph instance's canonical Slug fields, already resolved to nodes. The address and count fields locate the @@ -52,16 +52,30 @@ export interface ThreeSlugFillRule { * The GPU resources one Slug glyph batch binds. The clip-space rows and viewport drive the analytic half-pixel * dilation, so they must describe the same draw the returned position node feeds. */ -export interface ThreeSlugShaderResources { +interface ThreeSlugShaderResourceBase { readonly page: ThreeSlugPageResources; /** Drawing-buffer size in device pixels. */ readonly viewport: Node<'vec2'>; + readonly fillRule?: ThreeSlugFillRule; +} + +interface ThreeSlugShaderRowResources extends ThreeSlugShaderResourceBase { readonly modelViewProjectionRow0: Node<'vec4'>; readonly modelViewProjectionRow1: Node<'vec4'>; readonly modelViewProjectionRow3: Node<'vec4'>; - readonly fillRule?: ThreeSlugFillRule; + readonly modelViewProjection?: never; } +interface ThreeSlugShaderMatrixResources extends ThreeSlugShaderResourceBase { + /** Exact MVP selected per glyph when a renderer batches multiple model transforms into one draw. */ + readonly modelViewProjection: Node<'mat4'>; + readonly modelViewProjectionRow0?: never; + readonly modelViewProjectionRow1?: never; + readonly modelViewProjectionRow3?: never; +} + +export type ThreeSlugShaderResources = ThreeSlugShaderRowResources | ThreeSlugShaderMatrixResources; + /** * Everything the canonical Slug graph produces, so a program can consume a stage or compose over its final output. * @@ -80,7 +94,7 @@ export interface ThreeSlugShaderOutput { } /** - * Builds the canonical Slug node graph. This is the exact graph `ThreeSlugTarget` renders, so a program that composes + * Builds the canonical Slug node graph. This is the exact graph the command-buffer executor renders, so a program that composes * over the returned nodes inherits the technique's band walk, quadratic solve, and antialiasing footprint. * * `position` and `coverage` are two halves of one graph: the vertex half writes the varying the fragment half @@ -107,16 +121,26 @@ export function slugShader( instance.emOrigin.x.add(TSL.positionLocal.x.mul(instance.emSize.x)), instance.emOrigin.y.sub(TSL.positionLocal.y.mul(instance.emSize.y)), ); - const dilated = slugDilate( - localPosition, - outwardNormal, - emCoordinate, - instance.inverseScale, - resources.modelViewProjectionRow0, - resources.modelViewProjectionRow1, - resources.modelViewProjectionRow3, - resources.viewport, - ); + const dilated = + resources.modelViewProjection === undefined + ? slugDilate( + localPosition, + outwardNormal, + emCoordinate, + instance.inverseScale, + resources.modelViewProjectionRow0, + resources.modelViewProjectionRow1, + resources.modelViewProjectionRow3, + resources.viewport, + ) + : slugDilateMatrix( + localPosition, + outwardNormal, + emCoordinate, + instance.inverseScale, + resources.modelViewProjection, + resources.viewport, + ); renderCoordinate.assign(dilated.textureCoordinate); return TSL.vec3(dilated.position.x, dilated.position.y, 0); })(); diff --git a/packages/text/src/three/slug-target.ts b/packages/text/src/three/slug-target.ts deleted file mode 100644 index 75b8092b..00000000 --- a/packages/text/src/three/slug-target.ts +++ /dev/null @@ -1,428 +0,0 @@ -import * as TSL from 'three/tsl'; -import * as THREE from 'three/webgpu'; -import type { UniformNode } from 'three/webgpu'; - -import type { - GlyphBatchKey, - ParagraphId, - PreparedGlyphBatch, - PreparedParagraphBatchRevision, -} from '../paragraph-batch.js'; -import type { ParagraphBatchTarget, ParagraphBatchTargetUpdate } from '../paragraph-batch-attachment.js'; -import { slug, type SlugPageData } from '../raster/slug-technique.js'; -import { slugShader, type ThreeSlugPageResources } from './slug-shader.js'; -import { - instanceStorageBytes, - invalidatePboTexture, - retainedRunIdentities, - RetainedThreeGpuBytes, - RetainedThreeTargetRevision, - type RetainedThreeTargetResource, -} from './retained-target.js'; - -export interface ThreeSlugTargetOwner { - objectForParagraph(paragraph: ParagraphId): THREE.Object3D; - readonly renderOrderBase: number; -} - -interface ThreeSlugPage extends ThreeSlugPageResources { - readonly curveHeight: number; - readonly headerHeight: number; - readonly referenceHeight: number; - dispose(): void; -} - -interface SlugTargetResource extends RetainedThreeTargetResource { - readonly key: GlyphBatchKey; - readonly capacity: number; - readonly gpuBytes: number; - readonly material: THREE.MeshBasicNodeMaterial; - readonly viewport: UniformNode<'vec2', THREE.Vector2>; - readonly mvpRow0: UniformNode<'vec4', THREE.Vector4>; - readonly mvpRow1: UniformNode<'vec4', THREE.Vector4>; - readonly mvpRow3: UniformNode<'vec4', THREE.Vector4>; - update(batch: PreparedGlyphBatch): void; - geometry(count: number): THREE.InstancedBufferGeometry; - dispose(): void; -} - -const drawingBufferSize = new THREE.Vector2(); -const modelViewProjectionMatrix = new THREE.Matrix4(); - -export class ThreeSlugTargetRevision extends RetainedThreeTargetRevision {} - -export class ThreeSlugTarget implements ParagraphBatchTarget { - readonly technique: typeof slug = slug; - readonly #owner: ThreeSlugTargetOwner; - readonly #pages = new Map(); - readonly #gpuBytes = new RetainedThreeGpuBytes(); - #disposed = false; - - constructor(owner: ThreeSlugTargetOwner) { - this.#owner = owner; - } - - get gpuBytes(): number { - return this.#gpuBytes.total; - } - - stage( - previous: ThreeSlugTargetRevision | undefined, - next: PreparedParagraphBatchRevision, - ): ParagraphBatchTargetUpdate { - if (this.#disposed) throw new Error('Three Slug target has been disposed'); - if (previous?.canReuse(next) === true) { - let finished = false; - return { - status: 'ready', - stage: { - sourceRevision: next.revision, - commit: () => { - if (finished) throw new Error('Three Slug stage is no longer active'); - finished = true; - const state = previous.transfer(next, this.#owner.renderOrderBase); - return this.#gpuBytes.retain( - new ThreeSlugTargetRevision(next.revision, state.draws, state.resources, state.runIdentities), - ); - }, - abort: () => { - finished = true; - }, - }, - }; - } - const resources = new Map(); - const draws: THREE.Mesh[] = []; - try { - for (const batch of next.glyphBatches) resources.set(batch.key, this.#createResource(batch)); - 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('Slug 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; - mesh.onBeforeRender = (renderer, _scene, camera): void => { - renderer.getDrawingBufferSize(drawingBufferSize); - resource.viewport.value.copy(drawingBufferSize); - updateMvpUniforms(resource, mesh, camera); - }; - draws.push(mesh); - } - let finished = false; - return { - status: 'ready', - stage: { - sourceRevision: next.revision, - commit: () => { - if (finished) throw new Error('Three Slug stage is no longer active'); - finished = true; - for (let index = 0; index < draws.length; index += 1) - this.#owner.objectForParagraph(next.glyphRuns[index]!.paragraph).add(draws[index]!); - return this.#gpuBytes.retain( - new ThreeSlugTargetRevision(next.revision, draws, resources, retainedRunIdentities(next)), - ); - }, - abort: () => { - if (finished) return; - finished = true; - disposeStaged(draws, resources.values()); - }, - }, - }; - } catch (error) { - disposeStaged(draws, resources.values()); - throw error; - } - } - - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - for (const page of this.#pages.values()) page.dispose(); - this.#pages.clear(); - this.#gpuBytes.release(); - } - - #createResource(batch: PreparedGlyphBatch): SlugTargetResource { - const page = batch.font.data.pages[batch.binding.page]; - if (page === undefined) throw new TypeError('Slug binding references a missing decoded page'); - return createSlugTargetResource(batch, this.#page(page)); - } - - #page(data: SlugPageData): ThreeSlugPage { - let page = this.#pages.get(data.resource); - if (page !== undefined) return page; - const curves = ownedUint16(data.curveBytes); - const headers = ownedUint32(data.headerBytes); - const packedReferences = packReferencePairs(ownedUint16(data.referenceBytes), data.referenceWidth); - const curveTexture = dataTexture(curves, data.curveWidth, data.curveHeight, THREE.RGBAFormat, THREE.HalfFloatType); - const headerTexture = dataTexture( - headers, - data.headerWidth, - data.headerHeight, - THREE.RedIntegerFormat, - THREE.UnsignedIntType, - ); - const referenceTexture = dataTexture( - packedReferences.data, - packedReferences.width, - packedReferences.height, - THREE.RedIntegerFormat, - THREE.UnsignedIntType, - ); - page = { - curveTexture, - curveWidth: data.curveWidth, - curveHeight: data.curveHeight, - headerTexture, - headerWidth: data.headerWidth, - headerHeight: data.headerHeight, - referenceTexture, - referenceWidth: packedReferences.width, - referenceHeight: packedReferences.height, - dispose() { - curveTexture.dispose(); - headerTexture.dispose(); - referenceTexture.dispose(); - }, - }; - this.#pages.set(data.resource, page); - this.#gpuBytes.addShared(curves.byteLength + headers.byteLength + packedReferences.data.byteLength); - return page; - } -} - -function createSlugTargetResource(batch: PreparedGlyphBatch, page: ThreeSlugPage): SlugTargetResource { - const geometryValues = new Float32Array(batch.capacity * 4); - const emValues = new Float32Array(batch.capacity * 4); - const bandValues = new Float32Array(batch.capacity * 4); - const colorValues = new Float32Array(batch.capacity * 4); - const scalarValues = new Float32Array(batch.capacity * 4); - const addressValues = new Uint32Array(batch.capacity * 4); - const countValues = new Uint32Array(batch.capacity * 4); - const arrays = { - geometry: geometryValues, - em: emValues, - band: bandValues, - color: colorValues, - scalar: scalarValues, - address: addressValues, - count: countValues, - }; - writeSlugStorageRange(batch, arrays, 0, batch.instanceCount); - const attributes = { - geometry: storageAttribute(geometryValues), - em: storageAttribute(emValues), - band: storageAttribute(bandValues), - color: storageAttribute(colorValues), - scalar: storageAttribute(scalarValues), - address: storageAttribute(addressValues), - count: storageAttribute(countValues), - }; - const runStart = TSL.uniform(0, 'uint').onObjectUpdate( - ({ object }) => (object?.userData.pmndrsTextRunStart as number | undefined) ?? 0, - ); - const instance = TSL.instanceIndex.add(runStart); - const geometry = TSL.storage(attributes.geometry, 'vec4', attributes.geometry.count).setPBO(true).element(instance); - const em = TSL.storage(attributes.em, 'vec4', attributes.em.count).setPBO(true).element(instance); - const bandTransform = TSL.storage(attributes.band, 'vec4', attributes.band.count).setPBO(true).element(instance); - const color = TSL.storage(attributes.color, 'vec4', attributes.color.count).setPBO(true).element(instance); - const inverseScale = TSL.storage(attributes.scalar, 'vec4', attributes.scalar.count).setPBO(true).element(instance).x; - const addresses = TSL.storage(attributes.address, 'uvec4', attributes.address.count).setPBO(true).element(instance); - const counts = TSL.storage(attributes.count, 'uvec4', attributes.count.count).setPBO(true).element(instance); - const viewport: UniformNode<'vec2', THREE.Vector2> = TSL.uniform(new THREE.Vector2(1, 1)); - const mvpRow0: UniformNode<'vec4', THREE.Vector4> = TSL.uniform(new THREE.Vector4(1, 0, 0, 0)); - const mvpRow1: UniformNode<'vec4', THREE.Vector4> = TSL.uniform(new THREE.Vector4(0, 1, 0, 0)); - const mvpRow3: UniformNode<'vec4', THREE.Vector4> = TSL.uniform(new THREE.Vector4(0, 0, 0, 1)); - const shader = slugShader( - { - origin: geometry.xy, - size: geometry.zw, - emOrigin: em.xy, - emSize: em.zw, - inverseScale, - color, - bandTransform, - curveBaseTexel: addresses.x, - horizontalHeaderBase: addresses.y, - verticalHeaderBase: addresses.z, - referenceBase: addresses.w, - horizontalBandCount: counts.x, - verticalBandCount: counts.y, - }, - { - page, - viewport, - modelViewProjectionRow0: mvpRow0, - modelViewProjectionRow1: mvpRow1, - modelViewProjectionRow3: mvpRow3, - }, - ); - const material = new THREE.MeshBasicNodeMaterial({ - blending: THREE.NormalBlending, - depthTest: false, - depthWrite: false, - side: THREE.DoubleSide, - transparent: true, - }); - material.positionNode = shader.position; - material.colorNode = shader.color; - material.opacityNode = shader.opacity; - const allAttributes = Object.entries(attributes); - return { - key: batch.key, - capacity: batch.capacity, - gpuBytes: instanceStorageBytes(Object.values(attributes)), - material, - viewport, - mvpRow0, - mvpRow1, - mvpRow3, - update(next) { - for (const range of next.dirtyRanges) writeSlugStorageRange(next, arrays, range.start, range.count); - markStorageRanges(attributes.geometry, arrays.geometry, next.dirtyRanges); - markStorageRanges(attributes.em, arrays.em, next.dirtyRanges); - markStorageRanges(attributes.band, arrays.band, next.dirtyRanges); - markStorageRanges(attributes.color, arrays.color, next.dirtyRanges); - markStorageRanges(attributes.scalar, arrays.scalar, next.dirtyRanges); - markStorageRanges(attributes.address, arrays.address, next.dirtyRanges); - markStorageRanges(attributes.count, arrays.count, next.dirtyRanges); - }, - geometry(count) { - const result = unitQuad(); - result.instanceCount = count; - for (const [name, attribute] of allAttributes) result.setAttribute(`_pmndrsText_${name}`, attribute); - return result; - }, - dispose() { - material.dispose(); - }, - }; -} - -interface SlugTargetArrays { - readonly geometry: Float32Array; - readonly em: Float32Array; - readonly band: Float32Array; - readonly color: Float32Array; - readonly scalar: Float32Array; - readonly address: Uint32Array; - readonly count: Uint32Array; -} - -function writeSlugStorageRange( - batch: PreparedGlyphBatch, - arrays: SlugTargetArrays, - start: number, - count: number, -): void { - const storage = batch.storage; - for (let index = start; index < start + count; index += 1) { - const pair = index * 2; - const vector = index * 4; - arrays.geometry.set(storage.origins.subarray(pair, pair + 2), vector); - arrays.geometry.set(storage.sizes.subarray(pair, pair + 2), vector + 2); - arrays.em.set(storage.emOrigins.subarray(pair, pair + 2), vector); - arrays.em.set(storage.emSizes.subarray(pair, pair + 2), vector + 2); - arrays.band.set(storage.bandTransforms.subarray(vector, vector + 4), vector); - arrays.color.set(storage.colors.subarray(vector, vector + 4), vector); - arrays.scalar[vector] = storage.inverseScales[index]!; - arrays.address[vector] = storage.curveBases[index]!; - arrays.address[vector + 1] = storage.horizontalHeaderBases[index]!; - arrays.address[vector + 2] = storage.verticalHeaderBases[index]!; - arrays.address[vector + 3] = storage.referenceBases[index]!; - arrays.count[vector] = storage.horizontalBandCounts[index]!; - arrays.count[vector + 1] = storage.verticalBandCounts[index]!; - } -} - -function markStorageRanges( - attribute: THREE.StorageInstancedBufferAttribute, - source: Float32Array | Uint32Array, - ranges: readonly { readonly start: number; readonly count: number }[], -): void { - if (ranges.length === 0) return; - const target = attribute.array as Float32Array | Uint32Array; - attribute.clearUpdateRanges(); - for (const range of ranges) { - const start = range.start * 4; - const count = range.count * 4; - target.set(source.subarray(start, start + count), start); - attribute.addUpdateRange(start, count); - } - attribute.needsUpdate = true; - invalidatePboTexture(attribute); -} - -function updateMvpUniforms(resource: SlugTargetResource, object: THREE.Object3D, camera: THREE.Camera): void { - modelViewProjectionMatrix.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse); - modelViewProjectionMatrix.multiply(object.matrixWorld); - const values = modelViewProjectionMatrix.elements; - resource.mvpRow0.value.set(values[0]!, values[4]!, values[8]!, values[12]!); - resource.mvpRow1.value.set(values[1]!, values[5]!, values[9]!, values[13]!); - resource.mvpRow3.value.set(values[3]!, values[7]!, values[11]!, values[15]!); -} - -function storageAttribute(array: Float32Array | Uint32Array): THREE.StorageInstancedBufferAttribute { - const attribute = new THREE.StorageInstancedBufferAttribute(array, 4); - attribute.setUsage(THREE.DynamicDrawUsage); - attribute.needsUpdate = true; - return attribute; -} - -function dataTexture( - data: Uint16Array | Uint32Array, - width: number, - height: number, - format: THREE.PixelFormat, - type: THREE.TextureDataType, -): THREE.DataTexture { - const texture = new THREE.DataTexture(data, width, height, format, type); - texture.colorSpace = THREE.NoColorSpace; - texture.flipY = false; - texture.generateMipmaps = false; - texture.minFilter = THREE.NearestFilter; - texture.magFilter = THREE.NearestFilter; - texture.needsUpdate = true; - return texture; -} - -function ownedUint16(bytes: Uint8Array): Uint16Array { - const copy = bytes.slice(); - return new Uint16Array(copy.buffer, copy.byteOffset, copy.byteLength / 2); -} - -function ownedUint32(bytes: Uint8Array): Uint32Array { - const copy = bytes.slice(); - return new Uint32Array(copy.buffer, copy.byteOffset, copy.byteLength / 4); -} - -function packReferencePairs( - references: Uint16Array, - preferredWidth: number, -): { readonly data: Uint32Array; readonly width: number; readonly height: number } { - const texelCount = Math.ceil(references.length / 2); - const width = Math.min(preferredWidth, texelCount); - const height = Math.ceil(texelCount / width); - const data = new Uint32Array(width * height); - for (let index = 0; index < references.length; index += 1) - data[index >>> 1] = data[index >>> 1]! | (references[index]! << ((index & 1) * 16)); - return { data, width, height }; -} - -function unitQuad(): THREE.InstancedBufferGeometry { - 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)); - return geometry; -} - -function disposeStaged(draws: readonly THREE.Mesh[], resources: Iterable): void { - for (const draw of draws) draw.geometry.dispose(); - for (const resource of resources) resource.dispose(); -} diff --git a/packages/text/src/three/slug.ts b/packages/text/src/three/slug.ts index df5bc303..d54da1ef 100644 --- a/packages/text/src/three/slug.ts +++ b/packages/text/src/three/slug.ts @@ -1,7 +1 @@ -import { slug } from '../raster/slug-technique.js'; -import { registerThreeRasterProgram } from './program-registry.js'; -import { ThreeSlugTarget, type ThreeSlugTargetOwner } from './slug-target.js'; - -registerThreeRasterProgram(slug, (owner: ThreeSlugTargetOwner) => new ThreeSlugTarget(owner)); - export * from '../raster/slug-technique.js'; diff --git a/packages/text/src/three/text.ts b/packages/text/src/three/text.ts index 3660fb95..f88e2734 100644 --- a/packages/text/src/three/text.ts +++ b/packages/text/src/three/text.ts @@ -2,8 +2,8 @@ import * as THREE from 'three/webgpu'; import type { FormattedText, GlyphPaintInput, ParagraphSpan, TextInput } from '../formatted-text.js'; import { - acquireFontSelection, - assertFontSelection, + acquireFontSelectionForRuntime, + assertFontSelectionForRuntime, concreteFonts, releaseFontSelection, type FontSelection, @@ -11,123 +11,144 @@ import { } from '../loaded-font.js'; import type { GlyphBufferCapacity, - GlyphOriginUpdate, - GlyphSnapshot, - Paragraph, - ParagraphBatch, + ParagraphBaseProperties, ParagraphContentBox, - ParagraphLayout, ParagraphProperties, ParagraphStyle, - ParagraphUpdate, -} from '../index.js'; -import type { ParagraphBatchTarget, ParagraphBatchTargetRevision } from '../paragraph-batch-attachment.js'; +} from '../text-properties.js'; import type { AnyRasterTechnique } from '../raster-technique.js'; import type { TextRuntime } from '../text-runtime.js'; import { - threeRasterProgram, - type ThreeRasterTargetAccounting, - type ThreeRasterTargetOwner, -} from './program-registry.js'; + compileTextEngineFrameUpdate, + type TextEngineConstraint, + type TextEngineFrameLimits, + type TextEngineRegion, + type TextEngineStyleMutation, + type TextEngineStyleValue, + type TextEngineTextMutation, +} from '../internal/engine-frame-wire.js'; +import { + TextEngineStatusError, + type TextEnginePublication, + type TextEngineSession, +} from '../internal/text-engine-host.js'; +import { readTextEngineLayouts, readTextEngineMeasurements } from '../internal/layout-query-view.js'; +import { TextEngineRenderPlanView } from '../internal/render-plan-view.js'; +import type { ParagraphLayoutInspection, ParagraphLayoutSummary } from '../layout.js'; +import { textShaperAbi } from '../generated/text-shaper-abi.js'; +import { ThreeTextRenderPlanExecutor } from './engine-plan-target.js'; +import { + threeTextEngineCoordinator, + type ThreeTextMaterialLease, + type ThreeTextEngineCoordinator, + type ThreeTextEngineStackLease, +} from './engine-runtime.js'; +import type { ThreeTextMaterial } from './material.js'; -export interface ThreeRenderVariant { - readonly effects?: readonly unknown[]; -} +const MAX_TEXT_ENGINE_OUTPUT_BYTES = 64 * 1024 * 1024; +const TEXT_CHANGE = 1 << 0; +const STYLE_CHANGE = 1 << 1; +const GEOMETRY_CHANGE = 1 << 2; +const ALL_SEMANTIC_CHANGES = TEXT_CHANGE | STYLE_CHANGE | GEOMETRY_CHANGE; + +export type TextSpan = ParagraphSpan & + Readonly<{ material?: ThreeTextMaterial }>; + +type TextBaseProperties = ParagraphBaseProperties; -export type TextSpan = ParagraphSpan< - Technique, - Variant ->; +type TextContentProperties = + | Readonly<{ text: string; spans?: readonly TextSpan[] }> + | Readonly<{ text: FormattedText; spans?: never }>; -export type TextProperties = ParagraphProperties< - Technique, - Variant ->; +export type TextProperties = TextBaseProperties & + TextContentProperties & + Readonly<{ material?: ThreeTextMaterial }>; -export type StandaloneTextProperties< - Technique extends AnyRasterTechnique, - Variant = ThreeRenderVariant, -> = TextProperties & Readonly<{ capacity?: GlyphBufferCapacity }>; +export type StandaloneTextProperties = TextProperties & + Readonly<{ capacity?: GlyphBufferCapacity }>; -export type TextUpdate = ParagraphUpdate< - Technique, - Variant ->; +export type TextUpdate = + | (Partial> & + Readonly<{ text?: string; spans?: readonly TextSpan[]; material?: ThreeTextMaterial }>) + | (Partial> & + Readonly<{ text: FormattedText; spans?: never; material?: ThreeTextMaterial }>); -export interface TextGroupOptions { - readonly technique: Technique; +export interface TextGroupOptions { readonly capacity?: GlyphBufferCapacity; + /** Allows Rust to reorder compatible draws when descendants do not require compositing order. */ + readonly compositing?: 'ordered' | 'independent'; readonly renderOrder?: number; - readonly renderVariant?: Variant; -} - -type SameType = [Left] extends [Right] ? ([Right] extends [Left] ? true : false) : false; -type CompatibleTextChildren< - Technique extends AnyRasterTechnique, - Variant, - Children extends readonly THREE.Object3D[], -> = { - readonly [Index in keyof Children]: Children[Index] extends Text - ? SameType extends true - ? SameType extends true - ? Children[Index] - : never - : never - : Children[Index]; -}; - -interface DesiredTextState { + readonly material?: ThreeTextMaterial; +} + +export interface TextGlyphOriginSnapshot { + readonly layout: ParagraphLayoutInspection; + readonly shapedX: Float32Array; + readonly shapedY: Float32Array; + readonly displayedX: Float32Array; + readonly displayedY: Float32Array; +} + +export interface TextGlyphOriginUpdate { + readonly layout: ParagraphLayoutInspection; + readonly x: Float32Array; + readonly y: Float32Array; +} + +interface DesiredTextState { readonly font: FontSelection; readonly text: string; - readonly spans: readonly TextSpan[]; + readonly spans: readonly TextSpan[]; readonly contentBox: ParagraphContentBox; readonly style: ParagraphStyle; readonly paint: GlyphPaintInput; readonly rasterPixelRatio?: number; - readonly renderVariant?: Variant; + readonly material?: ThreeTextMaterial; } -export class Text extends THREE.Object3D { +type PendingTextMutation = Readonly<{ + start: number; + deleteCount: number; + insert: string; +}>; + +export class Text extends THREE.Object3D { readonly #runtime: TextRuntime; - readonly #technique: Technique; - #desired: DesiredTextState; + #desired: DesiredTextState; #leasedFonts: readonly LoadedFont[]; #standaloneCapacity: GlyphBufferCapacity; - #binding: ThreeTextBatchBinding | undefined; - #paragraph: Paragraph | undefined; - #textGroup: TextGroup | undefined; + #binding: ThreeTextBatchBinding | undefined; + #textGroup: TextGroup | undefined; #desiredRevision = 0; #appliedRevision = -1; + #semanticChanges = ALL_SEMANTIC_CHANGES; + readonly #textMutations: PendingTextMutation[] = []; #disposed = false; #error: unknown; onError: ((error: unknown) => void) | undefined; - constructor(properties: StandaloneTextProperties) { + constructor(properties: StandaloneTextProperties) { super(); const normalized = normalizeDesired(properties); const primary = concreteFonts(normalized.font)[0]; this.#runtime = primary.runtime; - this.#technique = primary.technique as Technique; this.#desired = normalized; this.#leasedFonts = selectedFonts(normalized); - acquireFonts(this.#leasedFonts, this.#runtime, this.#technique); + acquireFonts(this.#leasedFonts, this.#runtime); this.#standaloneCapacity = normalizeCapacity(properties.capacity ?? { size: 256, policy: 'grow' }); } - get textGroup(): TextGroup | undefined { + get textGroup(): TextGroup | undefined { return this.#textGroup; } get bound(): boolean { - return this.#paragraph !== undefined; + return this.#binding !== undefined; } get disposed(): boolean { return this.#disposed; } - get layout(): ParagraphLayout | undefined { - return this.#paragraph?.committed?.layout; - } get error(): unknown { - return this.#error ?? this.#binding?.error; + return this.#error ?? this.#textGroup?.error; } get gpuBytes(): number { return this.#binding?.gpuBytes ?? 0; @@ -142,12 +163,12 @@ export class Text) { - this.set({ text: value } as TextUpdate); + this.set({ text: value } as TextUpdate); } - get spans(): readonly TextSpan[] { + get spans(): readonly TextSpan[] { return this.#desired.spans; } - set spans(value: readonly TextSpan[]) { + set spans(value: readonly TextSpan[]) { this.set({ spans: value }); } get contentBox(): ParagraphContentBox { @@ -174,28 +195,49 @@ export class Text); + set material(value: ThreeTextMaterial | undefined) { + this.set({ material: value } as TextUpdate); } - set(update: TextUpdate): void { + set(update: TextUpdate): void { this.#assertActive(); - const next = normalizeDesired({ ...this.#desired, ...replacedContent(update) } as TextProperties< - Technique, - Variant - >); + const normalizedUpdate = replacedContent(update); + const changes = classifySemanticChanges(normalizedUpdate); + if (changes === 0) return; + const next = normalizeDesired({ ...this.#desired, ...normalizedUpdate } as TextProperties); + const textMutation = minimalTextMutation(this.#desired.text, next.text); const fonts = selectedFonts(next); - acquireFonts(fonts, this.#runtime, this.#technique); + acquireFonts(fonts, this.#runtime); releaseFonts(this.#leasedFonts); this.#leasedFonts = fonts; this.#desired = next; + if (textMutation !== undefined) this.#textMutations.push(textMutation); this.#desiredRevision += 1; + this.#semanticChanges |= changes; + } + + insertText(offset: number, value: string): void { + this.replaceText(offset, offset, value); + } + + deleteText(start: number, end: number): void { + this.replaceText(start, end, ''); + } + + replaceText(start: number, end: number, value: string): void { + this.#assertActive(); + assertTextRange(this.#desired.text, start, end); + if (typeof value !== 'string') throw new TypeError('replacement text must be a string'); + if (start === end && value.length === 0) return; + const text = this.#desired.text.slice(0, start) + value + this.#desired.text.slice(end); + const spans = editSpans(this.#desired.spans, start, end, value.length); + this.set({ text, spans } as TextUpdate); } - setSpan(index: number, span: TextSpan): void { + setSpan(index: number, span: TextSpan): void { const spans = [...this.spans]; if (!Number.isSafeInteger(index) || index < 0 || index >= spans.length) throw new RangeError('span index is outside the text'); @@ -211,29 +253,37 @@ export class Text(this); + const boundary = nearestTextGroup(this); if (boundary?.disposed) { this.#unbind(); } else if (boundary !== undefined) { - boundary.bindText(this); + boundary.bindText(eraseTextTechnique(this)); } else if (this.parent !== null) { if (this.#binding === undefined || this.#textGroup !== undefined) { this.#unbind(); - this.#binding = new ThreeTextBatchBinding(this.#runtime, this.#technique, this.#standaloneCapacity, undefined); + this.#binding = new ThreeTextBatchBinding(this.#runtime, this.#standaloneCapacity, undefined); } - this.#binding.reconcileStandalone(this); + this.#binding.reconcileStandalone(eraseTextTechnique(this)); try { this.#binding.synchronize(); this.#error = undefined; @@ -273,7 +323,7 @@ export class Text { + coreProperties(): ParagraphProperties { return { ...this.#desired, order: this.renderOrder, @@ -283,69 +333,66 @@ export class Text, - paragraph: Paragraph, - group: TextGroup | undefined, - ): void { + bind(binding: ThreeTextBatchBinding, group: TextGroup | undefined): void { if (this.#binding !== binding) this.#unbind(); this.#binding = binding; - this.#paragraph = paragraph; this.#textGroup = group; this.#appliedRevision = this.#desiredRevision; } - unbindFrom(binding: ThreeTextBatchBinding): void { + unbindFrom(binding: ThreeTextBatchBinding): void { if (this.#binding !== binding) return; this.#binding = undefined; - this.#paragraph = undefined; this.#textGroup = undefined; } - setParagraph(paragraph: Paragraph): void { - this.#paragraph = paragraph; - } get runtime(): TextRuntime { return this.#runtime; } - get technique(): Technique { - return this.#technique; - } #unbind(): void { const binding = this.#binding; const standalone = binding !== undefined && this.#textGroup === undefined; this.#binding = undefined; - this.#paragraph = undefined; this.#textGroup = undefined; if (standalone) binding.dispose(); - else binding?.removeText(this); + else binding?.removeText(eraseTextTechnique(this)); } #assertActive(): void { if (this.#disposed) throw new Error('text has been disposed'); } } -export class TextGroup extends THREE.Object3D { - readonly technique: Technique; +export class TextGroup extends THREE.Object3D { #capacity: GlyphBufferCapacity; - #renderVariant: Variant | undefined; - #binding: ThreeTextBatchBinding | undefined; + readonly #compositing: 'ordered' | 'independent'; + #material: ThreeTextMaterial | undefined; + #binding: ThreeTextBatchBinding | undefined; #disposed = false; #error: unknown; onError: ((error: unknown) => void) | undefined; - constructor(options: TextGroupOptions) { + constructor(options: TextGroupOptions = {}) { super(); - if (options?.technique === undefined) throw new TypeError('TextGroup requires a raster technique'); - this.technique = options.technique; this.#capacity = normalizeCapacity(options.capacity ?? { size: 4_096, policy: 'chunk' }); - this.#renderVariant = options.renderVariant; + this.#compositing = normalizeCompositing(options.compositing); + this.#material = options.material; if (options.renderOrder !== undefined) this.renderOrder = options.renderOrder; } get capacity(): GlyphBufferCapacity { return this.#capacity; } + get compositing(): 'ordered' | 'independent' { + return this.#compositing; + } get textCount(): number { return this.#binding?.textCount ?? 0; } @@ -353,28 +400,26 @@ export class TextGroup( - ...children: CompatibleTextChildren - ): this { + override add(...children: THREE.Object3D[]): this { this.#assertActive(); - for (const child of children) if (child instanceof Text) validateText(this, child as Text); - return super.add(...(children as readonly THREE.Object3D[])); + for (const child of children) if (child instanceof Text) validateText(child as Text); + return super.add(...children); } setCapacity(capacity: GlyphBufferCapacity): void { this.#assertActive(); @@ -394,13 +439,13 @@ export class TextGroup(this); + const texts = collectTextDescendants(this); if (texts.length !== 0) { - const first = texts[0]!; - validateText(this, first); - this.#binding ??= new ThreeTextBatchBinding(first.runtime, this.technique, this.#capacity, this); - this.#binding.reconcile(texts); try { + const runtime = texts[0]!.runtime; + for (const text of texts) validateBinding(runtime, text); + this.#binding ??= new ThreeTextBatchBinding(runtime, this.#capacity, this); + this.#binding.reconcile(texts); this.#binding.synchronize(); this.#error = undefined; } catch (error) { @@ -421,9 +466,9 @@ export class TextGroup): void { + bindText(text: Text): void { if (this.#disposed) return; - validateText(this, text); + validateText(text); } dispose(): void { if (this.#disposed) return; @@ -436,175 +481,753 @@ export class TextGroup implements ThreeRasterTargetOwner { +class ThreeTextBatchBinding { readonly #runtime: TextRuntime; - readonly #group: TextGroup | undefined; - readonly #batch: ParagraphBatch; - readonly #paragraphs = new Map, Paragraph>(); - readonly #textsByParagraph = new Map>(); - readonly #renderOrders = new Map, number>(); - readonly #target: ThreeRasterTargetAccounting; - readonly #attachment: ThreeTargetAttachment; + readonly #group: TextGroup | undefined; + readonly #coordinator: ThreeTextEngineCoordinator; + readonly #session: TextEngineSession; + readonly #target: ThreeTextRenderPlanExecutor; + readonly #paragraphs = new Map, RetainedEngineParagraph>(); + readonly #textsByParagraph = new Map>(); + readonly #removed: RetainedEngineParagraph[] = []; + readonly #measurements = new Map, ParagraphLayoutSummary>(); + readonly #layoutInspections = new Map, ParagraphLayoutInspection>(); + readonly #queryPlanView = new TextEngineRenderPlanView(); + readonly #freeParagraphIds: number[] = []; + #nextParagraphId = 1; + #engineRevision = 0; + #planRevision = 0; + #acknowledgedPublicationGeneration = 0; + #lastPublication: TextEnginePublication | undefined; + #requestCapacity: number; + #resultCapacity: number; + #textCapacity: number; + #materialInvalidated = false; #disposed = false; - constructor( - runtime: TextRuntime, - technique: Technique, - capacity: GlyphBufferCapacity, - group: TextGroup | undefined, - ) { + constructor(runtime: TextRuntime, capacity: GlyphBufferCapacity, group: TextGroup | undefined) { this.#runtime = runtime; this.#group = group; - this.#batch = runtime.createParagraphBatch({ - technique, - capacity, - ...(group?.renderVariant === undefined ? {} : { renderVariant: group.renderVariant }), + this.#coordinator = threeTextEngineCoordinator(runtime); + this.#requestCapacity = Math.max(64 * 1024, capacity.size * 32); + this.#resultCapacity = Math.max(256 * 1024, capacity.size * 160); + this.#textCapacity = capacity.size; + this.#session = this.#coordinator.createSession({ + requestCapacity: this.#requestCapacity, + resultCapacity: this.#resultCapacity, + textCapacity: this.#textCapacity, + }); + const owner = this; + this.#target = new ThreeTextRenderPlanExecutor(this.#coordinator, { + get drawRoot() { + return owner.#drawRoot(); + }, + get renderOrderBase() { + return owner.#renderOrderBase(); + }, + objectForTransform(transformId) { + const text = owner.#textsByParagraph.get(transformId); + if (text === undefined) throw new Error(`Three command buffer references unknown transform ${transformId}`); + return text; + }, + transformIds() { + return owner.#textsByParagraph.keys(); + }, }); - const program = threeRasterProgram(technique); - if (program === undefined) { - throw new TypeError( - `no Three raster program is registered for "${technique.id}"; register one with registerThreeRasterProgram`, - ); - } - const target = program(this); - const attached = target as unknown as ParagraphBatchTarget< - AnyRasterTechnique, - Variant, - ParagraphBatchTargetRevision - >; - const batch = this.#batch as unknown as ParagraphBatch; - this.#target = target; - this.#attachment = batch.attach(attached) as unknown as ThreeTargetAttachment; } get textCount(): number { return this.#paragraphs.size; } - get error(): unknown { - return this.#batch.preparationError ?? this.#attachment.error; - } get gpuBytes(): number { - const bytes = this.#target.gpuBytes; - return typeof bytes === 'number' ? bytes : 0; + return this.#target.gpuBytes; } get renderOrderBase(): number { return this.#group?.renderOrder ?? 0; } - objectForParagraph(id: number): THREE.Object3D { - const text = this.#textsByParagraph.get(id); - if (text === undefined) throw new Error('Three target cannot resolve a paragraph transform'); - return text; + measurement(text: Text): ParagraphLayoutSummary | undefined { + if (!this.#paragraphs.has(text)) return undefined; + this.synchronize(textShaperAbi.engine.semanticViewMasks.measurement); + return this.#measurements.get(text); + } + layoutInspection(text: Text): ParagraphLayoutInspection | undefined { + if (!this.#paragraphs.has(text)) return undefined; + this.synchronize(textShaperAbi.engine.semanticViewMasks.layoutInspection); + return this.#layoutInspections.get(text); + } + glyphOriginSnapshot(text: Text): TextGlyphOriginSnapshot | undefined { + const layout = this.layoutInspection(text); + if (layout === undefined) return undefined; + return { layout, ...this.#target.snapshotGlyphOrigins(layout.glyphStableIds, layout.x, layout.y) }; + } + setGlyphOrigins(text: Text, update: TextGlyphOriginUpdate): void { + const layout = this.layoutInspection(text); + if (layout === undefined || update.layout !== layout) { + throw new TypeError('glyph origins do not match the committed layout inspection'); + } + if (update.x.length !== layout.glyphStableIds.length || update.y.length !== layout.glyphStableIds.length) { + throw new RangeError('glyph origin arrays do not match the inspected glyph count'); + } + this.#target.setGlyphOriginOverrides(layout.glyphStableIds, update.x, update.y); + } + clearGlyphOrigins(text: Text): void { + const layout = this.#layoutInspections.get(text); + if (layout !== undefined) this.#target.clearGlyphOriginOverrides(layout.glyphStableIds); } - reconcile(texts: readonly Text[]): void { + reconcile(texts: readonly Text[]): void { const desired = new Set(texts); for (const text of [...this.#paragraphs.keys()]) if (!desired.has(text)) this.removeText(text); for (const text of texts) this.#ensureText(text, this.#group); } - reconcileStandalone(text: Text): void { + reconcileStandalone(text: Text): void { this.#ensureText(text, undefined); } - synchronize(): void { + synchronize(semanticViewMask = 0): void { if (this.#disposed) return; - for (const [text, paragraph] of this.#paragraphs) { - if (text.needsApply()) { - paragraph.set(text.coreProperties() as ParagraphUpdate); + this.#coordinator.assertFrameUpdateAllowed(); + if (this.#lastPublication !== undefined) this.retry(); + const ordered = [...this.#paragraphs.entries()].sort( + ([leftText, left], [rightText, right]) => leftText.renderOrder - rightText.renderOrder || left.id - right.id, + ); + const changed = ordered.flatMap(([text, paragraph], order) => { + const semanticChanges = + (paragraph.created ? ALL_SEMANTIC_CHANGES : text.semanticChanges()) | + (this.#materialInvalidated ? STYLE_CHANGE : 0); + return semanticChanges !== 0 || text.needsApply() || paragraph.order !== order + ? [{ text, paragraph, order, semanticChanges }] + : []; + }); + if (changed.length === 0 && this.#removed.length === 0) { + this.#target.syncTransforms(); + if (semanticViewMask !== 0 && !this.#hasSemanticViews(semanticViewMask)) { + this.#retainSemanticViews(this.#querySemanticViews(semanticViewMask), semanticViewMask); + } + return; + } + const paragraphMutations = [ + ...this.#removed.map((paragraph) => ({ opcode: 'remove' as const, paragraphId: paragraph.id })), + ...changed.map(({ paragraph, order }) => ({ + opcode: 'upsert' as const, + paragraphId: paragraph.id, + order, + })), + ]; + const textMutations: TextEngineTextMutation[] = []; + const styleMutations: TextEngineStyleMutation[] = []; + const constraints: TextEngineConstraint[] = []; + const regions: TextEngineRegion[] = []; + const pendingLeases = new Map(); + const pendingMaterials = new Map(); + const pendingChanges = new Map(); + let committed = false; + try { + for (const { text, paragraph, semanticChanges } of changed) { + pendingChanges.set(paragraph, semanticChanges); + const properties = text.coreProperties(); + const content = properties.text as string; + if (semanticChanges & TEXT_CHANGE) { + const pending = paragraph.created ? [{ start: 0, deleteCount: 0, insert: content }] : text.textMutations(); + for (const mutation of pending) textMutations.push({ paragraphId: paragraph.id, ...mutation }); + } + if (semanticChanges & STYLE_CHANGE) { + const leases: ThreeTextEngineStackLease[] = []; + const materials: ThreeTextMaterialLease[] = []; + pendingLeases.set(paragraph, leases); + pendingMaterials.set(paragraph, materials); + const styles = compileEngineStyles( + this.#coordinator, + paragraph.id, + properties, + this.#group?.material, + leases, + materials, + ); + styleMutations.push(...styles); + for (let styleId = styles.length + 1; styleId <= paragraph.styleCount; styleId += 1) { + styleMutations.push({ opcode: 'remove', paragraphId: paragraph.id, styleId }); + } + } + if (semanticChanges & GEOMETRY_CHANGE) { + const geometry = compileEngineGeometry(paragraph, properties.contentBox, regions.length, content.length); + constraints.push(geometry.constraint); + regions.push(geometry.region); + } + } + const totalTextLength = [...this.#paragraphs.keys()].reduce((total, text) => total + text.text.length, 0); + const limits = engineLimits( + Math.max(this.#paragraphs.size, paragraphMutations.length), + totalTextLength, + Math.max(regions.length, this.#paragraphs.size), + MAX_TEXT_ENGINE_OUTPUT_BYTES, + Math.max(textMutations.length, styleMutations.length), + ); + const frame = compileTextEngineFrameUpdate({ + sessionId: this.#session.handle, + policyHandle: this.#coordinator.policyHandle, + capabilitySet: 1, + expectedEngineRevision: this.#engineRevision, + consumedPlanRevision: this.#planRevision, + acknowledgedPublicationGeneration: this.#acknowledgedPublicationGeneration, + compositingIndependent: this.#group?.compositing === 'independent', + semanticViewMask, + limits, + paragraphMutations, + textMutations, + styleMutations, + constraints, + regions, + }); + let publication: TextEnginePublication; + try { + publication = this.#session.update(frame); + } catch (error) { + if (error instanceof TextEngineStatusError) { + error.message += + ` (paragraphs=${this.#paragraphs.size}, paragraph mutations=${paragraphMutations.length},` + + ` text mutations=${textMutations.length}, style mutations=${styleMutations.length},` + + ` constraints=${constraints.length}, regions=${regions.length}, text units=${totalTextLength},` + + ` limits=${JSON.stringify(limits)})`; + } + throw error; + } + this.#engineRevision = publication.engineRevision; + for (const removed of this.#removed) releaseStackLeases(removed.stackLeases); + for (const removed of this.#removed) releaseMaterialLeases(removed.materialLeases); + for (const removed of this.#removed) this.#freeParagraphIds.push(removed.id); + this.#removed.length = 0; + for (const [order, [text, paragraph]] of ordered.entries()) { + const semanticChanges = pendingChanges.get(paragraph) ?? 0; + if (semanticChanges === 0 && paragraph.order === order) continue; + const nextLeases = pendingLeases.get(paragraph); + if (nextLeases !== undefined) { + releaseStackLeases(paragraph.stackLeases); + releaseMaterialLeases(paragraph.materialLeases); + paragraph.stackLeases = nextLeases; + paragraph.materialLeases = pendingMaterials.get(paragraph) ?? []; + paragraph.styleCount = 1 + text.spans.length; + } + if (semanticChanges & TEXT_CHANGE) paragraph.textLength = text.text.length; + if (semanticChanges & GEOMETRY_CHANGE) paragraph.geometryRevision += 1; + paragraph.created = false; text.markApplied(); + paragraph.order = order; + } + this.#materialInvalidated = false; + this.#measurements.clear(); + this.#layoutInspections.clear(); + committed = true; + try { + this.#target.apply(publication); + this.#planRevision = publication.planRevision; + this.#lastPublication = undefined; + } catch (error) { + this.#lastPublication = ownPublication(publication); + throw error; } - if (this.#renderOrders.get(text) !== text.renderOrder) { - paragraph.order = text.renderOrder; - this.#renderOrders.set(text, text.renderOrder); + this.#acknowledgedPublicationGeneration = publication.publicationGeneration; + this.#retainSemanticViews(publication, semanticViewMask); + } catch (error) { + if (!committed) { + for (const leases of pendingLeases.values()) releaseStackLeases(leases); + for (const leases of pendingMaterials.values()) releaseMaterialLeases(leases); } + throw error; } - this.#runtime.update(); - this.#attachment.prepare(); - this.#attachment.commit()?.setRenderOrderBase(this.renderOrderBase); } setCapacity(value: GlyphBufferCapacity): void { - this.#batch.setCapacity(value); + this.#requestCapacity = Math.max(this.#requestCapacity, value.size * 32); + this.#resultCapacity = Math.max(this.#resultCapacity, value.size * 160); + this.#textCapacity = Math.max(this.#textCapacity, value.size); + this.#session.reserve(this.#requestCapacity, this.#resultCapacity, this.#textCapacity); } - setRenderVariant(value: Variant | undefined): void { - this.#batch.renderVariant = value; + invalidateMaterial(): void { + this.#materialInvalidated = true; } retry(): void { - this.#attachment.retry(); - } - removeText(text: Text): void { + const publication = this.#lastPublication; + if (publication === undefined) return; + this.#target.apply(publication); + this.#planRevision = publication.planRevision; + this.#acknowledgedPublicationGeneration = publication.publicationGeneration; + this.#lastPublication = undefined; + } + removeText(text: Text): void { const paragraph = this.#paragraphs.get(text); if (paragraph === undefined) return; this.#paragraphs.delete(text); this.#textsByParagraph.delete(paragraph.id); - this.#renderOrders.delete(text); - paragraph.dispose(); + this.#removed.push(paragraph); text.unbindFrom(this); } dispose(): void { if (this.#disposed) return; this.#disposed = true; for (const text of this.#paragraphs.keys()) text.unbindFrom(this); - this.#batch.dispose(); + this.#target.dispose(); + this.#session.dispose(); + for (const paragraph of this.#paragraphs.values()) releaseStackLeases(paragraph.stackLeases); + for (const paragraph of this.#removed) releaseStackLeases(paragraph.stackLeases); + for (const paragraph of this.#paragraphs.values()) releaseMaterialLeases(paragraph.materialLeases); + for (const paragraph of this.#removed) releaseMaterialLeases(paragraph.materialLeases); this.#paragraphs.clear(); this.#textsByParagraph.clear(); - this.#renderOrders.clear(); + this.#measurements.clear(); + this.#layoutInspections.clear(); + this.#removed.length = 0; + this.#freeParagraphIds.length = 0; } - #ensureText(text: Text, group: TextGroup | undefined): void { - validateBinding(this.#runtime, this.#batch.technique, text); + #ensureText(text: Text, group: TextGroup | undefined): void { + validateBinding(this.#runtime, text); let paragraph = this.#paragraphs.get(text); if (paragraph === undefined) { - paragraph = this.#batch.add(text.coreProperties()); + const id = this.#freeParagraphIds.pop() ?? this.#nextParagraphId++; + paragraph = { + id, + textLength: 0, + styleCount: 0, + order: -1, + geometryRevision: 0, + created: true, + stackLeases: [], + materialLeases: [], + }; this.#paragraphs.set(text, paragraph); - this.#textsByParagraph.set(paragraph.id, text); - this.#renderOrders.set(text, text.renderOrder); - text.bind(this, paragraph, group); + this.#textsByParagraph.set(id, text); + text.bind(this, group); + } + } + + #drawRoot(): THREE.Object3D { + if (this.#group !== undefined) return this.#group; + const text = this.#paragraphs.keys().next().value; + if (text === undefined) throw new Error('standalone text command buffer has no draw root'); + return text; + } + + #renderOrderBase(): number { + if (this.#group !== undefined) return this.#group.renderOrder; + return this.#paragraphs.keys().next().value?.renderOrder ?? 0; + } + + #querySemanticViews(semanticViewMask: number): TextEnginePublication { + const totalTextLength = [...this.#paragraphs.keys()].reduce((total, entry) => total + entry.text.length, 0); + const publication = this.#session.update( + compileTextEngineFrameUpdate({ + sessionId: this.#session.handle, + policyHandle: this.#coordinator.policyHandle, + capabilitySet: 1, + expectedEngineRevision: this.#engineRevision, + consumedPlanRevision: this.#planRevision, + acknowledgedPublicationGeneration: this.#acknowledgedPublicationGeneration, + compositingIndependent: this.#group?.compositing === 'independent', + semanticViewMask, + limits: engineLimits( + this.#paragraphs.size, + totalTextLength, + this.#paragraphs.size, + MAX_TEXT_ENGINE_OUTPUT_BYTES, + ), + }), + ); + this.#engineRevision = publication.engineRevision; + const plan = this.#queryPlanView.bind(publication); + for (const table of ['resources', 'buffers', 'patches', 'primitives', 'draws', 'retirements'] as const) { + if (plan.table(table).count !== 0) { + this.#lastPublication = ownPublication(publication); + throw new Error('a semantic-only text query unexpectedly published render work'); + } + } + this.#planRevision = publication.planRevision; + return publication; + } + + #hasSemanticViews(semanticViewMask: number): boolean { + if (semanticViewMask === textShaperAbi.engine.semanticViewMasks.measurement) { + return this.#measurements.size === this.#paragraphs.size; + } + if (semanticViewMask === textShaperAbi.engine.semanticViewMasks.layoutInspection) { + return this.#layoutInspections.size === this.#paragraphs.size; + } + return false; + } + + #retainSemanticViews(publication: TextEnginePublication, semanticViewMask: number): void { + if (semanticViewMask === 0) return; + if (semanticViewMask === textShaperAbi.engine.semanticViewMasks.measurement) { + for (const [paragraphId, measurement] of readTextEngineMeasurements(publication)) { + const measuredText = this.#textsByParagraph.get(paragraphId); + if (measuredText === undefined) throw new Error(`text engine measured unknown paragraph ${paragraphId}`); + this.#measurements.set(measuredText, measurement); + } + } else if (semanticViewMask === textShaperAbi.engine.semanticViewMasks.layoutInspection) { + for (const [paragraphId, layout] of readTextEngineLayouts(publication)) { + const inspectedText = this.#textsByParagraph.get(paragraphId); + if (inspectedText === undefined) throw new Error(`text engine inspected unknown paragraph ${paragraphId}`); + this.#measurements.set(inspectedText, layout); + this.#layoutInspections.set(inspectedText, layout); + } + } else { + throw new RangeError(`unsupported semantic view mask ${semanticViewMask}`); } + this.#acknowledgedPublicationGeneration = publication.publicationGeneration; } } +function compileEngineStyles( + coordinator: ThreeTextEngineCoordinator, + paragraphId: number, + properties: ParagraphProperties, + groupMaterial: ThreeTextMaterial | undefined, + leases: ThreeTextEngineStackLease[], + materialLeases: ThreeTextMaterialLease[], +): TextEngineStyleMutation[] { + const text = properties.text as string; + const rootStack = acquireEngineStack(coordinator, properties.font, leases); + const rootMaterial = (properties as TextProperties).material ?? groupMaterial; + const rootMaterialId = acquireEngineMaterial(coordinator, rootMaterial, materialLeases); + const styles: TextEngineStyleMutation[] = [ + { + opcode: 'upsert', + paragraphId, + styleId: 1, + cascadeOrder: 0, + start: 0, + end: text.length, + root: true, + value: engineStyleValue(properties.style ?? {}, properties.paint, 0, text.length, { + fontStackHandle: rootStack, + ...(rootMaterialId === undefined ? {} : { materialId: rootMaterialId }), + fontSize: properties.style?.fontSize ?? 16, + rasterPixelRatio: properties.rasterPixelRatio ?? 1, + }), + }, + ]; + for (const [index, span] of (properties.spans ?? []).entries()) { + const fontStackHandle = span.font === undefined ? undefined : acquireEngineStack(coordinator, span.font, leases); + const materialId = acquireEngineMaterial(coordinator, (span as TextSpan).material, materialLeases); + styles.push({ + opcode: 'upsert', + paragraphId, + styleId: index + 2, + cascadeOrder: index + 1, + start: span.start, + end: span.end, + value: engineStyleValue(span.style ?? {}, span.paint, span.start, span.end, { + ...(fontStackHandle === undefined ? {} : { fontStackHandle }), + ...(materialId === undefined ? {} : { materialId }), + }), + }); + } + return styles; +} + +function acquireEngineMaterial( + coordinator: ThreeTextEngineCoordinator, + material: ThreeTextMaterial | undefined, + leases: ThreeTextMaterialLease[], +): number | undefined { + if (material === undefined) return undefined; + const lease = coordinator.acquireMaterial(material); + leases.push(lease); + return lease.id; +} + +function acquireEngineStack( + coordinator: ThreeTextEngineCoordinator, + selection: FontSelection, + leases: ThreeTextEngineStackLease[], +): number { + const fonts = concreteFonts(selection) as readonly [ + LoadedFont, + ...LoadedFont[], + ]; + const lease = coordinator.acquireFontStack(fonts); + leases.push(lease); + return lease.handle; +} + +function engineStyleValue( + style: ParagraphStyle, + paint: GlyphPaintInput | undefined, + start: number, + end: number, + base: TextEngineStyleValue, +): TextEngineStyleValue { + return { + ...base, + ...(style.fontSize === undefined ? {} : { fontSize: style.fontSize }), + ...(style.lineHeight === undefined ? {} : { lineHeight: style.lineHeight }), + ...(style.letterSpacing === undefined ? {} : { letterSpacing: style.letterSpacing }), + ...(style.language === undefined ? {} : { language: style.language }), + ...(style.direction === undefined ? {} : { direction: style.direction }), + ...(style.features === undefined + ? {} + : { + features: style.features.map((feature) => ({ + tag: feature.tag, + value: feature.value ?? 1, + start: feature.start ?? start, + end: feature.end ?? end, + })), + }), + ...(paint === undefined ? {} : { foregroundRgba: packedForeground(paint) }), + }; +} + +function compileEngineGeometry( + paragraph: RetainedEngineParagraph, + contentBox: ParagraphContentBox | undefined, + regionStart: number, + textLength: number, +): { readonly constraint: TextEngineConstraint; readonly region: TextEngineRegion } { + const width = axis(contentBox?.width); + const height = axis(contentBox?.height); + const inlineEnd = width.mode === 'unconstrained' ? 0x01_00_00_00 : width.size; + const blockEnd = height.mode === 'unconstrained' ? 0x01_00_00_00 : height.size; + const maxLines = contentBox?.maxLines ?? Math.max(1, textLength); + const geometryRevision = paragraph.geometryRevision + 1; + return { + constraint: { + paragraphId: paragraph.id, + flowThreadId: paragraph.id, + geometryRevision, + width: width.size, + height: height.size, + viewportBlockStart: 0, + viewportBlockEnd: blockEnd, + resumeBlockOffset: 0, + maxLines, + regionStart, + resumeCluster: 0, + regionCount: 1, + resumeRegion: 0, + widthMode: width.mode, + heightMode: height.mode, + wrap: contentBox?.wrap ?? 'word', + align: contentBox?.align ?? 'start', + overflow: contentBox?.overflow ?? 'visible', + blockAlign: 'start', + }, + region: { + id: paragraph.id, + geometryRevision, + transformIndex: paragraph.id, + shape: 'rectangle', + exclusionStart: 0, + exclusionCount: 0, + writingMode: 'horizontal-tb', + textOrientation: 'mixed', + inlineStart: 0, + blockStart: 0, + inlineEnd, + blockEnd, + clipInlineStart: 0, + clipBlockStart: 0, + clipInlineEnd: inlineEnd, + clipBlockEnd: blockEnd, + }, + }; +} + +function axis(value: ParagraphContentBox['width'] | undefined): { + readonly mode: 'unconstrained' | 'at-most' | 'exact'; + readonly size: number; +} { + if (value === undefined || value.mode === 'unconstrained') return { mode: 'unconstrained', size: 0 }; + return { mode: value.mode, size: value.size }; +} + +function engineLimits( + paragraphCount: number, + textLength: number, + regionCount: number, + maxOutputBytes: number, + mutationRecordCount = 0, +): TextEngineFrameLimits { + return { + maxParagraphs: Math.max(1, paragraphCount), + maxClusters: Math.max(1, textLength * 2, mutationRecordCount), + maxLines: Math.max(1, textLength), + maxRegions: Math.max(1, regionCount), + maxExclusions: 1, + maxInlineObjects: 1, + maxSlotsPerBand: 8, + maxOutputBytes, + }; +} + +function packedForeground(paint: GlyphPaintInput): number { + const opacity = paint.opacity ?? 1; + if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1) { + throw new RangeError('opacity must be in [0, 1]'); + } + const input = paint.color ?? '#ffffff'; + const rgba = typeof input === 'string' ? parseHexColorBytes(input) : linearColorBytes(input); + const alpha = Math.round(rgba[3] * opacity); + return (rgba[0] | (rgba[1] << 8) | (rgba[2] << 16) | (alpha << 24)) >>> 0; +} + +function parseHexColorBytes(value: string): readonly [number, number, number, number] { + const match = /^#([0-9a-f]{6}|[0-9a-f]{8})$/iu.exec(value); + if (match === null) throw new TypeError('colors must be #rrggbb, #rrggbbaa, or linear RGBA'); + const hex = match[1]!; + return [ + Number.parseInt(hex.slice(0, 2), 16), + Number.parseInt(hex.slice(2, 4), 16), + Number.parseInt(hex.slice(4, 6), 16), + hex.length === 8 ? Number.parseInt(hex.slice(6), 16) : 255, + ]; +} + +function linearColorBytes(color: readonly number[]): readonly [number, number, number, number] { + if (color.length !== 4 || color.some((value) => !Number.isFinite(value) || value < 0 || value > 1)) { + throw new TypeError('linear RGBA colors must contain four finite channels in [0, 1]'); + } + const srgbByte = (value: number): number => { + const srgb = value <= 0.003_130_8 ? value * 12.92 : 1.055 * value ** (1 / 2.4) - 0.055; + return Math.round(srgb * 255); + }; + return [srgbByte(color[0]!), srgbByte(color[1]!), srgbByte(color[2]!), Math.round(color[3]! * 255)]; +} + +function releaseStackLeases(leases: readonly ThreeTextEngineStackLease[]): void { + for (const lease of leases) lease.release(); +} + +function releaseMaterialLeases(leases: readonly ThreeTextMaterialLease[]): void { + for (const lease of leases) lease.release(); +} + +function ownPublication(publication: TextEnginePublication): TextEnginePublication { + const bytes = publication.bytes.slice(); + return { ...publication, bytes, memoryBuffer: bytes.buffer, memoryGrew: false }; +} + /** * Replacement text carries its own formatting: a literal brings its spans and a * plain string brings none. Retaining the previous spans would reinterpret them * against unrelated text, so an update that replaces text without stating spans * clears the ones it replaced. */ -function replacedContent( - update: TextUpdate, -): TextUpdate { +function replacedContent(update: TextUpdate): TextUpdate { if (!('text' in update) || 'spans' in update) return update; - return { ...update, spans: [] } as TextUpdate; + return { ...update, spans: [] } as TextUpdate; +} + +function minimalTextMutation(previous: string, next: string): PendingTextMutation | undefined { + if (previous === next) return undefined; + const shared = Math.min(previous.length, next.length); + let start = 0; + while (start < shared) { + const previousCodePoint = previous.codePointAt(start)!; + if (previousCodePoint !== next.codePointAt(start)) break; + start += previousCodePoint > 0xffff ? 2 : 1; + } + let previousEnd = previous.length; + let nextEnd = next.length; + while (previousEnd > start && nextEnd > start) { + const previousStart = previousScalarStart(previous, previousEnd); + const nextStart = previousScalarStart(next, nextEnd); + if (previous.codePointAt(previousStart) !== next.codePointAt(nextStart)) break; + previousEnd = previousStart; + nextEnd = nextStart; + } + return { + start, + deleteCount: previousEnd - start, + insert: next.slice(start, nextEnd), + }; } -function normalizeDesired( - properties: TextProperties, -): DesiredTextState { +function previousScalarStart(value: string, end: number): number { + const last = end - 1; + const unit = value.charCodeAt(last); + const previous = value.charCodeAt(last - 1); + return unit >= 0xdc00 && unit <= 0xdfff && last > 0 && previous >= 0xd800 && previous <= 0xdbff ? last - 1 : last; +} + +function assertTextRange(text: string, start: number, end: number): void { + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || start > end || end > text.length) { + throw new RangeError('text edit range is outside the text'); + } + if (splitsSurrogatePair(text, start) || splitsSurrogatePair(text, end)) { + throw new RangeError('text edit range must not split a Unicode scalar'); + } +} + +function splitsSurrogatePair(text: string, offset: number): boolean { + if (offset <= 0 || offset >= text.length) return false; + const previous = text.charCodeAt(offset - 1); + const next = text.charCodeAt(offset); + return previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff; +} + +function editSpans( + spans: readonly TextSpan[], + start: number, + end: number, + insertLength: number, +): readonly TextSpan[] { + const delta = insertLength - (end - start); + return spans.flatMap((span) => { + if (span.end <= start) return [span]; + if (span.start >= end) return [{ ...span, start: span.start + delta, end: span.end + delta }]; + const retainsLeft = span.start < start; + const retainsRight = span.end > end; + if (retainsLeft && retainsRight) return [{ ...span, end: span.end + delta }]; + if (retainsLeft) return [{ ...span, end: start }]; + if (retainsRight) { + return [{ ...span, start: start + insertLength, end: span.end + delta }]; + } + return []; + }); +} + +function classifySemanticChanges(update: TextUpdate): number { + let changes = 0; + if (Object.hasOwn(update, 'text')) changes |= TEXT_CHANGE | STYLE_CHANGE | GEOMETRY_CHANGE; + if ( + Object.hasOwn(update, 'font') || + Object.hasOwn(update, 'spans') || + Object.hasOwn(update, 'style') || + Object.hasOwn(update, 'paint') || + Object.hasOwn(update, 'rasterPixelRatio') || + Object.hasOwn(update, 'material') + ) { + changes |= STYLE_CHANGE; + } + if (Object.hasOwn(update, 'contentBox')) changes |= GEOMETRY_CHANGE; + return changes; +} + +function normalizeDesired( + properties: TextProperties, +): DesiredTextState { if (properties === undefined) throw new TypeError('Text properties are required'); const formatted = typeof properties.text === 'string' ? undefined : (properties.text as FormattedText); return Object.freeze({ font: properties.font, text: formatted?.text ?? (properties.text as string), - spans: Object.freeze([ - ...((formatted?.spans as readonly TextSpan[]) ?? properties.spans ?? []), - ]), + spans: Object.freeze([...((formatted?.spans as readonly TextSpan[]) ?? properties.spans ?? [])]), contentBox: Object.freeze({ ...(properties.contentBox ?? {}) }), style: Object.freeze({ ...(properties.style ?? {}) }), paint: Object.freeze({ ...(properties.paint ?? {}) }), ...(properties.rasterPixelRatio === undefined ? {} : { rasterPixelRatio: properties.rasterPixelRatio }), - ...(properties.renderVariant === undefined ? {} : { renderVariant: properties.renderVariant }), + ...(properties.material === undefined ? {} : { material: properties.material }), }); } -function selectedFonts( - state: DesiredTextState, +function selectedFonts( + state: DesiredTextState, ): readonly LoadedFont[] { const fonts = new Set>(concreteFonts(state.font)); for (const span of state.spans) @@ -614,12 +1237,11 @@ function selectedFonts( function acquireFonts( fonts: readonly LoadedFont[], runtime: TextRuntime, - technique: Technique, ): void { const acquired: LoadedFont[] = []; try { for (const font of fonts) { - acquireFontSelection(font, runtime, technique); + acquireFontSelectionForRuntime(font, runtime); acquired.push(font); } } catch (error) { @@ -637,41 +1259,37 @@ function normalizeCapacity(value: GlyphBufferCapacity): GlyphBufferCapacity { throw new TypeError('glyph capacity policy is invalid'); return Object.freeze({ size: value.size, policy: value.policy }); } -function nearestTextGroup( - object: THREE.Object3D, -): TextGroup | undefined { +function normalizeCompositing(value: TextGroupOptions['compositing']): 'ordered' | 'independent' { + if (value === undefined || value === 'ordered') return 'ordered'; + if (value === 'independent') return value; + throw new TypeError('text group compositing mode is invalid'); +} +function nearestTextGroup(object: THREE.Object3D): TextGroup | undefined { let parent = object.parent; while (parent !== null) { - if (parent instanceof TextGroup) return parent as TextGroup; + if (parent instanceof TextGroup) return parent; parent = parent.parent; } return undefined; } -function collectTextDescendants( - group: TextGroup, -): Text[] { - const texts: Text[] = []; +function eraseTextTechnique(text: Text): Text { + return text as unknown as Text; +} +function collectTextDescendants(group: TextGroup): Text[] { + const texts: Text[] = []; for (const child of group.children) collect(child, texts); return texts; - function collect(object: THREE.Object3D, result: Text[]): void { + function collect(object: THREE.Object3D, result: Text[]): void { if (object instanceof TextGroup) return; - if (object instanceof Text) result.push(object as Text); + if (object instanceof Text && !object.disposed) result.push(object as Text); for (const child of object.children) collect(child, result); } } -function validateText( - group: TextGroup, - text: Text, -): void { - validateBinding(text.runtime, group.technique, text); +function validateText(text: Text): void { + validateBinding(text.runtime, text); } -function validateBinding( - runtime: TextRuntime, - technique: Technique, - text: Text, -): void { +function validateBinding(runtime: TextRuntime, text: Text): void { if (text.disposed) throw new TypeError('disposed text cannot be attached'); if (text.runtime !== runtime) throw new TypeError('text belongs to another Three font-cache domain'); - if (text.technique !== technique) throw new TypeError('text uses another raster technique'); - assertFontSelection(text.font, text.runtime, text.technique); + assertFontSelectionForRuntime(text.font, text.runtime); } diff --git a/packages/text/src/typegpu.ts b/packages/text/src/typegpu.ts deleted file mode 100644 index 6f2ae7d2..00000000 --- a/packages/text/src/typegpu.ts +++ /dev/null @@ -1,643 +0,0 @@ -import type { TgpuRoot } from 'typegpu'; - -import type { FormattedText, GlyphPaintInput, ParagraphSpan, TextInput } from './formatted-text.js'; -import type { ParagraphLayout } from './layout.js'; -import type { - GlyphBufferCapacity, - GlyphOriginUpdate, - GlyphSnapshot, - Paragraph, - ParagraphBatch, - ParagraphContentBox, - ParagraphId, - ParagraphProperties, - ParagraphSnapshot, - ParagraphUpdate, - TextPreparationError, -} from './paragraph-batch.js'; -import type { ParagraphStyle } from './paragraph.js'; -import type { - ParagraphBatchAttachment, - ParagraphBatchTarget, - ParagraphBatchTargetError, - ParagraphBatchTargetRevision, -} from './paragraph-batch-attachment.js'; -import type { AnyRasterTechnique } from './raster-technique.js'; -import { - createTextRuntime, - type AsyncTextUpdateOptions, - type LoadedFontRequest, - type TextRuntime, - type TextRuntimeOptions, - type TextRuntimeRevision, - type TextUpdateCallback, - type TextUpdateOutcome, -} from './text-runtime.js'; -import type { FontSelection, LoadedFont } from './loaded-font.js'; - -export interface TypeGpuFrame { - readonly viewProjection: Float32Array; - readonly viewport: readonly [width: number, height: number]; - readonly pixelRatio: number; -} - -export interface TypeGpuParagraphState { - readonly transform: Float32Array; - readonly visible: boolean; -} - -export interface TypeGpuParagraphBatchTargetRevision extends ParagraphBatchTargetRevision { - readonly draws: readonly Draw[]; -} - -/** - * Program-owned rendering target. Core supplies revisions; the program owns the exact TypeGPU - * buffers, bind groups, pipelines, resources, partial writes, and draw encoding they require. - */ -export interface TypeGpuParagraphBatchTarget< - Technique extends AnyRasterTechnique, - Variant, - Draw, - Revision extends TypeGpuParagraphBatchTargetRevision = TypeGpuParagraphBatchTargetRevision, -> extends ParagraphBatchTarget { - readonly root: TgpuRoot; - setParagraphState(paragraph: ParagraphId, state: TypeGpuParagraphState | undefined): void; - encode(pass: GPURenderPassEncoder, revision: Revision, frame: TypeGpuFrame): void; -} - -export interface TypeGpuTargetOptions { - readonly root: TgpuRoot; - readonly technique: Technique; - readonly colorFormat: GPUTextureFormat; - readonly depthStencil?: GPUDepthStencilState; - readonly sampleCount: number; -} - -declare const typeGpuRasterProgramTypes: unique symbol; - -interface TypeGpuRasterProgramTypeMap { - readonly variant: Variant; - readonly draw: Draw; - readonly revision: Revision; -} - -export interface AnyTypeGpuRasterProgram { - readonly technique: Technique; - readonly [typeGpuRasterProgramTypes]?: TypeGpuRasterProgramTypeMap< - unknown, - unknown, - TypeGpuParagraphBatchTargetRevision - >; - dispose(): void; -} - -export interface TypeGpuRasterProgram< - Technique extends AnyRasterTechnique, - Variant, - Draw, - Revision extends TypeGpuParagraphBatchTargetRevision, -> extends AnyTypeGpuRasterProgram { - readonly [typeGpuRasterProgramTypes]?: TypeGpuRasterProgramTypeMap; - createTarget( - options: TypeGpuTargetOptions, - ): TypeGpuParagraphBatchTarget; -} - -export type TypeGpuProgramTypesOf> = NonNullable< - Program[typeof typeGpuRasterProgramTypes] ->; -export type TypeGpuVariantOf> = - TypeGpuProgramTypesOf['variant']; -export type TypeGpuDrawOf> = - TypeGpuProgramTypesOf['draw']; -export type TypeGpuRevisionOf> = - TypeGpuProgramTypesOf['revision']; - -export function defineTypeGpuRasterProgram< - Technique extends AnyRasterTechnique, - Variant, - Draw, - Revision extends TypeGpuParagraphBatchTargetRevision, ->( - program: TypeGpuRasterProgram, -): TypeGpuRasterProgram { - return program; -} - -export interface TypeGpuRasterShader { - readonly technique: Technique; - readonly vertex: Vertex; - readonly fragment: Fragment; - readonly resources: Resources; -} - -export function defineTypeGpuRasterShader< - Technique extends AnyRasterTechnique, - Vertex, - Fragment, - Resources, - const Shader extends TypeGpuRasterShader, ->(shader: Shader): Shader { - return shader; -} - -export interface TypeGpuTextEngineOptions { - readonly root: TgpuRoot; - readonly colorFormat: GPUTextureFormat; - readonly depthStencil?: GPUDepthStencilState; - readonly sampleCount?: number; - readonly runtime?: TextRuntimeOptions; -} - -export interface TypeGpuParagraphBatchOptions< - Technique extends AnyRasterTechnique, - Program extends AnyTypeGpuRasterProgram, -> { - readonly technique: Technique; - readonly program: Program; - readonly capacity?: GlyphBufferCapacity; - readonly rasterPixelRatio?: number; - readonly renderVariant?: TypeGpuVariantOf; -} - -export type TypeGpuParagraphProperties = ParagraphProperties< - Technique, - Variant -> & - Readonly<{ transform?: ArrayLike; visible?: boolean }>; - -export type TypeGpuParagraphUpdate = ParagraphUpdate< - Technique, - Variant -> & - Readonly<{ transform?: ArrayLike; visible?: boolean }>; - -export interface TypeGpuParagraphSnapshot extends ParagraphSnapshot< - Technique, - Variant -> { - readonly transform: Float32Array; - readonly visible: boolean; -} - -export interface TypeGpuParagraph { - readonly id: ParagraphId; - readonly disposed: boolean; - readonly layout: ParagraphLayout | undefined; - font: FontSelection; - text: TextInput; - spans: readonly ParagraphSpan[]; - contentBox: ParagraphContentBox; - style: ParagraphStyle; - paint: GlyphPaintInput; - rasterPixelRatio: number; - order: number; - renderVariant: Variant | undefined; - visible: boolean; - set(properties: TypeGpuParagraphUpdate): void; - setSpan(index: number, span: ParagraphSpan): void; - removeSpan(index: number): void; - setTransform(columnMajorMatrix4: ArrayLike): void; - snapshotGlyphs(): GlyphSnapshot; - setGlyphOrigins(update: GlyphOriginUpdate): void; - clearGlyphOriginOverrides(): void; - snapshotProperties(): TypeGpuParagraphSnapshot; - dispose(): void; -} - -export interface TypeGpuParagraphBatch< - Technique extends AnyRasterTechnique, - Variant, - Program extends AnyTypeGpuRasterProgram, -> { - readonly technique: Technique; - readonly program: Program; - readonly current: TypeGpuRevisionOf | undefined; - readonly error: TextPreparationError | ParagraphBatchTargetError | undefined; - readonly disposed: boolean; - rasterPixelRatio: number; - renderVariant: Variant | undefined; - add(properties: TypeGpuParagraphProperties): TypeGpuParagraph; - has(paragraph: TypeGpuParagraph): boolean; - setCapacity(capacity: GlyphBufferCapacity): void; - retry(): void; - encode(pass: GPURenderPassEncoder, frame: TypeGpuFrame): void; - dispose(): void; -} - -export interface TypeGpuTextEngine { - readonly root: TgpuRoot; - readonly current: TextRuntimeRevision; - readonly disposed: boolean; - loadFont( - request: LoadedFontRequest, - options?: { readonly signal?: AbortSignal }, - ): Promise>; - createParagraphBatch< - Technique extends AnyRasterTechnique, - Variant, - Draw, - Revision extends TypeGpuParagraphBatchTargetRevision, - Program extends TypeGpuRasterProgram, - >( - options: TypeGpuParagraphBatchOptions, - ): TypeGpuParagraphBatch, Program>; - update(): TextRuntimeRevision; - updateAsync(options?: AsyncTextUpdateOptions): Promise; - updateAsync(callback: TextUpdateCallback): void; - updateAsync(options: AsyncTextUpdateOptions, callback: TextUpdateCallback): void; - dispose(): void; -} - -export async function createTypeGpuTextEngine(options: TypeGpuTextEngineOptions): Promise { - const sampleCount = options.sampleCount ?? 1; - if (!Number.isSafeInteger(sampleCount) || sampleCount < 1) - throw new RangeError('sampleCount must be a positive integer'); - const runtime = await createTextRuntime(options.runtime); - return new TypeGpuTextEngineImpl(runtime, options.root, options.colorFormat, options.depthStencil, sampleCount); -} - -class TypeGpuTextEngineImpl implements TypeGpuTextEngine { - readonly root: TgpuRoot; - readonly #runtime: TextRuntime; - readonly #colorFormat: GPUTextureFormat; - readonly #depthStencil: GPUDepthStencilState | undefined; - readonly #sampleCount: number; - readonly #batches = new Set< - TypeGpuParagraphBatchImpl> - >(); - #disposed = false; - - constructor( - runtime: TextRuntime, - root: TgpuRoot, - colorFormat: GPUTextureFormat, - depthStencil: GPUDepthStencilState | undefined, - sampleCount: number, - ) { - this.#runtime = runtime; - this.root = root; - this.#colorFormat = colorFormat; - this.#depthStencil = depthStencil; - this.#sampleCount = sampleCount; - } - - get current(): TextRuntimeRevision { - return this.#runtime.current; - } - get disposed(): boolean { - return this.#disposed; - } - loadFont( - request: LoadedFontRequest, - options?: { readonly signal?: AbortSignal }, - ): Promise> { - this.#assertActive(); - return this.#runtime.loadFont(request, options); - } - createParagraphBatch< - Technique extends AnyRasterTechnique, - Variant, - Draw, - Revision extends TypeGpuParagraphBatchTargetRevision, - Program extends TypeGpuRasterProgram, - >( - options: TypeGpuParagraphBatchOptions, - ): TypeGpuParagraphBatch, Program> { - this.#assertActive(); - if (options.program.technique !== options.technique) - throw new TypeError('TypeGPU program uses another raster technique'); - const batch = new TypeGpuParagraphBatchImpl, Program>( - this.#runtime, - this.root, - this.#colorFormat, - this.#depthStencil, - this.#sampleCount, - options, - (value) => this.#batches.delete(value), - ); - this.#batches.add( - batch as TypeGpuParagraphBatchImpl>, - ); - return batch; - } - update(): TextRuntimeRevision { - this.#assertActive(); - return this.#runtime.update(); - } - updateAsync(options?: AsyncTextUpdateOptions): Promise; - updateAsync(callback: TextUpdateCallback): void; - updateAsync(options: AsyncTextUpdateOptions, callback: TextUpdateCallback): void; - updateAsync( - options?: AsyncTextUpdateOptions | TextUpdateCallback, - callback?: TextUpdateCallback, - ): Promise | void { - this.#assertActive(); - if (typeof options === 'function') return this.#runtime.updateAsync(options); - if (callback !== undefined) return this.#runtime.updateAsync(options ?? {}, callback); - return this.#runtime.updateAsync(options); - } - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - for (const batch of [...this.#batches]) batch.dispose(); - this.#runtime.dispose(); - } - #assertActive(): void { - if (this.#disposed) throw new Error('TypeGPU text engine has been disposed'); - } -} - -class TypeGpuParagraphBatchImpl< - Technique extends AnyRasterTechnique, - Variant, - Program extends AnyTypeGpuRasterProgram, -> implements TypeGpuParagraphBatch { - readonly technique: Technique; - readonly program: Program; - readonly #batch: ParagraphBatch; - readonly #target: TypeGpuParagraphBatchTarget, TypeGpuRevisionOf>; - readonly #attachment: ParagraphBatchAttachment>; - readonly #paragraphs = new Map>(); - readonly #onDispose: ( - batch: TypeGpuParagraphBatchImpl>, - ) => void; - #disposed = false; - - constructor( - runtime: TextRuntime, - root: TgpuRoot, - colorFormat: GPUTextureFormat, - depthStencil: GPUDepthStencilState | undefined, - sampleCount: number, - options: TypeGpuParagraphBatchOptions, - onDispose: ( - batch: TypeGpuParagraphBatchImpl>, - ) => void, - ) { - this.technique = options.technique; - this.program = options.program; - this.#onDispose = onDispose; - this.#batch = runtime.createParagraphBatch({ - technique: options.technique, - ...(options.capacity === undefined ? {} : { capacity: options.capacity }), - ...(options.rasterPixelRatio === undefined ? {} : { rasterPixelRatio: options.rasterPixelRatio }), - ...(options.renderVariant === undefined ? {} : { renderVariant: options.renderVariant as Variant }), - }); - const program = options.program as unknown as TypeGpuRasterProgram< - Technique, - Variant, - TypeGpuDrawOf, - TypeGpuRevisionOf - >; - this.#target = program.createTarget({ - root, - technique: options.technique, - colorFormat, - ...(depthStencil === undefined ? {} : { depthStencil }), - sampleCount, - }); - this.#attachment = this.#batch.attach(this.#target); - } - - get current(): TypeGpuRevisionOf | undefined { - return this.#attachment.current; - } - get error(): TextPreparationError | ParagraphBatchTargetError | undefined { - return this.#batch.preparationError ?? this.#attachment.error; - } - get disposed(): boolean { - return this.#disposed; - } - get rasterPixelRatio(): number { - return this.#batch.rasterPixelRatio; - } - set rasterPixelRatio(value: number) { - this.#assertActive(); - this.#batch.rasterPixelRatio = value; - } - get renderVariant(): Variant | undefined { - return this.#batch.renderVariant; - } - set renderVariant(value: Variant | undefined) { - this.#assertActive(); - this.#batch.renderVariant = value; - } - add(properties: TypeGpuParagraphProperties): TypeGpuParagraph { - this.#assertActive(); - const { transform, visible, ...coreProperties } = properties; - const paragraph = this.#batch.add(coreProperties as ParagraphProperties); - const value = new TypeGpuParagraphImpl(paragraph, transform, visible, this.#target, () => { - this.#paragraphs.delete(paragraph.id); - }); - this.#paragraphs.set(paragraph.id, value); - return value; - } - has(paragraph: TypeGpuParagraph): boolean { - return this.#paragraphs.get(paragraph.id) === paragraph && !paragraph.disposed; - } - setCapacity(capacity: GlyphBufferCapacity): void { - this.#assertActive(); - this.#batch.setCapacity(capacity); - } - retry(): void { - this.#assertActive(); - this.#attachment.retry(); - } - encode(pass: GPURenderPassEncoder, frame: TypeGpuFrame): void { - this.#assertActive(); - assertFrame(frame); - this.#attachment.prepare(); - const revision = this.#attachment.commit(); - if (revision !== undefined) this.#target.encode(pass, revision, frame); - } - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - for (const paragraph of [...this.#paragraphs.values()]) paragraph.dispose(); - this.#attachment.dispose(); - this.#batch.dispose(); - this.#onDispose( - this as TypeGpuParagraphBatchImpl>, - ); - } - #assertActive(): void { - if (this.#disposed) throw new Error('TypeGPU paragraph batch has been disposed'); - } -} - -const IDENTITY_MATRIX = Object.freeze([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]); - -class TypeGpuParagraphImpl implements TypeGpuParagraph< - Technique, - Variant -> { - readonly #paragraph: Paragraph; - readonly #target: Pick, 'setParagraphState'>; - readonly #onDispose: () => void; - #transform: Float32Array; - #visible: boolean; - #disposed = false; - - constructor( - paragraph: Paragraph, - transform: ArrayLike | undefined, - visible: boolean | undefined, - target: Pick, 'setParagraphState'>, - onDispose: () => void, - ) { - this.#paragraph = paragraph; - this.#target = target; - this.#onDispose = onDispose; - this.#transform = copyTransform(transform ?? IDENTITY_MATRIX); - this.#visible = visible ?? true; - this.#publishTargetState(); - } - get id(): ParagraphId { - return this.#paragraph.id; - } - get disposed(): boolean { - return this.#disposed; - } - get layout(): ParagraphLayout | undefined { - return this.#paragraph.committed?.layout; - } - get font(): FontSelection { - return this.#paragraph.font; - } - set font(value: FontSelection) { - this.#paragraph.font = value; - } - get text(): string { - return this.#paragraph.snapshotProperties().text; - } - set text(value: TextInput) { - this.#paragraph.text = value; - } - get spans(): readonly ParagraphSpan[] { - return this.#paragraph.spans; - } - set spans(value: readonly ParagraphSpan[]) { - this.#paragraph.spans = value; - } - get contentBox(): ParagraphContentBox { - return this.#paragraph.contentBox; - } - set contentBox(value: ParagraphContentBox) { - this.#paragraph.contentBox = value; - } - get style(): ParagraphStyle { - return this.#paragraph.style; - } - set style(value: ParagraphStyle) { - this.#paragraph.style = value; - } - get paint(): GlyphPaintInput { - return this.#paragraph.paint; - } - set paint(value: GlyphPaintInput) { - this.#paragraph.paint = value; - } - get rasterPixelRatio(): number { - return this.#paragraph.rasterPixelRatio; - } - set rasterPixelRatio(value: number) { - this.#paragraph.rasterPixelRatio = value; - } - get order(): number { - return this.#paragraph.order; - } - set order(value: number) { - this.#paragraph.order = value; - } - get renderVariant(): Variant | undefined { - return this.#paragraph.renderVariant; - } - set renderVariant(value: Variant | undefined) { - this.#paragraph.renderVariant = value; - } - get visible(): boolean { - return this.#visible; - } - set visible(value: boolean) { - this.#assertActive(); - if (this.#visible === value) return; - this.#visible = value; - this.#publishTargetState(); - } - set(properties: TypeGpuParagraphUpdate): void { - this.#assertActive(); - const { transform, visible, ...coreProperties } = properties; - this.#paragraph.set(coreProperties as ParagraphUpdate); - if (transform !== undefined) this.#transform = copyTransform(transform); - if (visible !== undefined) this.#visible = visible; - if (transform !== undefined || visible !== undefined) this.#publishTargetState(); - } - setSpan(index: number, span: ParagraphSpan): void { - this.#paragraph.setSpan(index, span); - } - removeSpan(index: number): void { - this.#paragraph.removeSpan(index); - } - setTransform(columnMajorMatrix4: ArrayLike): void { - this.#assertActive(); - this.#transform = copyTransform(columnMajorMatrix4); - this.#publishTargetState(); - } - snapshotGlyphs(): GlyphSnapshot { - return this.#paragraph.snapshotGlyphs(); - } - setGlyphOrigins(update: GlyphOriginUpdate): void { - this.#paragraph.setGlyphOrigins(update); - } - clearGlyphOriginOverrides(): void { - this.#paragraph.clearGlyphOriginOverrides(); - } - snapshotProperties(): TypeGpuParagraphSnapshot { - return Object.freeze({ - ...this.#paragraph.snapshotProperties(), - transform: this.#transform.slice(), - visible: this.#visible, - }); - } - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - this.#target.setParagraphState(this.id, undefined); - this.#paragraph.dispose(); - this.#onDispose(); - } - #publishTargetState(): void { - this.#target.setParagraphState( - this.id, - Object.freeze({ transform: this.#transform.slice(), visible: this.#visible }), - ); - } - #assertActive(): void { - if (this.#disposed) throw new Error('TypeGPU paragraph has been disposed'); - } -} - -function copyTransform(value: ArrayLike): Float32Array { - if (value.length !== 16) throw new TypeError('paragraph transform must contain exactly 16 values'); - const result = Float32Array.from(value); - for (const component of result) - if (!Number.isFinite(component)) throw new TypeError('paragraph transform must be finite'); - return result; -} - -function assertFrame(frame: TypeGpuFrame): void { - copyTransform(frame.viewProjection); - if ( - frame.viewport.length !== 2 || - !Number.isFinite(frame.viewport[0]) || - frame.viewport[0] <= 0 || - !Number.isFinite(frame.viewport[1]) || - frame.viewport[1] <= 0 - ) - throw new TypeError('TypeGPU frame viewport must contain two positive finite values'); - if (!Number.isFinite(frame.pixelRatio) || frame.pixelRatio <= 0) - throw new TypeError('TypeGPU frame pixelRatio must be positive and finite'); -} - -export type { FormattedText }; diff --git a/packages/text/tests/fuzz/engine-wire-fuzz-smoke.test.mjs b/packages/text/tests/fuzz/engine-wire-fuzz-smoke.test.mjs new file mode 100644 index 00000000..1d9e9aa7 --- /dev/null +++ b/packages/text/tests/fuzz/engine-wire-fuzz-smoke.test.mjs @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import { copyIntoAllocation, engineUpdateBytes, renderPolicyBytes } from '../support/engine-abi.mjs'; + +const wasmUrl = new URL('../../dist/text_shaper.wasm', import.meta.url); +const abiUrl = new URL('../../dist/text-shaper-abi-v0.json', import.meta.url); +const mutationCount = 64; + +test('fixed-seed policy and frame mutations fail safely, deterministically, and recoverably', async () => { + const [wasm, abi] = await Promise.all([readFile(wasmUrl), readFile(abiUrl, 'utf8').then(JSON.parse)]); + const module = await WebAssembly.compile(wasm); + const policyCases = mutations(renderPolicyBytes(abi), 0x504f_4c59); + const frameCases = mutations(frameBytes(abi, 1), 0x4652_414d); + const first = await execute(module, abi, policyCases, frameCases); + const second = await execute(module, abi, policyCases, frameCases); + + assert.deepEqual(second, first); + assert.ok( + first.some(({ policyStatus }) => policyStatus === abi.status.ok), + 'mutations must retain valid policy paths', + ); + assert.ok( + first.some(({ policyStatus }) => policyStatus !== abi.status.ok), + 'mutations must retain malformed policy paths', + ); + assert.ok( + first.some(({ frameStatus }) => frameStatus === abi.status.ok), + 'mutations must retain valid frame paths', + ); + assert.ok( + first.some(({ frameStatus }) => frameStatus !== abi.status.ok), + 'mutations must retain malformed frame paths', + ); +}); + +async function execute(module, abi, policyCases, frameCases) { + const statuses = new Set(Object.values(abi.status)); + const outcomes = []; + for (let index = 0; index < mutationCount; index += 1) { + const instance = await WebAssembly.instantiate(module, {}); + const memory = instance.exports[abi.memory]; + const fn = Object.fromEntries( + Object.entries(abi.functions).map(([name, exported]) => [name, instance.exports[exported]]), + ); + assert.equal(fn.initialize(), abi.status.ok); + + const validPolicy = renderPolicyBytes(abi); + const validPolicyPointer = copyIntoAllocation(memory, fn.allocate, validPolicy); + assert.equal(fn.registerPolicy(1, validPolicyPointer, validPolicy.byteLength), abi.status.ok); + fn.deallocate(validPolicyPointer, validPolicy.byteLength); + + const policyCase = policyCases[index]; + const policyPointer = copyIntoAllocation(memory, fn.allocate, policyCase.bytes); + const policyStatus = fn.registerPolicy(2, policyPointer, policyCase.length); + fn.deallocate(policyPointer, policyCase.bytes.byteLength); + assert.ok(statuses.has(policyStatus)); + + const frameCase = frameCases[index]; + const requestCapacity = Math.max(abi.layouts.engineUpdateRequest.size, frameCase.bytes.byteLength); + assert.equal(fn.createSession(1, requestCapacity, abi.layouts.engineResult.size, 0), abi.status.ok); + const requestPointer = fn.requestPointer(1); + new Uint8Array(memory.buffer, requestPointer, frameCase.bytes.byteLength).set(frameCase.bytes); + const resultPointer = fn.textUpdate(1, requestPointer, frameCase.length); + const frameStatus = new DataView(memory.buffer).getUint32(resultPointer + abi.layouts.engineResult.status, true); + assert.ok(statuses.has(frameStatus)); + assert.equal(fn.disposeSession(1), abi.status.ok); + + const recovery = frameBytes(abi, 3); + assert.equal(fn.createSession(3, recovery.byteLength, abi.layouts.engineResult.size, 0), abi.status.ok); + const recoveryPointer = fn.requestPointer(3); + new Uint8Array(memory.buffer, recoveryPointer, recovery.byteLength).set(recovery); + const recoveredResult = fn.textUpdate(3, recoveryPointer, recovery.byteLength); + const recoveryStatus = new DataView(memory.buffer).getUint32( + recoveredResult + abi.layouts.engineResult.status, + true, + ); + assert.equal(recoveryStatus, abi.status.ok, `mutation ${index} poisoned the next valid transaction`); + assert.equal(fn.disposeSession(3), abi.status.ok); + outcomes.push({ policyStatus, frameStatus }); + } + return outcomes; +} + +function frameBytes(abi, sessionId) { + return engineUpdateBytes(abi, { + sessionId, + policyHandle: 1, + expectedEngineRevision: 0, + consumedPlanRevision: 0, + }); +} + +function mutations(base, seed) { + const result = [{ bytes: base.slice(), length: base.byteLength }]; + let state = seed >>> 0; + while (result.length < mutationCount) { + const bytes = base.slice(); + state = next(state); + const offset = state % bytes.byteLength; + state = next(state); + bytes[offset] ^= state & 0xff || 1; + state = next(state); + const length = state % 4 === 0 ? state % (bytes.byteLength + 1) : bytes.byteLength; + result.push({ bytes, length }); + } + return result; +} + +function next(value) { + return (Math.imul(value, 1_664_525) + 1_013_904_223) >>> 0; +} diff --git a/packages/text/tests/fuzz/paragraph-policy.test.mjs b/packages/text/tests/fuzz/paragraph-policy.test.mjs deleted file mode 100644 index ec37a953..00000000 --- a/packages/text/tests/fuzz/paragraph-policy.test.mjs +++ /dev/null @@ -1,280 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import test from 'node:test'; - -import { createParagraphEngine, createRuntimeShaper, FontRegistry } from '@pmndrs/text'; -import { createFontBaker } from '@pmndrs/text-font-baker'; - -test('fixed-seed paragraph policy mutations stay safe, finite, and deterministic', async () => { - const { font, shaper } = await runtime( - new URL('../../../../apps/benchmarks/fixtures/fonts/amiri-1.002/Amiri-Regular.ttf', import.meta.url), - ); - const paragraph = createParagraphEngine({ shaper }).create({ text: '', font: font.handle }); - const cases = fuzzCases(0x5e_ed_53_03, 64); - const first = []; - const second = []; - - for (const results of [first, second]) { - for (const entry of cases) { - if (!entry.text.isWellFormed()) { - assert.throws( - () => paragraph.update({ text: entry.text, font: font.handle, style: entry.style }), - /well-formed UTF-16/, - ); - results.push('invalid-utf16'); - continue; - } - paragraph.update({ - text: entry.text, - font: font.handle, - style: entry.style, - }); - const measured = paragraph.measure(entry.constraints); - assert.equal(paragraph.measure(entry.constraints), measured); - assertMeasurement(measured); - const layout = paragraph.layout(entry.constraints); - assert.equal(paragraph.layout(entry.constraints), layout); - assertMeasurement(layout); - assertLayoutBoundaries(entry.text, layout); - results.push(hashLayout(layout)); - } - } - assert.deepEqual(second, first); - paragraph.dispose(); - shaper.dispose(); - font.dispose(); -}); - -test('fixed-seed CJK boundary mutations stay safe, finite, and deterministic', async () => { - const { font, shaper } = await runtime( - new URL( - '../../../../apps/benchmarks/fixtures/fonts/noto-sans-cjk-2.004/NotoSansCJKjp-Regular.otf', - import.meta.url, - ), - ); - const paragraph = createParagraphEngine({ shaper }).create({ text: '', font: font.handle }); - const cases = cjkFuzzCases(0x5e_ed_54_04, 32); - assert.deepEqual(new Set(cases.map(({ style }) => style.language)), new Set(['zh-hans', 'zh-hant', 'ja', 'ko'])); - assert.deepEqual( - new Set(cases.map(({ constraints }) => constraints.width.mode)), - new Set(['unconstrained', 'at-most', 'exactly']), - ); - assert.deepEqual( - new Set(cases.map(({ constraints }) => constraints.height.mode)), - new Set(['unconstrained', 'at-most', 'exactly']), - ); - const first = []; - const second = []; - - for (const results of [first, second]) { - for (const entry of cases) { - paragraph.update({ text: entry.text, font: font.handle, style: entry.style }); - const measured = paragraph.measure(entry.constraints); - const layout = paragraph.layout(entry.constraints); - assertMeasurement(measured); - assertMeasurement(layout); - assertLayoutBoundaries(entry.text, layout); - results.push(hashLayout(layout)); - } - } - assert.deepEqual(second, first); - - for (const text of ['漢\ud800字', '漢\udc00字', '禰\udb40', '禰\udd00', '禰\udd00\udb40']) { - assert.throws(() => paragraph.update({ text, font: font.handle, style: { language: 'ja' } }), /well-formed UTF-16/); - } - for (const text of ['\u{FE00}', '\u{E0100}', '漢\u{FE00}\u{FE01}', '禰\u{E0100}\u{E0101}']) { - paragraph.update({ text, font: font.handle, style: { language: 'ja' } }); - const firstLayout = paragraph.layout({ width: { mode: 'exactly', size: 1 }, wrap: 'word' }); - assertLayoutBoundaries(text, firstLayout); - assert.equal(paragraph.layout({ width: { mode: 'exactly', size: 1 }, wrap: 'word' }), firstLayout); - } - for (const language of ['ja--jp', 'ja\0jp', 'ja_jp', '123']) { - assert.throws( - () => paragraph.update({ text: '漢字', font: font.handle, style: { language } }), - /invalid batch request/, - ); - } - paragraph.update({ text: '漢字', font: font.handle, style: { language: 'ja' } }); - for (const constraints of [ - { width: { mode: 'at-most', size: -1 } }, - { width: { mode: 'exactly', size: Number.NaN } }, - { width: { mode: 'exactly', size: Number.POSITIVE_INFINITY } }, - { height: { mode: 'at-most', size: -1 } }, - { maxLines: 0 }, - { maxLines: 1.5 }, - ]) { - assert.throws(() => paragraph.layout(constraints), RangeError); - } - - paragraph.dispose(); - shaper.dispose(); - font.dispose(); -}); - -function fuzzCases(seed, count) { - const random = xorshift(seed); - const tokens = ['abc', 'مرحبا', '123', ' ', '\n', 'لا', 'e\u0301', '\ud800', '…', '(אב)']; - const wraps = ['none', 'word', 'character']; - const aligns = ['start', 'center', 'end', 'justify']; - const overflows = ['visible', 'clip', 'ellipsis']; - const directions = ['auto', 'ltr', 'rtl']; - return Array.from({ length: count }, () => { - let text = ''; - const tokenCount = 1 + integer(random, 10); - for (let index = 0; index < tokenCount; index += 1) text += tokens[integer(random, tokens.length)]; - const widthMode = integer(random, 3); - const heightMode = integer(random, 3); - return { - text, - style: { - fontSize: 8 + integer(random, 65), - lineHeight: 0.75 + integer(random, 200) / 100, - letterSpacing: (integer(random, 81) - 40) / 10, - direction: directions[integer(random, directions.length)], - language: integer(random, 2) === 0 ? 'ar' : 'en', - }, - constraints: { - width: axis(widthMode, random), - height: axis(heightMode, random), - ...(integer(random, 3) === 0 ? { maxLines: 1 + integer(random, 5) } : {}), - wrap: wraps[integer(random, wraps.length)], - align: aligns[integer(random, aligns.length)], - overflow: overflows[integer(random, overflows.length)], - }, - }; - }); -} - -function cjkFuzzCases(seed, count) { - const random = xorshift(seed); - const tokens = [ - '漢字', - 'かなカナ', - '한글', - '한글', - '、。「」()', - ' ', - '\u{2000B}', - '、\u{FE00}。\u{FE01}', - '禰\u{E0100}', - ]; - const languages = ['zh-hans', 'zh-hant', 'ja', 'ko']; - const wraps = ['none', 'word', 'character']; - return Array.from({ length: count }, () => { - const shuffled = [...tokens]; - for (let index = shuffled.length - 1; index > 0; index -= 1) { - const selected = integer(random, index + 1); - const value = shuffled[index]; - shuffled[index] = shuffled[selected]; - shuffled[selected] = value; - } - const widthMode = integer(random, 3); - const heightMode = integer(random, 3); - return { - text: shuffled.join(''), - style: { - fontSize: 10 + integer(random, 55), - lineHeight: 0.75 + integer(random, 200) / 100, - letterSpacing: (integer(random, 41) - 20) / 10, - language: languages[integer(random, languages.length)], - }, - constraints: { - width: axis(widthMode, random), - height: axis(heightMode, random), - ...(integer(random, 3) === 0 ? { maxLines: 1 + integer(random, 5) } : {}), - wrap: wraps[integer(random, wraps.length)], - }, - }; - }); -} - -function axis(mode, random) { - if (mode === 0) return { mode: 'unconstrained' }; - return { mode: mode === 1 ? 'at-most' : 'exactly', size: integer(random, 321) }; -} - -function xorshift(seed) { - let state = seed >>> 0; - return () => { - state ^= state << 13; - state ^= state >>> 17; - state ^= state << 5; - return state >>> 0; - }; -} - -function integer(random, limit) { - return random() % limit; -} - -function assertMeasurement(value) { - for (const field of ['width', 'height', 'contentWidth', 'contentHeight', 'firstBaseline', 'lastBaseline']) { - assert.equal(Number.isFinite(value[field]), true, field); - assert.ok(value[field] >= 0, field); - } - assert.equal(typeof value.overflowed, 'boolean'); -} - -function assertLayoutBoundaries(text, layout) { - assert.equal(layout.glyphIds.length, layout.clusters.length); - assert.equal(layout.glyphIds.length, layout.x.length); - assert.equal(layout.glyphIds.length, layout.y.length); - assert.equal(layout.glyphIds.length, layout.glyphFlags.length); - assert.equal(layout.lineTextStarts.length, layout.lineTextEnds.length); - assert.equal(layout.lineTextStarts.length, layout.lineGlyphStarts.length); - assert.equal(layout.lineTextStarts.length, layout.lineGlyphCounts.length); - assert.equal(layout.lineTextStarts.length, layout.lineBaselines.length); - assert.equal(layout.lineTextStarts.length, layout.lineAdvances.length); - for (const offset of [...layout.clusters, ...layout.lineTextStarts, ...layout.lineTextEnds]) { - assert.ok(offset <= text.length); - assert.equal(isUtf16Boundary(text, offset), true, `UTF-16 boundary ${offset}`); - } - for (const value of [...layout.x, ...layout.y, ...layout.lineAdvances]) { - assert.equal(Number.isFinite(value), true); - } -} - -function isUtf16Boundary(text, offset) { - if (offset === 0 || offset === text.length) return true; - const previous = text.charCodeAt(offset - 1); - const current = text.charCodeAt(offset); - return !(previous >= 0xd800 && previous <= 0xdbff && current >= 0xdc00 && current <= 0xdfff); -} - -function hashLayout(layout) { - let hash = 2_166_136_261; - for (const values of [ - layout.glyphIds, - layout.clusters, - layout.x, - layout.y, - layout.glyphFlags, - layout.lineTextStarts, - layout.lineTextEnds, - layout.lineGlyphStarts, - layout.lineGlyphCounts, - layout.lineBaselines, - layout.lineAdvances, - ]) { - const bytes = new Uint8Array(values.buffer, values.byteOffset, values.byteLength); - for (const value of bytes) hash = Math.imul(hash ^ value, 16_777_619); - } - return hash >>> 0; -} - -async function runtime(sourceUrl) { - const [source, bakerWasm, shaperWasm] = await Promise.all([ - readFile(sourceUrl), - readFile(new URL('../../../font-baker/dist/font_baker.wasm', import.meta.url)), - readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), - ]); - const baker = await createFontBaker(bakerWasm); - const artifact = baker.bake({ - source, - descriptor: { formatVersion: 0, fontFaceIndex: 0 }, - }).artifacts[0].bytes; - const registry = new FontRegistry(); - const font = await registry.registerAsset(artifact); - const shaper = await createRuntimeShaper({ registry, wasm: shaperWasm }); - return { font, shaper }; -} diff --git a/packages/text/tests/fuzz/shaper-request.test.mjs b/packages/text/tests/fuzz/shaper-request.test.mjs deleted file mode 100644 index 7ed6baf8..00000000 --- a/packages/text/tests/fuzz/shaper-request.test.mjs +++ /dev/null @@ -1,184 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import test from 'node:test'; - -import { createFontBaker } from '../../../font-baker/dist/index.js'; -import { validateFontArtifact } from '../../../font-baker/dist/validator.js'; - -const wasmUrl = new URL('../../dist/text_shaper.wasm', import.meta.url); -const abiUrl = new URL('../../dist/text-shaper-abi-v0.json', import.meta.url); - -test('fixed-seed shaper request mutations fail safely and deterministically', async () => { - const [wasm, abi, source, bakerWasm] = await Promise.all([ - readFile(wasmUrl), - readFile(abiUrl, 'utf8').then(JSON.parse), - readFile(new URL('../../../../apps/benchmarks/fixtures/fonts/inter-v4.1/Inter-Regular.ttf', import.meta.url)), - readFile(new URL('../../../font-baker/dist/font_baker.wasm', import.meta.url)), - ]); - const artifact = (await createFontBaker(bakerWasm)).bake({ - source, - descriptor: { formatVersion: 0, fontFaceIndex: 0 }, - }).artifacts[0].bytes; - const font = await validateFontArtifact(artifact); - const module = await WebAssembly.compile(wasm); - const corpus = mutationCorpus(); - const first = await execute(module, abi, corpus, font); - const second = await execute(module, abi, corpus, font); - assert.deepEqual(first, second); - assert.deepEqual(first.slice(0, 2), [0, 0], 'both seed requests must reach shaping'); - assert.ok(first.every((status) => Number.isSafeInteger(status) && status >= 0 && status <= 7)); - assert.ok( - first.slice(2).some((status) => status === 0), - 'mutations must reach valid shaping paths', - ); - assert.ok( - first.slice(2).some((status) => status !== 0), - 'mutations must retain malformed paths', - ); -}); - -test('raw shaper allocations reject forged releases and recover after invalid requests', async () => { - const wasm = await readFile(wasmUrl); - const module = await WebAssembly.compile(wasm); - const instance = await WebAssembly.instantiate(module, {}); - const memory = instance.exports.memory; - const allocate = instance.exports.pmndrs_text_shaper_alloc; - const deallocate = instance.exports.pmndrs_text_shaper_dealloc; - const shapeBatch = instance.exports.pmndrs_text_shaper_shape_batch; - assert.ok(memory instanceof WebAssembly.Memory); - assert.equal(typeof allocate, 'function'); - assert.equal(typeof deallocate, 'function'); - assert.equal(typeof shapeBatch, 'function'); - - assert.equal(allocate(64 * 1024 * 1024 + 1), 0); - const bytes = shapeRequest(); - const pointer = allocate(bytes.byteLength); - assert.notEqual(pointer, 0); - new Uint8Array(memory.buffer, pointer, bytes.byteLength).set(bytes); - deallocate(pointer + 1, bytes.byteLength - 1); - deallocate(pointer, bytes.byteLength - 1); - assert.equal(shapeBatch(pointer, bytes.byteLength), 5); - deallocate(pointer, bytes.byteLength); - deallocate(pointer, bytes.byteLength); - - const recovered = allocate(bytes.byteLength); - assert.notEqual(recovered, 0); - deallocate(recovered, bytes.byteLength); -}); - -async function execute(module, abi, corpus, font) { - const instance = await WebAssembly.instantiate(module, {}); - const memory = instance.exports.memory; - const allocate = instance.exports[abi.functions.allocate]; - const deallocate = instance.exports[abi.functions.deallocate]; - const registerFont = instance.exports[abi.functions.registerFont]; - const disposeFont = instance.exports[abi.functions.disposeFont]; - const shapeBatch = instance.exports[abi.functions.shapeBatch]; - const reshapeRanges = instance.exports[abi.functions.reshapeRanges]; - const fontInputs = [font.shapingSfnt, font.glyphExtents, font.glyphExtentsAvailability]; - const fontPointers = fontInputs.map((bytes) => copyBytes(memory, allocate, bytes)); - assert.equal( - registerFont( - 1, - fontPointers[0], - fontInputs[0].byteLength, - fontPointers[1], - fontInputs[1].byteLength, - fontPointers[2], - fontInputs[2].byteLength, - ), - 0, - ); - for (let index = 0; index < fontInputs.length; index += 1) { - deallocate(fontPointers[index], fontInputs[index].byteLength); - } - const statuses = []; - for (const { bytes, reshape } of corpus) { - const pointer = allocate(bytes.length); - new Uint8Array(memory.buffer, pointer, bytes.length).set(bytes); - statuses.push((reshape ? reshapeRanges : shapeBatch)(pointer, bytes.length)); - deallocate(pointer, bytes.length); - } - const outOfBounds = memory.buffer.byteLength - 2; - statuses.push(shapeBatch(outOfBounds, 8)); - statuses.push(reshapeRanges(outOfBounds, 8)); - assert.equal(disposeFont(1), 0); - return statuses; -} - -function copyBytes(memory, allocate, bytes) { - const pointer = allocate(bytes.byteLength); - assert.notEqual(pointer, 0); - new Uint8Array(memory.buffer, pointer, bytes.byteLength).set(bytes); - return pointer; -} - -function mutationCorpus() { - const bases = [shapeRequest(), reshapeRequest()]; - const corpus = bases.map((bytes, reshape) => ({ bytes, reshape: reshape === 1 })); - let state = 0x504d_4e44; - for (const [baseIndex, base] of bases.entries()) { - for (let mutation = 0; mutation < 128; mutation++) { - const bytes = base.slice(); - state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0; - const offset = state % bytes.length; - state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0; - bytes[offset] ^= state & 0xff || 1; - corpus.push({ bytes, reshape: baseIndex === 1 }); - } - } - return corpus; -} - -function shapeRequest() { - const bytes = new Uint8Array(68); - const view = new DataView(bytes.buffer); - view.setUint32(0, 32, true); - view.setUint32(4, 1, true); - view.setUint32(8, 36, true); - view.setUint32(12, 1, true); - view.setUint32(16, 68, true); - view.setUint32(20, 0, true); - view.setUint32(24, 68, true); - view.setUint32(28, 0, true); - view.setUint16(32, 0x41, true); - writeRun(view, 36); - return bytes; -} - -function reshapeRequest() { - const bytes = new Uint8Array(100); - const view = new DataView(bytes.buffer); - view.setUint32(0, 40, true); - view.setUint32(4, 1, true); - view.setUint32(8, 44, true); - view.setUint32(12, 1, true); - view.setUint32(16, 76, true); - view.setUint32(20, 0, true); - view.setUint32(24, 76, true); - view.setUint32(28, 0, true); - view.setUint32(32, 76, true); - view.setUint32(36, 1, true); - view.setUint16(40, 0x41, true); - writeRun(view, 44); - view.setUint32(76, 0, true); - view.setUint32(80, 0, true); - view.setUint32(84, 1, true); - view.setUint32(88, 0, true); - view.setUint32(92, 1, true); - view.setUint32(96, 0x40, true); - return bytes; -} - -function writeRun(view, offset) { - view.setUint32(offset, 1, true); - view.setUint32(offset + 4, 0, true); - view.setUint32(offset + 8, 1, true); - view.setUint32(offset + 12, 0x4c61_746e, true); - view.setUint32(offset + 16, 0xffff_ffff, true); - view.setUint32(offset + 20, 0, true); - view.setUint16(offset + 24, 0, true); - view.setUint8(offset + 26, 0); - view.setUint8(offset + 27, 0); - view.setUint32(offset + 28, 0x40, true); -} diff --git a/packages/text/tests/integration/bidi-analysis.test.mjs b/packages/text/tests/integration/bidi-analysis.test.mjs deleted file mode 100644 index 0896d67f..00000000 --- a/packages/text/tests/integration/bidi-analysis.test.mjs +++ /dev/null @@ -1,61 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import test from 'node:test'; - -import { createRuntimeShaper } from '../../dist/index.js'; - -const wasm = await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)); - -test('direct-memory bidi ABI returns exact Unicode 17 levels and classes', async () => { - const shaper = await createRuntimeShaper({ wasm }); - try { - const text = 'אב(גד[&ef].)gh'; - const result = shaper.analyzeBidi(utf16(text), 'ltr'); - assert.deepEqual([...result.levels], [1, 1, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0]); - assert.deepEqual([...result.classes], [1, 1, 13, 1, 1, 13, 13, 0, 0, 13, 7, 13, 0, 0]); - assert.deepEqual([...result.paragraphStarts], [0]); - assert.deepEqual([...result.paragraphEnds], [14]); - assert.deepEqual([...result.paragraphLevels], [0]); - } finally { - shaper.dispose(); - } -}); - -test('bidi ABI is UTF-16-indexed and honors auto and explicit paragraph direction', async () => { - const shaper = await createRuntimeShaper({ wasm }); - try { - const text = utf16('\u{10940}😀 A\u2029אב'); - const automatic = own(shaper.analyzeBidi(text)); - assert.equal(automatic.levels.length, text.length); - assert.equal(automatic.levels[0], automatic.levels[1]); - assert.equal(automatic.levels[2], automatic.levels[3]); - assert.deepEqual([...automatic.paragraphStarts], [0, 7]); - assert.deepEqual([...automatic.paragraphEnds], [7, 9]); - assert.deepEqual([...automatic.paragraphLevels], [1, 1]); - - const ltr = own(shaper.analyzeBidi(text, 'ltr')); - const rtl = own(shaper.analyzeBidi(text, 'rtl')); - assert.deepEqual([...ltr.paragraphLevels], [0, 0]); - assert.deepEqual([...rtl.paragraphLevels], [1, 1]); - assert.throws(() => shaper.analyzeBidi(text, 'sideways'), /auto, ltr, or rtl/); - assert.throws(() => shaper.analyzeBidi(new Uint8Array(text), 'auto'), /Uint16Array/); - } finally { - shaper.dispose(); - } -}); - -function utf16(value) { - const units = []; - for (let index = 0; index < value.length; index += 1) units.push(value.charCodeAt(index)); - return Uint16Array.from(units); -} - -function own(result) { - return { - levels: result.levels.slice(), - classes: result.classes.slice(), - paragraphStarts: result.paragraphStarts.slice(), - paragraphEnds: result.paragraphEnds.slice(), - paragraphLevels: result.paragraphLevels.slice(), - }; -} diff --git a/packages/text/tests/integration/bitmap-baker.test.mjs b/packages/text/tests/integration/bitmap-baker.test.mjs index 8185d175..5c3aec67 100644 --- a/packages/text/tests/integration/bitmap-baker.test.mjs +++ b/packages/text/tests/integration/bitmap-baker.test.mjs @@ -2,12 +2,11 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; -import { RasterCoverageError } from '@pmndrs/text'; import { + bitmapBakerAbi, bitmapBakerFromCore, createBitmapBaker, createBitmapBakerFromInstance, - readBitmapBakerAbi, } from '@pmndrs/text/bakers/bitmap'; import { bitmap, bitmapDescriptor, bitmapRasterKey } from '@pmndrs/text/raster/bitmap'; import { validateBitmapArtifact } from '@pmndrs/text/bakers/bitmap/validate'; @@ -45,17 +44,16 @@ async function bake(core, source, pages) { } test('ships one generated progress import and bundles its generated ABI in TypeScript', async () => { - const { module, instance } = await setup(); + const { module } = await setup(); assert.deepEqual(WebAssembly.Module.imports(module), [ { module: 'env', name: 'pmndrs_text_bake_progress', kind: 'function' }, ]); - const generated = readBitmapBakerAbi(instance); - assert.deepEqual(generated, publishedAbi); + assert.deepEqual(bitmapBakerAbi, publishedAbi); assert.equal( WebAssembly.Module.exports(module).some(({ name }) => name.includes('abi_')), false, ); - assert.deepEqual(generated.versions, { + assert.deepEqual(bitmapBakerAbi.versions, { bitmapFormat: 0, generator: '0.0.0', ktx2: '0.5.0', @@ -158,10 +156,8 @@ test('bakes bounded coverage with deterministic progress and a validated selecti dispose() {}, }; const data = await bitmap.decode(font, runtimeRaster); - const paint = { color: [1, 1, 1, 1] }; - const selection = (glyphId) => ({ data, glyphId, fontSize: 16, originX: 0, originY: 0, rasterPixelRatio: 1, paint }); - assert.ok(bitmap.select(selection(43))); - assert.throws(() => bitmap.select(selection(45)), RasterCoverageError); + assert.equal(data.coverage[43 >> 3] & (1 << (43 & 7)), 1 << (43 & 7)); + assert.equal(data.coverage[45 >> 3] & (1 << (45 & 7)), 0); bitmap.dispose(data); const mismatchedPolicy = { diff --git a/packages/text/tests/integration/cjk-universality.test.mjs b/packages/text/tests/integration/cjk-universality.test.mjs deleted file mode 100644 index fcbef979..00000000 --- a/packages/text/tests/integration/cjk-universality.test.mjs +++ /dev/null @@ -1,299 +0,0 @@ -import assert from 'node:assert/strict'; -import { createHash } from 'node:crypto'; -import { readFile } from 'node:fs/promises'; -import test from 'node:test'; - -import { createParagraphEngine, createRuntimeShaper, FontRegistry } from '@pmndrs/text'; -import { createFontBaker } from '@pmndrs/text-font-baker'; -import { graphemeSegments } from 'unicode-segmenter/grapheme'; - -import { hashParagraphLayout } from '../../../../apps/benchmarks/src/benchmark/paragraph-layout-digest.ts'; - -const fixtureRoot = new URL('../../../../apps/benchmarks/fixtures/', import.meta.url); -const fontUrl = new URL('fonts/noto-sans-cjk-2.004/NotoSansCJKjp-Regular.otf', fixtureRoot); -const contractUrl = new URL('contracts/paragraph-cjk-layout-v0.json', fixtureRoot); -const oracleUrl = new URL('shaping/noto-sans-cjk/harfrust.json', fixtureRoot); -const layoutFields = [ - 'glyphFontSlots', - 'glyphIds', - 'clusters', - 'glyphFontSizes', - 'x', - 'y', - 'glyphFlags', - 'lineTextStarts', - 'lineTextEnds', - 'lineGlyphStarts', - 'lineGlyphCounts', - 'lineBaselines', - 'lineAdvances', -]; - -test('proves CJK shaping and paragraph universality through the public pipeline', async () => { - const [source, contract, oracle, bakerWasm, shaperWasm] = await Promise.all([ - readFile(fontUrl), - readJson(contractUrl), - readJson(oracleUrl), - readFile(new URL('../../../font-baker/dist/font_baker.wasm', import.meta.url)), - readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), - ]); - assert.equal(createHash('sha256').update(source).digest('hex'), contract.font.sourceSha256); - - const baker = await createFontBaker(bakerWasm); - const baked = baker.bake({ - source, - descriptor: { formatVersion: 0, fontFaceIndex: 0 }, - }); - const artifact = baked.artifacts[0]; - assert.ok(artifact); - assert.equal(artifact.sha256, contract.font.artifactSha256); - - const registry = new FontRegistry(); - const font = await registry.registerAsset(artifact.bytes); - assert.equal(font.shapingHash, contract.font.shapingHash); - const shaper = await createRuntimeShaper({ registry, wasm: shaperWasm }); - shaper.registerFont(font); - - const localizedGlyphs = assertOracleShaping(shaper, font.handle, oracle); - assert.notDeepEqual(localizedGlyphs.get('zh-Hans'), localizedGlyphs.get('zh-Hant')); - assert.notDeepEqual(localizedGlyphs.get('zh-Hans'), localizedGlyphs.get('ja')); - assert.deepEqual(localizedGlyphs.get('ja'), localizedGlyphs.get('ko')); - - const calls = { shape: 0, reshape: 0 }; - const requests = []; - const observed = observeShaper(shaper, calls, requests); - const engine = createParagraphEngine({ shaper: observed }); - const retained = []; - - for (const [caseId, expected] of Object.entries(contract.cases)) { - const before = { ...calls }; - const requestStart = requests.length; - const paragraph = engine.create({ - text: expected.text, - font: font.handle, - style: expected.style, - }); - const preparationRequests = requests.slice(requestStart); - assertContextualRuns(caseId, expected, preparationRequests); - - for (const constraintId of ['natural', 'wide', 'narrow']) { - const constraints = contract.constraints[constraintId]; - const measurement = paragraph.measure(constraints); - const layout = paragraph.layout(constraints); - const golden = expected.layouts[constraintId]; - assert.equal(paragraph.measure(constraints), measurement, `${caseId}.${constraintId} measure`); - assert.equal(paragraph.layout(constraints), layout, `${caseId}.${constraintId} layout`); - assertEquivalentMeasurement(measurement, layout, `${caseId}.${constraintId} measurement`); - assertGoldenLayout(layout, golden, `${caseId}.${constraintId}`); - assertSafeBoundaries(expected.text, layout, `${caseId}.${constraintId}`); - retained.push({ label: `${caseId}.${constraintId}`, layout, hash: golden.hash }); - } - - assert.deepEqual( - { shape: calls.shape - before.shape, reshape: calls.reshape - before.reshape }, - expected.calls, - `${caseId} boundary calls`, - ); - paragraph.dispose(); - } - - const boundaryText = '甲\u{2000B}、\u{FE00}。\u{FE01}禰\u{E0100}한'; - const boundaryParagraph = engine.create({ - text: boundaryText, - font: font.handle, - style: { fontSize: 32, language: 'ja' }, - }); - const boundaryLayout = boundaryParagraph.layout({ - width: { mode: 'exactly', size: 64 }, - wrap: 'word', - }); - assertSafeBoundaries(boundaryText, boundaryLayout, 'supplementary/SVS/IVS/Jamo boundary probe'); - boundaryParagraph.dispose(); - - const interference = engine.create({ - text: '骨直辺', - font: font.handle, - style: { language: 'zh-hant' }, - }); - interference.layout({ width: { mode: 'exactly', size: 40 }, wrap: 'word' }); - for (const { label, layout, hash } of retained) { - assert.equal(hashParagraphLayout(layout), hash, `${label} owns its positioned result`); - } - - interference.dispose(); - shaper.dispose(); - font.dispose(); -}); - -function assertOracleShaping(shaper, fontHandle, oracle) { - const localized = new Map(); - for (const expected of oracle.cases) { - const textUtf16 = utf16(expected.text); - const shaped = shaper.shapeBatch({ - textUtf16, - features: [], - runs: [ - { - font: fontHandle, - textStart: 0, - textEnd: textUtf16.length, - direction: expected.segment.direction, - script: expected.segment.script, - language: expected.segment.language, - clusterLevel: 0, - flags: 0x40, - featureStart: 0, - featureCount: 0, - }, - ], - }); - assert.deepEqual([...shaped.fontHandles], [fontHandle], expected.id); - assert.deepEqual([...shaped.runGlyphStarts], [0], expected.id); - assert.deepEqual([...shaped.runGlyphCounts], [expected.glyphs.length], expected.id); - assert.deepEqual( - { - glyphIds: [...shaped.glyphIds], - clusters: [...shaped.clusters], - xAdvances: [...shaped.xAdvances], - yAdvances: [...shaped.yAdvances], - xOffsets: [...shaped.xOffsets], - yOffsets: [...shaped.yOffsets], - glyphFlags: [...shaped.glyphFlags], - }, - { - glyphIds: expected.glyphs.map(({ glyphId }) => glyphId), - clusters: expected.glyphs.map(({ cluster }) => cluster), - xAdvances: expected.glyphs.map(({ xAdvance }) => xAdvance), - yAdvances: expected.glyphs.map(({ yAdvance }) => yAdvance), - xOffsets: expected.glyphs.map(({ xOffset }) => xOffset), - yOffsets: expected.glyphs.map(({ yOffset }) => yOffset), - glyphFlags: expected.glyphs.map(({ flags }) => flags), - }, - expected.id, - ); - if (expected.id.startsWith('locl-')) { - localized.set(expected.segment.language, [...shaped.glyphIds]); - } - } - return localized; -} - -function assertContextualRuns(caseId, expected, requests) { - assert.equal(requests.length, 1, `${caseId} uses one broad shape request`); - const request = requests[0]; - const boundaries = graphemeBoundaries(expected.text); - const contentRuns = request.runs.filter(({ textStart }) => textStart < expected.text.length); - assert.ok(contentRuns.length > 0, `${caseId} has content runs`); - for (const run of contentRuns) { - assert.equal(run.language, expected.style.language, `${caseId} language`); - assert.equal(boundaries.has(run.textStart), true, `${caseId} run start ${run.textStart}`); - assert.equal(boundaries.has(run.textEnd), true, `${caseId} run end ${run.textEnd}`); - assert.notEqual(run.script, 'Bopo', `${caseId} must not assign punctuation to Bopomofo`); - } - const scripts = new Set(contentRuns.map(({ script }) => script)); - const required = { - simplified: ['Hani'], - japanese: ['Hani', 'Hira'], - korean: ['Hani', 'Hang'], - mixed: ['Hani', 'Hang', 'Hira', 'Latn'], - }[caseId]; - assert.deepEqual(scripts, new Set(required), `${caseId} contextual scripts`); -} - -function assertGoldenLayout(layout, golden, label) { - assert.deepEqual(measurementOf(layout), golden.measurement, `${label} layout measurement`); - for (const field of layoutFields) { - assert.deepEqual([...layout[field]], golden[field], `${label}.${field}`); - } - assert.equal(hashParagraphLayout(layout), golden.hash, `${label} hash`); - for (const value of [ - layout.width, - layout.height, - layout.contentWidth, - layout.contentHeight, - layout.firstBaseline, - layout.lastBaseline, - ...layout.x, - ...layout.y, - ...layout.lineBaselines, - ...layout.lineAdvances, - ]) { - assert.equal(Number.isFinite(value), true, `${label} finite geometry`); - } -} - -function assertSafeBoundaries(text, layout, label) { - const boundaries = graphemeBoundaries(text); - for (const [kind, offsets] of [ - ['cluster', layout.clusters], - ['line start', layout.lineTextStarts], - ['line end', layout.lineTextEnds], - ]) { - for (const offset of offsets) { - assert.equal(boundaries.has(offset), true, `${label} ${kind} ${offset} is grapheme-safe`); - assert.equal(isUtf16Boundary(text, offset), true, `${label} ${kind} ${offset} is UTF-16-safe`); - } - } -} - -function observeShaper(shaper, calls, requests) { - return { - registry: shaper.registry, - registerFont: (font) => shaper.registerFont(font), - disposeFont: (font) => shaper.disposeFont(font), - analyzeBidi: (text, direction) => shaper.analyzeBidi(text, direction), - shapeBatch: (request) => { - calls.shape += 1; - requests.push(request); - return shaper.shapeBatch(request); - }, - reshapeRanges: (request) => { - calls.reshape += 1; - return shaper.reshapeRanges(request); - }, - memoryReport: () => shaper.memoryReport(), - dispose: () => shaper.dispose(), - }; -} - -function measurementOf(layout) { - return { - width: layout.width, - height: layout.height, - contentWidth: layout.contentWidth, - contentHeight: layout.contentHeight, - firstBaseline: layout.firstBaseline, - lastBaseline: layout.lastBaseline, - overflowed: layout.overflowed, - }; -} - -function assertEquivalentMeasurement(measurement, layout, label) { - assert.equal(measurement.overflowed, layout.overflowed, `${label}.overflowed`); - for (const field of ['width', 'height', 'contentWidth', 'contentHeight', 'firstBaseline', 'lastBaseline']) { - assert.ok(Math.abs(measurement[field] - layout[field]) < 0.0001, `${label}.${field}`); - } -} - -function graphemeBoundaries(text) { - const boundaries = new Set([0]); - for (const segment of graphemeSegments(text)) { - boundaries.add(segment.index + segment.segment.length); - } - return boundaries; -} - -function isUtf16Boundary(text, offset) { - if (offset === 0 || offset === text.length) return true; - const previous = text.charCodeAt(offset - 1); - const current = text.charCodeAt(offset); - return !(previous >= 0xd800 && previous <= 0xdbff && current >= 0xdc00 && current <= 0xdfff); -} - -function utf16(text) { - return Uint16Array.from({ length: text.length }, (_, index) => text.charCodeAt(index)); -} - -async function readJson(url) { - return JSON.parse(await readFile(url, 'utf8')); -} diff --git a/packages/text/tests/integration/empty-paragraph-features.test.mjs b/packages/text/tests/integration/empty-paragraph-features.test.mjs deleted file mode 100644 index 1cec1fe3..00000000 --- a/packages/text/tests/integration/empty-paragraph-features.test.mjs +++ /dev/null @@ -1,65 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import test from 'node:test'; - -import { createRuntimeShaper, createTextRuntime, FontRegistry } from '@pmndrs/text'; -import { bitmap } from '@pmndrs/text/raster/bitmap'; - -const interUrl = new URL('../../../../apps/benchmarks/fixtures/rendering/inter-bitmap-16.font.glb', import.meta.url); - -function dataUrl(bytes) { - return `data:application/octet-stream;base64,${Buffer.from(bytes).toString('base64')}`; -} - -test('a paragraph-wide font feature survives empty text and still rejects an empty explicit range', async () => { - const registry = new FontRegistry(); - const shaper = await createRuntimeShaper({ - registry, - wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), - }); - const runtime = await createTextRuntime({ registry, shaper }); - const inter = await runtime.loadFont({ - input: { baked: dataUrl(await readFile(interUrl)) }, - raster: { technique: bitmap, options: { strikes: [16] } }, - }); - const batch = runtime.createParagraphBatch({ technique: bitmap }); - - // An unbounded feature covers whatever it is applied to, so empty text makes it vacuous. A feature-styled - // input field is created before its first character is typed, and that must not fail preparation. - const field = batch.add({ - font: inter, - text: '', - style: { features: [{ tag: 'kern' }, { tag: 'liga' }] }, - }); - runtime.update(); - assert.equal(batch.preparationError, undefined, 'an unbounded feature must not fail an empty paragraph'); - assert.equal(batch.current.paragraphs[0].layout.glyphIds.length, 0); - - // The same paragraph shapes normally once it has text, proving the feature was carried rather than discarded. - field.text = 'Waffle'; - runtime.update(); - assert.equal(batch.preparationError, undefined); - assert.ok(batch.current.paragraphs[0].layout.glyphIds.length > 0); - - // An explicitly empty range is still a caller error rather than a vacuous no-op. Preparation reports it as a - // typed failure and keeps the prior revision current, rather than throwing out of the synchronization call. - const explicit = batch.add({ - font: inter, - text: 'Waffle', - style: { features: [{ tag: 'kern', start: 2, end: 2 }] }, - }); - const published = batch.current.revision; - assert.throws( - () => runtime.update(), - (error) => - error.kind === 'preparation-failed' && - /feature kern must be a non-empty UTF-16 range/.test(String(error.cause?.message)), - ); - assert.equal(batch.current.revision, published, 'a rejected feature range must preserve the prior revision'); - - explicit.dispose(); - field.dispose(); - batch.dispose(); - runtime.dispose(); - inter.dispose(); -}); diff --git a/packages/text/tests/integration/engine-frame-wire.test.mjs b/packages/text/tests/integration/engine-frame-wire.test.mjs new file mode 100644 index 00000000..3c071837 --- /dev/null +++ b/packages/text/tests/integration/engine-frame-wire.test.mjs @@ -0,0 +1,243 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import { compileTextEngineFrameUpdate } from '../../dist/internal/engine-frame-wire.js'; +import { engineFrameUpdateBytes } from '../support/engine-abi.mjs'; + +const abiUrl = new URL('../../dist/text-shaper-abi-v0.json', import.meta.url); + +test('production frame compiler preserves the established benchmark request bytes', async () => { + const abi = JSON.parse(await readFile(abiUrl, 'utf8')); + const text = 'A😀B'; + const units = Array.from({ length: text.length }, (_, index) => text.charCodeAt(index)); + const limits = { maxClusters: 8, maxLines: 8, maxOutputBytes: 65_536 }; + const expected = engineFrameUpdateBytes(abi, { + sessionId: 5, + policyHandle: 11, + fontStackHandle: 7, + textMutation: { start: 0, deleteCount: 0, insert: units }, + style: { textEnd: text.length, fontSize: 24, lineHeight: 1.2, rasterPixelRatio: 2 }, + geometry: { width: 320, height: 180, maxLines: 8, revision: 9 }, + limits, + }); + const actual = compileTextEngineFrameUpdate({ + sessionId: 5, + policyHandle: 11, + capabilitySet: 1, + expectedEngineRevision: 0, + consumedPlanRevision: 0, + acknowledgedPublicationGeneration: 0, + limits: { + ...limits, + maxParagraphs: 1, + maxRegions: 1, + maxExclusions: 1, + maxInlineObjects: 1, + maxSlotsPerBand: 1, + }, + paragraphMutations: [{ opcode: 'upsert', paragraphId: 1, order: 0 }], + textMutations: [{ paragraphId: 1, start: 0, deleteCount: 0, insert: text }], + styleMutations: [ + { + opcode: 'upsert', + paragraphId: 1, + styleId: 1, + cascadeOrder: 0, + start: 0, + end: text.length, + root: true, + value: { fontStackHandle: 7, fontSize: 24, lineHeight: 1.2, rasterPixelRatio: 2 }, + }, + ], + constraints: [ + { + paragraphId: 1, + flowThreadId: 1, + geometryRevision: 0, + width: 320, + height: 180, + viewportBlockStart: 0, + viewportBlockEnd: 180, + resumeBlockOffset: 0, + maxLines: 8, + regionStart: 0, + resumeCluster: 0, + regionCount: 1, + resumeRegion: 0, + widthMode: 'exact', + heightMode: 'exact', + wrap: 'word', + align: 'start', + overflow: 'visible', + blockAlign: 'start', + }, + ], + regions: [ + { + id: 1, + geometryRevision: 9, + shape: 'rectangle', + exclusionStart: 0, + exclusionCount: 0, + writingMode: 'horizontal-tb', + textOrientation: 'mixed', + inlineStart: 0, + blockStart: 0, + inlineEnd: 320, + blockEnd: 180, + clipInlineStart: 0, + clipBlockStart: 0, + clipInlineEnd: 320, + clipBlockEnd: 180, + }, + ], + }); + assert.deepEqual(actual, expected); +}); + +test('production frame compiler carries full style, polygon, exclusion, and inline-object payloads', async () => { + const abi = JSON.parse(await readFile(abiUrl, 'utf8')); + const bytes = compileTextEngineFrameUpdate({ + sessionId: 1, + policyHandle: 2, + capabilitySet: 1, + expectedEngineRevision: 3, + consumedPlanRevision: 4, + acknowledgedPublicationGeneration: 5, + semanticViewMask: 6, + compositingIndependent: true, + limits: { + maxParagraphs: 4, + maxClusters: 32, + maxLines: 16, + maxRegions: 2, + maxExclusions: 2, + maxInlineObjects: 2, + maxSlotsPerBand: 3, + maxOutputBytes: 1_048_576, + }, + paragraphMutations: [{ opcode: 'upsert', paragraphId: 3, order: 2 }], + styleMutations: [ + { + opcode: 'upsert', + paragraphId: 3, + styleId: 9, + cascadeOrder: 2, + start: 1, + end: 5, + value: { + fontStackHandle: 7, + materialId: 8, + language: 'ja', + features: [{ tag: 'kern', value: 1, start: 1, end: 5 }], + fontSize: 18, + lineHeight: 1.25, + letterSpacing: 0.5, + wordSpacing: 1.5, + baselineShift: -2, + rasterPixelRatio: 2, + direction: 'rtl', + foregroundRgba: 0x1122_3344, + decoration: { + style: 'solid', + rgba: 0x5566_7788, + underline: true, + lineThrough: true, + skipInk: true, + thickness: 1, + offset: 2, + }, + }, + }, + ], + constraints: [], + regions: [ + { + id: 1, + geometryRevision: 1, + shape: 'polygon', + vertices: [ + { inline: 0, block: 0 }, + { inline: 100, block: 0 }, + { inline: 100, block: 100 }, + ], + exclusionStart: 0, + exclusionCount: 1, + writingMode: 'vertical-rl', + textOrientation: 'upright', + inlineStart: 0, + blockStart: 0, + inlineEnd: 100, + blockEnd: 100, + clipInlineStart: 0, + clipBlockStart: 0, + clipInlineEnd: 100, + clipBlockEnd: 100, + }, + ], + exclusions: [ + { + id: 2, + regionId: 1, + geometryRevision: 1, + shape: 'polygon', + vertices: [ + { inline: 20, block: 20 }, + { inline: 40, block: 20 }, + { inline: 30, block: 40 }, + ], + wrapSide: 'largest', + inlineStart: 20, + blockStart: 20, + inlineEnd: 40, + blockEnd: 40, + marginInline: 2, + marginBlock: 3, + }, + ], + inlineObjects: [ + { + paragraphId: 3, + id: 3, + contentRevision: 1, + textOffset: 4, + materialId: 8, + resourceId: 10, + resourceGeneration: 1, + inlineExtent: 12, + blockExtent: 14, + baselineOffset: 2, + marginInlineStart: 1, + marginInlineEnd: 1, + marginBlockStart: 0, + marginBlockEnd: 0, + baselineAlignment: 'alphabetic', + }, + ], + policyParameters: Uint8Array.of(7, 8, 9), + }); + const request = abi.layouts.engineUpdateRequest; + const header = new DataView(bytes.buffer, bytes.byteOffset, request.size); + assert.equal(header.getUint32(request.flags, true), abi.engine.frameFlags.compositingIndependent); + assert.equal(header.getUint32(request.byteLength, true), bytes.byteLength); + assert.equal(header.getUint32(request.styleMutationCount, true), 1); + assert.equal(header.getUint32(request.regionCount, true), 1); + assert.equal(header.getUint32(request.exclusionCount, true), 1); + assert.equal(header.getUint32(request.inlineObjectCount, true), 1); + assert.equal(header.getUint32(request.policyParametersLength, true), 3); + assert.deepEqual(bytes.slice(header.getUint32(request.policyParametersOffset, true)), Uint8Array.of(7, 8, 9)); + const styleOffset = header.getUint32(request.styleMutationsOffset, true); + const style = abi.layouts.engineStyleMutation; + const styleView = new DataView(bytes.buffer, bytes.byteOffset + styleOffset, style.size); + assert.equal(styleView.getUint32(style.paragraphId, true), 3); + assert.equal(styleView.getUint8(style.direction), 2); + assert.equal(styleView.getUint16(style.languageLength, true), 2); + assert.equal(styleView.getUint16(style.featureCount, true), 1); + assert.equal(styleView.getUint32(style.materialId, true), 8); + assert.equal(styleView.getUint32(style.decorationFlags, true), 13); + const inlineObjectOffset = header.getUint32(request.inlineObjectsOffset, true); + const inlineObject = abi.layouts.engineInlineObject; + const inlineObjectView = new DataView(bytes.buffer, bytes.byteOffset + inlineObjectOffset, inlineObject.size); + assert.equal(inlineObjectView.getUint32(inlineObject.paragraphId, true), 3); +}); diff --git a/packages/text/tests/integration/font-binding-wire.test.mjs b/packages/text/tests/integration/font-binding-wire.test.mjs new file mode 100644 index 00000000..99609df2 --- /dev/null +++ b/packages/text/tests/integration/font-binding-wire.test.mjs @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import { gunzipSync } from 'node:zlib'; + +import { validateFontArtifact } from '@pmndrs/text-font-baker/validate'; + +import { validateBitmapArtifact } from '../../dist/bakers/bitmap-validator.js'; +import { validateMsdfArtifact } from '../../dist/bakers/msdf-validator.js'; +import { validateSlugArtifact } from '../../dist/bakers/slug-validator.js'; +import { firstPartyFontBindingBytes } from '../../dist/internal/font-binding-wire.js'; +import { bitmap, bitmapDescriptor } from '../../dist/raster/bitmap-technique.js'; +import { msdf, msdfDescriptor } from '../../dist/raster/msdf.js'; +import { slug, slugDescriptor } from '../../dist/raster/slug-technique.js'; +import { defineRasterResourceId } from '../../dist/raster-technique.js'; +import { techniqueProof } from '../../scripts/support/render-technique-proof.mjs'; + +const fixtureRoot = new URL('../../../../apps/benchmarks/fixtures/rendering/', import.meta.url); +const abiUrl = new URL('../../dist/text-shaper-abi-v0.json', import.meta.url); + +test('production first-party bindings preserve every proven field-major raster lane', async () => { + const abi = JSON.parse(await readFile(abiUrl, 'utf8')); + for (const name of ['bitmap', 'mtsdf', 'slug']) { + const { core, raster, loaded } = await fixture(name); + const actual = firstPartyFontBindingBytes(loaded); + const expected = techniqueProof(abi, name, raster).bindingBytes; + const strikeRows = core.glyphCount * (name === 'bitmap' ? raster.strikes.length : 1); + for (const [table, rows] of [ + ['glyphF32', core.glyphCount], + ['glyphU32', core.glyphCount], + ['strikeF32', strikeRows], + ['strikeU32', strikeRows], + ['resourceF32', resourceCount(actual, abi)], + ['resourceU32', resourceCount(actual, abi)], + ]) { + assert.deepEqual( + tableBytes(actual, abi, table, rows), + tableBytes(expected, abi, table, rows), + `${name} ${table}`, + ); + } + } +}); + +async function fixture(name) { + const files = { + bitmap: ['inter-bitmap-16.font.glb', false], + mtsdf: ['inter-mtsdf.font.glb.gz', true], + slug: ['inter-slug.font.glb.gz', true], + }; + const [file, compressed] = files[name]; + const stored = await readFile(new URL(file, fixtureRoot)); + const bytes = compressed ? gunzipSync(stored) : stored; + const core = await validateFontArtifact(bytes); + const identity = core.document.extensions.PMNDRS_font.rasters[0]; + const context = { + rasterKey: identity.rasterKey, + shapingHash: core.shapingHash, + glyphCount: core.glyphCount, + glyphIdWidth: 16, + }; + if (name === 'bitmap') { + const raster = await validateBitmapArtifact(bytes, { ...context, descriptor: bitmapDescriptor({ strikes: [16] }) }); + const data = { + strikes: raster.strikes.map((strike, strikeIndex) => ({ + ...strike, + pages: strike.pages.map((page, pageIndex) => ({ + ...page, + format: 'r8unorm', + resource: defineRasterResourceId(`test.bitmap.${strikeIndex}.${pageIndex}`), + })), + })), + }; + return { core, raster, loaded: { font: core, technique: bitmap, data } }; + } + if (name === 'mtsdf') { + const raster = await validateMsdfArtifact(bytes, { ...context, descriptor: msdfDescriptor() }); + const extension = raster.document.extensions.PMNDRS_font_distance_field; + const data = { + resource: defineRasterResourceId('test.mtsdf'), + binding: { + width: Math.max(...raster.pages.map((page) => page.width)), + height: Math.max(...raster.pages.map((page) => page.height)), + layers: raster.pages.length, + }, + emSize: extension.emSize, + pixelRange: extension.pixelRange, + planeUnitsPerEm: extension.planeUnitsPerEm, + records: raster.records, + pages: raster.pages, + }; + return { core, raster, loaded: { font: core, technique: msdf, data } }; + } + const raster = await validateSlugArtifact(bytes, { ...context, descriptor: slugDescriptor() }); + const extension = raster.document.extensions.PMNDRS_font_slug; + const data = { + planeUnitsPerEm: extension.planeUnitsPerEm, + records: raster.records, + pages: raster.pages.map((page, pageIndex) => ({ + ...page, + resource: defineRasterResourceId(`test.slug.${pageIndex}`), + })), + }; + return { core, raster, loaded: { font: core, technique: slug, data } }; +} + +function resourceCount(bytes, abi) { + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32( + abi.layouts.fontBindingRequest.resourceCount, + true, + ); +} + +function tableBytes(bytes, abi, name, rows) { + const request = abi.layouts.fontBindingRequest; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const count = view.getUint8(request[`${name}FieldCount`]); + const offset = view.getUint32(request[`${name}Offset`], true); + return offset === 0 ? new Uint8Array() : bytes.slice(offset, offset + count * rows * 4); +} diff --git a/packages/text/tests/integration/mtsdf-baker.test.mjs b/packages/text/tests/integration/mtsdf-baker.test.mjs index 7df05887..86aa6d54 100644 --- a/packages/text/tests/integration/mtsdf-baker.test.mjs +++ b/packages/text/tests/integration/mtsdf-baker.test.mjs @@ -2,13 +2,11 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; -import { RasterCoverageError } from '@pmndrs/text'; - import { createMsdfBaker, createMsdfBakerFromInstance, + msdfBakerAbi, msdfBakerFromCore, - readMsdfBakerAbi, } from '@pmndrs/text/bakers/msdf'; import { MsdfArtifactValidationError, validateMsdfArtifact } from '@pmndrs/text/bakers/msdf/validate'; import { @@ -33,17 +31,6 @@ const showcaseShapingHash = '3f8183c0d56b8b225b8a6a7b2fda80966579b46636b96975434 const publishedAbi = JSON.parse(await readFile(abiUrl, 'utf8')); const progressImports = { env: { pmndrs_text_bake_progress() {} } }; -/** Packs one canonical glyph batch through the portable technique, as core does before any renderer sees it. */ -function packedStorage(data, glyphs) { - const storage = msdf.createStorage(glyphs.length); - msdf.writeStorage(storage, { start: 0, count: glyphs.length }, { data, binding: data.binding, glyphs }); - return storage; -} - -function glyphInput(data, glyphId, index, paint) { - return { data, glyphId, fontSize: 64, originX: 12 + index * 80, originY: 24, rasterPixelRatio: 1, paint }; -} - async function setup() { const [wasm, source] = await Promise.all([readFile(wasmUrl), readFile(fontUrl)]); const module = await WebAssembly.compile(wasm); @@ -57,17 +44,16 @@ async function setup() { } test('ships one generated progress import and bundles its artifact contract in TypeScript', async () => { - const { module, instance } = await setup(); + const { module } = await setup(); assert.deepEqual(WebAssembly.Module.imports(module), [ { module: 'env', name: 'pmndrs_text_bake_progress', kind: 'function' }, ]); - const generated = readMsdfBakerAbi(instance); - assert.deepEqual(generated, publishedAbi); + assert.deepEqual(msdfBakerAbi, publishedAbi); assert.equal( WebAssembly.Module.exports(module).some(({ name }) => name.includes('abi_')), false, ); - assert.deepEqual(generated.artifactBaker.versions, { + assert.deepEqual(msdfBakerAbi.artifactBaker.versions, { generator: '0.0.0', ktx2: '0.5.0', msdfFormat: 0, @@ -199,23 +185,7 @@ test('bakes and validates authenticated 32 px/em quality policies', async () => assert.equal(data.pixelRange, 4); const records = views[extension.recordBufferView]; assert.ok(records); - const glyphId = firstPresentGlyph(records); - const outlined = { color: [1, 1, 1, 1], outline: { color: [0, 0, 0, 1], width: 2 } }; - const storage = packedStorage(data, [{ ...glyphInput(data, glyphId, 0, outlined), fontSize: 32 }]); - assert.equal(storage.outlineWidths[0], 0.5); - assert.throws( - () => - packedStorage(data, [ - { - ...glyphInput(data, glyphId, 0, { - color: [1, 1, 1, 1], - outline: { color: [0, 0, 0, 1], width: 2.0001 }, - }), - fontSize: 32, - }, - ]), - /2-atlas-pixel field limit/, - ); + assert.ok(firstPresentGlyph(records) >= 0); } finally { msdf.dispose(data); } @@ -269,9 +239,8 @@ test('bakes bounded coverage with deterministic progress and a validated selecti dispose() {}, }; const data = await msdf.decode(font, runtimeRaster); - const paint = { color: [1, 1, 1, 1] }; - assert.ok(msdf.select(glyphInput(data, 43, 0, paint))); - assert.throws(() => msdf.select(glyphInput(data, 45, 0, paint)), RasterCoverageError); + assert.equal(data.coverage[43 >> 3] & (1 << (43 & 7)), 1 << (43 & 7)); + assert.equal(data.coverage[45 >> 3] & (1 << (45 & 7)), 0); msdf.dispose(data); }); @@ -658,33 +627,12 @@ async function exerciseRuntime(result, rasterArtifact, extension, rasterKey) { assert.ok(decodedPageBytes < paddedBindingBytes, 'decoded pages must not carry the binding padding'); const glyphIds = firstPresentGlyphByPage(records, data.pages.length); - const decorated = { - color: [1, 0.75, 0.5, 1], - outline: { color: [0, 0, 0, 1], width: 2 }, - shadow: { color: [0, 0, 0, 0.5], offset: [3, 4] }, - }; - const glyphs = [...glyphIds].map((glyphId, index) => glyphInput(data, glyphId, index, decorated)); - for (const glyph of glyphs) { - assert.deepEqual(msdf.select(glyph), { resource: data.resource, pipelineVariant: 0, binding: data.binding }); - } - const storage = packedStorage(data, glyphs); - assert.equal(storage.outlineWidths[0], 0.25); - assert.deepEqual([...storage.pageIndices], [...glyphIds.keys()], 'each baked page keeps its own record page index'); - assert.ok(storage.shadowOffsets[0] > 0, 'a positive shadow offset packs a positive horizontal UV displacement'); - assert.ok(storage.shadowOffsets[1] > 0, 'a positive shadow offset packs a positive vertical UV displacement'); - assert.deepEqual([...storage.fillColors.slice(0, 4)], decorated.color); - assert.deepEqual([...storage.shadowColors.slice(0, 4)], decorated.shadow.color); - - // Dropping the decoration is a structural change: the shadow no longer widens the instance quad. - const plain = packedStorage( - data, - [...glyphIds].map((glyphId, index) => glyphInput(data, glyphId, index, { color: [0.25, 0.5, 1, 0.75] })), + const recordView = new DataView(records.buffer, records.byteOffset, records.byteLength); + assert.deepEqual( + [...glyphIds].map((glyphId) => recordView.getUint16(glyphId * 20 + 16, true)), + [...glyphIds.keys()], + 'each baked page keeps its own record page index', ); - assert.equal(plain.outlineWidths[0], 0); - assert.deepEqual([...plain.shadowOffsets.slice(0, 2)], [0, 0]); - assert.ok(plain.sizes[0] < storage.sizes[0], 'removing the shadow shrinks the packed instance width'); - assert.ok(plain.sizes[1] < storage.sizes[1], 'removing the shadow shrinks the packed instance height'); - assert.deepEqual([...plain.fillColors.slice(0, 4)], [0.25, 0.5, 1, 0.75]); msdf.dispose(data); } diff --git a/packages/text/tests/integration/mtsdf-generator.test.mjs b/packages/text/tests/integration/mtsdf-generator.test.mjs index cbc6e12f..dfb208ec 100644 --- a/packages/text/tests/integration/mtsdf-generator.test.mjs +++ b/packages/text/tests/integration/mtsdf-generator.test.mjs @@ -7,7 +7,7 @@ import { MtsdfGenerationError, createMtsdfGenerator, createMtsdfGeneratorFromInstance, - readMtsdfGeneratorAbi, + mtsdfGeneratorAbi, } from '../../dist/internal/mtsdf-generator.js'; import { mtsdfOracleCases } from '../fixtures/mtsdf-oracle-cases.mjs'; @@ -24,11 +24,11 @@ async function setup() { } test('ships an optimized module with the exact progress import and TypeScript ABI', async () => { - const { module, instance } = await setup(); + const { module } = await setup(); assert.deepEqual(WebAssembly.Module.imports(module), [ { module: 'env', name: 'pmndrs_text_bake_progress', kind: 'function' }, ]); - assert.deepEqual(readMtsdfGeneratorAbi(instance), publishedAbi); + assert.deepEqual(mtsdfGeneratorAbi, publishedAbi); assert.equal( WebAssembly.Module.exports(module).some(({ name }) => name.includes('abi_')), false, diff --git a/packages/text/tests/integration/paragraph-bidi-policy.test.mjs b/packages/text/tests/integration/paragraph-bidi-policy.test.mjs deleted file mode 100644 index 5730c2e3..00000000 --- a/packages/text/tests/integration/paragraph-bidi-policy.test.mjs +++ /dev/null @@ -1,266 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import test from 'node:test'; - -import { createParagraphEngine, createRuntimeShaper, FontRegistry } from '@pmndrs/text'; -import { createFontBaker } from '@pmndrs/text-font-baker'; -import { hashParagraphLayout } from '../../../../apps/benchmarks/src/benchmark/paragraph-layout-digest.ts'; - -const contractUrl = new URL( - '../../../../apps/benchmarks/fixtures/contracts/paragraph-bidi-layout-v0.json', - import.meta.url, -); -const contract = JSON.parse(await readFile(contractUrl, 'utf8')); - -test('lays out exact mixed-direction Amiri goldens through retained GLB shaping data', async () => { - const { font, shaper } = await runtime('amiri-1.002/Amiri-Regular.ttf'); - const calls = { shape: 0, reshape: 0 }; - const requests = []; - const observed = observeShaper(shaper, calls, requests); - const engine = createParagraphEngine({ shaper: observed }); - let retainedLayout; - - assert.equal(contract.generatedBy, 'apps/benchmarks/scripts/generate-paragraph-bidi-contract.mts'); - assert.equal(font.shapingHash, contract.fonts.amiri.shapingHash); - for (const fixture of Object.values(contract.bidi)) { - const paragraph = engine.create({ - text: fixture.text, - font: font.handle, - style: fixture.style, - }); - const layout = paragraph.layout(fixture.constraints); - assertGoldenLayout(layout, fixture.layout, true); - assertLineTopology(layout, fixture.text); - if (retainedLayout === undefined) retainedLayout = layout; - } - - assert.deepEqual(calls, { shape: 2, reshape: 0 }); - assert.deepEqual( - requests - .filter(({ shape }) => shape !== undefined) - .map(({ shape }) => - shape.runs.slice(0, shape.runs.length / 2).map((run) => ({ - text: String.fromCharCode(...shape.textUtf16.slice(run.textStart, run.textEnd)), - direction: run.direction, - script: run.script, - })), - ), - [ - [ - { text: 'ABC ', direction: 'ltr', script: 'Latn' }, - { text: 'مرحبا ', direction: 'rtl', script: 'Arab' }, - { text: '123', direction: 'ltr', script: 'Arab' }, - { text: ' ', direction: 'ltr', script: 'Arab' }, - { text: 'DEF', direction: 'ltr', script: 'Latn' }, - ], - [ - { text: 'مرحبا ', direction: 'rtl', script: 'Arab' }, - { text: 'ABC 123', direction: 'ltr', script: 'Latn' }, - { text: ' ', direction: 'rtl', script: 'Latn' }, - { text: 'عالم', direction: 'rtl', script: 'Arab' }, - ], - ], - ); - assert.equal( - hashParagraphLayout(retainedLayout), - contract.bidi.ltr.layout.hash, - 'later borrowed Wasm results must not mutate an earlier paragraph layout', - ); - shaper.dispose(); - font.dispose(); -}); - -test('applies exact alignment, clipping, max-lines, and ellipsis policies without hidden calls', async () => { - const { font, shaper } = await runtime('inter-v4.1/Inter-Regular.ttf'); - const calls = { shape: 0, reshape: 0 }; - const requests = []; - const observed = observeShaper(shaper, calls, requests); - const paragraph = createParagraphEngine({ shaper: observed }).create({ - text: contract.policies.text, - font: font.handle, - style: contract.policies.style, - }); - assert.equal(calls.shape, 1, 'text and every per-run ellipsis are prepared in one batch'); - - // Boundary reshaping requests the whole run as context, which is the context the retained paragraph shape already - // used, so it can only return the glyphs it already returned. These counts therefore assert that no policy reaches - // the shaper again: every layout below is produced from the one retained shape. A future narrowed context — a - // truncated line taking final forms, or a line shaped in isolation — is what should raise them again. - const expectedCrossings = { - start: 0, - center: 0, - end: 0, - justify: 0, - clip: 0, - maxLines: 0, - ellipsisOne: 0, - ellipsisHeightOne: 0, - ellipsisHeightTwo: 0, - }; - const layouts = {}; - for (const [id, fixture] of Object.entries(contract.policies.cases)) { - const measured = paragraph.measure(fixture.constraints); - assert.deepEqual(measured, fixture.layout.measurement); - const layout = paragraph.layout(fixture.constraints); - layouts[id] = layout; - assertGoldenLayout(layout, fixture.layout, false); - assertLineTopology(layout, contract.policies.text); - assert.equal(calls.reshape, expectedCrossings[id], `${id} reshape boundary count`); - } - - assert.equal(layouts.clip.glyphIds.length, layouts.start.glyphIds.length); - assert.equal(layouts.clip.height, 60); - assert.equal(layouts.clip.overflowed, true); - assert.deepEqual([...layouts.maxLines.lineTextEnds], [8, 19]); - assert.equal(layouts.maxLines.contentHeight, 160); - assert.equal(layouts.ellipsisOne.glyphIds.at(-1), 1503); - assert.equal(layouts.ellipsisOne.clusters.at(-1), 8); - assert.equal(layouts.ellipsisHeightOne.glyphIds, layouts.ellipsisOne.glyphIds); - assert.notEqual(layouts.ellipsisHeightTwo.glyphIds, layouts.ellipsisHeightOne.glyphIds); - assertAlignmentOffsets(layouts.start, layouts.center, layouts.end, 180); - assert.deepEqual( - [...layouts.justify.lineAdvances].slice(0, -1), - Array(layouts.justify.lineAdvances.length - 1).fill(180), - 'justification fills every non-final soft-wrapped line', - ); - assert.ok( - (layouts.justify.lineAdvances.at(-1) ?? 180) < 180, - 'justification leaves the final line at its natural advance', - ); - for (const layout of [layouts.ellipsisOne, layouts.ellipsisHeightOne, layouts.ellipsisHeightTwo]) { - const end = layout.lineTextEnds.at(-1) ?? contract.policies.text.length; - assert.ok(end < contract.policies.text.length, 'ellipsis truncates a source range'); - assert.equal(layout.clusters.at(-1), end, 'ellipsis glyph is anchored at the truncation boundary'); - } - assert.deepEqual( - requests.filter(({ ranges }) => ranges !== undefined).map(({ ranges }) => ranges.length), - [], - 'no policy issues a reshape request while the shaping context is the whole run', - ); - - shaper.dispose(); - font.dispose(); -}); - -async function runtime(relativeFontPath) { - const [source, bakerWasm, shaperWasm] = await Promise.all([ - readFile(new URL(`../../../../apps/benchmarks/fixtures/fonts/${relativeFontPath}`, import.meta.url)), - readFile(new URL('../../../font-baker/dist/font_baker.wasm', import.meta.url)), - readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), - ]); - const baker = await createFontBaker(bakerWasm); - const artifact = baker.bake({ - source, - descriptor: { formatVersion: 0, fontFaceIndex: 0 }, - }).artifacts[0].bytes; - const registry = new FontRegistry(); - const font = await registry.registerAsset(artifact); - const shaper = await createRuntimeShaper({ registry, wasm: shaperWasm }); - return { font, shaper }; -} - -function observeShaper(shaper, calls, requests) { - return { - registry: shaper.registry, - registerFont: (font) => shaper.registerFont(font), - disposeFont: (font) => shaper.disposeFont(font), - analyzeBidi: (text, direction) => shaper.analyzeBidi(text, direction), - shapeBatch: (shape) => { - calls.shape += 1; - requests.push({ shape }); - return shaper.shapeBatch(shape); - }, - reshapeRanges: (request) => { - calls.reshape += 1; - requests.push({ ranges: request.ranges.map((range) => ({ ...range })) }); - return shaper.reshapeRanges(request); - }, - memoryReport: () => shaper.memoryReport(), - dispose: () => shaper.dispose(), - }; -} - -function assertGoldenLayout(layout, golden, full) { - assert.deepEqual( - { - width: layout.width, - height: layout.height, - contentWidth: layout.contentWidth, - contentHeight: layout.contentHeight, - firstBaseline: layout.firstBaseline, - lastBaseline: layout.lastBaseline, - overflowed: layout.overflowed, - }, - golden.measurement, - ); - const fields = full - ? [ - 'glyphFontSlots', - 'glyphIds', - 'clusters', - 'glyphFontSizes', - 'x', - 'y', - 'glyphFlags', - 'lineTextStarts', - 'lineTextEnds', - 'lineGlyphStarts', - 'lineGlyphCounts', - 'lineBaselines', - 'lineAdvances', - ] - : [ - 'glyphIds', - 'clusters', - 'x', - 'lineTextStarts', - 'lineTextEnds', - 'lineGlyphStarts', - 'lineGlyphCounts', - 'lineBaselines', - 'lineAdvances', - ]; - for (const field of fields) assert.deepEqual([...layout[field]], golden[field], field); - assert.equal(hashParagraphLayout(layout), golden.hash); -} - -function assertLineTopology(layout, text) { - assert.equal(layout.lineTextStarts.length, layout.lineTextEnds.length); - assert.equal(layout.lineTextStarts.length, layout.lineGlyphStarts.length); - assert.equal(layout.lineTextStarts.length, layout.lineGlyphCounts.length); - let previousEnd = 0; - let previousGlyphEnd = 0; - let previousBaseline = -Infinity; - for (let index = 0; index < layout.lineTextStarts.length; index += 1) { - const start = layout.lineTextStarts[index]; - const end = layout.lineTextEnds[index]; - const glyphStart = layout.lineGlyphStarts[index]; - const glyphCount = layout.lineGlyphCounts[index]; - const baseline = layout.lineBaselines[index]; - const advance = layout.lineAdvances[index]; - assert.ok(start >= previousEnd && start <= end && end <= text.length, `line ${index} text range`); - assert.equal(glyphStart, previousGlyphEnd, `line ${index} glyph range is contiguous`); - assert.ok(glyphStart + glyphCount <= layout.glyphIds.length, `line ${index} glyph range is bounded`); - assert.ok(baseline > previousBaseline, `line ${index} baseline is strictly increasing`); - assert.ok(Number.isFinite(advance) && advance >= 0, `line ${index} advance is finite`); - previousEnd = end; - previousGlyphEnd = glyphStart + glyphCount; - previousBaseline = baseline; - } - assert.equal(previousGlyphEnd, layout.glyphIds.length, 'line glyph ranges cover the positioned output'); -} - -function assertAlignmentOffsets(start, center, end, width) { - for (let line = 0; line < start.lineGlyphStarts.length; line += 1) { - const glyph = start.lineGlyphStarts[line]; - if (glyph === undefined || start.lineGlyphCounts[line] === 0) continue; - const naturalX = start.x[glyph]; - const advance = start.lineAdvances[line]; - assertClose(center.x[glyph] - naturalX, (width - advance) / 2, `center line ${line}`); - assertClose(end.x[glyph] - naturalX, width - advance, `end line ${line}`); - } -} - -function assertClose(actual, expected, label) { - assert.ok(Math.abs(actual - expected) < 0.0001, `${label}: ${actual} != ${expected}`); -} diff --git a/packages/text/tests/integration/paragraph-measurement.test.mjs b/packages/text/tests/integration/paragraph-measurement.test.mjs deleted file mode 100644 index 37a75e74..00000000 --- a/packages/text/tests/integration/paragraph-measurement.test.mjs +++ /dev/null @@ -1,377 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import test from 'node:test'; - -import { createParagraphEngine, createRuntimeShaper, FontRegistry } from '@pmndrs/text'; -import { createFontBaker } from '@pmndrs/text-font-baker'; -import { hashParagraphLayout } from '../../../../apps/benchmarks/src/benchmark/paragraph-layout-digest.ts'; - -const fontDirectory = new URL('../../../../apps/benchmarks/fixtures/fonts/inter-v4.1/', import.meta.url); -const shapingDirectory = new URL('../../../../apps/benchmarks/fixtures/shaping/inter-regular/', import.meta.url); -const paragraphContract = new URL( - '../../../../apps/benchmarks/fixtures/contracts/paragraph-layout-v0.json', - import.meta.url, -); - -test('measures the exact GLB-extracted HarfRust paragraph without positioned arrays', async () => { - const [{ font, shaper }, oracle, contract] = await Promise.all([ - runtime(), - readJson(new URL('harfrust.json', shapingDirectory)), - readJson(paragraphContract), - ]); - const calls = { shape: 0, reshape: 0 }; - const reshapeRequests = []; - const observedShaper = observeShaper(shaper, calls, reshapeRequests); - const engine = createParagraphEngine({ shaper: observedShaper }); - const expected = oracle.cases.find(({ id }) => id === 'paragraph'); - const layoutGoldens = contract.goldens; - assert.ok(expected); - const paragraph = engine.create({ - text: expected.text, - font: font.handle, - style: { - fontSize: 32, - lineHeight: 1.3, - language: 'en', - direction: 'ltr', - features: [], - }, - }); - - assert.equal(calls.shape, 1, 'preparation must shape the paragraph once'); - assert.equal(calls.reshape, 0); - const interfering = engine.create({ text: 'AV', font: font.handle }); - assert.equal(calls.shape, 2, 'each prepared paragraph performs one broad shape'); - const expectedNaturalWidth = - (expected.glyphs.reduce((sum, glyph) => sum + glyph.xAdvance, 0) * 32) / font.metrics.unitsPerEm; - const natural = paragraph.measure(); - assert.equal(natural.width, expectedNaturalWidth); - assert.deepEqual(natural, layoutGoldens.natural.measurement); - assert.equal('glyphIds' in natural, false); - - const wideConstraints = { width: { mode: 'at-most', size: 720 } }; - const wide = paragraph.measure(wideConstraints); - assert.deepEqual(wide, layoutGoldens.wide.measurement); - assert.equal(paragraph.measure(wideConstraints), wide, 'equivalent measurements reuse one object'); - assert.deepEqual(paragraph.measure({ width: { mode: 'at-most', size: 360 } }), layoutGoldens.narrow.measurement); - assert.deepEqual(calls, { shape: 2, reshape: 0 }, 'width-only reflow must not enter Wasm'); - - const naturalLayout = paragraph.layout(); - assert.deepEqual(calls, { shape: 2, reshape: 0 }, 'unbroken layout reuses the broad shape'); - assert.deepEqual( - [...naturalLayout.glyphIds], - expected.glyphs.map(({ glyphId }) => glyphId), - ); - assert.deepEqual( - [...naturalLayout.clusters], - expected.glyphs.map(({ cluster }) => cluster), - ); - assert.deepEqual( - [...naturalLayout.glyphFlags], - expected.glyphs.map(({ flags }) => flags), - ); - assert.deepEqual([...naturalLayout.x], expectedNaturalX(expected.glyphs, 32 / font.metrics.unitsPerEm)); - assert.deepEqual( - [...naturalLayout.y], - expected.glyphs.map(({ yOffset }) => - Math.fround(naturalLayout.firstBaseline - (yOffset * 32) / font.metrics.unitsPerEm), - ), - ); - assertLayoutLines(naturalLayout, layoutGoldens.natural.layout); - assert.equal(hashParagraphLayout(naturalLayout), layoutGoldens.natural.layout.hash); - - const wideLayout = paragraph.layout(wideConstraints); - assert.deepEqual(calls, { shape: 2, reshape: 0 }, 'wide boundaries lay out from the retained shape'); - assert.equal(paragraph.layout(wideConstraints), wideLayout, 'equivalent layout reuses one object'); - assertLayoutLines(wideLayout, layoutGoldens.wide.layout); - assert.deepEqual( - [...wideLayout.glyphIds], - expected.glyphs.map(({ glyphId }) => glyphId), - ); - assert.deepEqual( - [...wideLayout.clusters], - expected.glyphs.map(({ cluster }) => cluster), - ); - assert.deepEqual( - [...wideLayout.glyphFlags], - expected.glyphs.map(({ flags }) => flags), - ); - assert.equal(hashParagraphLayout(wideLayout), layoutGoldens.wide.layout.hash); - - const narrowConstraints = { width: { mode: 'at-most', size: 360 } }; - const narrowLayout = paragraph.layout(narrowConstraints); - // These requests used to carry `contextStart: 0, contextEnd: 56` — the whole run, which is the context the retained - // shape was produced with, so the shaper returned the glyphs it had already returned. The golden layout hashes below - // are unchanged by removing them, which is the proof. Reinstate them under a narrowed context. - assert.deepEqual(calls, { shape: 2, reshape: 0 }, 'narrow boundaries lay out from the retained shape'); - assert.deepEqual(reshapeRequests, []); - assertLayoutLines(narrowLayout, layoutGoldens.narrow.layout); - assert.equal(hashParagraphLayout(narrowLayout), layoutGoldens.narrow.layout.hash); - const exactHeight = paragraph.layout({ - ...narrowConstraints, - height: { mode: 'exactly', size: 200 }, - }); - assert.deepEqual(calls, { shape: 2, reshape: 0 }, 'height-only layout reuses positioned lines'); - assert.equal(exactHeight.height, 200); - assert.equal(exactHeight.glyphIds, narrowLayout.glyphIds); - const postLayoutInterfering = engine.create({ text: 'ffi', font: font.handle }); - assert.equal( - hashParagraphLayout(narrowLayout), - layoutGoldens.narrow.layout.hash, - 'positioned arrays own their Wasm results', - ); - - postLayoutInterfering.dispose(); - interfering.dispose(); - paragraph.dispose(); - assert.throws(() => paragraph.measure(), /disposed/); - shaper.dispose(); - font.dispose(); -}); - -test('resolves features and updates as one new broad-shape revision', async () => { - const { font, shaper } = await runtime(); - const calls = { shape: 0, reshape: 0 }; - const paragraph = createParagraphEngine({ shaper: observeShaper(shaper, calls) }).create({ - text: 'AVATAR', - font: font.handle, - style: { fontSize: 32, direction: 'ltr', language: 'en' }, - }); - assert.equal(paragraph.measure().width, 119.75); - assert.deepEqual(calls, { shape: 1, reshape: 0 }); - const defaultLayout = paragraph.layout(); - - paragraph.update({ - text: 'AVATAR', - font: font.handle, - style: { - fontSize: 32, - direction: 'ltr', - language: 'en', - features: [{ tag: 'kern', value: 0 }], - }, - }); - assert.equal(paragraph.measure().width, 129.5625); - assert.deepEqual(calls, { shape: 2, reshape: 0 }); - const unkernedLayout = paragraph.layout(); - assert.notEqual(unkernedLayout, defaultLayout); - assert.notDeepEqual([...unkernedLayout.x], [...defaultLayout.x]); - shaper.dispose(); - font.dispose(); -}); - -test('bounds retained paragraph layouts while keeping recent constraints hot', async () => { - const { font, shaper } = await runtime(); - const paragraph = createParagraphEngine({ shaper }).create({ - text: 'A short paragraph whose width changes repeatedly.', - font: font.handle, - }); - const constraints = Array.from({ length: 40 }, (_, index) => ({ - width: { mode: 'at-most', size: 120 + index }, - })); - const first = paragraph.layout(constraints[0]); - for (const constraint of constraints.slice(1)) paragraph.layout(constraint); - const mostRecent = paragraph.layout(constraints.at(-1)); - - assert.equal(paragraph.layout(constraints.at(-1)), mostRecent); - assert.notEqual(paragraph.layout(constraints[0]), first); - - paragraph.dispose(); - shaper.dispose(); - font.dispose(); -}); - -test('validates spans, constraints, empty text, and lifecycle deterministically', async () => { - const { font, shaper } = await runtime(); - const engine = createParagraphEngine({ shaper }); - const empty = engine.create({ text: '', font: font.handle }); - assert.deepEqual(empty.measure(), { - width: 0, - height: 0, - contentWidth: 0, - contentHeight: 0, - firstBaseline: 0, - lastBaseline: 0, - overflowed: false, - }); - assert.deepEqual( - empty.measure({ - width: { mode: 'exactly', size: 20 }, - height: { mode: 'exactly', size: 10 }, - }), - { - width: 20, - height: 10, - contentWidth: 0, - contentHeight: 0, - firstBaseline: 0, - lastBaseline: 0, - overflowed: false, - }, - ); - assert.throws( - () => engine.create({ text: 'e\u0301', font: font.handle, spans: [{ start: 1, end: 2 }] }), - /extended-grapheme boundaries/, - ); - assert.throws(() => empty.measure({ width: { mode: 'at-most', size: Number.NaN } }), /finite/); - assert.throws(() => empty.measure({ width: { mode: 'invalid', size: 10 } }), /width mode/); - assert.throws(() => empty.measure({ maxLines: 0 }), /positive safe integer/); - assert.throws(() => empty.measure({ wrap: 'invalid' }), /wrap must/); - assert.throws(() => empty.measure({ align: 'invalid' }), /align must/); - assert.throws(() => empty.measure({ overflow: 'invalid' }), /overflow must/); - assert.throws(() => engine.create(null), /paragraph input must be an object/); - assert.throws( - () => engine.create({ text: 'a', font: font.handle, style: null }), - /paragraph style must be an object/, - ); - assert.throws(() => engine.create({ text: 'a', font: font.handle, spans: null }), /paragraph spans must be an array/); - assert.throws( - () => engine.create({ text: 'a', font: font.handle, spans: [null] }), - /paragraph span must be an object/, - ); - assert.throws( - () => engine.create({ text: 'a', font: font.handle, style: { features: null } }), - /paragraph style features must be an array/, - ); - assert.throws(() => empty.measure(null), /paragraph constraints must be an object/); - assert.throws(() => empty.layout([]), /paragraph constraints must be an object/); - assert.throws(() => empty.measure({ width: null }), /width constraint must be an object/); - assert.throws( - () => engine.create({ text: 'a', font: font.handle, style: { language: null } }), - /language must be a string/, - ); - const emptyLayout = empty.layout(); - assert.deepEqual(emptyLayout, { - width: 0, - height: 0, - contentWidth: 0, - contentHeight: 0, - firstBaseline: 0, - lastBaseline: 0, - overflowed: false, - fontHandles: new Uint32Array(), - glyphFontSlots: new Uint16Array(), - glyphIds: new Uint16Array(), - clusters: new Uint32Array(), - glyphFontSizes: new Float32Array(), - x: new Float32Array(), - y: new Float32Array(), - glyphFlags: new Uint16Array(), - lineTextStarts: new Uint32Array(), - lineTextEnds: new Uint32Array(), - lineGlyphStarts: new Uint32Array(), - lineGlyphCounts: new Uint32Array(), - lineBaselines: new Float32Array(), - lineAdvances: new Float32Array(), - }); - const singleLine = engine.create({ text: 'a', font: font.handle }).measure(); - const trailingBreak = engine.create({ text: 'a\n', font: font.handle }).measure(); - assert.equal(trailingBreak.contentHeight, singleLine.contentHeight * 2); - assert.equal(trailingBreak.lastBaseline, singleLine.lastBaseline + singleLine.contentHeight); - const hardBreakEllipsis = engine.create({ text: 'a\nb', font: font.handle }).layout({ - maxLines: 1, - overflow: 'ellipsis', - }); - assert.deepEqual([...hardBreakEllipsis.lineTextEnds], [1]); - assert.equal(hardBreakEllipsis.clusters.at(-1), 1); - assert.ok([...hardBreakEllipsis.clusters].every((cluster) => cluster <= 1)); - shaper.dispose(); - font.dispose(); -}); - -test('sweeps nested spans without repeatedly resolving active styles', async () => { - const { font, shaper } = await runtime(); - let registrations = 0; - const observed = { - registry: shaper.registry, - registerFont: (registered) => { - registrations += 1; - return shaper.registerFont(registered); - }, - disposeFont: (registered) => shaper.disposeFont(registered), - analyzeBidi: (text, direction) => shaper.analyzeBidi(text, direction), - shapeBatch: (request) => shaper.shapeBatch(request), - reshapeRanges: (request) => shaper.reshapeRanges(request), - memoryReport: () => shaper.memoryReport(), - dispose: () => shaper.dispose(), - }; - const text = 'a'.repeat(320); - const spans = Array.from({ length: 128 }, (_, index) => ({ - start: index, - end: text.length - index, - })); - const engine = createParagraphEngine({ shaper: observed }); - const nested = engine.create({ text, font: font.handle, spans }); - assert.equal(registrations, 1, 'the root style is registered once, not once per active span'); - const plain = engine.create({ text, font: font.handle }); - assert.deepEqual(nested.measure(), plain.measure()); - assert.deepEqual(nested.layout(), plain.layout()); - nested.dispose(); - plain.dispose(); - shaper.dispose(); - font.dispose(); -}); - -async function runtime() { - const [source, bakerWasm, shaperWasm] = await Promise.all([ - readFile(new URL('Inter-Regular.ttf', fontDirectory)), - readFile(new URL('../../../font-baker/dist/font_baker.wasm', import.meta.url)), - readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), - ]); - const baker = await createFontBaker(bakerWasm); - const artifact = baker.bake({ - source, - descriptor: { formatVersion: 0, fontFaceIndex: 0 }, - }).artifacts[0].bytes; - const registry = new FontRegistry(); - const font = await registry.registerAsset(artifact); - const shaper = await createRuntimeShaper({ registry, wasm: shaperWasm }); - return { font, shaper }; -} - -function observeShaper(shaper, calls, reshapeRequests = []) { - return { - registry: shaper.registry, - registerFont: (font) => shaper.registerFont(font), - disposeFont: (font) => shaper.disposeFont(font), - analyzeBidi: (text, direction) => shaper.analyzeBidi(text, direction), - shapeBatch: (request) => { - calls.shape += 1; - return shaper.shapeBatch(request); - }, - reshapeRanges: (request) => { - calls.reshape += 1; - reshapeRequests.push({ ranges: request.ranges.map((range) => ({ ...range })) }); - return shaper.reshapeRanges(request); - }, - memoryReport: () => shaper.memoryReport(), - dispose: () => shaper.dispose(), - }; -} - -function expectedNaturalX(glyphs, scale) { - let cursor = 0; - return glyphs.map(({ xAdvance, xOffset }) => { - const positioned = Math.fround(cursor + xOffset * scale); - cursor += Math.abs(xAdvance) * scale; - return positioned; - }); -} - -function assertLayoutLines(layout, golden) { - assert.equal(layout.glyphIds.length, golden.glyphCount); - for (const key of [ - 'lineTextStarts', - 'lineTextEnds', - 'lineGlyphStarts', - 'lineGlyphCounts', - 'lineBaselines', - 'lineAdvances', - ]) { - assert.deepEqual([...layout[key]], golden[key]); - } -} - -async function readJson(url) { - return JSON.parse(await readFile(url, 'utf8')); -} diff --git a/packages/text/tests/integration/render-plan-frame-abi.test.mjs b/packages/text/tests/integration/render-plan-frame-abi.test.mjs new file mode 100644 index 00000000..d142aceb --- /dev/null +++ b/packages/text/tests/integration/render-plan-frame-abi.test.mjs @@ -0,0 +1,422 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import { copyIntoAllocation, engineUpdateBytes, renderPolicyBytes } from '../support/engine-abi.mjs'; + +const wasmUrl = new URL('../../dist/text_shaper.wasm', import.meta.url); +const abiUrl = new URL('../../dist/text-shaper-abi-v0.json', import.meta.url); +const sessionId = 5; +const policyHandle = 11; + +test('publishes retained frame transactions through aligned A/B Wasm arenas', async () => { + const [wasm, abi] = await Promise.all([readFile(wasmUrl), readFile(abiUrl, 'utf8').then(JSON.parse)]); + const instance = await WebAssembly.instantiate(await WebAssembly.compile(wasm), {}); + const memory = instance.exports[abi.memory]; + const fn = Object.fromEntries( + Object.entries(abi.functions).map(([name, exported]) => [name, instance.exports[exported]]), + ); + assert.ok(memory instanceof WebAssembly.Memory); + assert.equal(fn.initialize(), abi.status.ok); + + const policy = renderPolicyBytes(abi); + const policyPointer = copyIntoAllocation(memory, fn.allocate, policy); + assert.equal(fn.registerPolicy(policyHandle, policyPointer, policy.byteLength), abi.status.ok); + fn.deallocate(policyPointer, policy.byteLength); + + const requestLayout = abi.layouts.engineUpdateRequest; + const resultLayout = abi.layouts.engineResult; + assert.equal(resultLayout.size, 144); + assert.equal(requestLayout.size, 136); + assert.equal(resultLayout.alignment, 16); + assert.equal(abi.layouts.engineBuffer.size, 36); + assert.equal(abi.layouts.enginePatch.size, 36); + assert.equal(abi.layouts.enginePrimitive.size, 64); + assert.equal(abi.layouts.engineDraw.size, 64); + assert.deepEqual( + [ + abi.layouts.engineTextMutation.size, + abi.layouts.engineStyleMutation.size, + abi.layouts.engineConstraint.size, + abi.layouts.engineFlowVertex.size, + abi.layouts.engineRegion.size, + abi.layouts.engineExclusion.size, + abi.layouts.engineInlineObject.size, + ], + [24, 92, 56, 8, 60, 48, 60], + ); + assert.equal(abi.layouts.engineInlineObject.alignment, 4); + assert.equal(abi.layouts.engineInlineObject.baselineAlignment, 52); + assert.equal(abi.engine.textMutationOpcodes.replaceUtf16, 1); + assert.equal(abi.engine.textEncodings.utf16Le, 1); + assert.equal(abi.engine.styleMutationOpcodes.upsert, 1); + assert.equal(abi.engine.styleMutationOpcodes.remove, 2); + assert.deepEqual(abi.engine.styleFlags, { root: 1 }); + assert.deepEqual(abi.engine.decorationStyles, { + dashed: 4, + dotted: 3, + double: 2, + none: 0, + solid: 1, + wavy: 5, + }); + assert.deepEqual(abi.engine.decorationFlags, { + all: 15, + lineThrough: 4, + overline: 2, + skipInk: 8, + underline: 1, + }); + assert.equal(abi.engine.styleFields.all, 8191); + assert.equal(abi.layouts.engineStyleMutation.cascadeOrder, 8); + assert.equal(abi.layouts.engineStyleMutation.rasterPixelRatio, 64); + assert.deepEqual(abi.engine.flowShapeKinds, { polygon: 2, rectangle: 1 }); + assert.deepEqual(abi.engine.writingModes, { horizontalTb: 1, verticalLr: 3, verticalRl: 2 }); + assert.deepEqual(abi.engine.textOrientations, { mixed: 1, sideways: 3, upright: 2 }); + assert.deepEqual(abi.engine.axisModes, { atMost: 2, exact: 3, unconstrained: 1 }); + assert.deepEqual(abi.engine.wrapModes, { character: 3, none: 1, word: 2 }); + assert.deepEqual(abi.engine.inlineAlignments, { center: 2, end: 3, justify: 4, start: 1 }); + assert.deepEqual(abi.engine.overflowModes, { clip: 2, ellipsis: 3, visible: 1 }); + assert.deepEqual(abi.engine.blockAlignments, { center: 2, end: 3, start: 1 }); + assert.deepEqual(abi.engine.exclusionWrapSides, { both: 1, inlineEnd: 3, inlineStart: 2, largest: 4 }); + assert.deepEqual(abi.engine.inlineObjectBaselines, { alphabetic: 1, middle: 3, textBottom: 4, textTop: 2 }); + assert.equal(abi.engine.defaultSessionTextCapacity, 1024); + assert.deepEqual(abi.engine.semanticF32Fields, { + blockExtent: 3, + blockOrigin: 7, + blockStart: 1, + fontSize: 4, + foregroundAlpha: 11, + foregroundBlue: 10, + foregroundGreen: 9, + foregroundRed: 8, + inlineExtent: 2, + inlineOrigin: 6, + inlineStart: 0, + inverseFontSize: 12, + rasterPixelRatio: 5, + }); + assert.deepEqual(abi.engine.semanticU32Fields, { + clusterId: 1, + flowThreadId: 3, + foregroundRgba: 0, + regionId: 2, + stableGlyphId: 5, + transformIndex: 4, + }); + assert.equal(fn.createSession(sessionId, requestLayout.size, resultLayout.size, 0), abi.status.ok); + assert.equal(fn.sessionCount(), 1); + let requestPointer = fn.requestPointer(sessionId); + assert.notEqual(requestPointer, 0); + assert.equal(requestPointer % 16, 0); + assert.ok(fn.requestCapacity(sessionId) >= requestLayout.size); + + writeRequest(memory, requestPointer, abi, 0, 0); + const firstPointer = fn.textUpdate(sessionId, requestPointer, requestLayout.size); + assert.notEqual(firstPointer, 0); + assert.equal(firstPointer % resultLayout.alignment, 0); + assertResult(memory, firstPointer, abi, { + status: abi.status.ok, + engineRevision: 1, + planRevision: 1, + requiredBaseRevision: 0, + publicationGeneration: 1, + outputSlot: 0, + flags: abi.engine.resultFlags.checkpoint, + }); + const firstHeader = resultBytes(memory, firstPointer, resultLayout).slice(); + + const warmBuffer = memory.buffer; + writeRequest(memory, requestPointer, abi, 1, 1, 1); + const secondPointer = fn.textUpdate(sessionId, requestPointer, requestLayout.size); + assert.strictEqual(memory.buffer, warmBuffer, 'a warm empty transaction must not grow Wasm memory'); + assert.notEqual(secondPointer, firstPointer); + assertResult(memory, secondPointer, abi, { + status: abi.status.ok, + engineRevision: 2, + planRevision: 2, + requiredBaseRevision: 1, + publicationGeneration: 2, + outputSlot: 1, + flags: 0, + }); + assert.deepEqual(resultBytes(memory, firstPointer, resultLayout), firstHeader); + const secondHeader = resultBytes(memory, secondPointer, resultLayout).slice(); + + writeRequest(memory, requestPointer, abi, 2, 2, 3); + const futureFencePointer = fn.textUpdate(sessionId, requestPointer, requestLayout.size); + assertResult(memory, futureFencePointer, abi, { + status: abi.status.revisionConflict, + engineRevision: 2, + planRevision: 2, + requiredBaseRevision: 2, + publicationGeneration: 2, + outputSlot: 0, + flags: 0, + }); + assert.deepEqual(resultBytes(memory, secondPointer, resultLayout), secondHeader); + + writeRequest(memory, requestPointer, abi, 1, 2, 1); + const failedPointer = fn.textUpdate(sessionId, requestPointer, requestLayout.size); + assert.notEqual(failedPointer, secondPointer); + assertResult(memory, failedPointer, abi, { + status: abi.status.revisionConflict, + engineRevision: 2, + planRevision: 2, + requiredBaseRevision: 2, + publicationGeneration: 2, + outputSlot: 0, + flags: 0, + }); + assert.deepEqual(resultBytes(memory, secondPointer, resultLayout), secondHeader); + + writeRequest(memory, requestPointer, abi, 2, 0, 2); + const checkpointPointer = fn.textUpdate(sessionId, requestPointer, requestLayout.size); + assertResult(memory, checkpointPointer, abi, { + status: abi.status.ok, + engineRevision: 3, + planRevision: 3, + requiredBaseRevision: 0, + publicationGeneration: 3, + outputSlot: 0, + flags: abi.engine.resultFlags.checkpoint, + }); + const checkpointHeader = resultBytes(memory, checkpointPointer, resultLayout).slice(); + + assert.equal(fn.reserveSession(sessionId, 512, resultLayout.size, 8), abi.status.ok); + requestPointer = fn.requestPointer(sessionId); + assert.ok(fn.requestCapacity(sessionId) >= 512); + const textWarmBuffer = memory.buffer; + const insertLength = writeRequest(memory, requestPointer, abi, 3, 3, 3, [ + { start: 0, deleteCount: 0, insert: [0x61, 0x62, 0x63] }, + ]); + const insertedPointer = fn.textUpdate(sessionId, requestPointer, insertLength); + assert.strictEqual(memory.buffer, textWarmBuffer); + assertResult(memory, insertedPointer, abi, { + status: abi.status.ok, + engineRevision: 4, + planRevision: 4, + requiredBaseRevision: 3, + publicationGeneration: 4, + outputSlot: 1, + flags: 0, + }); + assert.deepEqual(resultBytes(memory, checkpointPointer, resultLayout), checkpointHeader); + + const retainedEditLength = writeRequest(memory, requestPointer, abi, 4, 4, 4, [ + { start: 1, deleteCount: 1, insert: [0x58] }, + ]); + const retainedEditPointer = fn.textUpdate(sessionId, requestPointer, retainedEditLength); + assert.strictEqual(memory.buffer, textWarmBuffer); + assertResult(memory, retainedEditPointer, abi, { + status: abi.status.ok, + engineRevision: 5, + planRevision: 5, + requiredBaseRevision: 4, + publicationGeneration: 5, + outputSlot: 0, + flags: 0, + }); + const retainedHeader = resultBytes(memory, retainedEditPointer, resultLayout).slice(); + + const invalidEditLength = writeRequest(memory, requestPointer, abi, 5, 5, 5, [ + { start: 9, deleteCount: 0, insert: [0x21] }, + ]); + const invalidEditPointer = fn.textUpdate(sessionId, requestPointer, invalidEditLength); + assertResult(memory, invalidEditPointer, abi, { + status: abi.status.invalidRequest, + engineRevision: 5, + planRevision: 5, + requiredBaseRevision: 5, + publicationGeneration: 5, + outputSlot: 1, + flags: 0, + }); + assert.deepEqual(resultBytes(memory, retainedEditPointer, resultLayout), retainedHeader); + + const geometry = geometryRequestBytes(abi, 5, 5, 5); + new Uint8Array(memory.buffer, requestPointer, geometry.byteLength).set(geometry); + const geometryPointer = fn.textUpdate(sessionId, requestPointer, geometry.byteLength); + assertResult(memory, geometryPointer, abi, { + status: abi.status.ok, + engineRevision: 6, + planRevision: 6, + requiredBaseRevision: 5, + publicationGeneration: 6, + outputSlot: 1, + flags: 0, + }); + const geometryHeader = resultBytes(memory, geometryPointer, resultLayout).slice(); + + const invalidGeometry = geometryRequestBytes(abi, 6, 6, 6); + const exclusionOffset = new DataView(invalidGeometry.buffer).getUint32(requestLayout.exclusionsOffset, true); + new DataView(invalidGeometry.buffer).setUint32(exclusionOffset + abi.layouts.engineExclusion.regionId, 9, true); + new Uint8Array(memory.buffer, requestPointer, invalidGeometry.byteLength).set(invalidGeometry); + const invalidGeometryPointer = fn.textUpdate(sessionId, requestPointer, invalidGeometry.byteLength); + assertResult(memory, invalidGeometryPointer, abi, { + status: abi.status.invalidRequest, + engineRevision: 6, + planRevision: 6, + requiredBaseRevision: 6, + publicationGeneration: 6, + outputSlot: 0, + flags: 0, + }); + assert.deepEqual(resultBytes(memory, geometryPointer, resultLayout), geometryHeader); + + const oldBuffer = memory.buffer; + const grownCapacity = 8 * 1024 * 1024; + assert.equal(fn.reserveSession(sessionId, grownCapacity, grownCapacity, 0), abi.status.ok); + assert.notStrictEqual(memory.buffer, oldBuffer); + assert.equal(oldBuffer.byteLength, 0, 'memory.grow must detach fixed-length views in the pinned runtime'); + requestPointer = fn.requestPointer(sessionId); + assert.equal(requestPointer % 16, 0); + assert.ok(fn.requestCapacity(sessionId) >= grownCapacity); + + assert.equal(fn.disposeSession(sessionId), abi.status.ok); + assert.equal(fn.sessionCount(), 0); + assert.equal(fn.disposeSession(sessionId), abi.status.sessionMissing); + assert.equal(fn.textUpdate(sessionId, requestPointer, requestLayout.size), 0); + assert.equal(fn.disposePolicy(policyHandle), abi.status.ok); +}); + +function writeRequest( + memory, + pointer, + abi, + expectedEngineRevision, + consumedPlanRevision, + acknowledgedPublicationGeneration = 0, + textMutations = [], +) { + const bytes = engineUpdateBytes(abi, { + sessionId, + policyHandle, + expectedEngineRevision, + consumedPlanRevision, + acknowledgedPublicationGeneration, + textMutations, + }); + new Uint8Array(memory.buffer, pointer, bytes.byteLength).set(bytes); + return bytes.byteLength; +} + +function geometryRequestBytes(abi, expectedEngineRevision, consumedPlanRevision, acknowledgedPublicationGeneration) { + const request = abi.layouts.engineUpdateRequest; + const constraint = abi.layouts.engineConstraint; + const region = abi.layouts.engineRegion; + const exclusion = abi.layouts.engineExclusion; + const inlineObject = abi.layouts.engineInlineObject; + const constraintOffset = request.size; + const regionOffset = constraintOffset + constraint.size; + const exclusionOffset = regionOffset + region.size; + const inlineObjectOffset = exclusionOffset + exclusion.size; + const bytes = new Uint8Array(inlineObjectOffset + inlineObject.size); + bytes.set( + engineUpdateBytes(abi, { + sessionId, + policyHandle, + expectedEngineRevision, + consumedPlanRevision, + acknowledgedPublicationGeneration, + }), + ); + const view = new DataView(bytes.buffer); + view.setUint32(request.byteLength, bytes.byteLength, true); + for (const [offsetField, countField, offset] of [ + ['constraintsOffset', 'constraintCount', constraintOffset], + ['regionsOffset', 'regionCount', regionOffset], + ['exclusionsOffset', 'exclusionCount', exclusionOffset], + ['inlineObjectsOffset', 'inlineObjectCount', inlineObjectOffset], + ]) { + view.setUint32(request[offsetField], offset, true); + view.setUint32(request[countField], 1, true); + } + + view.setUint32(constraintOffset + constraint.paragraphId, 1, true); + view.setUint32(constraintOffset + constraint.flowThreadId, 1, true); + view.setFloat32(constraintOffset + constraint.width, 100, true); + view.setFloat32(constraintOffset + constraint.height, 100, true); + view.setFloat32(constraintOffset + constraint.viewportBlockEnd, 100, true); + view.setUint32(constraintOffset + constraint.maxLines, 1, true); + view.setUint16(constraintOffset + constraint.regionCount, 1, true); + view.setUint8(constraintOffset + constraint.widthMode, abi.engine.axisModes.exact); + view.setUint8(constraintOffset + constraint.heightMode, abi.engine.axisModes.exact); + view.setUint8(constraintOffset + constraint.wrap, abi.engine.wrapModes.word); + view.setUint8(constraintOffset + constraint.align, abi.engine.inlineAlignments.start); + view.setUint8(constraintOffset + constraint.overflow, abi.engine.overflowModes.clip); + view.setUint8(constraintOffset + constraint.blockAlign, abi.engine.blockAlignments.start); + + view.setUint32(regionOffset + region.id, 1, true); + view.setUint32(regionOffset + region.geometryRevision, 1, true); + view.setUint32(regionOffset + region.transformIndex, 1, true); + view.setUint16(regionOffset + region.exclusionCount, 1, true); + view.setUint8(regionOffset + region.shape, abi.engine.flowShapeKinds.rectangle); + view.setUint8(regionOffset + region.writingMode, abi.engine.writingModes.horizontalTb); + view.setUint8(regionOffset + region.textOrientation, abi.engine.textOrientations.mixed); + for (const field of ['inlineEnd', 'blockEnd', 'clipInlineEnd', 'clipBlockEnd']) { + view.setFloat32(regionOffset + region[field], 100, true); + } + + view.setUint32(exclusionOffset + exclusion.id, 2, true); + view.setUint32(exclusionOffset + exclusion.regionId, 1, true); + view.setUint32(exclusionOffset + exclusion.geometryRevision, 1, true); + view.setUint8(exclusionOffset + exclusion.shape, abi.engine.flowShapeKinds.rectangle); + view.setUint8(exclusionOffset + exclusion.wrapSide, abi.engine.exclusionWrapSides.both); + view.setFloat32(exclusionOffset + exclusion.inlineStart, 20, true); + view.setFloat32(exclusionOffset + exclusion.blockStart, 20, true); + view.setFloat32(exclusionOffset + exclusion.inlineEnd, 40, true); + view.setFloat32(exclusionOffset + exclusion.blockEnd, 40, true); + + view.setUint32(inlineObjectOffset + inlineObject.paragraphId, 1, true); + view.setUint32(inlineObjectOffset + inlineObject.id, 3, true); + view.setUint32(inlineObjectOffset + inlineObject.contentRevision, 1, true); + view.setUint32(inlineObjectOffset + inlineObject.textOffset, 1, true); + view.setUint32(inlineObjectOffset + inlineObject.materialId, 1, true); + view.setUint32(inlineObjectOffset + inlineObject.resourceId, 4, true); + view.setUint32(inlineObjectOffset + inlineObject.resourceGeneration, 1, true); + view.setFloat32(inlineObjectOffset + inlineObject.inlineExtent, 10, true); + view.setFloat32(inlineObjectOffset + inlineObject.blockExtent, 10, true); + view.setUint8(inlineObjectOffset + inlineObject.baselineAlignment, abi.engine.inlineObjectBaselines.alphabetic); + return bytes; +} + +function assertResult(memory, pointer, abi, expected) { + const layout = abi.layouts.engineResult; + const view = new DataView(memory.buffer, pointer, layout.size); + assert.equal(view.getUint32(layout.abiVersion, true), abi.version); + assert.equal(view.getUint32(layout.byteLength, true), layout.size); + assert.equal(view.getUint32(layout.sessionId, true), sessionId); + for (const [field, value] of Object.entries(expected)) { + assert.equal(view.getUint32(layout[field], true), value, field); + } + if (expected.status === abi.status.ok) { + assert.equal(view.getUint32(layout.policyHandle, true), policyHandle); + assert.equal(view.getUint32(layout.capabilitySet, true), 1); + assert.notEqual( + view.getUint32(layout.policyFingerprintLow, true) | view.getUint32(layout.policyFingerprintHigh, true), + 0, + 'a successful plan identifies its validated policy bytes', + ); + } else { + assert.equal(view.getUint32(layout.policyHandle, true), 0); + assert.equal(view.getUint32(layout.policyFingerprintLow, true), 0); + assert.equal(view.getUint32(layout.policyFingerprintHigh, true), 0); + } + for (const field of [ + 'semanticsCount', + 'resourceCount', + 'bufferCount', + 'patchCount', + 'primitiveCount', + 'drawCount', + 'retirementCount', + 'diagnosticCount', + ]) { + assert.equal(view.getUint32(layout[field], true), 0, field); + } +} + +function resultBytes(memory, pointer, layout) { + return new Uint8Array(memory.buffer, pointer, layout.size); +} diff --git a/packages/text/tests/integration/render-policy-registration.test.mjs b/packages/text/tests/integration/render-policy-registration.test.mjs new file mode 100644 index 00000000..8f03f8b2 --- /dev/null +++ b/packages/text/tests/integration/render-policy-registration.test.mjs @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import { renderPolicyBytes } from '../support/engine-abi.mjs'; + +const wasmUrl = new URL('../../dist/text_shaper.wasm', import.meta.url); +const abiUrl = new URL('../../dist/text-shaper-abi-v0.json', import.meta.url); + +test('registers compiler-mapped render policies as retained typed Wasm state', async () => { + const [wasm, abi] = await Promise.all([readFile(wasmUrl), readFile(abiUrl, 'utf8').then(JSON.parse)]); + const module = await WebAssembly.compile(wasm); + const instance = await WebAssembly.instantiate(module, {}); + const memory = instance.exports[abi.memory]; + const initialize = instance.exports[abi.functions.initialize]; + const allocate = instance.exports[abi.functions.allocate]; + const deallocate = instance.exports[abi.functions.deallocate]; + const register = instance.exports[abi.functions.registerPolicy]; + const dispose = instance.exports[abi.functions.disposePolicy]; + const count = instance.exports[abi.functions.policyCount]; + assert.ok(memory instanceof WebAssembly.Memory); + const initialMemoryBytes = memory.buffer.byteLength; + assert.equal(initialize(), abi.status.ok); + const initializedMemoryBytes = memory.buffer.byteLength; + assert.ok(initializedMemoryBytes > initialMemoryBytes, 'initialization must prewarm the shared record workspace'); + assert.equal(initialize(), abi.status.ok); + assert.equal(memory.buffer.byteLength, initializedMemoryBytes, 'repeated initialization must not grow memory'); + assert.equal(typeof allocate, 'function'); + assert.equal(typeof deallocate, 'function'); + assert.equal(typeof register, 'function'); + assert.equal(typeof dispose, 'function'); + assert.equal(typeof count, 'function'); + assert.equal(abi.layouts.policyRequest.size, 44); + assert.equal(abi.layouts.policyProgram.size, 64); + assert.deepEqual(abi.layouts.policyInput, { alignment: 2, field: 1, reserved: 2, scope: 0, size: 4 }); + assert.deepEqual(abi.policy.inputScopes, { glyph: 2, resource: 3, semantic: 1, strike: 4 }); + + const bytes = renderPolicyBytes(abi); + const pointer = allocate(bytes.byteLength); + assert.notEqual(pointer, 0); + new Uint8Array(memory.buffer, pointer, bytes.byteLength).set(bytes); + const beforePolicyMemoryBytes = memory.buffer.byteLength; + + assert.equal(count(), 0); + assert.equal(register(7, pointer, bytes.byteLength), abi.status.ok); + const registeredPolicyMemoryBytes = memory.buffer.byteLength; + assert.ok(registeredPolicyMemoryBytes > beforePolicyMemoryBytes, 'policy registration must prewarm its exact lanes'); + assert.equal(register(7, pointer, bytes.byteLength), abi.status.ok, 'identical registration is idempotent'); + assert.equal(memory.buffer.byteLength, registeredPolicyMemoryBytes, 'idempotent registration must not grow memory'); + assert.equal(count(), 1); + + const request = abi.layouts.policyRequest; + const program = abi.layouts.policyProgram; + const input = abi.layouts.policyInput; + const inputsOffset = new DataView(bytes.buffer).getUint32(request.inputsOffset, true); + new DataView(memory.buffer).setUint8(pointer + inputsOffset + input.scope, abi.policy.inputScopes.glyph); + assert.equal(register(7, pointer, bytes.byteLength), abi.status.policyConflict); + new DataView(memory.buffer).setUint8(pointer + inputsOffset + input.scope, abi.policy.inputScopes.semantic); + const programsOffset = new DataView(bytes.buffer).getUint32(request.programsOffset, true); + new DataView(memory.buffer).setUint32(pointer + programsOffset + program.techniqueId, 2, true); + assert.equal(register(7, pointer, bytes.byteLength), abi.status.policyConflict); + assert.equal(count(), 1); + + deallocate(pointer, bytes.byteLength); + assert.equal(count(), 1, 'validated policy state must not borrow the registration allocation'); + assert.equal(dispose(7), abi.status.ok); + assert.equal(dispose(7), abi.status.policyMissing); + assert.equal(count(), 0); + assert.equal(register(8, pointer, bytes.byteLength), abi.status.invalidRequest); +}); diff --git a/packages/text/tests/integration/shaper-registration.test.mjs b/packages/text/tests/integration/shaper-registration.test.mjs index 0d7ce91b..93306a8f 100644 --- a/packages/text/tests/integration/shaper-registration.test.mjs +++ b/packages/text/tests/integration/shaper-registration.test.mjs @@ -2,15 +2,15 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; -import { createRuntimeShaper, FontRegistry } from '@pmndrs/text'; +import { FontRegistry } from '@pmndrs/text'; +import { createRuntimeShaper } from '../../dist/shaper.js'; import { createFontBaker } from '@pmndrs/text-font-baker'; import { validateFontArtifact } from '@pmndrs/text-font-baker/validate'; +import { fontBindingBytes, renderPolicyBytes, renderPolicyBytesFromPrograms } from '../support/engine-abi.mjs'; const fixtureDirectory = new URL('../../../../apps/benchmarks/fixtures/fonts/inter-v4.1/', import.meta.url); const shaperWasmUrl = new URL('../../dist/text_shaper.wasm', import.meta.url); const shaperAbiUrl = new URL('../../dist/text-shaper-abi-v0.json', import.meta.url); -const shapingDirectory = new URL('../../../../apps/benchmarks/fixtures/shaping/inter-regular/', import.meta.url); - async function fixture() { const [source, bakerWasm, shaperWasm] = await Promise.all([ readFile(new URL('Inter-Regular.ttf', fixtureDirectory)), @@ -78,383 +78,414 @@ test('the shaper registers only the exact shaping views retained from the valida assert.throws(() => shaper.memoryReport(), /disposed/); }); -test('shaper ownership stays scoped to its FontRegistry', async () => { - const { artifact, shaperWasm } = await fixture(); - const firstRegistry = new FontRegistry(); - const secondRegistry = new FontRegistry(); - const foreign = await firstRegistry.registerAsset(artifact); - const shaper = await createRuntimeShaper({ registry: secondRegistry, wasm: shaperWasm }); - - assert.throws(() => shaper.registerFont(foreign), /not active in this shaper's registry/); - shaper.dispose(); - foreign.dispose(); -}); - -test('re-registering the same artifact creates a new lifecycle without reviving stale handles', async () => { +test('compiled Wasm retains ordered font stacks and prevents dangling font disposal', async () => { const { artifact, shaperWasm } = await fixture(); - const registry = new FontRegistry(); - const first = await registry.registerAsset(artifact); - const firstHandle = first.handle; - const shaper = await createRuntimeShaper({ registry, wasm: shaperWasm }); - const request = { - textUtf16: utf16('A'), - features: [], - runs: [ - { - font: firstHandle, - textStart: 0, - textEnd: 1, - direction: 'ltr', - script: 'Latn', - language: 'en', - clusterLevel: 0, - flags: 0x40, - featureStart: 0, - featureCount: 0, - }, - ], - }; - const ranges = [{ run: 0, itemStart: 0, itemEnd: 1, contextStart: 0, contextEnd: 1, flags: 0x40 }]; + const [validated, abi] = await Promise.all([ + validateFontArtifact(artifact), + readFile(shaperAbiUrl, 'utf8').then(JSON.parse), + ]); + const instance = await WebAssembly.instantiate(await WebAssembly.compile(shaperWasm), {}); + const memory = instance.exports[abi.memory]; + const fn = Object.fromEntries( + Object.entries(abi.functions).map(([name, exported]) => [name, instance.exports[exported]]), + ); + assert.equal(fn.initialize(), abi.status.ok); + const allocations = [ + copyToWasm(memory, fn.allocate, validated.shapingSfnt), + copyToWasm(memory, fn.allocate, validated.glyphExtents), + copyToWasm(memory, fn.allocate, validated.glyphExtentsAvailability), + ]; + assert.equal( + fn.registerFont( + 101, + allocations[0].pointer, + allocations[0].length, + allocations[1].pointer, + allocations[1].length, + allocations[2].pointer, + allocations[2].length, + ), + abi.status.ok, + ); + for (const allocation of allocations) fn.deallocate(allocation.pointer, allocation.length); - shaper.registerFont(first); - assert.equal(shaper.shapeBatch(request).glyphIds.length, 1); - first.dispose(); - assert.throws(() => shaper.shapeBatch(request), /font handle .* is not registered/); - assert.throws(() => shaper.reshapeRanges({ ...request, ranges }), /font handle .* is not registered/); + assert.deepEqual(abi.layouts.fontBindingStrike, { alignment: 4, ppem: 0, reserved: 4, size: 8 }); + assert.deepEqual(abi.layouts.fontBindingResource, { + alignment: 4, + generation: 4, + id: 0, + kind: 8, + reference: 12, + reserved: 10, + size: 16, + }); + const bindingBytes = fontBindingBytes(abi, { + techniqueId: 1, + glyphCount: validated.glyphExtents.byteLength / 8, + strikes: [0], + resources: [{ id: 71, generation: 1, kind: 1, reference: 19 }], + resourceIndices: new Array(validated.glyphExtents.byteLength / 8).fill(0), + glyphF32: [new Array(validated.glyphExtents.byteLength / 8).fill(1)], + }); + const binding = copyToWasm(memory, fn.allocate, bindingBytes); + assert.equal(fn.fontBindingCount(), 0); + assert.equal(fn.registerFontBinding(101, 101, binding.pointer, binding.length), abi.status.ok); + assert.equal(fn.registerFontBinding(101, 101, binding.pointer, binding.length), abi.status.ok); + assert.equal(fn.fontBindingCount(), 1); + new DataView(memory.buffer).setUint32(binding.pointer + abi.layouts.fontBindingRequest.techniqueId, 2, true); + assert.equal(fn.registerFontBinding(101, 101, binding.pointer, binding.length), abi.status.policyConflict); + assert.equal( + fn.registerFontBinding(102, 101, binding.pointer, binding.length), + abi.status.ok, + 'one shaping font may carry another independently selectable raster binding', + ); + fn.deallocate(binding.pointer, binding.length); + assert.equal(fn.fontBindingCount(), 2, 'binding state must not borrow the registration allocation'); - const second = await registry.registerAsset(artifact); - assert.notEqual(second.handle, firstHandle); - assert.equal(second.shapingHash, first.shapingHash); - shaper.registerFont(second); - const secondRequest = { - ...request, - runs: [{ ...request.runs[0], font: second.handle }], - }; - assert.equal(shaper.shapeBatch(secondRequest).glyphIds.length, 1); - assert.throws(() => shaper.shapeBatch(request), /font handle .* is not registered/); + const stack = copyToWasm(memory, fn.allocate, Uint8Array.of(101, 0, 0, 0)); + assert.equal(fn.registerFontStack(17, stack.pointer, 1), abi.status.ok); + fn.deallocate(stack.pointer, stack.length); + assert.equal(fn.fontStackCount(), 1); - second.dispose(); - shaper.dispose(); -}); + const policyBytes = renderPolicyBytes(abi); + const policy = copyToWasm(memory, fn.allocate, policyBytes); + assert.equal(fn.registerPolicy(23, policy.pointer, policy.length), abi.status.ok); + fn.deallocate(policy.pointer, policy.length); + assert.equal(fn.createSession(29, 2048, 64 * 1024, 4), abi.status.ok); + const styleWarmBuffer = memory.buffer; + const initialUpdate = engineStyleUpdateBytes(abi, { + sessionId: 29, + policyHandle: 23, + fontStackHandle: 17, + text: [0x61, 0x62, 0x63, 0x64], + geometry: true, + }); + assert.equal(fn.planCount(), 0); + let requestPointer = fn.requestPointer(29); + new Uint8Array(memory.buffer, requestPointer, initialUpdate.byteLength).set(initialUpdate); + let resultPointer = fn.textUpdate(29, requestPointer, initialUpdate.byteLength); + assert.strictEqual(memory.buffer, styleWarmBuffer, 'the pre-reserved first style update must not grow Wasm memory'); + let result = new DataView(memory.buffer, resultPointer, abi.layouts.engineResult.size); + assert.equal(result.getUint32(abi.layouts.engineResult.status, true), abi.status.ok); + assert.equal(result.getUint32(abi.layouts.engineResult.engineRevision, true), 1); + for (const field of ['resourceCount', 'bufferCount', 'patchCount', 'primitiveCount', 'drawCount']) { + assert.ok(result.getUint32(abi.layouts.engineResult[field], true) > 0, `${field} must be nonempty`); + } + assert.equal(fn.planCount(), 1, 'text_update must shape retained runs through HarfRust'); -test('shapes every pinned HarfRust case exactly from GLB-extracted font data', async () => { - const [{ artifact, shaperWasm }, corpus, oracle] = await Promise.all([ - fixture(), - readJson(new URL('cases.json', shapingDirectory)), - readJson(new URL('harfrust.json', shapingDirectory)), - ]); - const registry = new FontRegistry(); - const font = await registry.registerAsset(artifact); - const shaper = await createRuntimeShaper({ registry, wasm: shaperWasm }); - shaper.registerFont(font); - const corpusCases = new Map(corpus.cases.map((entry) => [entry.id, entry])); + const warmUpdate = engineStyleUpdateBytes(abi, { + sessionId: 29, + policyHandle: 23, + fontStackHandle: 17, + expectedEngineRevision: 1, + consumedPlanRevision: 1, + acknowledgedPublicationGeneration: 1, + textEnd: 4, + geometry: true, + }); + requestPointer = fn.requestPointer(29); + new Uint8Array(memory.buffer, requestPointer, warmUpdate.byteLength).set(warmUpdate); + resultPointer = fn.textUpdate(29, requestPointer, warmUpdate.byteLength); + assert.strictEqual(memory.buffer, styleWarmBuffer, 'the identical nonempty frame must stay allocation-free'); + result = new DataView(memory.buffer, resultPointer, abi.layouts.engineResult.size); + assert.equal(result.getUint32(abi.layouts.engineResult.status, true), abi.status.ok); + assert.equal(result.getUint32(abi.layouts.engineResult.engineRevision, true), 2); + assert.equal(result.getUint32(abi.layouts.engineResult.patchCount, true), 0); - for (const expected of oracle.cases) { - const fixtureCase = corpusCases.get(expected.id); - assert.ok(fixtureCase, `missing corpus case ${expected.id}`); - const request = shapeRequest(font.handle, expected, fixtureCase); - assertShapedBatch(shaper.shapeBatch(request), font.handle, expected.glyphs); - assertShapedBatch( - shaper.reshapeRanges({ - ...request, - ranges: [ - { - run: 0, - itemStart: 0, - itemEnd: request.textUtf16.length, - contextStart: 0, - contextEnd: request.textUtf16.length, - flags: 0x40, - }, - ], - }), - font.handle, - expected.glyphs, - ); - } + const removeRoot = engineStyleUpdateBytes(abi, { + sessionId: 29, + policyHandle: 23, + fontStackHandle: 17, + expectedEngineRevision: 2, + consumedPlanRevision: 2, + acknowledgedPublicationGeneration: 2, + removeRoot: true, + }); + requestPointer = fn.requestPointer(29); + new Uint8Array(memory.buffer, requestPointer, removeRoot.byteLength).set(removeRoot); + resultPointer = fn.textUpdate(29, requestPointer, removeRoot.byteLength); + assert.strictEqual(memory.buffer, styleWarmBuffer, 'an invalid retained style update must not grow Wasm memory'); + result = new DataView(memory.buffer, resultPointer, abi.layouts.engineResult.size); + assert.equal(result.getUint32(abi.layouts.engineResult.status, true), abi.status.invalidRequest); + assert.equal(result.getUint32(abi.layouts.engineResult.engineRevision, true), 2); + assert.equal(fn.planCount(), 1, 'an aborted update must not perform another shape'); + assert.equal(fn.disposeSession(29), abi.status.ok); + assert.equal(fn.disposePolicy(23), abi.status.ok); - assert.equal(shaper.memoryReport().planCount, 3); - const first = oracle.cases[0]; - const firstCase = corpusCases.get(first.id); - shaper.shapeBatch(shapeRequest(font.handle, first, firstCase)); - assert.equal(shaper.memoryReport().planCount, 3, 'equivalent plans must be reused'); - font.dispose(); - assert.equal(shaper.memoryReport().planCount, 0, 'font disposal must release its plans'); - shaper.dispose(); + assert.equal(fn.disposeFont(101), abi.status.fontInUse); + assert.equal(fn.disposeFontStack(17), abi.status.ok); + assert.equal(fn.disposeFontStack(17), abi.status.fontStackMissing); + assert.equal(fn.disposeFont(101), abi.status.ok); + assert.equal(fn.fontBindingCount(), 0); }); -test('rejects malformed batch fields before or at the Wasm trust boundary', async () => { - const { artifact, shaperWasm } = await fixture(); - const registry = new FontRegistry(); - const font = await registry.registerAsset(artifact); - const shaper = await createRuntimeShaper({ registry, wasm: shaperWasm }); - shaper.registerFont(font); - const base = { - textUtf16: utf16('A'), - features: [], - runs: [ - { - font: font.handle, - textStart: 0, - textEnd: 1, - direction: 'ltr', - script: 'Latn', - language: 'en', - clusterLevel: 0, - flags: 0x40, - featureStart: 0, - featureCount: 0, - }, - ], - }; - assert.throws( - () => shaper.shapeBatch({ ...base, runs: [{ ...base.runs[0], script: 'Latin' }] }), - /exactly four bytes/, - ); - assert.throws(() => shaper.shapeBatch({ ...base, runs: [{ ...base.runs[0], flags: 0x100 }] }), /unknown bits/); - assert.throws( - () => - shaper.reshapeRanges({ - ...base, - ranges: [{ run: 0, itemStart: 0, itemEnd: 2, contextStart: 0, contextEnd: 2, flags: 0 }], - }), - /outside its run context/, +test('text_update advances missing clusters through an ordered font stack', async () => { + const [interArtifact, devanagariArtifact, shaperWasm, abi] = await Promise.all([ + readFile(new URL('../../../../apps/benchmarks/fixtures/rendering/inter-bitmap-16.font.glb', import.meta.url)), + readFile( + new URL( + '../../../../apps/benchmarks/fixtures/rendering/noto-sans-devanagari-bitmap-16.font.glb', + import.meta.url, + ), + ), + readFile(shaperWasmUrl), + readFile(shaperAbiUrl, 'utf8').then(JSON.parse), + ]); + const [inter, devanagari] = await Promise.all([ + validateFontArtifact(interArtifact), + validateFontArtifact(devanagariArtifact), + ]); + const instance = await WebAssembly.instantiate(await WebAssembly.compile(shaperWasm), {}); + const memory = instance.exports[abi.memory]; + const fn = Object.fromEntries( + Object.entries(abi.functions).map(([name, exported]) => [name, instance.exports[exported]]), ); + assert.equal(fn.initialize(), abi.status.ok); + registerValidatedFont({ abi, fn, memory }, 101, inter); + registerValidatedFont({ abi, fn, memory }, 202, devanagari); + registerSimpleBinding({ abi, fn, memory }, 1001, 101, inter, 71, 1); + registerSimpleBinding({ abi, fn, memory }, 1002, 202, devanagari, 72, 2); - for (const language of ['zh-hans', 'zh-hant', 'ja', 'ko']) { - assert.doesNotThrow(() => shaper.shapeBatch({ ...base, runs: [{ ...base.runs[0], language }] })); - } - assert.equal(shaper.memoryReport().planCount, 4, 'canonical CJK languages must remain distinct shape-plan inputs'); - for (const language of ['en\0us', 'en\nus', 'en--us']) { - assert.throws(() => shaper.shapeBatch({ ...base, runs: [{ ...base.runs[0], language }] }), /invalid batch request/); - } - assert.throws( - () => shaper.shapeBatch({ ...base, runs: [{ ...base.runs[0], language: '' }] }), - /must encode to 1\.\.65535 UTF-8 bytes/, + const stack = copyToWasm(memory, fn.allocate, Uint8Array.of(0xe9, 3, 0, 0, 0xea, 3, 0, 0)); + assert.equal(fn.registerFontStack(17, stack.pointer, 2), abi.status.ok); + fn.deallocate(stack.pointer, stack.length); + const policyBytes = twoTechniquePolicyBytes(abi); + const policy = copyToWasm(memory, fn.allocate, policyBytes); + assert.equal(fn.registerPolicy(23, policy.pointer, policy.length), abi.status.ok); + fn.deallocate(policy.pointer, policy.length); + assert.equal(fn.createSession(29, 2048, 64 * 1024, 0), abi.status.ok); + + const update = engineStyleUpdateBytes(abi, { + sessionId: 29, + policyHandle: 23, + fontStackHandle: 17, + text: [0x0915], + geometry: true, + }); + const requestPointer = fn.requestPointer(29); + new Uint8Array(memory.buffer, requestPointer, update.byteLength).set(update); + const resultPointer = fn.textUpdate(29, requestPointer, update.byteLength); + const result = new DataView(memory.buffer, resultPointer, abi.layouts.engineResult.size); + assert.equal(result.getUint32(abi.layouts.engineResult.status, true), abi.status.ok); + const primitivesOffset = result.getUint32(abi.layouts.engineResult.primitivesOffset, true); + assert.equal( + new DataView(memory.buffer).getUint32( + resultPointer + primitivesOffset + abi.layouts.enginePrimitive.techniqueId, + true, + ), + 2, + 'the fallback glyph must retain its own raster technique in the Rust render plan', ); - assert.throws( - () => - shaper.shapeBatch({ - ...base, - runs: [{ ...base.runs[0], language: 'a'.repeat(0x1_0000) }], - }), - /must encode to 1\.\.65535 UTF-8 bytes/, + assert.equal( + fn.planCount(), + 2, + 'Inter must shape .notdef before the Devanagari cluster advances to the fallback font', ); - assert.equal(shaper.memoryReport().planCount, 4, 'invalid languages must not create plans'); - shaper.dispose(); - font.dispose(); }); -test("bounds each font's least-recently-used shape plans", async () => { +test('shaper ownership stays scoped to its FontRegistry', async () => { const { artifact, shaperWasm } = await fixture(); - const registry = new FontRegistry(); - const font = await registry.registerAsset(artifact); - const shaper = await createRuntimeShaper({ registry, wasm: shaperWasm }); - shaper.registerFont(font); - const base = { - textUtf16: utf16('A'), - features: [], - runs: [ - { - font: font.handle, - textStart: 0, - textEnd: 1, - direction: 'ltr', - script: 'Latn', - clusterLevel: 0, - flags: 0x40, - featureStart: 0, - featureCount: 0, - }, - ], - }; - - for (let index = 0; index < 80; index += 1) { - shaper.shapeBatch({ - ...base, - runs: [{ ...base.runs[0], language: `en-x-${index.toString(36)}` }], - }); - } - assert.equal(shaper.memoryReport().planCount, 64); - shaper.shapeBatch({ ...base, runs: [{ ...base.runs[0], language: 'en-x-0' }] }); - assert.equal(shaper.memoryReport().planCount, 64, 'revisiting an evicted plan must preserve the bound'); + const firstRegistry = new FontRegistry(); + const secondRegistry = new FontRegistry(); + const foreign = await firstRegistry.registerAsset(artifact); + const shaper = await createRuntimeShaper({ registry: secondRegistry, wasm: shaperWasm }); + assert.throws(() => shaper.registerFont(foreign), /not active in this shaper's registry/); shaper.dispose(); - font.dispose(); + foreign.dispose(); }); -test('one coarse call shapes and reshapes multiple runs with absolute UTF-16 clusters', async () => { - const [{ artifact, shaperWasm }, oracle] = await Promise.all([ - fixture(), - readJson(new URL('harfrust.json', shapingDirectory)), - ]); - const registry = new FontRegistry(); - const font = await registry.registerAsset(artifact); - const shaper = await createRuntimeShaper({ registry, wasm: shaperWasm }); - shaper.registerFont(font); - const expectedRuns = [ - oracle.cases.find(({ id }) => id === 'ligature-default'), - oracle.cases.find(({ id }) => id === 'combining-mark'), +function copyToWasm(memory, allocate, source) { + const bytes = new Uint8Array(source.buffer, source.byteOffset, source.byteLength); + const pointer = allocate(bytes.byteLength); + assert.notEqual(pointer, 0); + new Uint8Array(memory.buffer, pointer, bytes.byteLength).set(bytes); + return { pointer, length: bytes.byteLength }; +} + +function registerValidatedFont({ abi, fn, memory }, handle, validated) { + const allocations = [ + copyToWasm(memory, fn.allocate, validated.shapingSfnt), + copyToWasm(memory, fn.allocate, validated.glyphExtents), + copyToWasm(memory, fn.allocate, validated.glyphExtentsAvailability), ]; - assert.ok(expectedRuns.every(Boolean)); - const combinedText = expectedRuns.map(({ text }) => text).join(''); - const textUtf16 = utf16(combinedText); - let start = 0; - const runs = expectedRuns.map((expected) => { - const end = start + utf16(expected.text).length; - const run = { - font: font.handle, - textStart: start, - textEnd: end, - direction: expected.segment.direction, - script: expected.segment.script, - language: expected.segment.language, - clusterLevel: 0, - flags: 0x40, - featureStart: 0, - featureCount: 0, - }; - start = end; - return run; - }); - const request = { textUtf16, runs, features: [] }; - const expectedGlyphs = expectedRuns.map((expected, run) => - expected.glyphs.map((glyph) => ({ ...glyph, cluster: glyph.cluster + runs[run].textStart })), - ); - assertMultiRun(shaper.shapeBatch(request), font.handle, expectedGlyphs); - assert.equal(shaper.memoryReport().planCount, 1); - assertMultiRun( - shaper.reshapeRanges({ - ...request, - ranges: runs.map((run, index) => ({ - run: index, - itemStart: run.textStart, - itemEnd: run.textEnd, - contextStart: run.textStart, - contextEnd: run.textEnd, - flags: run.flags, - })), - }), - font.handle, - expectedGlyphs, + assert.equal( + fn.registerFont( + handle, + allocations[0].pointer, + allocations[0].length, + allocations[1].pointer, + allocations[1].length, + allocations[2].pointer, + allocations[2].length, + ), + abi.status.ok, ); - assert.equal(shaper.memoryReport().planCount, 1); - shaper.dispose(); - font.dispose(); -}); + for (const allocation of allocations) fn.deallocate(allocation.pointer, allocation.length); +} -function shapeRequest(font, expected, fixtureCase) { - const textUtf16 = utf16(expected.text); - assert.equal(textUtf16.length, fixtureCase.utf16Length); - const features = expected.segment.features.map((source) => { - const match = /^(.{4})(?:=(\d+))?$/.exec(source); - assert.ok(match, `unsupported fixture feature ${source}`); - return { - tag: match[1], - value: match[2] === undefined ? 1 : Number(match[2]), - start: 0, - end: textUtf16.length, - }; +function registerSimpleBinding({ abi, fn, memory }, bindingHandle, shapingHandle, validated, resourceId, techniqueId) { + const glyphCount = validated.glyphExtents.byteLength / 8; + const bytes = fontBindingBytes(abi, { + techniqueId, + glyphCount, + strikes: [0], + resources: [{ id: resourceId, generation: 1, kind: 1, reference: resourceId }], + resourceIndices: new Array(glyphCount).fill(0), + glyphF32: [new Array(glyphCount).fill(1)], }); - return { - textUtf16, - features, - runs: [ - { - font, - textStart: 0, - textEnd: textUtf16.length, - direction: expected.segment.direction, - script: expected.segment.script, - language: expected.segment.language, - clusterLevel: 0, - flags: 0x40, - featureStart: 0, - featureCount: features.length, - }, + const allocation = copyToWasm(memory, fn.allocate, bytes); + assert.equal( + fn.registerFontBinding(bindingHandle, shapingHandle, allocation.pointer, allocation.length), + abi.status.ok, + ); + fn.deallocate(allocation.pointer, allocation.length); +} + +function twoTechniquePolicyBytes(abi) { + const program = (techniqueId, programId) => ({ + techniqueId, + programId, + f32InputCount: 1, + u32InputCount: 0, + buffers: [{ id: 1, scalar: abi.policy.scalarTypes.f32, vectorWidth: 1 }], + operations: [ + { opcode: abi.policy.opcodes.loadF32, target: 0, operand0: 0 }, + { opcode: abi.policy.opcodes.storeF32, operand0: 0, immediate0: 1 }, ], - }; + }); + return renderPolicyBytesFromPrograms(abi, [program(1, 1), program(2, 2)]); } -function assertShapedBatch(actual, fontHandle, glyphs) { - assert.deepEqual([...actual.fontHandles], [fontHandle]); - assert.deepEqual([...actual.runFontSlots], [0]); - assert.deepEqual([...actual.runGlyphStarts], [0]); - assert.deepEqual([...actual.runGlyphCounts], [glyphs.length]); - assert.deepEqual( - { - glyphIds: [...actual.glyphIds], - clusters: [...actual.clusters], - xAdvances: [...actual.xAdvances], - yAdvances: [...actual.yAdvances], - xOffsets: [...actual.xOffsets], - yOffsets: [...actual.yOffsets], - glyphFlags: [...actual.glyphFlags], - }, - { - glyphIds: glyphs.map(({ glyphId }) => glyphId), - clusters: glyphs.map(({ cluster }) => cluster), - xAdvances: glyphs.map(({ xAdvance }) => xAdvance), - yAdvances: glyphs.map(({ yAdvance }) => yAdvance), - xOffsets: glyphs.map(({ xOffset }) => xOffset), - yOffsets: glyphs.map(({ yOffset }) => yOffset), - glyphFlags: glyphs.map(({ flags }) => flags), - }, +function engineStyleUpdateBytes( + abi, + { + sessionId, + policyHandle, + fontStackHandle, + expectedEngineRevision = 0, + consumedPlanRevision = 0, + acknowledgedPublicationGeneration = 0, + text = [], + textEnd = text.length, + removeRoot = false, + geometry = false, + }, +) { + const request = abi.layouts.engineUpdateRequest; + const paragraphRecord = abi.layouts.engineParagraphMutation; + const textRecord = abi.layouts.engineTextMutation; + const styleRecord = abi.layouts.engineStyleMutation; + const paragraphRecordOffset = align(request.size, paragraphRecord.alignment); + const textRecordOffset = text.length === 0 ? 0 : paragraphRecordOffset + paragraphRecord.size; + const styleRecordOffset = align( + paragraphRecordOffset + paragraphRecord.size + (text.length === 0 ? 0 : textRecord.size), + styleRecord.alignment, ); -} + const textPayloadOffset = styleRecordOffset + styleRecord.size; + const textPayloadEnd = textPayloadOffset + text.length * 2; + const constraint = abi.layouts.engineConstraint; + const region = abi.layouts.engineRegion; + const constraintOffset = geometry ? align(textPayloadEnd, constraint.alignment) : 0; + const regionOffset = geometry ? align(constraintOffset + constraint.size, region.alignment) : 0; + const byteLength = geometry ? regionOffset + region.size : textPayloadEnd; + const bytes = new Uint8Array(byteLength); + const view = new DataView(bytes.buffer); + view.setUint32(request.abiVersion, abi.version, true); + view.setUint32(request.byteLength, bytes.byteLength, true); + view.setUint32(request.sessionId, sessionId, true); + view.setUint32(request.expectedEngineRevision, expectedEngineRevision, true); + view.setUint32(request.consumedPlanRevision, consumedPlanRevision, true); + view.setUint32(request.acknowledgedPublicationGeneration, acknowledgedPublicationGeneration, true); + view.setUint32(request.policyHandle, policyHandle, true); + view.setUint32(request.capabilitySet, 1, true); + view.setUint32(request.maxParagraphs, 1, true); + for (const field of [ + 'maxClusters', + 'maxLines', + 'maxRegions', + 'maxExclusions', + 'maxInlineObjects', + 'maxSlotsPerBand', + ]) { + view.setUint32(request[field], field === 'maxClusters' ? 2 : 1, true); + } + view.setUint32(request.maxOutputBytes, 64 * 1024, true); + view.setUint32(request.paragraphMutationsOffset, paragraphRecordOffset, true); + view.setUint32(request.paragraphMutationCount, 1, true); + view.setUint32(request.textMutationsOffset, textRecordOffset, true); + view.setUint32(request.textMutationCount, text.length === 0 ? 0 : 1, true); + view.setUint32(request.styleMutationsOffset, styleRecordOffset, true); + view.setUint32(request.styleMutationCount, 1, true); + if (geometry) { + view.setUint32(request.constraintsOffset, constraintOffset, true); + view.setUint32(request.constraintCount, 1, true); + view.setUint32(request.regionsOffset, regionOffset, true); + view.setUint32(request.regionCount, 1, true); + } + + view.setUint8(paragraphRecordOffset + paragraphRecord.opcode, abi.engine.paragraphMutationOpcodes.upsert); + view.setUint32(paragraphRecordOffset + paragraphRecord.paragraphId, 1, true); -function assertMultiRun(actual, fontHandle, runs) { - const glyphs = runs.flat(); - const starts = []; - let start = 0; - for (const run of runs) { - starts.push(start); - start += run.length; + if (text.length > 0) { + view.setUint8(textRecordOffset + textRecord.opcode, abi.engine.textMutationOpcodes.replaceUtf16); + view.setUint8(textRecordOffset + textRecord.encoding, abi.engine.textEncodings.utf16Le); + view.setUint32(textRecordOffset + textRecord.paragraphId, 1, true); + view.setUint32(textRecordOffset + textRecord.insertOffset, textPayloadOffset, true); + view.setUint32(textRecordOffset + textRecord.insertCount, text.length, true); + for (const [index, unit] of text.entries()) view.setUint16(textPayloadOffset + index * 2, unit, true); } - assert.deepEqual([...actual.fontHandles], [fontHandle]); - assert.deepEqual( - [...actual.runFontSlots], - runs.map(() => 0), - ); - assert.deepEqual([...actual.runGlyphStarts], starts); - assert.deepEqual( - [...actual.runGlyphCounts], - runs.map((run) => run.length), - ); - assert.deepEqual( - [...actual.glyphIds], - glyphs.map(({ glyphId }) => glyphId), - ); - assert.deepEqual( - [...actual.clusters], - glyphs.map(({ cluster }) => cluster), - ); - assert.deepEqual( - [...actual.xAdvances], - glyphs.map(({ xAdvance }) => xAdvance), - ); - assert.deepEqual( - [...actual.yAdvances], - glyphs.map(({ yAdvance }) => yAdvance), - ); - assert.deepEqual( - [...actual.xOffsets], - glyphs.map(({ xOffset }) => xOffset), - ); - assert.deepEqual( - [...actual.yOffsets], - glyphs.map(({ yOffset }) => yOffset), - ); - assert.deepEqual( - [...actual.glyphFlags], - glyphs.map(({ flags }) => flags), + + view.setUint8( + styleRecordOffset + styleRecord.opcode, + removeRoot ? abi.engine.styleMutationOpcodes.remove : abi.engine.styleMutationOpcodes.upsert, ); -} + view.setUint32(styleRecordOffset + styleRecord.paragraphId, 1, true); + view.setUint32(styleRecordOffset + styleRecord.styleId, 1, true); + if (!removeRoot) { + view.setUint8(styleRecordOffset + styleRecord.flags, abi.engine.styleFlags.root); + view.setUint32( + styleRecordOffset + styleRecord.fieldMask, + abi.engine.styleFields.fontStack | + abi.engine.styleFields.fontSize | + abi.engine.styleFields.lineHeight | + abi.engine.styleFields.rasterPixelRatio, + true, + ); + view.setUint32(styleRecordOffset + styleRecord.textEnd, textEnd, true); + view.setUint32(styleRecordOffset + styleRecord.fontStackHandle, fontStackHandle, true); + view.setFloat32(styleRecordOffset + styleRecord.fontSize, 16, true); + view.setFloat32(styleRecordOffset + styleRecord.lineHeight, 1.2, true); + view.setFloat32(styleRecordOffset + styleRecord.rasterPixelRatio, 1, true); + } + if (geometry) { + view.setUint32(constraintOffset + constraint.paragraphId, 1, true); + view.setUint32(constraintOffset + constraint.flowThreadId, 1, true); + view.setFloat32(constraintOffset + constraint.width, 100, true); + view.setFloat32(constraintOffset + constraint.height, 100, true); + view.setFloat32(constraintOffset + constraint.viewportBlockEnd, 100, true); + view.setUint32(constraintOffset + constraint.maxLines, 1, true); + view.setUint16(constraintOffset + constraint.regionCount, 1, true); + view.setUint8(constraintOffset + constraint.widthMode, abi.engine.axisModes.exact); + view.setUint8(constraintOffset + constraint.heightMode, abi.engine.axisModes.exact); + view.setUint8(constraintOffset + constraint.wrap, abi.engine.wrapModes.word); + view.setUint8(constraintOffset + constraint.align, abi.engine.inlineAlignments.start); + view.setUint8(constraintOffset + constraint.overflow, abi.engine.overflowModes.clip); + view.setUint8(constraintOffset + constraint.blockAlign, abi.engine.blockAlignments.start); -function utf16(value) { - return Uint16Array.from({ length: value.length }, (_, index) => value.charCodeAt(index)); + view.setUint32(regionOffset + region.id, 1, true); + view.setUint32(regionOffset + region.geometryRevision, 1, true); + view.setUint32(regionOffset + region.transformIndex, 1, true); + view.setUint8(regionOffset + region.shape, abi.engine.flowShapeKinds.rectangle); + view.setUint8(regionOffset + region.writingMode, abi.engine.writingModes.horizontalTb); + view.setUint8(regionOffset + region.textOrientation, abi.engine.textOrientations.mixed); + for (const field of ['inlineEnd', 'blockEnd', 'clipInlineEnd', 'clipBlockEnd']) { + view.setFloat32(regionOffset + region[field], 100, true); + } + } + return bytes; } -async function readJson(url) { - return JSON.parse(await readFile(url, 'utf8')); +function align(value, alignment) { + return Math.ceil(value / alignment) * alignment; } diff --git a/packages/text/tests/integration/slug-baker.test.mjs b/packages/text/tests/integration/slug-baker.test.mjs index 9b35b6ba..4257ae1e 100644 --- a/packages/text/tests/integration/slug-baker.test.mjs +++ b/packages/text/tests/integration/slug-baker.test.mjs @@ -5,8 +5,8 @@ import test from 'node:test'; import { createSlugBaker, createSlugBakerFromInstance, - readSlugBakerAbi, SlugBakeError, + slugBakerAbi, slugBakerFromCore, } from '../../dist/bakers/slug.js'; import { validateSlugArtifact } from '../../dist/bakers/slug-validator.js'; @@ -31,15 +31,14 @@ async function setup() { } test('ships the generated generic direct/segmented Slug ABI', async () => { - const { module, instance } = await setup(); + const { module } = await setup(); assert.deepEqual(WebAssembly.Module.imports(module), [ { module: 'env', name: 'pmndrs_text_bake_progress', kind: 'function' }, ]); - const generated = readSlugBakerAbi(instance); - assert.deepEqual(generated, JSON.parse(await readFile(abiUrl, 'utf8'))); - assert.equal(generated.response.magic, 'PMSL'); - assert.equal(generated.segmented.chunkByteLength, 8 * 1024 * 1024); - assert.deepEqual(generated.versions, { + assert.deepEqual(slugBakerAbi, JSON.parse(await readFile(abiUrl, 'utf8'))); + assert.equal(slugBakerAbi.response.magic, 'PMSL'); + assert.equal(slugBakerAbi.segmented.chunkByteLength, 8 * 1024 * 1024); + assert.deepEqual(slugBakerAbi.versions, { generator: '0.0.0', ktx2: '0.5.0', readFonts: '0.42.1', diff --git a/packages/text/tests/integration/text-engine-host.test.mjs b/packages/text/tests/integration/text-engine-host.test.mjs new file mode 100644 index 00000000..787a229e --- /dev/null +++ b/packages/text/tests/integration/text-engine-host.test.mjs @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import { TextEngineHost } from '../../dist/internal/text-engine-host.js'; +import { + firstPartyTechniqueWireIds, + firstPartyThreeRenderPolicyBytes, +} from '../../dist/internal/render-policy-wire.js'; +import { createRuntimeShaper } from '../../dist/shaper.js'; +import { engineUpdateBytes, renderPolicyBytes } from '../support/engine-abi.mjs'; + +const wasmUrl = new URL('../../dist/text_shaper.wasm', import.meta.url); +const abiUrl = new URL('../../dist/text-shaper-abi-v0.json', import.meta.url); + +test('production text-engine host publishes borrowed A/B plans through the runtime shaper instance', async () => { + const [wasm, abi] = await Promise.all([readFile(wasmUrl), readFile(abiUrl, 'utf8').then(JSON.parse)]); + const shaper = await createRuntimeShaper({ wasm }); + const host = new TextEngineHost(shaper); + const policyHandle = 11; + const sessionId = 5; + host.registerPolicy(policyHandle, renderPolicyBytes(abi)); + const firstRequest = engineUpdateBytes(abi, { + sessionId, + policyHandle, + expectedEngineRevision: 0, + consumedPlanRevision: 0, + }); + const session = host.createSession({ + handle: sessionId, + requestCapacity: firstRequest.byteLength, + resultCapacity: abi.layouts.engineResult.size, + }); + + const first = session.update(firstRequest); + assert.equal(first.engineRevision, 1); + assert.equal(first.planRevision, 1); + assert.equal(first.requiredBaseRevision, 0); + assert.equal(first.publicationGeneration, 1); + assert.equal(first.outputSlot, 0); + assert.equal(first.policyHandle, policyHandle); + assert.equal(first.bytes.byteLength, abi.layouts.engineResult.size); + const retainedFirst = first.bytes.slice(); + + const second = session.update( + engineUpdateBytes(abi, { + sessionId, + policyHandle, + expectedEngineRevision: first.engineRevision, + consumedPlanRevision: first.planRevision, + acknowledgedPublicationGeneration: first.publicationGeneration, + }), + ); + assert.equal(second.engineRevision, 2); + assert.equal(second.planRevision, 2); + assert.equal(second.requiredBaseRevision, first.planRevision); + assert.equal(second.publicationGeneration, 2); + assert.equal(second.outputSlot, 1); + assert.deepEqual(first.bytes, retainedFirst, 'publishing slot B must not mutate borrowed slot A'); + + host.dispose(); + assert.throws(() => session.update(firstRequest), /disposed/); + shaper.dispose(); +}); + +test('one deterministic Three policy registers Bitmap, MSDF, and Slug with material-directed draws', async () => { + const [wasm, abi] = await Promise.all([readFile(wasmUrl), readFile(abiUrl, 'utf8').then(JSON.parse)]); + assert.deepEqual(firstPartyTechniqueWireIds, { + bitmap: 0x1775_3b8c, + msdf: 0xf9a7_e4fd, + slug: 0xf22c_7908, + }); + const bytes = firstPartyThreeRenderPolicyBytes(); + const request = abi.layouts.policyRequest; + const program = abi.layouts.policyProgram; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + assert.equal(view.getUint32(request.programCount, true), 3); + const programsOffset = view.getUint32(request.programsOffset, true); + const expectedTechniques = [ + firstPartyTechniqueWireIds.bitmap, + firstPartyTechniqueWireIds.msdf, + firstPartyTechniqueWireIds.slug, + ]; + for (const [index, techniqueId] of expectedTechniques.entries()) { + const offset = programsOffset + index * program.size; + assert.equal(view.getUint32(offset + program.techniqueId, true), techniqueId); + assert.equal(view.getUint32(offset + program.programId, true), index + 1); + assert.ok(view.getUint32(offset + program.drawKeyMask, true) & abi.policy.batchFields.material); + assert.equal(view.getUint32(offset + program.storageKeyMask, true) & abi.policy.batchFields.material, 0); + } + + const shaper = await createRuntimeShaper({ wasm }); + const host = new TextEngineHost(shaper); + host.registerPolicy(12, bytes); + host.dispose(); + shaper.dispose(); +}); diff --git a/packages/text/tests/integration/text-runtime-v1.test.mjs b/packages/text/tests/integration/text-runtime-v1.test.mjs deleted file mode 100644 index 72a6396b..00000000 --- a/packages/text/tests/integration/text-runtime-v1.test.mjs +++ /dev/null @@ -1,385 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import test from 'node:test'; - -import { - createFontStack, - createRuntimeShaper, - createTextRuntime, - FontLeaseError, - FontRegistry, - span, - txt, -} from '@pmndrs/text'; -import { bitmap } from '@pmndrs/text/raster/bitmap'; -import { createTypeGpuTextEngine, defineTypeGpuRasterProgram } from '@pmndrs/text/typegpu'; - -const interUrl = new URL('../../../../apps/benchmarks/fixtures/rendering/inter-bitmap-16.font.glb', import.meta.url); -const devanagariUrl = new URL( - '../../../../apps/benchmarks/fixtures/rendering/noto-sans-devanagari-bitmap-16.font.glb', - import.meta.url, -); - -test('target-v1 runtime loads, falls back, batches, recovers capacity, and retains ownership', async () => { - const [interBytes, devanagariBytes] = await Promise.all([readFile(interUrl), readFile(devanagariUrl)]); - const registry = new FontRegistry(); - const shaper = await createRuntimeShaper({ - registry, - wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), - }); - const runtime = await createTextRuntime({ registry, shaper }); - const [inter, devanagari] = await Promise.all([ - runtime.loadFont({ - input: { baked: dataUrl(interBytes) }, - raster: { technique: bitmap, options: { strikes: [16] } }, - }), - runtime.loadFont({ - input: { baked: dataUrl(devanagariBytes) }, - raster: { technique: bitmap, options: { strikes: [16] } }, - }), - ]); - const uiFont = createFontStack(inter, devanagari); - const warning = span(devanagari, { color: '#ff00ff', fontSize: 18 }); - const labels = runtime.createParagraphBatch({ - technique: bitmap, - capacity: { size: 32, policy: 'fixed' }, - }); - const label = labels.add({ - font: uiFont, - text: txt`Latin and fallback: देवनागरी; ${warning`styled`}`, - }); - - assert.throws(() => inter.dispose(), FontLeaseError); - assert.equal(runtime.hasPendingChanges, true); - const first = runtime.update(); - assert.equal(first.revision, 1); - assert.equal(first.paragraphBatches.length, 1); - assert.equal(labels.current.paragraphs.length, 1); - assert.ok(labels.current.glyphBatches.length >= 2, 'fallback fonts must become separate physical glyph batches'); - assert.ok(labels.current.glyphRuns.length >= 2, 'fallback ordering must remain explicit in ordered runs'); - const firstInterKey = labels.current.glyphBatches.find((batch) => batch.font === inter).key; - assert.equal( - [...labels.current.paragraphs[0].layout.glyphIds].includes(0), - false, - 'available fallback glyphs must replace primary .notdef glyphs', - ); - assert.equal(runtime.update(), first, 'a clean synchronous update must preserve revision identity'); - - const targetEvents = []; - const attachment = labels.attach({ - technique: bitmap, - stage(previous, next) { - targetEvents.push(['stage', previous?.sourceRevision, next.revision]); - return { - status: 'ready', - stage: { - sourceRevision: next.revision, - commit() { - targetEvents.push(['commit', next.revision]); - return { - sourceRevision: next.revision, - dispose() { - targetEvents.push(['retire', next.revision]); - }, - }; - }, - abort() { - targetEvents.push(['abort', next.revision]); - }, - }, - }; - }, - dispose() { - targetEvents.push(['dispose-target']); - }, - }); - assert.deepEqual(targetEvents, [], 'attachment observation must not stage engine work'); - attachment.prepare(); - assert.equal(attachment.candidate.sourceRevision, 1); - attachment.commit(); - assert.equal(attachment.current.sourceRevision, 1); - - label.text = 'This replacement is intentionally longer than the fixed capacity.'; - label.text = 'The final desired replacement still exceeds thirty-two glyphs.'; - let overflow; - try { - runtime.update(); - assert.fail('fixed capacity should reject the prepared generation'); - } catch (error) { - overflow = error; - } - assert.equal(overflow.kind, 'capacity-exceeded'); - assert.equal(overflow.batch, labels); - assert.ok(overflow.required > 32); - assert.equal(labels.preparationError, overflow); - assert.equal(labels.hasPendingChanges, false, 'an unchanged failed generation must be latched'); - assert.equal(labels.current.revision, 1, 'failed preparation must preserve the complete prior revision'); - - labels.setCapacity({ size: overflow.required, policy: 'fixed' }); - const recovered = runtime.update(); - assert.equal(recovered.revision, 2); - assert.equal(labels.current.revision, 2); - assert.equal(labels.has(label), true); - assert.equal(labels.preparationError, undefined); - const resizedInterKey = labels.current.glyphBatches.find((batch) => batch.font === inter).key; - assert.notEqual(resizedInterKey, firstInterKey, 'explicit capacity replacement must retire physical keys'); - assert.equal(attachment.source.revision, 2); - assert.equal(attachment.current.sourceRevision, 1, 'core publication must not mutate live target state'); - attachment.prepare(); - attachment.commit(); - assert.equal(attachment.current.sourceRevision, 2); - assert.ok(targetEvents.some((event) => event[0] === 'retire' && event[1] === 1)); - - const beforePaintStorage = labels.current.glyphBatches.find((batch) => batch.font === inter).storage; - label.paint = { color: '#00ff00' }; - runtime.update(); - assert.equal( - labels.current.glyphBatches.find((batch) => batch.font === inter).key, - resizedInterKey, - 'compatible content updates must preserve physical batch-key identity', - ); - - label.order = 10; - runtime.update(); - assert.ok( - labels.current.glyphBatches.every((batch) => batch.dirtyRanges.length === 0), - 'order-only publication must not report canonical instance uploads', - ); - assert.equal( - labels.current.glyphBatches.find((batch) => batch.font === inter).storage, - beforePaintStorage, - 'compatible batches must alternate retained staging storage instead of allocating every revision', - ); - - const snapshot = label.snapshotGlyphs(); - const movedX = snapshot.displayedX.slice(); - movedX[0] += 4; - label.setGlyphOrigins({ topology: snapshot.topology, x: movedX, y: snapshot.displayedY }); - runtime.update(); - assert.equal(label.snapshotGlyphs().displayedX[0], movedX[0]); - const movedBatch = labels.current.glyphBatches.find((batch) => batch.font === inter); - assert.deepEqual(movedBatch.dirtyRanges, [{ start: 0, count: 1 }]); - - label.set({ text: 'Snapshot A', spans: [] }); - const preparingA = runtime.updateAsync(); - label.text = 'Desired state B remains pending'; - const outcomeA = await preparingA; - assert.equal(outcomeA.status, 'published'); - assert.equal(label.committed.layout.glyphIds.length, 10, 'async preparation must use its call-time snapshot'); - assert.equal(labels.hasPendingChanges, true, 'later desired state must remain dirty after publishing the snapshot'); - runtime.update(); - assert.equal(label.committed.layout.glyphIds.length, 31); - - label.text = 'Superseded A'; - const supersededA = runtime.updateAsync(); - label.text = 'Synchronous B'; - runtime.update(); - const supersededOutcome = await supersededA; - assert.equal(supersededOutcome.status, 'superseded'); - assert.equal(supersededOutcome.byRevision, supersededOutcome.revision + 1); - assert.equal(label.committed.layout.glyphIds.length, 13); - - label.text = 'Callback publication'; - const progress = []; - let callbackWasSynchronous = true; - const callbackResult = await new Promise((resolve) => { - const returned = runtime.updateAsync({ onProgress: (value) => progress.push(value) }, (result) => { - assert.equal(callbackWasSynchronous, false); - resolve(result); - }); - assert.equal(returned, undefined, 'callback form must expose no Promise'); - callbackWasSynchronous = false; - }); - assert.equal(callbackResult.ok, true); - assert.equal(callbackResult.value.status, 'published'); - assert.deepEqual( - progress.map(({ preparedParagraphs, totalParagraphs }) => [preparedParagraphs, totalParagraphs]), - [ - [0, 1], - [1, 1], - ], - ); - - const aborted = new AbortController(); - aborted.abort('not needed'); - const abortedRevision = runtime.current; - const abortedOutcome = await runtime.updateAsync({ signal: aborted.signal }); - assert.equal(abortedOutcome.status, 'aborted'); - assert.equal(abortedOutcome.reason, 'not needed'); - assert.equal(runtime.current, abortedRevision, 'aborted preparation must not publish'); - - labels.setCapacity({ size: 1, policy: 'fixed' }); - label.text = 'Capacity failure'; - await assert.rejects(runtime.updateAsync(), (error) => { - assert.equal(error.kind, 'capacity-exceeded'); - assert.equal(error.batch, labels); - return true; - }); - labels.setCapacity({ size: 64, policy: 'fixed' }); - runtime.update(); - - labels.dispose(); - assert.equal(label.disposed, true); - assert.deepEqual(targetEvents.at(-1), ['dispose-target']); - inter.dispose(); - devanagari.dispose(); - runtime.dispose(); -}); - -test('the maintained TypeGPU engine retains core handles and delegates exact target ownership', async () => { - const registry = new FontRegistry(); - const shaper = await createRuntimeShaper({ - registry, - wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), - }); - const root = { device: {} }; - const events = []; - const states = new Map(); - const program = defineTypeGpuRasterProgram({ - technique: bitmap, - createTarget(options) { - assert.equal(options.root, root); - return { - root, - technique: bitmap, - setParagraphState(paragraph, state) { - events.push(['state', paragraph, state?.visible]); - if (state === undefined) states.delete(paragraph); - else states.set(paragraph, state); - }, - stage(previous, next) { - events.push(['stage', previous?.sourceRevision, next.revision]); - return { - status: 'ready', - stage: { - sourceRevision: next.revision, - commit() { - return { - sourceRevision: next.revision, - draws: Object.freeze([{ glyphs: next.glyphRuns.reduce((sum, run) => sum + run.count, 0) }]), - dispose() { - events.push(['retire', next.revision]); - }, - }; - }, - abort() {}, - }, - }; - }, - encode(_pass, revision, frame) { - events.push(['encode', revision.sourceRevision, revision.draws[0]?.glyphs, frame.pixelRatio]); - }, - dispose() { - events.push(['dispose-target']); - }, - }; - }, - dispose() {}, - }); - const engine = await createTypeGpuTextEngine({ - root, - colorFormat: 'rgba8unorm', - runtime: { registry, shaper }, - }); - const inter = await engine.loadFont({ - input: { baked: dataUrl(await readFile(interUrl)) }, - raster: { technique: bitmap, options: { strikes: [16] } }, - }); - const labels = engine.createParagraphBatch({ technique: bitmap, program }); - const label = labels.add({ font: inter, text: 'TypeGPU' }); - const identity = label; - - engine.update(); - labels.encode( - {}, - { - viewProjection: Float32Array.from([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]), - viewport: [320, 200], - pixelRatio: 2, - }, - ); - assert.deepEqual(events.at(-1), ['encode', 1, 7, 2]); - assert.equal(labels.current.sourceRevision, 1); - - label.setTransform([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 10, 20, 0, 1]); - label.visible = false; - assert.equal(label, identity, 'renderer-owned transform changes must preserve the core paragraph handle'); - assert.equal(states.get(label.id).transform[12], 10); - assert.equal(states.get(label.id).visible, false); - assert.equal(engine.current.revision, 1, 'renderer-owned state must not schedule shaping'); - - label.text = 'TypeGPU retained'; - engine.update(); - labels.encode( - {}, - { - viewProjection: Float32Array.from([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]), - viewport: [320, 200], - pixelRatio: 1, - }, - ); - assert.equal(labels.current.sourceRevision, 2); - assert.ok(events.some((event) => event[0] === 'retire' && event[1] === 1)); - - labels.dispose(); - assert.equal(label.disposed, true); - assert.equal(states.size, 0); - inter.dispose(); - engine.dispose(); - assert.deepEqual(events.at(-1), ['dispose-target']); -}); - -function dataUrl(bytes) { - return `data:model/gltf-binary;base64,${bytes.toString('base64')}`; -} - -const fontAwesomeUrl = new URL( - '../../../../apps/benchmarks/fixtures/rendering/font-awesome-free-6.7.2-bitmap-16.font.glb', - import.meta.url, -); - -/** - * Font fallback inspects shaped glyph identity, and the shaping request appends one ellipsis run per source run - * clustered past the end of the text so overflow can be measured. Those runs are not glyphs of the paragraph, and a - * primary font without U+2026 shapes them to .notdef. Substituting a font for one authored a span outside the text, - * which failed preparation for the whole batch rather than for one paragraph. - * - * The icon-font-first stack is the ordinary configuration that reaches this: Font Awesome carries neither the Latin - * text nor the ellipsis. The fallback must land in the middle of the text, because a stack whose substitution happens - * to end at the final cluster is the one arrangement where the stray entry is dropped harmlessly. - */ -test('font fallback ignores the ellipsis runs shaped past the end of the text', async () => { - const [awesomeBytes, interBytes] = await Promise.all([readFile(fontAwesomeUrl), readFile(interUrl)]); - const registry = new FontRegistry(); - const shaper = await createRuntimeShaper({ - registry, - wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), - }); - const runtime = await createTextRuntime({ registry, shaper }); - const [awesome, inter] = await Promise.all([ - runtime.loadFont({ - input: { baked: dataUrl(awesomeBytes) }, - raster: { technique: bitmap, options: { strikes: [16] } }, - }), - runtime.loadFont({ - input: { baked: dataUrl(interBytes) }, - raster: { technique: bitmap, options: { strikes: [16] } }, - }), - ]); - const batch = runtime.createParagraphBatch({ technique: bitmap }); - const paragraph = batch.add({ font: createFontStack(awesome, inter), text: 'hello\nworld' }); - - const first = runtime.update(); - assert.equal(first.preparationError, undefined, 'a primary font without U+2026 must still prepare'); - assert.equal(batch.current.paragraphs[0].layout.glyphIds.length, 10); - - // The retained paragraph re-enters preparation here, so the stray selection would reappear on the second pass. - paragraph.style = { fontSize: 18 }; - assert.equal(runtime.update().preparationError, undefined, 'reparation must not accumulate a stray fallback span'); - assert.equal(batch.current.paragraphs[0].layout.glyphIds.length, 10); - - batch.dispose(); - runtime.dispose(); - awesome.dispose(); - inter.dispose(); -}); diff --git a/packages/text/tests/integration/text-spans.test.mjs b/packages/text/tests/integration/text-spans.test.mjs deleted file mode 100644 index c17b8377..00000000 --- a/packages/text/tests/integration/text-spans.test.mjs +++ /dev/null @@ -1,542 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import test from 'node:test'; -import { gunzipSync } from 'node:zlib'; - -import { - createFontStack, - createRuntimeShaper, - createTextRuntime, - FontRegistry, - span, - SpanNestingError, - txt, -} from '@pmndrs/text'; -import { bitmap } from '@pmndrs/text/three/bitmap'; -import { msdf } from '@pmndrs/text/three/msdf'; -import { Text, TextGroup } from '@pmndrs/text/three'; -import * as THREE from 'three/webgpu'; - -const interUrl = new URL('../../../../apps/benchmarks/fixtures/rendering/inter-bitmap-16.font.glb', import.meta.url); -const interMsdfUrl = new URL('../../../../apps/benchmarks/fixtures/rendering/inter-mtsdf.font.glb.gz', import.meta.url); -const devanagariUrl = new URL( - '../../../../apps/benchmarks/fixtures/rendering/noto-sans-devanagari-bitmap-16.font.glb', - import.meta.url, -); - -const BLUE = [0, 0, 1, 1]; -const RED = [1, 0, 0, 1]; -const GREEN = [0, 1, 0, 1]; -const WHITE = [1, 1, 1, 1]; - -test('a style-only span inherits the surrounding font while overriding its own shaping style', async () => { - const runtime = await createBitmapRuntime(); - const inter = await loadInter(runtime); - const batch = runtime.createParagraphBatch({ technique: bitmap }); - - const emphasis = span({ fontSize: 24 }); - const literal = txt`Score ${emphasis`99`} pts`; - assert.equal(literal.text, 'Score 99 pts'); - assert.deepEqual(literal.spans, [{ start: 6, end: 8, style: { fontSize: 24 } }]); - - const formatted = batch.add({ font: inter, text: literal, paint: { color: '#0000ff' } }); - const unformatted = batch.add({ font: inter, text: 'Score 99 pts' }); - runtime.update(); - - const layout = formatted.committed.layout; - assert.deepEqual([...layout.fontHandles], [inter.font.handle], 'a style-only span must select no further font'); - assert.deepEqual( - [...layout.glyphFontSlots], - Array.from({ length: 12 }, () => 0), - ); - assert.deepEqual( - [...layout.glyphIds], - [...unformatted.committed.layout.glyphIds], - 'the span must shape from the surrounding font, so its glyph ids must match the unformatted paragraph', - ); - assert.equal(layout.glyphIds.includes(0), false, 'the inherited font must resolve every glyph'); - assert.deepEqual([...layout.glyphFontSizes], [16, 16, 16, 16, 16, 16, 24, 24, 16, 16, 16, 16]); - - const run = runFor(batch, formatted); - assert.equal(run.count, 10, 'the ten visible glyphs of "Score 99 pts" must become one contiguous run'); - assert.deepEqual( - glyphColors(batch, run), - Array.from({ length: 10 }, () => BLUE), - 'a span that states no paint must inherit the paragraph paint instead of resetting it', - ); - - batch.dispose(); - inter.dispose(); - runtime.dispose(); -}); - -test('nested spans compose inner formatting over the enclosing span across exact UTF-16 ranges', async () => { - const runtime = await createBitmapRuntime(); - const inter = await loadInter(runtime); - const batch = runtime.createParagraphBatch({ technique: bitmap }); - - const outer = span({ fontSize: 24, color: '#ff0000' }); - const inner = span({ fontSize: 12, color: '#00ff00' }); - const literal = txt`a${outer`b${inner`c`}d`}e`; - assert.equal(literal.text, 'abcde'); - assert.deepEqual(literal.spans, [ - { start: 1, end: 4, style: { fontSize: 24 }, paint: { color: '#ff0000' } }, - { start: 2, end: 3, style: { fontSize: 12 }, paint: { color: '#00ff00' } }, - ]); - - const nested = batch.add({ font: inter, text: literal }); - runtime.update(); - assert.deepEqual( - [...nested.committed.layout.glyphFontSizes], - [16, 24, 12, 24, 16], - 'the inner span must override the enclosing span on the range they share', - ); - assert.deepEqual(glyphColors(batch, runFor(batch, nested)), [WHITE, RED, GREEN, RED, WHITE]); - - // An astral character occupies two UTF-16 code units, so composed ranges are - // only correct when they count code units rather than code points. - const astral = txt`🎯${inner`hit`}`; - assert.equal(astral.text, '🎯hit'); - assert.equal(astral.text.length, 5); - assert.deepEqual(astral.spans, [{ start: 2, end: 5, style: { fontSize: 12 }, paint: { color: '#00ff00' } }]); - - const surrogate = batch.add({ font: inter, text: astral }); - runtime.update(); - assert.deepEqual([...surrogate.committed.layout.clusters], [0, 2, 3, 4]); - assert.deepEqual([...surrogate.committed.layout.glyphFontSizes], [16, 12, 12, 12]); - - batch.dispose(); - inter.dispose(); - runtime.dispose(); -}); - -test('a tuple-extended format binds the same span as the direct call', async () => { - const runtime = await createBitmapRuntime(); - const inter = await loadInter(runtime); - const batch = runtime.createParagraphBatch({ technique: bitmap }); - - const alertStyle = { color: '#ff0000', fontSize: 24 }; - const alertFormat = [inter, alertStyle]; - const spread = span(...alertFormat); - const direct = span(inter, alertStyle); - - const spreadLiteral = txt`Alert ${spread`now`}`; - const directLiteral = txt`Alert ${direct`now`}`; - assert.deepEqual(spreadLiteral, directLiteral); - assert.deepEqual(spreadLiteral.spans, [ - { start: 6, end: 9, font: inter, style: { fontSize: 24 }, paint: { color: '#ff0000' } }, - ]); - - const spreadParagraph = batch.add({ font: inter, text: spreadLiteral }); - const directParagraph = batch.add({ font: inter, text: directLiteral }); - runtime.update(); - - const spreadLayout = spreadParagraph.committed.layout; - const directLayout = directParagraph.committed.layout; - assert.deepEqual([...spreadLayout.glyphIds], [...directLayout.glyphIds]); - assert.deepEqual([...spreadLayout.glyphFontSizes], [16, 16, 16, 16, 16, 16, 24, 24, 24]); - assert.deepEqual([...spreadLayout.glyphFontSizes], [...directLayout.glyphFontSizes]); - assert.equal(spreadLayout.width, directLayout.width); - assert.deepEqual( - glyphColors(batch, runFor(batch, spreadParagraph)), - glyphColors(batch, runFor(batch, directParagraph)), - ); - - batch.dispose(); - inter.dispose(); - runtime.dispose(); -}); - -test('a plain string stays valid wherever a formatted literal is accepted', async () => { - const runtime = await createBitmapRuntime(); - const inter = await loadInter(runtime); - const batch = runtime.createParagraphBatch({ technique: bitmap }); - - const emphasis = span({ fontSize: 24 }); - const interpolated = txt`before ${'middle'} after`; - assert.equal(interpolated.text, 'before middle after'); - assert.deepEqual(interpolated.spans, [], 'an interpolated string carries no formatting of its own'); - - const insideSpan = txt`x${emphasis`plain ${'value'}`}y`; - assert.equal(insideSpan.text, 'xplain valuey'); - assert.deepEqual(insideSpan.spans, [{ start: 1, end: 12, style: { fontSize: 24 } }]); - - const literalParagraph = batch.add({ font: inter, text: txt`Score ${emphasis`99`} pts` }); - const stringParagraph = batch.add({ font: inter, text: 'Score 99 pts' }); - runtime.update(); - assert.deepEqual( - [...stringParagraph.committed.layout.glyphFontSizes], - Array.from({ length: 12 }, () => 16), - ); - assert.deepEqual([...literalParagraph.committed.layout.glyphIds], [...stringParagraph.committed.layout.glyphIds]); - - // Replacement content owns its formatting, so a plain string must clear the - // spans of the literal it replaces rather than reinterpret them. - literalParagraph.text = 'Tally'; - runtime.update(); - assert.deepEqual(literalParagraph.spans, []); - assert.equal(literalParagraph.committed.layout.glyphIds.length, 5); - assert.deepEqual([...literalParagraph.committed.layout.glyphFontSizes], [16, 16, 16, 16, 16]); - assert.equal(batch.preparationError, undefined); - - batch.dispose(); - inter.dispose(); - runtime.dispose(); -}); - -test('Three Text shapes and draws a formatted literal through the real render lifecycle', async () => { - const runtime = await createBitmapRuntime(); - const [inter, devanagari] = await Promise.all([loadInter(runtime), loadDevanagari(runtime)]); - - const scene = new THREE.Scene(); - const group = new TextGroup({ technique: bitmap }); - const warning = span(devanagari, { color: '#ff00ff', fontSize: 18 }); - const label = new Text({ font: createFontStack(inter, devanagari), text: txt`Alert ${warning`देव`}!` }); - group.add(label); - scene.add(group); - - assert.equal(label.bound, false, 'constructing a formatted Text must not shape eagerly'); - scene.updateMatrixWorld(); - assert.equal(label.bound, true); - assert.equal(label.text, 'Alert देव!'); - assert.deepEqual( - label.spans.map(({ start, end, font, style, paint }) => ({ start, end, font, style, paint })), - [{ start: 6, end: 9, font: devanagari, style: { fontSize: 18 }, paint: { color: '#ff00ff' } }], - ); - - const layout = label.layout; - assert.deepEqual([...layout.fontHandles], [inter.font.handle, devanagari.font.handle]); - assert.deepEqual( - [...layout.glyphFontSlots], - [0, 0, 0, 0, 0, 0, 1, 1, 1, 0], - 'only the span range may shape from the span font', - ); - assert.deepEqual([...layout.glyphFontSizes], [16, 16, 16, 16, 16, 16, 18, 18, 18, 16]); - assert.deepEqual([...layout.clusters], [0, 1, 2, 3, 4, 5, 6, 6, 8, 9]); - assert.equal(layout.glyphIds.includes(0), false, 'every span glyph must resolve in its selected font'); - - const draws = label.children.filter((child) => child.isMesh); - assert.deepEqual( - draws.map((mesh) => [mesh.userData.pmndrsTextRunStart, mesh.geometry.instanceCount]), - [ - [0, 5], - [0, 3], - [5, 1], - ], - 'the span font must split the drawn glyph ranges around the surrounding font', - ); - assert.equal(group.error, undefined); - - // A plain string replaces the literal and its spans through the same setter. - label.text = 'Alert'; - scene.updateMatrixWorld(); - assert.deepEqual(label.spans, []); - assert.deepEqual([...label.layout.fontHandles], [inter.font.handle]); - assert.equal(label.layout.glyphIds.length, 5); - assert.equal(group.error, undefined); - assert.deepEqual( - label.children.filter((child) => child.isMesh).map((mesh) => mesh.geometry.instanceCount), - [5], - ); - - group.dispose(); - label.removeFromParent(); - label.dispose(); - inter.dispose(); - devanagari.dispose(); - runtime.dispose(); -}); - -test('a span keeps every surrounding paint property it does not state', async () => { - const runtime = await createBitmapRuntime(); - const inter = await loadMsdfInter(runtime); - const batch = runtime.createParagraphBatch({ technique: msdf }); - - // Each span states exactly one paint property, so the three it omits must - // survive from the paragraph. "d" carries no span and fixes the inherited - // values the other three are measured against. - const paragraph = batch.add({ - font: inter, - text: 'abcd', - paint: { - color: '#ff0000', - opacity: 0.5, - outline: { color: '#00ff00', width: 1 }, - shadow: { color: '#0000ff', offset: [1, 2] }, - }, - spans: [ - { start: 0, end: 1, paint: { color: '#ffffff' } }, - { start: 1, end: 2, paint: { opacity: 1 } }, - { start: 2, end: 3, paint: { outline: { color: '#ffffff', width: 1 } } }, - ], - }); - runtime.update(); - assert.equal(batch.preparationError, undefined); - - const painted = msdfGlyphPaint(batch, runFor(batch, paragraph)); - assert.equal(painted.length, 4); - assert.deepEqual(painted[3], { fill: [1, 0, 0, 0.5], outline: [0, 1, 0, 0.5], shadow: [0, 0, 1, 0.5] }); - assert.deepEqual( - painted[0], - { fill: [1, 1, 1, 0.5], outline: [0, 1, 0, 0.5], shadow: [0, 0, 1, 0.5] }, - 'a colour-only span must keep the surrounding opacity, outline, and shadow', - ); - assert.deepEqual( - painted[1], - { fill: [1, 0, 0, 1], outline: [0, 1, 0, 1], shadow: [0, 0, 1, 1] }, - 'an opacity-only span must keep the surrounding colour, outline, and shadow and re-apply its own opacity to each', - ); - assert.deepEqual( - painted[2], - { fill: [1, 0, 0, 0.5], outline: [1, 1, 1, 0.5], shadow: [0, 0, 1, 0.5] }, - 'an outline-only span must keep the surrounding colour, opacity, and shadow', - ); - - // A dropped outline or shadow would leave a zero width and a zero offset, so - // the geometric parts are asserted as well as the colours. - const physical = batchFor(batch, runFor(batch, paragraph)); - const first = runFor(batch, paragraph).start; - const widths = [...physical.storage.outlineWidths.slice(first, first + 4)]; - assert.equal( - widths.every((value) => value === widths[0] && value > 0), - true, - `outline widths were ${widths}`, - ); - const offsets = [...physical.storage.shadowOffsets.slice(first * 2, (first + 4) * 2)]; - assert.deepEqual(offsets, [ - offsets[0], - offsets[1], - offsets[0], - offsets[1], - offsets[0], - offsets[1], - offsets[0], - offsets[1], - ]); - assert.equal(offsets[0] > 0 && offsets[1] > 0, true, `shadow offsets were ${offsets}`); - - batch.dispose(); - inter.dispose(); - runtime.dispose(); -}); - -test('nested spans merge paint per property from the outermost inward', async () => { - const runtime = await createBitmapRuntime(); - const inter = await loadInter(runtime); - const batch = runtime.createParagraphBatch({ technique: bitmap }); - - const outer = span({ color: '#00ff00' }); - const inner = span({ opacity: 0.25 }); - const literal = txt`a${outer`b${inner`c`}d`}e`; - assert.deepEqual(literal.spans, [ - { start: 1, end: 4, paint: { color: '#00ff00' } }, - { start: 2, end: 3, paint: { opacity: 0.25 } }, - ]); - - const paragraph = batch.add({ font: inter, text: literal, paint: { color: '#ff0000', opacity: 0.5 } }); - runtime.update(); - assert.equal(batch.preparationError, undefined); - assert.deepEqual( - glyphColors(batch, runFor(batch, paragraph)), - [ - [1, 0, 0, 0.5], - [0, 1, 0, 0.5], - [0, 1, 0, 0.25], - [0, 1, 0, 0.5], - [1, 0, 0, 0.5], - ], - 'the innermost span states only opacity, so it must inherit the colour of the span enclosing it', - ); - - batch.dispose(); - inter.dispose(); - runtime.dispose(); -}); - -test('spans that overlap without nesting are rejected, and nesting order is not load-bearing', async () => { - const runtime = await createBitmapRuntime(); - const inter = await loadInter(runtime); - const batch = runtime.createParagraphBatch({ technique: bitmap }); - - assert.throws( - () => - batch.add({ - font: inter, - text: 'abcdef', - spans: [ - { start: 0, end: 4, paint: { color: '#ff0000' } }, - { start: 2, end: 6, paint: { color: '#00ff00' } }, - ], - }), - (error) => { - assert.equal(error instanceof SpanNestingError, true, `expected a SpanNestingError, received ${error}`); - assert.equal(error.name, 'SpanNestingError'); - assert.deepEqual(error.enclosing, { index: 0, start: 0, end: 4 }); - assert.deepEqual(error.overlapping, { index: 1, start: 2, end: 6 }); - assert.equal( - error.message, - 'paragraph spans must be disjoint or nested: span 0 [0, 4) partially overlaps span 1 [2, 6)', - ); - return true; - }, - ); - - // Precedence follows containment, so a producer that emits a contained span - // before the span enclosing it still resolves innermost-first. - const reversed = batch.add({ - font: inter, - text: 'abcdef', - spans: [ - { start: 2, end: 4, paint: { color: '#00ff00' } }, - { start: 0, end: 6, paint: { color: '#ff0000' } }, - ], - }); - runtime.update(); - assert.equal(batch.preparationError, undefined); - assert.deepEqual(glyphColors(batch, runFor(batch, reversed)), [RED, RED, GREEN, GREEN, RED, RED]); - - batch.dispose(); - inter.dispose(); - runtime.dispose(); -}); - -test('a span font size re-shapes advances and moves where a line wraps', async () => { - const runtime = await createBitmapRuntime(); - const inter = await loadInter(runtime); - const batch = runtime.createParagraphBatch({ technique: bitmap }); - - const contentBox = { width: { mode: 'at-most', size: 120 }, wrap: 'word' }; - const plain = batch.add({ font: inter, text: 'wide wide wide', contentBox }); - const emphasis = span({ fontSize: 32 }); - const styled = batch.add({ font: inter, text: txt`${emphasis`wide`} wide wide`, contentBox }); - runtime.update(); - assert.equal(batch.preparationError, undefined); - - const plainLayout = plain.committed.layout; - const styledLayout = styled.committed.layout; - assert.deepEqual([...plainLayout.lineGlyphCounts], [14], 'the unformatted paragraph must fit on one line'); - assert.deepEqual( - [...styledLayout.lineGlyphCounts], - [10, 4], - 'a span font size must change line breaking, not only glyph scale', - ); - assert.deepEqual([...plainLayout.lineAdvances], [117.28125]); - assert.deepEqual([...styledLayout.lineAdvances], [117.28125, 36.09375]); - - // Shaped advances inside the span double with its font size, and the text - // after the span moves by the difference rather than staying put. - assert.deepEqual([...plainLayout.x.slice(0, 4)], [0, 13.09375, 16.96875, 26.765625]); - assert.deepEqual([...styledLayout.x.slice(0, 4)], [0, 26.1875, 33.9375, 53.53125]); - assert.equal(plainLayout.x[4], 36.09375); - assert.equal(styledLayout.x[4], 72.1875, 'the glyph after the span must shift by the re-shaped advance'); - assert.deepEqual([...styledLayout.glyphFontSizes.slice(0, 5)], [32, 32, 32, 32, 16]); - - batch.dispose(); - inter.dispose(); - runtime.dispose(); -}); - -test('a nested style-only span shapes with the font its enclosing span selected', async () => { - const runtime = await createBitmapRuntime(); - const [inter, devanagari] = await Promise.all([loadInter(runtime), loadDevanagari(runtime)]); - const batch = runtime.createParagraphBatch({ technique: bitmap }); - - const script = span(devanagari); - const emphasis = span({ fontSize: 24 }); - const literal = txt`Alert ${script`दे${emphasis`व`}`}!`; - assert.equal(literal.text, 'Alert देव!'); - assert.deepEqual(literal.spans, [ - { start: 6, end: 9, font: devanagari }, - { start: 8, end: 9, style: { fontSize: 24 } }, - ]); - - const paragraph = batch.add({ font: inter, text: literal }); - runtime.update(); - assert.equal(batch.preparationError, undefined); - - const layout = paragraph.committed.layout; - assert.deepEqual([...layout.fontHandles], [inter.font.handle, devanagari.font.handle]); - assert.deepEqual( - [...layout.glyphFontSlots], - [0, 0, 0, 0, 0, 0, 1, 1, 1, 0], - 'the inner span states no font, so it must shape from the font its enclosing span selected', - ); - assert.deepEqual([...layout.glyphFontSizes], [16, 16, 16, 16, 16, 16, 16, 16, 24, 16]); - assert.deepEqual([...layout.clusters], [0, 1, 2, 3, 4, 5, 6, 6, 8, 9]); - assert.equal(layout.glyphIds.includes(0), false, 'the inherited span font must resolve every glyph'); - - batch.dispose(); - inter.dispose(); - devanagari.dispose(); - runtime.dispose(); -}); - -async function createBitmapRuntime() { - const registry = new FontRegistry(); - const shaper = await createRuntimeShaper({ - registry, - wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), - }); - return createTextRuntime({ registry, shaper }); -} - -function loadInter(runtime) { - return loadBitmapFont(runtime, interUrl); -} - -function loadDevanagari(runtime) { - return loadBitmapFont(runtime, devanagariUrl); -} - -async function loadBitmapFont(runtime, url) { - return runtime.loadFont({ - input: { baked: dataUrl(await readFile(url)) }, - raster: { technique: bitmap, options: { strikes: [16] } }, - }); -} - -async function loadMsdfInter(runtime) { - return runtime.loadFont({ - input: { baked: dataUrl(gunzipSync(await readFile(interMsdfUrl))) }, - raster: { technique: msdf }, - }); -} - -function runFor(batch, paragraph) { - const run = batch.current.glyphRuns.find((entry) => entry.paragraph === paragraph.id); - if (run === undefined) throw new Error('the published revision has no run for the paragraph'); - return run; -} - -function batchFor(batch, run) { - const physical = batch.current.glyphBatches.find((entry) => entry.key === run.batch); - if (physical === undefined) throw new Error('the published revision has no physical batch for the run'); - return physical; -} - -function glyphColors(batch, run) { - const physical = batchFor(batch, run); - const colors = []; - for (let index = 0; index < run.count; index += 1) { - colors.push([...physical.storage.colors.slice((run.start + index) * 4, (run.start + index + 1) * 4)]); - } - return colors; -} - -function msdfGlyphPaint(batch, run) { - const physical = batchFor(batch, run); - const painted = []; - for (let index = 0; index < run.count; index += 1) { - const start = (run.start + index) * 4; - painted.push({ - fill: [...physical.storage.fillColors.slice(start, start + 4)], - outline: [...physical.storage.outlineColors.slice(start, start + 4)], - shadow: [...physical.storage.shadowColors.slice(start, start + 4)], - }); - } - return painted; -} - -function dataUrl(bytes) { - return `data:model/gltf-binary;base64,${bytes.toString('base64')}`; -} diff --git a/packages/text/tests/integration/three-engine-runtime.test.mjs b/packages/text/tests/integration/three-engine-runtime.test.mjs new file mode 100644 index 00000000..56b54838 --- /dev/null +++ b/packages/text/tests/integration/three-engine-runtime.test.mjs @@ -0,0 +1,941 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import { gunzipSync } from 'node:zlib'; + +import { validateFontArtifact } from '@pmndrs/text-font-baker/validate'; +import { read as readKtx2 } from 'ktx-parse'; +import * as THREE from 'three/webgpu'; + +import { validateBitmapArtifact } from '../../dist/bakers/bitmap-validator.js'; +import { validateMsdfArtifact } from '../../dist/bakers/msdf-validator.js'; +import { validateSlugArtifact } from '../../dist/bakers/slug-validator.js'; +import { textShaperAbi } from '../../dist/generated/text-shaper-abi.js'; +import { compileTextEngineFrameUpdate } from '../../dist/internal/engine-frame-wire.js'; +import { firstPartyThreeRenderPolicyBytes } from '../../dist/internal/render-policy-wire.js'; +import { TextEngineRenderPlanView } from '../../dist/internal/render-plan-view.js'; +import { LoadedFontImpl } from '../../dist/loaded-font.js'; +import { FontRegistry } from '../../dist/loader.js'; +import { bitmap, bitmapDescriptor } from '../../dist/raster/bitmap-technique.js'; +import { msdf, msdfDescriptor } from '../../dist/raster/msdf.js'; +import { defineRasterResourceId } from '../../dist/raster-technique.js'; +import { slug, slugDescriptor } from '../../dist/raster/slug-technique.js'; +import { createRuntimeShaper } from '../../dist/shaper.js'; +import { ThreeTextEngineCoordinator } from '../../dist/three/engine-runtime.js'; +import { ThreeTextRenderPlanExecutor } from '../../dist/three/engine-plan-target.js'; +import { defineTextMaterial } from '../../dist/three/material.js'; + +const fixtureRoot = new URL('../../../../apps/benchmarks/fixtures/rendering/', import.meta.url); +const wasmUrl = new URL('../../dist/text_shaper.wasm', import.meta.url); + +test('Three coordinator shares shaping data across technique bindings and reference-counts stack handles', async () => { + const [bitmapBytes, compressedMsdf, compressedSlug, wasm] = await Promise.all([ + readFile(new URL('inter-bitmap-16.font.glb', fixtureRoot)), + readFile(new URL('inter-mtsdf.font.glb.gz', fixtureRoot)), + readFile(new URL('inter-slug.font.glb.gz', fixtureRoot)), + readFile(wasmUrl), + ]); + const msdfBytes = gunzipSync(compressedMsdf); + const slugBytes = gunzipSync(compressedSlug); + const [bitmapCore, msdfCore, slugCore] = await Promise.all([ + validateFontArtifact(bitmapBytes), + validateFontArtifact(msdfBytes), + validateFontArtifact(slugBytes), + ]); + assert.equal(bitmapCore.shapingHash, msdfCore.shapingHash); + assert.equal(bitmapCore.shapingHash, slugCore.shapingHash); + const registry = new FontRegistry(); + const registered = await registry.registerAsset(bitmapBytes); + const shaper = await createRuntimeShaper({ registry, wasm }); + shaper.registerFont(registered); + const bitmapRaster = await validateBitmapArtifact(bitmapBytes, { + descriptor: bitmapDescriptor({ strikes: [16] }), + rasterKey: bitmapCore.document.extensions.PMNDRS_font.rasters[0].rasterKey, + shapingHash: bitmapCore.shapingHash, + glyphCount: bitmapCore.glyphCount, + glyphIdWidth: 16, + }); + const msdfRaster = await validateMsdfArtifact(msdfBytes, { + descriptor: msdfDescriptor(), + rasterKey: msdfCore.document.extensions.PMNDRS_font.rasters[0].rasterKey, + shapingHash: msdfCore.shapingHash, + glyphCount: msdfCore.glyphCount, + glyphIdWidth: 16, + }); + const slugRaster = await validateSlugArtifact(slugBytes, { + descriptor: slugDescriptor(), + rasterKey: slugCore.document.extensions.PMNDRS_font.rasters[0].rasterKey, + shapingHash: slugCore.shapingHash, + glyphCount: slugCore.glyphCount, + glyphIdWidth: 16, + }); + const bitmapFont = new LoadedFontImpl({ + runtime: undefined, + font: registered, + technique: bitmap, + raster: undefined, + data: { + strikes: bitmapRaster.strikes.map((strike, strikeIndex) => ({ + ...strike, + pages: strike.pages.map((page, pageIndex) => ({ + ...page, + format: 'r8unorm', + resource: defineRasterResourceId(`coordinator.bitmap.${strikeIndex}.${pageIndex}`), + })), + })), + }, + release: () => undefined, + }); + const extension = msdfRaster.document.extensions.PMNDRS_font_distance_field; + const msdfFont = new LoadedFontImpl({ + runtime: undefined, + font: registered, + technique: msdf, + raster: undefined, + data: { + resource: defineRasterResourceId('coordinator.mtsdf'), + binding: { + width: Math.max(...msdfRaster.pages.map((page) => page.width)), + height: Math.max(...msdfRaster.pages.map((page) => page.height)), + layers: msdfRaster.pages.length, + }, + emSize: extension.emSize, + pixelRange: extension.pixelRange, + planeUnitsPerEm: extension.planeUnitsPerEm, + records: msdfRaster.records, + pages: msdfRaster.pages, + }, + release: () => undefined, + }); + const slugExtension = slugRaster.document.extensions.PMNDRS_font_slug; + const slugFont = new LoadedFontImpl({ + runtime: undefined, + font: registered, + technique: slug, + raster: undefined, + data: { + planeUnitsPerEm: slugExtension.planeUnitsPerEm, + records: slugRaster.records, + pages: slugRaster.pages.map((page, pageIndex) => ({ + resource: defineRasterResourceId(`coordinator.slug.${pageIndex}`), + curveWidth: page.curveWidth, + curveHeight: page.curveHeight, + curveBytes: readKtx2(page.curve.bytes).levels[0].levelData.slice(), + headerCount: page.headerCount, + headerWidth: page.headerWidth, + headerHeight: page.headerHeight, + headerBytes: page.headers.bytes.slice(), + referenceCount: page.referenceCount, + referenceWidth: page.referenceWidth, + referenceHeight: page.referenceHeight, + referenceBytes: page.references.bytes.slice(), + })), + }, + release: () => undefined, + }); + const coordinator = new ThreeTextEngineCoordinator(shaper); + const materialCalls = []; + const primaryMaterial = coordinator.acquireMaterial( + defineTextMaterial((context) => { + materialCalls.push(`primary:${context.technique}`); + const material = context.createDefaultMaterial(); + material.depthTest = true; + return material; + }), + ); + const secondaryMaterial = coordinator.acquireMaterial( + defineTextMaterial((context) => { + materialCalls.push(`secondary:${context.technique}`); + return context.createDefaultMaterial(); + }), + ); + const first = coordinator.acquireFontStack([bitmapFont, msdfFont]); + const bitmapReference = coordinator.host.wireIdentities.resolve(bitmapFont.data.strikes[0].pages[0].resource); + const msdfReference = coordinator.host.wireIdentities.resolve(msdfFont.data.resource); + assert.equal(coordinator.resolveResource(bitmapReference).technique, bitmap.id); + assert.equal(coordinator.resolveResource(msdfReference).technique, msdf.id); + const shared = coordinator.acquireFontStack([bitmapFont, msdfFont]); + const reversed = coordinator.acquireFontStack([msdfFont, bitmapFont]); + const slugFirst = coordinator.acquireFontStack([slugFont, msdfFont, bitmapFont]); + assert.equal(shared.handle, first.handle); + assert.notEqual(reversed.handle, first.handle, 'fallback order is part of stack identity'); + const session = coordinator.createSession({ + requestCapacity: 4_096, + resultCapacity: textShaperAbi.layouts.engineResult.size, + textCapacity: 16, + }); + const initialRequest = compileTextEngineFrameUpdate({ + sessionId: session.handle, + policyHandle: coordinator.policyHandle, + capabilitySet: 1, + expectedEngineRevision: 0, + consumedPlanRevision: 0, + acknowledgedPublicationGeneration: 0, + limits: { + maxParagraphs: 2, + maxClusters: 16, + maxLines: 8, + maxRegions: 2, + maxExclusions: 1, + maxInlineObjects: 1, + maxSlotsPerBand: 2, + maxOutputBytes: 1024 * 1024, + }, + paragraphMutations: [ + { opcode: 'upsert', paragraphId: 1, order: 0 }, + { opcode: 'upsert', paragraphId: 2, order: 1 }, + ], + textMutations: [ + { paragraphId: 1, start: 0, deleteCount: 0, insert: 'abc' }, + { paragraphId: 2, start: 0, deleteCount: 0, insert: 'def' }, + ], + styleMutations: [ + { + opcode: 'upsert', + paragraphId: 1, + styleId: 1, + cascadeOrder: 0, + start: 0, + end: 3, + root: true, + value: { + fontStackHandle: first.handle, + materialId: primaryMaterial.id, + fontSize: 16, + rasterPixelRatio: 1, + foregroundRgba: 0xffff_ffff, + }, + }, + { + opcode: 'upsert', + paragraphId: 2, + styleId: 1, + cascadeOrder: 0, + start: 0, + end: 3, + root: true, + value: { + fontStackHandle: first.handle, + materialId: secondaryMaterial.id, + fontSize: 16, + rasterPixelRatio: 1, + foregroundRgba: 0xffff_ffff, + }, + }, + ], + constraints: [ + { + paragraphId: 1, + flowThreadId: 1, + geometryRevision: 1, + width: 256, + height: 128, + viewportBlockStart: 0, + viewportBlockEnd: 128, + resumeBlockOffset: 0, + maxLines: 8, + regionStart: 0, + resumeCluster: 0, + regionCount: 1, + resumeRegion: 0, + widthMode: 'at-most', + heightMode: 'at-most', + wrap: 'word', + align: 'start', + overflow: 'visible', + blockAlign: 'start', + }, + { + paragraphId: 2, + flowThreadId: 2, + geometryRevision: 1, + width: 256, + height: 128, + viewportBlockStart: 0, + viewportBlockEnd: 128, + resumeBlockOffset: 0, + maxLines: 8, + regionStart: 1, + resumeCluster: 0, + regionCount: 1, + resumeRegion: 0, + widthMode: 'at-most', + heightMode: 'at-most', + wrap: 'word', + align: 'start', + overflow: 'visible', + blockAlign: 'start', + }, + ], + regions: [ + { + id: 1, + geometryRevision: 1, + shape: 'rectangle', + exclusionStart: 0, + exclusionCount: 0, + writingMode: 'horizontal-tb', + textOrientation: 'mixed', + inlineStart: 0, + blockStart: 0, + inlineEnd: 256, + blockEnd: 128, + clipInlineStart: 0, + clipBlockStart: 0, + clipInlineEnd: 256, + clipBlockEnd: 128, + }, + { + id: 2, + geometryRevision: 1, + shape: 'rectangle', + exclusionStart: 0, + exclusionCount: 0, + writingMode: 'horizontal-tb', + textOrientation: 'mixed', + inlineStart: 0, + blockStart: 0, + inlineEnd: 256, + blockEnd: 128, + clipInlineStart: 0, + clipBlockStart: 0, + clipInlineEnd: 256, + clipBlockEnd: 128, + }, + ], + }); + const publication = session.update(initialRequest); + assert.ok( + publication.bytes.byteLength > textShaperAbi.layouts.engineResult.size, + 'the host must reserve the reported result watermark and retry the cold publication', + ); + const plan = new TextEngineRenderPlanView().bind(publication); + for (const name of ['resources', 'buffers', 'patches', 'primitives', 'draws']) { + assert.ok(plan.table(name).count > 0, `${name} must come from the Rust publication`); + } + const patches = plan.table('patches'); + const firstPatch = plan.record(patches, 0); + assert.ok(plan.u16(firstPatch) > 0); + assert.throws(() => plan.record(patches, patches.count), /outside its table/); + const drawLayout = textShaperAbi.layouts.engineDraw; + const draws = plan.table('draws'); + assert.equal(draws.count, 2, 'cluster identity must not split compatible paragraph draws'); + assert.deepEqual( + adjacentMaterialGroups(plan, draws, drawLayout.materialId), + [primaryMaterial.id, secondaryMaterial.id], + 'Rust gathers child paragraphs into one ordered command buffer', + ); + assert.deepEqual( + Array.from({ length: draws.count }, (_, index) => plan.u32(plan.record(draws, index) + drawLayout.transformId)), + [0, 0], + 'draw-level transform is zero when the policy selects indexed transform storage', + ); + const drawRoot = new THREE.Object3D(); + const paragraphObjects = new Map([ + [1, new THREE.Object3D()], + [2, new THREE.Object3D()], + ]); + paragraphObjects.get(1).position.x = 3; + paragraphObjects.get(2).position.x = 7; + const target = new ThreeTextRenderPlanExecutor(coordinator, { + drawRoot, + renderOrderBase: 10, + objectForTransform(transformId) { + const object = paragraphObjects.get(transformId); + if (object === undefined) throw new Error(`unknown paragraph transform ${transformId}`); + return object; + }, + transformIds: () => paragraphObjects.keys(), + }); + target.apply(publication); + assert.equal(target.draws.length, 2); + assert.equal(target.draws[0].material.depthTest, true); + assert.deepEqual(materialCalls, ['primary:pmndrs.bitmap', 'secondary:pmndrs.bitmap']); + assert.deepEqual( + target.draws.map((draw) => draw.parent), + [drawRoot, drawRoot], + 'indexed draws share one renderer node instead of splitting by scene transform', + ); + assert.deepEqual( + target.draws.map((draw) => draw.geometry.instanceCount), + [3, 3], + ); + assert.deepEqual( + target.draws.map((draw) => draw.renderOrder), + [10, 11], + ); + const transformIndexAttribute = target.draws[0].geometry.getAttribute('_pmndrsText_15'); + const transformTableAttribute = target.draws[0].geometry.getAttribute('_pmndrsTextTransforms'); + assert.ok(transformIndexAttribute.array instanceof Uint32Array); + assert.ok(transformTableAttribute.array instanceof Float32Array); + assert.deepEqual( + target.draws.map((draw) => transformIndexAttribute.array[draw.userData.pmndrsTextRunStart]), + [1, 2], + 'Rust packs the renderer sidecar slot once per glyph instance', + ); + assert.equal(transformTableAttribute.array[1 * 16 + 12], 3); + assert.equal(transformTableAttribute.array[2 * 16 + 12], 7); + assert.deepEqual( + Array.from(transformTableAttribute.array.subarray(16, 32)), + Array.from(paragraphObjects.get(1).matrixWorld.elements, Math.fround), + ); + assert.deepEqual( + Array.from(transformTableAttribute.array.subarray(32, 48)), + Array.from(paragraphObjects.get(2).matrixWorld.elements, Math.fround), + ); + const unchangedTransformVersion = transformTableAttribute.version; + assert.equal(target.syncTransforms(), 0); + assert.equal(transformTableAttribute.version, unchangedTransformVersion, 'unchanged matrices schedule no upload'); + paragraphObjects.get(1).position.x = 4; + assert.equal(target.syncTransforms(), 1); + assert.equal(transformTableAttribute.version, unchangedTransformVersion + 1); + assert.equal(transformTableAttribute.array[1 * 16 + 12], 4); + paragraphObjects.get(1).visible = false; + assert.equal(target.syncTransforms(), 1); + assert.deepEqual( + Array.from(transformTableAttribute.array.subarray(16, 32)), + Array(16).fill(0), + 'indexed draws suppress only the hidden Text instances without splitting the shared draw', + ); + paragraphObjects.get(1).visible = true; + assert.equal(target.syncTransforms(), 1); + assert.equal(transformTableAttribute.array[1 * 16 + 12], 4); + + const reorderedPublication = session.update( + compileTextEngineFrameUpdate({ + sessionId: session.handle, + policyHandle: coordinator.policyHandle, + capabilitySet: 1, + expectedEngineRevision: publication.engineRevision, + consumedPlanRevision: publication.planRevision, + acknowledgedPublicationGeneration: 0, + limits: { + maxParagraphs: 2, + maxClusters: 16, + maxLines: 8, + maxRegions: 2, + maxExclusions: 1, + maxInlineObjects: 1, + maxSlotsPerBand: 2, + maxOutputBytes: 1024 * 1024, + }, + paragraphMutations: [ + { opcode: 'upsert', paragraphId: 1, order: 1 }, + { opcode: 'upsert', paragraphId: 2, order: 0 }, + ], + }), + ); + const reorderedPlan = plan.bind(reorderedPublication); + const reorderedDraws = reorderedPlan.table('draws'); + assert.equal(reorderedDraws.count, 2); + assert.deepEqual( + adjacentMaterialGroups(reorderedPlan, reorderedDraws, drawLayout.materialId), + [secondaryMaterial.id, primaryMaterial.id], + 'lifecycle-only reorder retains both paragraphs and changes shared draw order', + ); + assert.deepEqual( + Array.from({ length: reorderedDraws.count }, (_, index) => + reorderedPlan.u32(reorderedPlan.record(reorderedDraws, index) + drawLayout.transformId), + ), + [0, 0], + ); + const previousDraws = [...target.draws]; + target.apply(reorderedPublication); + assert.equal(target.draws[0], previousDraws[1], 'reorder retains the secondary draw object'); + assert.equal(target.draws[1], previousDraws[0], 'reorder retains the primary draw object'); + assert.deepEqual( + target.draws.map((draw) => draw.parent), + [drawRoot, drawRoot], + ); + assert.deepEqual( + target.draws.map((draw) => draw.renderOrder), + [10, 11], + ); + const reorderedTargetDraws = [...target.draws]; + const coalescedPublication = session.update( + compileTextEngineFrameUpdate({ + sessionId: session.handle, + policyHandle: coordinator.policyHandle, + capabilitySet: 1, + expectedEngineRevision: reorderedPublication.engineRevision, + consumedPlanRevision: reorderedPublication.planRevision, + acknowledgedPublicationGeneration: 0, + limits: { + maxParagraphs: 2, + maxClusters: 16, + maxLines: 8, + maxRegions: 2, + maxExclusions: 1, + maxInlineObjects: 1, + maxSlotsPerBand: 2, + maxOutputBytes: 1024 * 1024, + }, + styleMutations: [ + { + opcode: 'upsert', + paragraphId: 2, + styleId: 1, + cascadeOrder: 0, + start: 0, + end: 3, + root: true, + value: { + fontStackHandle: first.handle, + materialId: primaryMaterial.id, + fontSize: 16, + rasterPixelRatio: 1, + foregroundRgba: 0xffff_ffff, + }, + }, + ], + }), + ); + const coalescedPlan = plan.bind(coalescedPublication); + const coalescedDraws = coalescedPlan.table('draws'); + assert.equal(coalescedDraws.count, 1, 'same-material paragraphs coalesce across indexed transforms'); + assert.equal(coalescedPlan.u32(coalescedPlan.record(coalescedDraws, 0) + drawLayout.transformId), 0); + target.apply(coalescedPublication); + assert.equal(target.draws[0], reorderedTargetDraws[1], 'coalescing retains the compatible primary draw'); + assert.equal(reorderedTargetDraws[0].parent, null, 'coalescing retires only the incompatible secondary draw'); + assert.equal(target.draws.length, 1); + assert.equal(target.draws[0].geometry.instanceCount, 6); + const coalescedTransformIndices = target.draws[0].geometry.getAttribute('_pmndrsText_15').array; + const coalescedStart = target.draws[0].userData.pmndrsTextRunStart; + assert.deepEqual( + Array.from(coalescedTransformIndices.subarray(coalescedStart, coalescedStart + 6)), + [2, 2, 2, 1, 1, 1], + ); + const bitmapGpuBytes = target.gpuBytes; + const msdfPublication = session.update( + compileTextEngineFrameUpdate({ + sessionId: session.handle, + policyHandle: coordinator.policyHandle, + capabilitySet: 1, + expectedEngineRevision: coalescedPublication.engineRevision, + consumedPlanRevision: coalescedPublication.planRevision, + acknowledgedPublicationGeneration: 0, + limits: { + maxParagraphs: 2, + maxClusters: 16, + maxLines: 8, + maxRegions: 2, + maxExclusions: 1, + maxInlineObjects: 1, + maxSlotsPerBand: 2, + maxOutputBytes: 1024 * 1024, + }, + styleMutations: [2, 1].map((paragraphId) => ({ + opcode: 'upsert', + paragraphId, + styleId: 1, + cascadeOrder: 0, + start: 0, + end: 3, + root: true, + value: { + fontStackHandle: reversed.handle, + materialId: primaryMaterial.id, + fontSize: 16, + rasterPixelRatio: 1, + foregroundRgba: 0xffff_ffff, + }, + })), + }), + ); + const msdfPlan = plan.bind(msdfPublication); + const msdfDraws = msdfPlan.table('draws'); + assert.equal(msdfDraws.count, 1); + assert.equal(msdfPlan.u32(msdfPlan.record(msdfDraws, 0) + drawLayout.programId), 2); + target.apply(msdfPublication); + assert.equal(target.draws.length, 1); + assert.equal(target.draws[0].geometry.instanceCount, 6); + for (const policyBufferId of [1, 2, 3, 4, 5, 6, 7, 15]) { + assert.ok(target.draws[0].geometry.getAttribute(`_pmndrsText_${policyBufferId}`)); + } + assert.equal( + target.gpuBytes, + textStorageBytes(target.draws) + + msdfFont.data.binding.width * msdfFont.data.binding.height * msdfFont.data.binding.layers * 4, + 'retired Bitmap state is excluded and the live MSDF atlas is included', + ); + assert.notEqual(target.gpuBytes, bitmapGpuBytes); + const msdfGpuBytes = target.gpuBytes; + const slugPublication = session.update( + compileTextEngineFrameUpdate({ + sessionId: session.handle, + policyHandle: coordinator.policyHandle, + capabilitySet: 1, + expectedEngineRevision: msdfPublication.engineRevision, + consumedPlanRevision: msdfPublication.planRevision, + acknowledgedPublicationGeneration: 0, + limits: { + maxParagraphs: 2, + maxClusters: 16, + maxLines: 8, + maxRegions: 2, + maxExclusions: 1, + maxInlineObjects: 1, + maxSlotsPerBand: 2, + maxOutputBytes: 1024 * 1024, + }, + styleMutations: [2, 1].map((paragraphId) => ({ + opcode: 'upsert', + paragraphId, + styleId: 1, + cascadeOrder: 0, + start: 0, + end: 3, + root: true, + value: { + fontStackHandle: slugFirst.handle, + materialId: primaryMaterial.id, + fontSize: 16, + rasterPixelRatio: 1, + foregroundRgba: 0xffff_ffff, + }, + })), + }), + ); + const slugPlan = plan.bind(slugPublication); + const slugDraws = slugPlan.table('draws'); + assert.equal(slugDraws.count, 1); + assert.equal(slugPlan.u32(slugPlan.record(slugDraws, 0) + drawLayout.programId), 3); + target.apply(slugPublication); + assert.equal(target.draws.length, 1); + assert.equal(target.draws[0].geometry.instanceCount, 6); + for (const policyBufferId of [1, 2, 3, 4, 5, 6, 7, 15]) { + assert.ok(target.draws[0].geometry.getAttribute(`_pmndrsText_${policyBufferId}`)); + } + const resourceLayout = textShaperAbi.layouts.engineResource; + const draw = slugPlan.record(slugDraws, 0); + const resource = slugPlan.record(slugPlan.table('resources'), slugPlan.u32(draw + drawLayout.resourceStart)); + const slugResource = coordinator.resolveResource(slugPlan.u32(resource + resourceLayout.referenceId)); + assert.equal(slugResource.technique, slug.id); + assert.equal( + target.gpuBytes, + textStorageBytes(target.draws) + slugPageGpuBytes(slugResource.page), + 'retired MSDF state is excluded and the live Slug page is included', + ); + assert.notEqual(target.gpuBytes, msdfGpuBytes); + assert.deepEqual(materialCalls, [ + 'primary:pmndrs.bitmap', + 'secondary:pmndrs.bitmap', + 'primary:pmndrs.msdf', + 'primary:pmndrs.slug', + ]); + assert.ok(target.gpuBytes > 0); + + const directPolicyHandle = 2; + coordinator.host.registerPolicy( + directPolicyHandle, + firstPartyThreeRenderPolicyBytes(coordinator.host.wireIdentities, 'direct'), + ); + const directSession = coordinator.createSession({ + requestCapacity: 4_096, + resultCapacity: 1024 * 1024, + textCapacity: 16, + }); + const directRequest = initialRequest.slice(); + const requestLayout = textShaperAbi.layouts.engineUpdateRequest; + const directRequestView = new DataView(directRequest.buffer, directRequest.byteOffset, directRequest.byteLength); + directRequestView.setUint32(requestLayout.sessionId, directSession.handle, true); + directRequestView.setUint32(requestLayout.policyHandle, directPolicyHandle, true); + const directPublication = directSession.update(directRequest); + const directPlan = plan.bind(directPublication); + const directDraws = directPlan.table('draws'); + assert.deepEqual( + Array.from({ length: directDraws.count }, (_, index) => + directPlan.u32(directPlan.record(directDraws, index) + drawLayout.transformId), + ), + [1, 2], + 'the direct policy makes transform identity an authoritative Rust draw boundary', + ); + const directTarget = new ThreeTextRenderPlanExecutor(coordinator, { + drawRoot, + renderOrderBase: 20, + objectForTransform(transformId) { + const object = paragraphObjects.get(transformId); + if (object === undefined) throw new Error(`unknown paragraph transform ${transformId}`); + return object; + }, + transformIds: () => paragraphObjects.keys(), + }); + directTarget.apply(directPublication); + assert.equal(directTarget.draws.length, 2); + for (const [index, directDraw] of directTarget.draws.entries()) { + assert.equal(directDraw.geometry.getAttribute('_pmndrsText_15'), undefined); + assert.equal(directDraw.geometry.getAttribute('_pmndrsTextTransforms'), undefined); + assert.equal(directDraw.matrixAutoUpdate, false); + assert.equal(directDraw.matrix.elements[12], index === 0 ? 4 : 7); + } + assert.equal(directTarget.syncTransforms(), 0); + paragraphObjects.get(2).position.x = 9; + assert.equal(directTarget.syncTransforms(), 1); + assert.equal(directTarget.draws[1].matrix.elements[12], 9); + paragraphObjects.get(2).visible = false; + assert.equal(directTarget.syncTransforms(), 1); + assert.equal(directTarget.draws[1].visible, false); + paragraphObjects.get(2).visible = true; + assert.equal(directTarget.syncTransforms(), 1); + assert.equal(directTarget.draws[1].visible, true); + directTarget.dispose(); + directSession.dispose(); + + const hybridPolicyHandle = 3; + coordinator.host.registerPolicy( + hybridPolicyHandle, + firstPartyThreeRenderPolicyBytes(coordinator.host.wireIdentities, { + bitmap: 'indexed', + msdf: 'direct', + slug: 'indexed', + }), + ); + const hybridSession = coordinator.createSession({ + requestCapacity: 4_096, + resultCapacity: 1024 * 1024, + textCapacity: 16, + }); + const hybridRequest = initialRequest.slice(); + const hybridRequestView = new DataView(hybridRequest.buffer, hybridRequest.byteOffset, hybridRequest.byteLength); + hybridRequestView.setUint32(requestLayout.sessionId, hybridSession.handle, true); + hybridRequestView.setUint32(requestLayout.policyHandle, hybridPolicyHandle, true); + const hybridInitialPublication = hybridSession.update(hybridRequest); + const hybridTarget = new ThreeTextRenderPlanExecutor(coordinator, { + drawRoot, + renderOrderBase: 30, + objectForTransform(transformId) { + const object = paragraphObjects.get(transformId); + if (object === undefined) throw new Error(`unknown paragraph transform ${transformId}`); + return object; + }, + transformIds: () => paragraphObjects.keys(), + }); + hybridTarget.apply(hybridInitialPublication); + const hybridPublication = hybridSession.update( + compileTextEngineFrameUpdate({ + sessionId: hybridSession.handle, + policyHandle: hybridPolicyHandle, + capabilitySet: 1, + expectedEngineRevision: hybridInitialPublication.engineRevision, + consumedPlanRevision: hybridInitialPublication.planRevision, + acknowledgedPublicationGeneration: 0, + limits: { + maxParagraphs: 2, + maxClusters: 16, + maxLines: 8, + maxRegions: 2, + maxExclusions: 1, + maxInlineObjects: 1, + maxSlotsPerBand: 2, + maxOutputBytes: 1024 * 1024, + }, + styleMutations: [ + { + opcode: 'upsert', + paragraphId: 2, + styleId: 1, + cascadeOrder: 0, + start: 0, + end: 3, + root: true, + value: { + fontStackHandle: reversed.handle, + materialId: secondaryMaterial.id, + fontSize: 16, + rasterPixelRatio: 1, + foregroundRgba: 0xffff_ffff, + }, + }, + ], + }), + ); + const hybridPlan = plan.bind(hybridPublication); + const hybridDraws = hybridPlan.table('draws'); + assert.deepEqual( + Array.from({ length: hybridDraws.count }, (_, index) => { + const hybridDraw = hybridPlan.record(hybridDraws, index); + return [hybridPlan.u32(hybridDraw + drawLayout.programId), hybridPlan.u32(hybridDraw + drawLayout.transformId)]; + }), + [ + [1, 0], + [2, 2], + ], + 'one Rust publication may mix indexed and direct program contracts', + ); + hybridTarget.apply(hybridPublication); + const [hybridIndexedDraw, hybridDirectDraw] = hybridTarget.draws; + assert.ok(hybridIndexedDraw.geometry.getAttribute('_pmndrsText_15')); + assert.ok(hybridIndexedDraw.geometry.getAttribute('_pmndrsTextTransforms')); + assert.equal(hybridDirectDraw.geometry.getAttribute('_pmndrsText_15'), undefined); + assert.equal(hybridDirectDraw.geometry.getAttribute('_pmndrsTextTransforms'), undefined); + assert.equal(hybridDirectDraw.matrix.elements[12], 9); + let hybridDirectDisposals = 0; + hybridDirectDraw.material.addEventListener('dispose', () => (hybridDirectDisposals += 1)); + paragraphObjects.set(20, new THREE.Object3D()); + hybridTarget.apply(hybridPublication); + assert.equal(hybridDirectDisposals, 0, 'indexed transform growth must preserve unrelated direct materials'); + assert.equal(hybridTarget.draws[1].material, hybridDirectDraw.material); + paragraphObjects.get(1).position.x = 6; + paragraphObjects.get(2).position.x = 10; + assert.equal(hybridTarget.syncTransforms(), 2); + assert.equal(hybridTarget.draws[0].geometry.getAttribute('_pmndrsTextTransforms').array[1 * 16 + 12], 6); + assert.equal(hybridDirectDraw.matrix.elements[12], 10); + hybridTarget.dispose(); + paragraphObjects.delete(20); + hybridSession.dispose(); + + const stablePolicyHandle = 4; + coordinator.host.registerPolicy( + stablePolicyHandle, + firstPartyThreeRenderPolicyBytes(coordinator.host.wireIdentities, 'indexed', [], 'stable'), + ); + const stableSession = coordinator.createSession({ + requestCapacity: 4_096, + resultCapacity: 1024 * 1024, + textCapacity: 16, + }); + const stableRequest = initialRequest.slice(); + const stableRequestView = new DataView(stableRequest.buffer, stableRequest.byteOffset, stableRequest.byteLength); + stableRequestView.setUint32(requestLayout.sessionId, stableSession.handle, true); + stableRequestView.setUint32(requestLayout.policyHandle, stablePolicyHandle, true); + const stableInitialPublication = stableSession.update(stableRequest); + const stablePlan = plan.bind(stableInitialPublication); + const stableBuffers = stablePlan.table('buffers'); + const bufferLayout = textShaperAbi.layouts.engineBuffer; + const orderBinding = textShaperAbi.engine.internalBufferBindings.order; + const orderRecord = Array.from({ length: stableBuffers.count }, (_, index) => + stablePlan.record(stableBuffers, index), + ).find((record) => stablePlan.u16(record + bufferLayout.policyBufferId) === orderBinding); + assert.ok(orderRecord !== undefined, 'stable policy must publish its logical-order buffer'); + const orderBufferId = stablePlan.u32(orderRecord + bufferLayout.id); + const stableDraws = stablePlan.table('draws'); + assert.ok( + Array.from({ length: stableDraws.count }, (_, index) => + stablePlan.u32(stablePlan.record(stableDraws, index) + drawLayout.indirectBufferId), + ).every((id) => id === orderBufferId), + 'every stable draw must address physical records through the published order buffer', + ); + const stableTarget = new ThreeTextRenderPlanExecutor(coordinator, { + drawRoot, + renderOrderBase: 40, + objectForTransform(transformId) { + const object = paragraphObjects.get(transformId); + if (object === undefined) throw new Error(`unknown paragraph transform ${transformId}`); + return object; + }, + transformIds: () => paragraphObjects.keys(), + }); + stableTarget.apply(stableInitialPublication); + const stableOrder = stableTarget.draws[0].geometry.getAttribute(`_pmndrsText_${orderBinding}`); + const stableIds = stableTarget.draws[0].geometry.getAttribute('_pmndrsText_14'); + assert.ok(stableOrder.array instanceof Uint32Array); + assert.ok(stableIds.array instanceof Uint32Array); + const physicalIds = stableIds.array.slice(); + const initialFirstPhysicalSlot = stableOrder.array[stableTarget.draws[0].userData.pmndrsTextRunStart]; + const stablePreviousDraws = [...stableTarget.draws]; + const stableReorderedPublication = stableSession.update( + compileTextEngineFrameUpdate({ + sessionId: stableSession.handle, + policyHandle: stablePolicyHandle, + capabilitySet: 1, + expectedEngineRevision: stableInitialPublication.engineRevision, + consumedPlanRevision: stableInitialPublication.planRevision, + acknowledgedPublicationGeneration: 0, + limits: { + maxParagraphs: 2, + maxClusters: 16, + maxLines: 8, + maxRegions: 2, + maxExclusions: 1, + maxInlineObjects: 1, + maxSlotsPerBand: 2, + maxOutputBytes: 1024 * 1024, + }, + paragraphMutations: [ + { opcode: 'upsert', paragraphId: 1, order: 1 }, + { opcode: 'upsert', paragraphId: 2, order: 0 }, + ], + }), + ); + const stableReorderedPlan = plan.bind(stableReorderedPublication); + const stablePatches = stableReorderedPlan.table('patches'); + const patchLayout = textShaperAbi.layouts.enginePatch; + assert.ok(stablePatches.count > 0); + assert.ok( + Array.from({ length: stablePatches.count }, (_, index) => + stableReorderedPlan.u32(stableReorderedPlan.record(stablePatches, index) + patchLayout.bufferId), + ).every((id) => id === orderBufferId), + 'lifecycle-only reorder must leave stable physical glyph records untouched', + ); + stableTarget.apply(stableReorderedPublication); + assert.deepEqual(stableIds.array, physicalIds, 'Three retains the stable physical glyph table across reorder'); + assert.equal(stableTarget.draws[0], stablePreviousDraws[1]); + assert.equal(stableTarget.draws[1], stablePreviousDraws[0]); + assert.notEqual( + stableOrder.array[stableTarget.draws[0].userData.pmndrsTextRunStart], + initialFirstPhysicalSlot, + 'the reordered logical draw begins at a different retained physical slot', + ); + stableTarget.dispose(); + stableSession.dispose(); + + target.dispose(); + session.dispose(); + first.release(); + first.release(); + const stillShared = coordinator.acquireFontStack([bitmapFont, msdfFont]); + assert.equal(stillShared.handle, shared.handle, 'one outstanding lease must retain the stack'); + shared.release(); + stillShared.release(); + const replacement = coordinator.acquireFontStack([bitmapFont, msdfFont]); + assert.notEqual(replacement.handle, first.handle, 'a retired stack handle is not immediately reused'); + replacement.release(); + reversed.release(); + slugFirst.release(); + primaryMaterial.release(); + secondaryMaterial.release(); + bitmapFont.dispose(); + assert.throws( + () => coordinator.resolveResource(bitmapReference), + /unknown resource/u, + 'disposed fonts must release decoded renderer resources from the coordinator', + ); + assert.equal(coordinator.resolveResource(msdfReference).technique, msdf.id); + msdfFont.dispose(); + slugFont.dispose(); + coordinator.dispose(); + assert.throws(() => coordinator.acquireFontStack([bitmapFont]), /disposed/); + shaper.dispose(); + registered.dispose(); +}); + +function adjacentMaterialGroups(plan, draws, materialOffset) { + const groups = []; + for (let index = 0; index < draws.count; index += 1) { + const material = plan.u32(plan.record(draws, index) + materialOffset); + if (groups.at(-1) !== material) groups.push(material); + } + return groups; +} + +function textStorageBytes(draws) { + const arrays = new Set(); + for (const draw of draws) { + for (const [name, attribute] of Object.entries(draw.geometry.attributes)) { + if (name.startsWith('_pmndrsText')) arrays.add(attribute.array); + } + } + return [...arrays].reduce((bytes, array) => bytes + array.byteLength, 0); +} + +function slugPageGpuBytes(page) { + const references = new Uint16Array( + page.referenceBytes.buffer, + page.referenceBytes.byteOffset, + page.referenceBytes.byteLength / 2, + ); + const texels = Math.ceil(references.length / 2); + const width = Math.min(page.referenceWidth, texels); + const height = Math.ceil(texels / width); + return page.curveBytes.byteLength + page.headerBytes.byteLength + width * height * 4; +} diff --git a/packages/text/tests/integration/three-shader.test.mjs b/packages/text/tests/integration/three-shader.test.mjs index c32392f7..f0f131f4 100644 --- a/packages/text/tests/integration/three-shader.test.mjs +++ b/packages/text/tests/integration/three-shader.test.mjs @@ -2,9 +2,9 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; -import { createRuntimeShaper, createTextRuntime, defineRasterTechnique, FontRegistry } from '@pmndrs/text'; +import { createTextRuntime, FontRegistry } from '@pmndrs/text'; import { bitmap } from '@pmndrs/text/three/bitmap'; -import { bitmapShader, msdfShader, registerThreeRasterProgram, slugShader, Text } from '@pmndrs/text/three'; +import { bitmapShader, defineTextMaterial, msdfShader, slugShader, Text } from '@pmndrs/text/three'; import * as TSL from 'three/tsl'; import * as THREE from 'three/webgpu'; @@ -16,45 +16,50 @@ test('the canonical technique shaders are exported as callable node builders', ( assert.equal(typeof slugShader, 'function'); }); -test('a custom Three program composes over the exported Bitmap shader in the real draw path', async () => { +test('a custom Three material composes over the Bitmap shader in the Rust command-buffer draw path', async () => { const registry = new FontRegistry(); - const shaper = await createRuntimeShaper({ + const runtime = await createTextRuntime({ registry, wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), }); - const runtime = await createTextRuntime({ registry, shaper }); - const composedBitmap = defineRasterTechnique({ ...bitmap, id: 'tests.composed-bitmap' }); - const built = []; - registerThreeRasterProgram(composedBitmap, (owner) => new ComposedTarget(owner, composedBitmap, built)); - const font = await runtime.loadFont({ input: { baked: dataUrl(await readFile(fontUrl)) }, - raster: { technique: composedBitmap, options: { strikes: [16] } }, + raster: { technique: bitmap, options: { strikes: [16] } }, + }); + const built = []; + const material = defineTextMaterial((context) => { + const composed = context.createDefaultMaterial(); + composed.colorNode = TSL.vec3(context.shader.color.r, 0, context.shader.color.b); + built.push({ context, material: composed }); + return composed; }); const scene = new THREE.Scene(); - const label = new Text({ font, text: 'Composed' }); + const label = new Text({ font, material, text: 'Composed' }); scene.add(label); scene.updateMatrixWorld(); + assert.equal(label.error, undefined); const draws = label.children.filter((child) => child.isMesh); - assert.equal(draws.length, 1, 'the custom program must produce a real draw through the retained lifecycle'); - assert.equal(built.length, 1, 'the custom program must build exactly one composed material'); - const { shader, material } = built[0]; - - assert.deepEqual( - Object.keys(shader).sort(), - ['atlasUv', 'clipPosition', 'color', 'coverage', 'opacity', 'position'], - 'the canonical Bitmap shader must return its documented named outputs', - ); - for (const [name, node] of Object.entries(shader)) { + assert.equal(draws.length, 1, 'the Rust publication must produce one real custom-material draw'); + assert.equal(built.length, 1, 'the material factory must run once for one retained realization'); + const { context, material: realized } = built[0]; + assert.equal(context.technique, bitmap.id); + assert.deepEqual(Object.keys(context.shader).sort(), [ + 'atlasUv', + 'clipPosition', + 'color', + 'coverage', + 'opacity', + 'position', + ]); + for (const [name, node] of Object.entries(context.shader)) { assert.ok(node?.isNode === true, `canonical Bitmap output "${name}" must be a TSL node`); } + assert.equal(draws[0].material, realized); - assert.equal(draws[0].material, material); - assert.equal(material.positionNode, shader.position, 'the program must reuse the canonical vertex placement'); - assert.equal(material.vertexNode, shader.clipPosition, 'the program must inherit the canonical pixel snapping'); - assert.equal(material.opacityNode, shader.opacity, 'the program must reuse the canonical coverage and paint alpha'); - assert.notEqual(material.colorNode, shader.color, 'the program must be free to emit its own final colour'); + scene.updateMatrixWorld(); + assert.equal(label.children[0], draws[0], 'an unchanged frame must retain the draw and material realization'); + assert.equal(built.length, 1); label.removeFromParent(); label.dispose(); @@ -62,112 +67,6 @@ test('a custom Three program composes over the exported Bitmap shader in the rea runtime.dispose(); }); -class ComposedTarget { - #owner; - #built; - #textures = new Map(); - - constructor(owner, technique, built) { - this.technique = technique; - this.#owner = owner; - this.#built = built; - } - - stage(_previous, next) { - const resources = new Map(); - for (const batch of next.glyphBatches) resources.set(batch.key, this.#createResource(batch)); - const draws = []; - for (let index = 0; index < next.glyphRuns.length; index += 1) { - const run = next.glyphRuns[index]; - const resource = resources.get(run.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, paragraph: run.paragraph }); - } - const dispose = () => { - for (const { mesh } of draws) { - mesh.removeFromParent(); - mesh.geometry.dispose(); - } - for (const resource of resources.values()) resource.material.dispose(); - }; - return { - status: 'ready', - stage: { - sourceRevision: next.revision, - commit: () => { - for (const { mesh, paragraph } of draws) this.#owner.objectForParagraph(paragraph).add(mesh); - return { sourceRevision: next.revision, dispose }; - }, - abort: dispose, - }, - }; - } - - dispose() { - for (const texture of this.#textures.values()) texture.dispose(); - this.#textures.clear(); - } - - #createResource(batch) { - const page = batch.font.data.strikes[batch.binding.strike].pages[batch.binding.page]; - const attributes = { - origins: storageAttribute(batch.storage.origins, 2), - sizes: storageAttribute(batch.storage.sizes, 2), - uvOrigins: storageAttribute(batch.storage.uvOrigins, 2), - uvSizes: storageAttribute(batch.storage.uvSizes, 2), - colors: storageAttribute(batch.storage.colors, 4), - }; - const instance = TSL.instanceIndex.add(TSL.uniform(0, 'uint')); - const shader = bitmapShader( - { - origin: TSL.storage(attributes.origins, 'vec2', attributes.origins.count).element(instance), - size: TSL.storage(attributes.sizes, 'vec2', attributes.sizes.count).element(instance), - uvOrigin: TSL.storage(attributes.uvOrigins, 'vec2', attributes.uvOrigins.count).element(instance), - uvSize: TSL.storage(attributes.uvSizes, 'vec2', attributes.uvSizes.count).element(instance), - color: TSL.storage(attributes.colors, 'vec4', attributes.colors.count).element(instance), - }, - { page: this.#texture(page) }, - ); - const material = new THREE.MeshBasicNodeMaterial({ transparent: true }); - material.positionNode = shader.position; - material.vertexNode = shader.clipPosition; - material.colorNode = shader.color.mul(TSL.vec3(1, 0, 0)); - material.opacityNode = shader.opacity; - this.#built.push({ shader, material }); - 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; - return geometry; - }, - }; - } - - #texture(page) { - 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.needsUpdate = true; - this.#textures.set(page.resource, texture); - return texture; - } -} - -function storageAttribute(array, itemSize) { - const attribute = new THREE.StorageInstancedBufferAttribute(new Float32Array(array), itemSize); - attribute.needsUpdate = true; - return attribute; -} - function dataUrl(bytes) { return `data:model/gltf-binary;base64,${bytes.toString('base64')}`; } diff --git a/packages/text/tests/integration/three-v1.test.mjs b/packages/text/tests/integration/three-v1.test.mjs index 0b97954f..73e01142 100644 --- a/packages/text/tests/integration/three-v1.test.mjs +++ b/packages/text/tests/integration/three-v1.test.mjs @@ -1,28 +1,69 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; +import { gunzipSync } from 'node:zlib'; -import { createRuntimeShaper, createTextRuntime, FontRegistry } from '@pmndrs/text'; +import { createFontStack, createTextRuntime, FontRegistry } from '@pmndrs/text'; import { bitmap } from '@pmndrs/text/three/bitmap'; -import { Text, TextGroup } from '@pmndrs/text/three'; +import { slug } from '@pmndrs/text/three/slug'; +import { defineTextMaterial, Text, TextGroup } from '@pmndrs/text/three'; import * as THREE from 'three/webgpu'; const fontUrl = new URL('../../../../apps/benchmarks/fixtures/rendering/inter-bitmap-16.font.glb', import.meta.url); +const densityFontUrl = new URL( + '../../../../apps/benchmarks/fixtures/rendering/inter-bitmap-16-32.font.glb', + import.meta.url, +); +const amiriFontUrl = new URL( + '../../../../apps/benchmarks/fixtures/rendering/amiri-bitmap-16.font.glb', + import.meta.url, +); +const iconSlugFontUrl = new URL( + '../../../../apps/benchmarks/fixtures/rendering/font-awesome-free-6.7.2-slug.font.glb.gz', + import.meta.url, +); test('Three Text and TextGroup late-bind, synchronize, reparent, and dispose through the scene graph', async () => { const registry = new FontRegistry(); - const shaper = await createRuntimeShaper({ + const runtime = await createTextRuntime({ registry, wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), }); - const runtime = await createTextRuntime({ registry, shaper }); const font = await runtime.loadFont({ input: { baked: dataUrl(await readFile(fontUrl)) }, raster: { technique: bitmap, options: { strikes: [16] } }, }); + const editedSpans = new Text({ + font, + text: 'ABCD', + spans: [ + { start: 0, end: 2, paint: { color: '#ff0000' } }, + { start: 2, end: 4, paint: { color: '#00ff00' } }, + ], + }); + editedSpans.insertText(1, 'X'); + editedSpans.insertText(3, 'Y'); + assert.deepEqual( + editedSpans.spans.map(({ start, end }) => ({ start, end })), + [ + { start: 0, end: 3 }, + { start: 4, end: 6 }, + ], + 'insertion inside a span extends it while insertion at a boundary stays outside', + ); + editedSpans.deleteText(1, 4); + assert.equal(editedSpans.text, 'ACD'); + assert.deepEqual( + editedSpans.spans.map(({ start, end }) => ({ start, end })), + [ + { start: 0, end: 1 }, + { start: 1, end: 3 }, + ], + ); + editedSpans.dispose(); const scene = new THREE.Scene(); - const group = new TextGroup({ technique: bitmap, renderOrder: 12 }); + const group = new TextGroup({ renderOrder: 12 }); const container = new THREE.Object3D(); const label = new Text({ font, text: 'First frame' }); container.add(label); @@ -34,10 +75,44 @@ test('Three Text and TextGroup late-bind, synchronize, reparent, and dispose thr assert.equal(label.bound, true); assert.equal(label.textGroup, group); assert.equal(group.textCount, 1); - assert.ok(label.layout.glyphIds.length > 0); - const firstDraws = label.children.filter((child) => child.isMesh); + assert.equal(label.layout, undefined, 'rendering must not materialize layout readback'); + assert.equal(group.error, undefined); + const firstDraws = group.children.filter((child) => child.isMesh); assert.ok(firstDraws.length > 0); + assert.equal(firstDraws[0].geometry.instanceCount, 10, 'the GPU plan omits the non-rendering space glyph'); assert.equal(firstDraws[0].renderOrder, 12); + const measurement = label.measureLayout(); + assert.ok(measurement, 'layout measurement must be available through an explicit Rust query'); + assert.equal(measurement.width, measurement.contentWidth); + assert.equal(measurement.height, measurement.contentHeight); + assert.ok(measurement.firstBaseline > 0); + assert.equal(measurement.firstBaseline, measurement.lastBaseline); + assert.equal(measurement.overflowed, false); + assert.equal(measurement.glyphCount, 11, 'layout summary retains the non-rendering space glyph'); + assert.equal(measurement.lineCount, 1); + assert.equal(measurement.missingGlyphCount, 0); + assert.equal(label.measureLayout(), measurement, 'an unchanged committed layout must reuse its queried measurement'); + const inspection = label.inspectLayout(); + assert.ok(inspection, 'per-glyph layout must be available only through an explicit Rust inspection query'); + assert.equal(inspection.glyphIds.length, measurement.glyphCount); + assert.equal(inspection.glyphStableIds.length, inspection.glyphIds.length); + assert.equal(inspection.lineGlyphCounts.length, measurement.lineCount); + assert.equal(label.inspectLayout(), inspection, 'an unchanged committed layout must reuse its copied inspection'); + assert.equal(label.layout, undefined, 'query data must not restore layout arrays to rendering'); + assert.equal(group.children.filter((child) => child.isMesh)[0], firstDraws[0]); + + const origins = label.snapshotGlyphOrigins(); + assert.equal(origins.layout, inspection); + assert.deepEqual(origins.displayedX, origins.shapedX); + assert.deepEqual(origins.displayedY, origins.shapedY); + const presentedX = origins.shapedX.slice(); + presentedX[0] += 3; + label.setGlyphOrigins({ layout: inspection, x: presentedX, y: origins.shapedY }); + const presented = label.snapshotGlyphOrigins(); + assert.equal(presented.shapedX[0], origins.shapedX[0], 'presentation must not mutate authoritative layout'); + assert.equal(presented.displayedX[0], origins.shapedX[0] + 3); + label.clearGlyphOriginOverrides(); + assert.deepEqual(label.snapshotGlyphOrigins().displayedX, origins.shapedX); group.renderOrder = 20; scene.updateMatrixWorld(); @@ -45,20 +120,22 @@ test('Three Text and TextGroup late-bind, synchronize, reparent, and dispose thr label.renderOrder = 7; scene.updateMatrixWorld(); - assert.equal(label.children.filter((child) => child.isMesh)[0].renderOrder, 20); - assert.equal(label.layout.glyphIds.length, 11, 'render-order-only updates must preserve the shaped paragraph'); + assert.equal(group.children.filter((child) => child.isMesh)[0].renderOrder, 20); + assert.equal(firstDraws[0].geometry.instanceCount, 10, 'render-order-only updates must preserve the Rust plan'); label.text = 'Only the final desired value'; label.text = 'Updated'; scene.updateMatrixWorld(); - assert.equal(label.layout.glyphIds.length, 7); - assert.ok(label.children.some((child) => child.isMesh)); - assert.equal(label.children.filter((child) => child.isMesh)[0], firstDraws[0]); + assert.equal(label.layout, undefined); + assert.ok(group.children.some((child) => child.isMesh)); + assert.equal(group.children.filter((child) => child.isMesh)[0], firstDraws[0]); assert.equal( firstDraws[0].geometry.instanceCount, 7, 'compatible revisions must retain draws and resize live counts', ); + assert.notEqual(label.measureLayout(), measurement, 'a semantic update must invalidate the measurement cache'); + assert.notEqual(label.inspectLayout(), inspection, 'a semantic update must invalidate the inspection cache'); scene.add(label); scene.updateMatrixWorld(); @@ -84,6 +161,536 @@ test('Three Text and TextGroup late-bind, synchronize, reparent, and dispose thr runtime.dispose(); }); +test('Three retries an unapplied Rust publication before requesting another engine delta', async () => { + const registry = new FontRegistry(); + const instrumented = await createInstrumentedRuntime(registry); + const runtime = instrumented.runtime; + const font = await runtime.loadFont({ + input: { baked: dataUrl(await readFile(fontUrl)) }, + raster: { technique: bitmap, options: { strikes: [16] } }, + }); + let failMaterial = true; + let label; + const material = defineTextMaterial((context) => { + if (failMaterial) { + assert.throws(() => label.measureLayout(), /cannot reenter Three render-plan application/u); + throw new Error('deliberate material realization failure'); + } + return context.createDefaultMaterial(); + }); + const scene = new THREE.Scene(); + const group = new TextGroup(); + label = new Text({ font, material, text: 'Retry me' }); + group.add(label); + scene.add(group); + + scene.updateMatrixWorld(); + assert.match(String(group.error), /deliberate material realization failure/u); + assert.equal(label.error, group.error, 'group-owned failures must remain visible from the child Text'); + assert.equal(instrumented.crossings, 1); + assert.equal(group.children.filter((child) => child.isMesh).length, 0); + + failMaterial = false; + scene.updateMatrixWorld(); + assert.equal(group.error, undefined); + assert.equal(label.error, undefined); + assert.equal(instrumented.crossings, 1, 'retrying an owned command buffer must not cross into Rust again'); + assert.equal(group.children.filter((child) => child.isMesh).length, 1); + + group.dispose(); + label.dispose(); + font.dispose(); + runtime.dispose(); +}); + +test('TextGroup drops disposed descendants and reuses their committed transform identities', async () => { + const registry = new FontRegistry(); + const runtime = await createTextRuntime({ + registry, + wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), + }); + const font = await runtime.loadFont({ + input: { baked: dataUrl(await readFile(fontUrl)) }, + raster: { technique: bitmap, options: { strikes: [16] } }, + }); + const scene = new THREE.Scene(); + const group = new TextGroup(); + const survivor = new Text({ font, text: 'A' }); + group.add(survivor); + scene.add(group); + scene.updateMatrixWorld(); + + let retainedTransformBytes; + for (let index = 0; index < 12; index += 1) { + const transient = new Text({ font, text: 'B' }); + group.add(transient); + scene.updateMatrixWorld(); + const draw = group.children.find((child) => child.isMesh); + assert.ok(draw); + const transformBytes = draw.geometry.getAttribute('_pmndrsTextTransforms').array.byteLength; + retainedTransformBytes ??= transformBytes; + assert.equal(transformBytes, retainedTransformBytes, 'committed removals must make transform identities reusable'); + + transient.dispose(); + assert.doesNotThrow( + () => scene.updateMatrixWorld(), + 'a disposed child may remain attached until its host removes it', + ); + assert.equal(group.error, undefined); + assert.equal(group.textCount, 1); + transient.removeFromParent(); + } + + const draw = group.children.find((child) => child.isMesh); + assert.equal(draw.geometry.instanceCount, 1); + group.dispose(); + survivor.dispose(); + font.dispose(); + runtime.dispose(); +}); + +test('Three retires materials bound to a replaced buffer generation', async () => { + const registry = new FontRegistry(); + const runtime = await createTextRuntime({ + registry, + wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), + }); + const font = await runtime.loadFont({ + input: { baked: dataUrl(await readFile(fontUrl)) }, + raster: { technique: bitmap, options: { strikes: [16] } }, + }); + const materials = []; + const disposed = new Set(); + const material = defineTextMaterial((context) => { + const created = context.createDefaultMaterial(); + created.addEventListener('dispose', () => disposed.add(created)); + materials.push(created); + return created; + }); + const scene = new THREE.Scene(); + const group = new TextGroup({ capacity: { size: 2, policy: 'grow' } }); + const label = new Text({ font, material, text: 'AB' }); + group.add(label); + scene.add(group); + scene.updateMatrixWorld(); + const initialMaterial = group.children.find((child) => child.isMesh)?.material; + assert.equal(initialMaterial, materials[0]); + + label.text = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + scene.updateMatrixWorld(); + assert.ok(materials.length > 1, 'growing physical buffers must realize a material for the new generation'); + assert.ok(disposed.has(initialMaterial), 'the material retaining the retired generation must be disposed'); + + group.dispose(); + label.dispose(); + font.dispose(); + runtime.dispose(); +}); + +test('one Rust plan partitions a mixed Bitmap to Slug fallback stack', async () => { + const registry = new FontRegistry(); + const runtime = await createTextRuntime({ + registry, + wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), + }); + const [latin, icon] = await Promise.all([ + runtime.loadFont({ + input: { baked: dataUrl(await readFile(fontUrl)) }, + raster: { technique: bitmap, options: { strikes: [16] } }, + }), + runtime.loadFont({ + input: { baked: dataUrl(gunzipSync(await readFile(iconSlugFontUrl))) }, + raster: { technique: slug, options: {} }, + }), + ]); + const realizedTechniques = []; + const material = defineTextMaterial((context) => { + realizedTechniques.push(context.technique); + return context.createDefaultMaterial(); + }); + const scene = new THREE.Scene(); + const label = new Text({ + font: createFontStack(latin, icon), + material, + text: 'Hello \uf0ac', + }); + scene.add(label); + scene.updateMatrixWorld(); + + const draws = label.children.filter((child) => child.isMesh); + assert.equal(label.error, undefined); + assert.equal(draws.length, 2, 'Rust must partition fallback glyphs by renderer program and resource'); + assert.equal( + draws.reduce((count, draw) => count + draw.geometry.instanceCount, 0), + 6, + ); + assert.deepEqual(realizedTechniques.sort(), [bitmap.id, slug.id].sort()); + assert.deepEqual( + draws.map((draw) => draw.geometry.getAttribute('_pmndrsText_2').itemSize).sort(), + [2, 4], + 'Bitmap vec2 and Slug vec4 records must coexist without a user technique selector', + ); + + label.dispose(); + latin.dispose(); + icon.dispose(); + runtime.dispose(); +}); + +test('TextGroup realizes two public Text objects as one indexed Rust draw', async () => { + const registry = new FontRegistry(); + const instrumented = await createInstrumentedRuntime(registry); + const runtime = instrumented.runtime; + const font = await runtime.loadFont({ + input: { baked: dataUrl(await readFile(fontUrl)) }, + raster: { technique: bitmap, options: { strikes: [16] } }, + }); + const scene = new THREE.Scene(); + const group = new TextGroup({ renderOrder: 3 }); + const left = new Text({ font, text: 'AB' }); + const right = new Text({ font, text: 'CD' }); + left.position.x = 2; + right.position.x = 5; + group.add(left, right); + scene.add(group); + scene.updateMatrixWorld(); + + assert.equal(group.error, undefined); + const draws = group.children.filter((child) => child.isMesh); + assert.equal(draws.length, 1, 'compatible paragraphs must batch in Rust before Three sees the plan'); + assert.equal(draws[0].geometry.instanceCount, 4); + const start = draws[0].userData.pmndrsTextRunStart; + const indices = draws[0].geometry.getAttribute('_pmndrsText_15').array; + assert.deepEqual(Array.from(indices.subarray(start, start + 4)), [1, 1, 2, 2]); + const transforms = draws[0].geometry.getAttribute('_pmndrsTextTransforms'); + assert.equal(transforms.array[1 * 16 + 12], 2); + assert.equal(transforms.array[2 * 16 + 12], 5); + + const initialLeftMeasurement = left.measureLayout(); + const initialRightMeasurement = right.measureLayout(); + assert.ok(initialLeftMeasurement); + assert.ok(initialRightMeasurement); + instrumented.reset(); + left.set({}); + assert.equal(left.measureLayout(), initialLeftMeasurement, 'an empty update must preserve the cached measurement'); + scene.updateMatrixWorld(); + assert.equal(instrumented.crossings, 0, 'an empty update and cached measurement must not cross into Rust'); + + left.deleteText(1, 2); + scene.updateMatrixWorld(); + assert.deepEqual(instrumented.latestTextMutations(), [{ start: 1, deleteCount: 1, insert: '' }]); + assert.equal(left.text, 'A'); + + left.insertText(1, 'B'); + scene.updateMatrixWorld(); + assert.deepEqual(instrumented.latestTextMutations(), [{ start: 1, deleteCount: 0, insert: 'B' }]); + assert.equal(left.text, 'AB'); + + left.text = 'AY'; + scene.updateMatrixWorld(); + assert.deepEqual( + instrumented.latestTextMutations(), + [{ start: 1, deleteCount: 1, insert: 'Y' }], + 'declarative assignment must serialize its smallest scalar-aligned replacement', + ); + + left.replaceText(1, 2, 'Z'); + left.deleteText(0, 1); + left.insertText(0, 'A'); + scene.updateMatrixWorld(); + assert.deepEqual(instrumented.latestTextMutations(), [ + { start: 1, deleteCount: 1, insert: 'Z' }, + { start: 0, deleteCount: 1, insert: '' }, + { start: 0, deleteCount: 0, insert: 'A' }, + ]); + assert.equal(left.text, 'AZ'); + + left.replaceText(0, 2, '🌍'); + assert.throws(() => left.insertText(1, '!'), /must not split a Unicode scalar/u); + scene.updateMatrixWorld(); + assert.equal(left.text, '🌍'); + left.text = 'AB'; + scene.updateMatrixWorld(); + + instrumented.reset(); + left.contentBox = { width: { mode: 'exact', size: 100 }, wrap: 'word' }; + const resizedMeasurement = left.measureLayout(); + assert.ok(resizedMeasurement, 'a pending mutation must produce its requested measurement'); + assert.notEqual(resizedMeasurement, initialLeftMeasurement); + assert.deepEqual( + right.measureLayout(), + initialRightMeasurement, + 'one requested semantic publication must populate every retained paragraph', + ); + scene.updateMatrixWorld(); + assert.equal(instrumented.crossings, 1, 'mutation, render plan, and demanded measurement must share one text_update'); + + const leftOrigins = left.snapshotGlyphOrigins(); + const rightOrigins = right.snapshotGlyphOrigins(); + assert.equal(leftOrigins.shapedX.length, 2); + assert.equal(rightOrigins.shapedX.length, 2); + const shiftedRightX = rightOrigins.shapedX.slice(); + const shiftedLeftX = leftOrigins.shapedX.slice(); + shiftedLeftX[0] += 2; + shiftedRightX[0] += 4; + const originsAttribute = draws[0].geometry.getAttribute('_pmndrsText_1'); + const canonicalOrigins = originsAttribute.array; + const pboUploadOrigins = new Float32Array(canonicalOrigins.length + 4); + pboUploadOrigins.set(canonicalOrigins); + originsAttribute.array = pboUploadOrigins; + originsAttribute.clearUpdateRanges(); + left.setGlyphOrigins({ layout: leftOrigins.layout, x: shiftedLeftX, y: leftOrigins.shapedY }); + const leftUploadRanges = originsAttribute.updateRanges.map((range) => ({ ...range })); + right.setGlyphOrigins({ layout: rightOrigins.layout, x: shiftedRightX, y: rightOrigins.shapedY }); + assert.ok( + leftUploadRanges.every(({ start: rangeStart, count }) => + originsAttribute.updateRanges.some( + (range) => range.start <= rangeStart && range.start + range.count >= rangeStart + count, + ), + ), + 'separate presentation edits may coalesce but must retain every earlier upload range', + ); + assert.equal(left.snapshotGlyphOrigins().displayedX[0], leftOrigins.shapedX[0] + 2); + assert.equal(right.snapshotGlyphOrigins().displayedX[0], rightOrigins.shapedX[0] + 4); + assert.deepEqual( + pboUploadOrigins.subarray(0, canonicalOrigins.length), + canonicalOrigins, + 'WebGL2 PBO replacement storage must receive the same dirty ranges as canonical plan storage', + ); + assert.deepEqual(pboUploadOrigins.subarray(canonicalOrigins.length), new Float32Array(4)); + + const version = transforms.version; + right.position.x = 7; + scene.updateMatrixWorld(); + assert.equal(group.children.filter((child) => child.isMesh)[0], draws[0]); + assert.equal(transforms.version, version + 1); + assert.equal(transforms.array[2 * 16 + 12], 7); + assert.equal( + right.snapshotGlyphOrigins().displayedX[0], + rightOrigins.shapedX[0] + 4, + 'transform-only updates must not cross into Rust or discard presentation overrides', + ); + + originsAttribute.clearUpdateRanges(); + left.clearGlyphOriginOverrides(); + const clearedOriginRanges = originsAttribute.updateRanges.map((range) => ({ ...range })); + assert.ok(clearedOriginRanges.length > 0); + right.style = { ...right.style, color: '#00ff00' }; + scene.updateMatrixWorld(); + assert.ok( + clearedOriginRanges.every(({ start: rangeStart, count }) => + originsAttribute.updateRanges.some( + (range) => range.start <= rangeStart && range.start + range.count >= rangeStart + count, + ), + ), + 'a second plan before rendering must retain every earlier pending upload range', + ); + + right.style = { ...right.style, fontSize: 20 }; + scene.updateMatrixWorld(); + const resizedOrigins = right.snapshotGlyphOrigins(); + assert.notEqual(resizedOrigins.layout, rightOrigins.layout); + assert.deepEqual( + resizedOrigins.displayedX, + resizedOrigins.shapedX, + 'an authoritative command-buffer update must retire the previous presentation override', + ); + + instrumented.reset(); + left.text = 'ABC'; + const replacedMeasurement = left.measureLayout(); + assert.equal(replacedMeasurement?.glyphCount, 3); + scene.updateMatrixWorld(); + assert.equal(instrumented.crossings, 1, 'text replacement and demanded measurement must share one text_update'); + const replacedDraws = group.children.filter((child) => child.isMesh); + assert.equal(replacedDraws.length, 1); + assert.equal(replacedDraws[0].geometry.instanceCount, 5, 'the published command buffer must include the new glyph'); + + group.dispose(); + left.dispose(); + right.dispose(); + font.dispose(); + runtime.dispose(); +}); + +async function createInstrumentedRuntime(registry) { + const wasm = await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)); + const abi = JSON.parse(await readFile(new URL('../../dist/text-shaper-abi-v0.json', import.meta.url), 'utf8')); + const originalInstantiate = WebAssembly.instantiate; + let crossings = 0; + let latestRequest; + WebAssembly.instantiate = async (source, imports) => { + const instance = await originalInstantiate(source, imports); + const exports = { ...instance.exports }; + const update = exports[abi.functions.textUpdate]; + assert.equal(typeof update, 'function', 'instrumented shaper must export text_update'); + exports[abi.functions.textUpdate] = (...arguments_) => { + crossings += 1; + const [, pointer, length] = arguments_; + latestRequest = new Uint8Array(exports.memory.buffer, pointer, length).slice(); + return update(...arguments_); + }; + return { exports }; + }; + try { + const runtime = await createTextRuntime({ registry, wasm }); + return { + runtime, + get crossings() { + return crossings; + }, + reset() { + crossings = 0; + }, + latestTextMutations() { + assert.ok(latestRequest, 'a text update request must have been captured'); + const request = abi.layouts.engineUpdateRequest; + const mutation = abi.layouts.engineTextMutation; + const view = new DataView(latestRequest.buffer, latestRequest.byteOffset, latestRequest.byteLength); + const offset = view.getUint32(request.textMutationsOffset, true); + const count = view.getUint32(request.textMutationCount, true); + return Array.from({ length: count }, (_recordValue, index) => { + const record = offset + index * mutation.size; + const insertOffset = view.getUint32(record + mutation.insertOffset, true); + const insertCount = view.getUint32(record + mutation.insertCount, true); + const insert = String.fromCharCode( + ...Array.from({ length: insertCount }, (_unitValue, unit) => view.getUint16(insertOffset + unit * 2, true)), + ); + return { + start: view.getUint32(record + mutation.textStart, true), + deleteCount: view.getUint32(record + mutation.deleteCount, true), + insert, + }; + }); + }, + }; + } finally { + WebAssembly.instantiate = originalInstantiate; + } +} + +test('Bitmap strike changes fully initialize a replacement indexed batch', async () => { + const registry = new FontRegistry(); + const runtime = await createTextRuntime({ + registry, + wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), + }); + const font = await runtime.loadFont({ + input: { baked: dataUrl(await readFile(densityFontUrl)) }, + raster: { technique: bitmap, options: { strikes: [16, 32] } }, + }); + const scene = new THREE.Scene(); + const group = new TextGroup(); + const label = new Text({ + font, + rasterPixelRatio: 2, + text: 'AB', + style: { fontSize: 8 }, + contentBox: { width: { mode: 'exact', size: 80 }, wrap: 'word' }, + }); + group.add(label); + scene.add(group); + scene.updateMatrixWorld(); + assert.equal(group.error, undefined); + + label.style = { ...label.style, fontSize: 16 }; + scene.updateMatrixWorld(); + assert.equal(group.error, undefined, 'crossing from the 16 ppem strike to 32 ppem must publish successfully'); + const draw = group.children.find((child) => child.isMesh); + assert.ok(draw); + const start = draw.userData.pmndrsTextRunStart; + const transforms = draw.geometry.getAttribute('_pmndrsText_15').array; + assert.deepEqual(Array.from(transforms.subarray(start, start + draw.geometry.instanceCount)), [1, 1]); + + label.contentBox = { ...label.contentBox, width: { mode: 'exact', size: 40 } }; + scene.updateMatrixWorld(); + assert.equal(group.error, undefined, 'width-only reflow must retain the initialized transform stream'); + + group.dispose(); + label.dispose(); + font.dispose(); + runtime.dispose(); +}); + +test('Rust ellipsis reshapes only the narrowed unsafe line boundary', async () => { + const registry = new FontRegistry(); + const runtime = await createTextRuntime({ + registry, + wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), + }); + const font = await runtime.loadFont({ + input: { baked: dataUrl(await readFile(amiriFontUrl)) }, + raster: { technique: bitmap, options: { strikes: [16] } }, + }); + const text = 'مرحبا بالعالم'; + + const scene = new THREE.Scene(); + const label = new Text({ + font, + text, + style: { fontSize: 16 }, + contentBox: { + width: { mode: 'exact', size: 37 }, + maxLines: 1, + wrap: 'none', + overflow: 'ellipsis', + }, + }); + scene.add(label); + scene.updateMatrixWorld(); + assert.equal(label.error, undefined); + const inspection = label.inspectLayout(); + assert.ok(inspection); + assert.equal(inspection.lineTextEnds[0], 3, 'the fixed width must preserve the unsafe-boundary fixture'); + assert.equal(inspection.clusters.at(-1), 3, 'the ellipsis is anchored at the truncation boundary'); + assert.deepEqual([...inspection.glyphIds], [61, 2613, 2598, 6597]); + assert.deepEqual([...inspection.clusters], [2, 1, 0, 3]); + assert.deepEqual([...inspection.x], [0.23199999332427979, 10.807999610900879, 18.375999450683594, 23.91200065612793]); + + label.dispose(); + font.dispose(); + runtime.dispose(); +}); + +test('TextGroup atomically replaces child paragraphs without multiplying retained text capacity', async () => { + const registry = new FontRegistry(); + const runtime = await createTextRuntime({ + registry, + wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), + }); + const font = await runtime.loadFont({ + input: { baked: dataUrl(await readFile(fontUrl)) }, + raster: { technique: bitmap, options: { strikes: [16] } }, + }); + const scene = new THREE.Scene(); + const group = new TextGroup({ capacity: { size: 4_096, policy: 'grow' } }); + const first = [new Text({ font, text: 'A' }), new Text({ font, text: 'B' })]; + group.add(...first); + scene.add(group); + scene.updateMatrixWorld(); + + const second = ['C', 'D', 'E'].map((text) => new Text({ font, text })); + group.remove(...first); + group.add(...second); + scene.updateMatrixWorld(); + assert.equal(group.error, undefined); + assert.equal(group.children.filter((child) => child.isMesh).length, 1); + assert.equal(group.children.find((child) => child.isMesh).geometry.instanceCount, 3); + + const third = ['Y', 'Z'].map((text) => new Text({ font, text })); + group.remove(...second); + group.add(...third); + scene.updateMatrixWorld(); + assert.equal(group.error, undefined, 'a recycled Rust paragraph must not retain its previous semantic contents'); + assert.equal(group.children.filter((child) => child.isMesh).length, 1); + assert.equal(group.children.find((child) => child.isMesh).geometry.instanceCount, 2); + + group.dispose(); + for (const text of [...first, ...second, ...third]) text.dispose(); + font.dispose(); + runtime.dispose(); +}); + function dataUrl(bytes) { return `data:model/gltf-binary;base64,${bytes.toString('base64')}`; } diff --git a/packages/text/tests/integration/uikit-layout-fixture.test.mjs b/packages/text/tests/integration/uikit-layout-fixture.test.mjs deleted file mode 100644 index eb6efa2c..00000000 --- a/packages/text/tests/integration/uikit-layout-fixture.test.mjs +++ /dev/null @@ -1,107 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import test from 'node:test'; - -import { createParagraphEngine, createRuntimeShaper, FontRegistry } from '@pmndrs/text'; -import { createFontBaker } from '@pmndrs/text-font-baker'; - -import { - createUikitLayoutFixture, - YogaMeasureMode, -} from '../../../../apps/benchmarks/src/benchmark/uikit-layout-fixture.ts'; - -const text = 'office AVATAR café — ffi, kerning, marks, and wrapping.'; - -test('mirrors the current uikit CustomLayouting and resolved content-box flow', async () => { - const { font, shaper } = await runtime(); - const input = { - text, - font: font.handle, - style: { fontSize: 31, lineHeight: 1.23, direction: 'ltr', language: 'en' }, - }; - const paragraph = createParagraphEngine({ shaper }).create(input); - const fixture = createUikitLayoutFixture(paragraph, { wrap: 'word', overflow: 'clip' }); - const custom = fixture.customLayouting(); - - assert.deepEqual( - { - minWidth: custom.minWidth, - minHeight: custom.minHeight, - firstBaseline: custom.firstBaseline, - }, - { - minWidth: 96.0576171875, - minHeight: 38.13, - firstBaseline: 30.34185546875, - }, - ); - assert.equal(fixture.calls.layout, 0, 'intrinsic sizing must not materialize glyph arrays'); - - const natural = custom.measure(Number.NaN, YogaMeasureMode.Undefined, Number.NaN, YogaMeasureMode.Undefined); - assert.deepEqual(natural, { width: 821.14, height: 38.14 }); - const atMost = custom.measure(360, YogaMeasureMode.AtMost, 90, YogaMeasureMode.AtMost); - assert.deepEqual(atMost, { width: 345.41, height: 90 }); - const exactWidth = custom.measure(420.001, YogaMeasureMode.Exactly, NaN, YogaMeasureMode.Undefined); - assert.deepEqual(exactWidth, { width: 420.01, height: 114.4 }); - for (let index = 0; index < 20; index += 1) { - assert.deepEqual(custom.measure(360, YogaMeasureMode.AtMost, 90, YogaMeasureMode.AtMost), atMost); - } - assert.equal(fixture.calls.layout, 0); - - const beforeDefinite = fixture.calls.measure; - assert.deepEqual(fixture.resolveYogaLeaf(401.237, YogaMeasureMode.Exactly, 150.111, YogaMeasureMode.Exactly), { - width: 401.24, - height: 150.12, - measured: false, - }); - assert.equal(fixture.calls.measure, beforeDefinite, 'Yoga skips leaf measurement for two exact axes'); - assert.throws( - () => custom.measure(Number.NaN, YogaMeasureMode.AtMost, 10, YogaMeasureMode.Exactly), - /Yoga width must be finite/, - ); - assert.throws(() => custom.measure(10, 99, 10, YogaMeasureMode.Exactly), /measure mode/); - - const resolved = fixture.layoutResolvedBox([401.24, 150.12], [7, 11, 13, 17], [1, 2, 3, 4]); - assert.deepEqual(resolved.contentBox, { width: 367.24, height: 126.12 }); - assert.equal(fixture.calls.layout, 1); - assert.equal(resolved.layout.width, 367.24); - assert.equal(resolved.layout.height, 126.12); - assert.equal(resolved.centeredX[0], Math.fround(resolved.layout.x[0] - 179.62)); - assert.equal(resolved.centeredY[0], Math.fround(67.06 - resolved.layout.y[0])); - - const dirtyBeforePaint = fixture.dirtyCount; - fixture.updatePaint(); - fixture.updateRaster(); - assert.equal(fixture.dirtyCount, dirtyBeforePaint); - assert.deepEqual([fixture.paintRevision, fixture.rasterRevision], [1, 1]); - - fixture.updateShapingPolicy({ maxLines: 2 }); - assert.equal(fixture.dirtyCount, dirtyBeforePaint + 1); - fixture.updateParagraph({ ...input, text: `${text} Updated.` }); - assert.equal(fixture.dirtyCount, dirtyBeforePaint + 2); - assert.notDeepEqual( - fixture.customLayouting().measure(360, YogaMeasureMode.AtMost, 90, YogaMeasureMode.AtMost), - atMost, - ); - - paragraph.dispose(); - shaper.dispose(); - font.dispose(); -}); - -async function runtime() { - const [source, bakerWasm, shaperWasm] = await Promise.all([ - readFile(new URL('../../../../apps/benchmarks/fixtures/fonts/inter-v4.1/Inter-Regular.ttf', import.meta.url)), - readFile(new URL('../../../font-baker/dist/font_baker.wasm', import.meta.url)), - readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), - ]); - const baker = await createFontBaker(bakerWasm); - const artifact = baker.bake({ - source, - descriptor: { formatVersion: 0, fontFaceIndex: 0 }, - }).artifacts[0].bytes; - const registry = new FontRegistry(); - const font = await registry.registerAsset(artifact); - const shaper = await createRuntimeShaper({ registry, wasm: shaperWasm }); - return { font, shaper }; -} diff --git a/packages/text/tests/package/bitmap-technique.test.mjs b/packages/text/tests/package/bitmap-technique.test.mjs deleted file mode 100644 index cf74df12..00000000 --- a/packages/text/tests/package/bitmap-technique.test.mjs +++ /dev/null @@ -1,82 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { defineRasterResourceId } from '@pmndrs/text'; -import { bitmap } from '@pmndrs/text/raster/bitmap'; - -function records() { - const bytes = new Uint8Array(40); - const view = new DataView(bytes.buffer); - view.setUint16(16, 0xffff, true); - view.setInt16(20, -2, true); - view.setInt16(22, -3, true); - view.setInt16(24, 8, true); - view.setInt16(26, 10, true); - view.setUint16(28, 4, true); - view.setUint16(30, 5, true); - view.setUint16(32, 14, true); - view.setUint16(34, 18, true); - view.setUint16(36, 0, true); - return bytes; -} - -function strike(index, ppem) { - const resource = defineRasterResourceId(`test/bitmap/strike/${index}/page/0`); - const binding = Object.freeze({ strike: index, page: 0, ppem, width: 32, height: 32 }); - return { - ppem, - planeUnitsPerEm: 16, - records: records(), - pages: [{ width: 32, height: 32, format: 'r8unorm', bytes: new Uint8Array(32 * 32), resource }], - bindings: [binding], - }; -} - -const data = { strikes: [strike(0, 16), strike(1, 32)] }; -const paint = { color: [1, 0.5, 0.25, 1] }; - -function glyph(glyphId, fontSize = 16) { - return { - data, - glyphId, - fontSize, - originX: 100, - originY: 50, - rasterPixelRatio: 1, - paint, - }; -} - -test('portable Bitmap selection omits absent records and chooses a physical strike page', () => { - assert.equal(bitmap.select(glyph(0)), undefined); - assert.deepEqual(bitmap.select(glyph(1, 31)), { - resource: data.strikes[1].pages[0].resource, - pipelineVariant: 0, - binding: data.strikes[1].bindings[0], - }); -}); - -test('portable Bitmap storage packs positive-down origins and top-left UVs', () => { - const storage = bitmap.createStorage(2); - const binding = data.strikes[0].bindings[0]; - bitmap.writeStorage(storage, { start: 1, count: 1 }, { data, binding, glyphs: [glyph(1)] }); - - assert.deepEqual([...storage.origins], [0, 0, 98, 40]); - assert.deepEqual([...storage.sizes], [0, 0, 10, 13]); - assert.deepEqual([...storage.uvOrigins], [0, 0, 4 / 32, 5 / 32]); - assert.deepEqual([...storage.uvSizes], [0, 0, 10 / 32, 13 / 32]); - assert.deepEqual([...storage.colors.slice(4)], paint.color); -}); - -test('portable Bitmap storage rejects a binding from outside its decoded data', () => { - const storage = bitmap.createStorage(1); - assert.throws( - () => - bitmap.writeStorage( - storage, - { start: 0, count: 1 }, - { data, binding: { ...data.strikes[0].bindings[0] }, glyphs: [glyph(1)] }, - ), - /binding does not belong/, - ); -}); diff --git a/packages/text/tests/package/frame-transfer-pool.test.mjs b/packages/text/tests/package/frame-transfer-pool.test.mjs new file mode 100644 index 00000000..6bb6b637 --- /dev/null +++ b/packages/text/tests/package/frame-transfer-pool.test.mjs @@ -0,0 +1,199 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createFrameTransferPool, + isFrameTransferPublicationV0, + returnFrameTransfer, +} from '../../dist/internal/frame-transfer-pool.js'; + +const limits = { + minimumCapacity: 256, + maximumBufferBytes: 1_024, + maximumOutstandingTransfers: 1, + maximumOutstandingBytes: 1_024, + maximumPooledBuffers: 1, + maximumPooledBytes: 1_024, +}; + +test('frame transfers copy opaque Wasm bytes once and return the same storage to the worker pool', () => { + const pool = createFrameTransferPool(limits); + const source = Uint8Array.from({ length: 129 }, (_, index) => (index * 17) & 0xff); + let rootPublication; + const first = pool.transfer(source, { sessionId: 7, planRevision: 11 }, (message, transfer) => { + rootPublication = structuredClone(message, { transfer }); + }); + + assert.equal(first.ok, true); + assert.equal(first.publication.buffer.byteLength, 0); + assert.equal(isFrameTransferPublicationV0(rootPublication), true); + assert.equal(rootPublication.capacity, 256); + assert.deepEqual(new Uint8Array(rootPublication.buffer, 0, rootPublication.byteLength), source); + assert.deepEqual(pool.stats(), { + allocations: 1, + poolHits: 0, + transfers: 1, + returns: 0, + rejectedReturns: 0, + discardedReturns: 0, + detachedTransferFailures: 0, + backpressureEvents: 0, + bytesCopied: 129, + transferredBytes: 256, + outstandingTransfers: 1, + outstandingBytes: 256, + pooledBuffers: 0, + pooledBytes: 0, + }); + + const blocked = pool.transfer(source, { sessionId: 7, planRevision: 12 }, () => { + assert.fail('backpressure must reject before sending'); + }); + assert.deepEqual(blocked, { ok: false, reason: 'backpressure' }); + + let returned; + returnFrameTransfer(rootPublication, (message, transfer) => { + returned = structuredClone(message, { transfer }); + }); + assert.equal(rootPublication.buffer.byteLength, 0); + assert.deepEqual(pool.acceptReturn(returned), { ok: true, pooled: true }); + + let secondRootPublication; + const second = pool.transfer(source.subarray(0, 64), { sessionId: 7, planRevision: 12 }, (message, transfer) => { + secondRootPublication = structuredClone(message, { transfer }); + }); + assert.equal(second.ok, true); + assert.equal(secondRootPublication.transferId, 2); + assert.deepEqual( + new Uint8Array(secondRootPublication.buffer, 0, secondRootPublication.byteLength), + source.subarray(0, 64), + ); + assert.deepEqual(pool.stats(), { + allocations: 1, + poolHits: 1, + transfers: 2, + returns: 1, + rejectedReturns: 0, + discardedReturns: 0, + detachedTransferFailures: 0, + backpressureEvents: 1, + bytesCopied: 193, + transferredBytes: 512, + outstandingTransfers: 1, + outstandingBytes: 256, + pooledBuffers: 0, + pooledBytes: 0, + }); +}); + +test('frame transfers reject detached misuse, forged returns, and oversized publications', () => { + const pool = createFrameTransferPool(limits); + const oversized = pool.transfer(new Uint8Array(1_025), { sessionId: 1, planRevision: 0 }, () => { + assert.fail('oversized transfer must reject before sending'); + }); + assert.deepEqual(oversized, { ok: false, reason: 'oversized' }); + + let rootPublication; + const transferred = pool.transfer(new Uint8Array(1), { sessionId: 1, planRevision: 0 }, (message, transfer) => { + rootPublication = structuredClone(message, { transfer }); + }); + assert.equal(transferred.ok, true); + assert.deepEqual( + pool.acceptReturn({ + type: 'pmndrs-text-frame-return-v0', + protocolVersion: 0, + transferId: 99, + capacity: 256, + buffer: new ArrayBuffer(256), + }), + { ok: false, reason: 'unknown-transfer' }, + ); + assert.deepEqual( + pool.acceptReturn({ + type: 'pmndrs-text-frame-return-v0', + protocolVersion: 0, + transferId: rootPublication.transferId, + capacity: 512, + buffer: new ArrayBuffer(512), + }), + { ok: false, reason: 'capacity-mismatch' }, + ); + + let returned; + returnFrameTransfer(rootPublication, (message, transfer) => { + returned = structuredClone(message, { transfer }); + }); + assert.throws(() => returnFrameTransfer(rootPublication, () => {}), /detached/); + assert.deepEqual(pool.acceptReturn(returned), { ok: true, pooled: true }); + assert.deepEqual(pool.acceptReturn(returned), { ok: false, reason: 'unknown-transfer' }); +}); + +test('failed send retains worker ownership and never consumes outstanding capacity', () => { + const pool = createFrameTransferPool(limits); + const source = new Uint8Array(16); + const thrown = new Error('postMessage failed'); + const failure = pool.transfer(source, { sessionId: 1, planRevision: 1 }, () => { + throw thrown; + }); + assert.equal(failure.ok, false); + assert.equal(failure.reason, 'transfer-failed'); + assert.equal(failure.error, thrown); + + const missingTransfer = pool.transfer(source, { sessionId: 1, planRevision: 1 }, () => {}); + assert.equal(missingTransfer.ok, false); + assert.equal(missingTransfer.reason, 'transfer-failed'); + assert.match(String(missingTransfer.error), /without detaching/); + assert.deepEqual(pool.stats(), { + allocations: 1, + poolHits: 1, + transfers: 0, + returns: 0, + rejectedReturns: 0, + discardedReturns: 0, + detachedTransferFailures: 0, + backpressureEvents: 0, + bytesCopied: 32, + transferredBytes: 0, + outstandingTransfers: 0, + outstandingBytes: 0, + pooledBuffers: 1, + pooledBytes: 256, + }); +}); + +test('returned buffers over the pool bound become collectible only after worker ownership resumes', () => { + const pool = createFrameTransferPool({ + ...limits, + maximumPooledBuffers: 0, + maximumPooledBytes: 0, + }); + let rootPublication; + assert.equal( + pool.transfer(new Uint8Array(32), { sessionId: 1, planRevision: 1 }, (message, transfer) => { + rootPublication = structuredClone(message, { transfer }); + }).ok, + true, + ); + + let returned; + returnFrameTransfer(rootPublication, (message, transfer) => { + returned = structuredClone(message, { transfer }); + }); + assert.deepEqual(pool.acceptReturn(returned), { ok: true, pooled: false }); + assert.deepEqual(pool.stats(), { + allocations: 1, + poolHits: 0, + transfers: 1, + returns: 1, + rejectedReturns: 0, + discardedReturns: 1, + detachedTransferFailures: 0, + backpressureEvents: 0, + bytesCopied: 32, + transferredBytes: 256, + outstandingTransfers: 0, + outstandingBytes: 0, + pooledBuffers: 0, + pooledBytes: 0, + }); +}); diff --git a/packages/text/tests/package/mtsdf-technique.test.mjs b/packages/text/tests/package/mtsdf-technique.test.mjs deleted file mode 100644 index 9419f85f..00000000 --- a/packages/text/tests/package/mtsdf-technique.test.mjs +++ /dev/null @@ -1,82 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { defineRasterResourceId } from '@pmndrs/text'; -import { msdf } from '@pmndrs/text/raster/msdf'; - -const binding = Object.freeze({ width: 32, height: 32, layers: 1 }); -const records = new Uint8Array(40); -const view = new DataView(records.buffer); -view.setUint16(16, 0xffff, true); -view.setInt16(20, -2, true); -view.setInt16(22, -3, true); -view.setInt16(24, 8, true); -view.setInt16(26, 10, true); -view.setUint16(28, 4, true); -view.setUint16(30, 5, true); -view.setUint16(32, 14, true); -view.setUint16(34, 18, true); -view.setUint16(36, 0, true); - -const data = { - resource: defineRasterResourceId('test/msdf/font/atlas'), - binding, - emSize: 16, - pixelRange: 8, - planeUnitsPerEm: 16, - records, - pages: [{ width: 32, height: 32, format: 'rgba8unorm', bytes: new Uint8Array(32 * 32 * 4) }], -}; - -const paint = { - color: [1, 0.5, 0.25, 1], - outline: { color: [0, 1, 0, 1], width: 1 }, - shadow: { color: [0, 0, 1, 0.5], offset: [2, 3] }, -}; - -function glyph(glyphId) { - return { - data, - glyphId, - fontSize: 16, - originX: 100, - originY: 50, - rasterPixelRatio: 1, - paint, - }; -} - -test('portable MSDF selection omits absent records and retains one atlas binding', () => { - assert.equal(msdf.select(glyph(0)), undefined); - assert.deepEqual(msdf.select(glyph(1)), { - resource: data.resource, - pipelineVariant: 0, - binding, - }); -}); - -test('portable MSDF storage packs positive-down paragraph origins without renderer objects', () => { - const storage = msdf.createStorage(2); - msdf.writeStorage(storage, { start: 1, count: 1 }, { data, binding, glyphs: [glyph(1)] }); - - assert.deepEqual([...storage.origins], [0, 0, 98, 40]); - assert.deepEqual([...storage.sizes], [0, 0, 12, 16]); - assert.deepEqual([...storage.uvBounds.slice(4)], [4 / 32, 5 / 32, 14 / 32, 18 / 32]); - assert.deepEqual([...storage.fillColors.slice(4)], paint.color); - assert.deepEqual([...storage.outlineColors.slice(4)], paint.outline.color); - assert.deepEqual([...storage.shadowColors.slice(4)], paint.shadow.color); - assert.equal(storage.outlineWidths[1], 0.125); - assert.equal(storage.pageIndices[1], 0); -}); - -test('portable MSDF storage rejects mismatched bindings and invalid ranges', () => { - const storage = msdf.createStorage(1); - assert.throws( - () => msdf.writeStorage(storage, { start: 0, count: 1 }, { data, binding: { ...binding }, glyphs: [glyph(1)] }), - /binding does not belong/, - ); - assert.throws( - () => msdf.writeStorage(storage, { start: 1, count: 1 }, { data, binding, glyphs: [glyph(1)] }), - /outside its capacity/, - ); -}); diff --git a/packages/text/tests/package/raster-lifecycle.test.mjs b/packages/text/tests/package/raster-lifecycle.test.mjs deleted file mode 100644 index ea5588fb..00000000 --- a/packages/text/tests/package/raster-lifecycle.test.mjs +++ /dev/null @@ -1,46 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { defineRasterBatchStage } from '@pmndrs/text'; - -test('raster stages publish once and transfer ownership before abort', () => { - let commits = 0; - let releases = 0; - const batch = { dispose() {} }; - const stage = defineRasterBatchStage( - batch, - () => { - commits += 1; - }, - () => { - releases += 1; - }, - ); - - assert.equal(stage.batch, batch); - stage.commit(); - stage.commit(); - stage.abort(); - assert.equal(commits, 1); - assert.equal(releases, 0); -}); - -test('raster-stage abort is idempotent and prevents later publication', () => { - let commits = 0; - let releases = 0; - const stage = defineRasterBatchStage( - { dispose() {} }, - () => { - commits += 1; - }, - () => { - releases += 1; - }, - ); - - stage.abort(); - stage.abort(); - stage.commit(); - assert.equal(commits, 0); - assert.equal(releases, 1); -}); diff --git a/packages/text/tests/package/raster-technique.test.mjs b/packages/text/tests/package/raster-technique.test.mjs index a810f73d..f1a77a1a 100644 --- a/packages/text/tests/package/raster-technique.test.mjs +++ b/packages/text/tests/package/raster-technique.test.mjs @@ -4,7 +4,6 @@ import test from 'node:test'; import { defineRasterResourceId, defineRasterTechnique } from '@pmndrs/text'; function technique(id) { - const resource = defineRasterResourceId('test/page/0'); return defineRasterTechnique({ id, kind: 'test', @@ -16,13 +15,6 @@ function technique(id) { async decode() { return {}; }, - select() { - return { resource, pipelineVariant: 0, binding: {} }; - }, - createStorage(capacity) { - return { glyphs: new Uint16Array(capacity) }; - }, - writeStorage() {}, dispose() {}, }); } diff --git a/packages/text/tests/package/slug-technique.test.mjs b/packages/text/tests/package/slug-technique.test.mjs deleted file mode 100644 index 958d2ec9..00000000 --- a/packages/text/tests/package/slug-technique.test.mjs +++ /dev/null @@ -1,93 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { defineRasterResourceId } from '@pmndrs/text'; -import { slug } from '@pmndrs/text/raster/slug'; - -const records = new Uint8Array(80); -const view = new DataView(records.buffer); -view.setUint16(8, 0xffff, true); -view.setInt16(40, -2, true); -view.setInt16(42, -3, true); -view.setInt16(44, 8, true); -view.setInt16(46, 10, true); -view.setUint16(48, 0, true); -view.setUint16(50, 2, true); -view.setUint16(52, 3, true); -view.setUint32(56, 1, true); -view.setUint32(60, 2, true); -view.setUint32(64, 4, true); -view.setUint32(68, 6, true); -view.setUint32(72, 8, true); -view.setUint32(76, 5, true); - -const page = { - resource: defineRasterResourceId('test/slug/font/page/0'), - curveWidth: 8, - curveHeight: 8, - curveBytes: new Uint8Array(8 * 8 * 8), - headerCount: 16, - headerWidth: 4, - headerHeight: 4, - headerBytes: new Uint8Array(4 * 4 * 4), - referenceCount: 16, - referenceWidth: 4, - referenceHeight: 4, - referenceBytes: new Uint8Array(4 * 4 * 2), -}; -const binding = Object.freeze({ - page: 0, - curveWidth: 8, - curveHeight: 8, - headerWidth: 4, - headerHeight: 4, - referenceWidth: 4, - referenceHeight: 4, -}); -const data = { planeUnitsPerEm: 16, records, pages: [page], bindings: [binding] }; -const paint = { color: [1, 0.5, 0.25, 1] }; - -function glyph(glyphId) { - return { - data, - glyphId, - fontSize: 16, - originX: 100, - originY: 50, - rasterPixelRatio: 1, - paint, - }; -} - -test('portable Slug selection omits absent records and retains one analytic page binding', () => { - assert.equal(slug.select(glyph(0)), undefined); - assert.deepEqual(slug.select(glyph(1)), { - resource: page.resource, - pipelineVariant: 0, - binding, - }); -}); - -test('portable Slug storage packs positive-down geometry and exact analytic addresses', () => { - const storage = slug.createStorage(2); - slug.writeStorage(storage, { start: 1, count: 1 }, { data, binding, glyphs: [glyph(1)] }); - - assert.deepEqual([...storage.origins], [0, 0, 98, 40]); - assert.deepEqual([...storage.sizes], [0, 0, 10, 13]); - assert.equal(storage.inverseScales[1], 1 / 16); - assert.deepEqual([...storage.colors.slice(4)], paint.color); - assert.equal(storage.curveBases[1], 1); - assert.equal(storage.horizontalHeaderBases[1], 4); - assert.equal(storage.verticalHeaderBases[1], 6); - assert.equal(storage.referenceBases[1], 8); - assert.equal(storage.horizontalBandCounts[1], 2); - assert.equal(storage.verticalBandCounts[1], 3); -}); - -test('portable Slug storage rejects a binding from outside its decoded data', () => { - const storage = slug.createStorage(1); - assert.throws( - () => slug.writeStorage(storage, { start: 0, count: 1 }, { data, binding: { ...binding }, glyphs: [glyph(1)] }), - /binding does not belong/, - ); -}); diff --git a/packages/text/tests/support/engine-abi.d.mts b/packages/text/tests/support/engine-abi.d.mts new file mode 100644 index 00000000..973a723c --- /dev/null +++ b/packages/text/tests/support/engine-abi.d.mts @@ -0,0 +1,57 @@ +export interface EngineUpdateFields { + readonly sessionId: number; + readonly policyHandle: number; + readonly expectedEngineRevision: number; + readonly consumedPlanRevision: number; + readonly acknowledgedPublicationGeneration?: number; + readonly textMutations?: readonly { + readonly paragraphId?: number; + readonly start: number; + readonly deleteCount: number; + readonly insert: readonly number[]; + }[]; +} + +export function renderPolicyBytes(abi: object): Uint8Array; +export function kernelPolicyBytes(abi: object): Uint8Array; +export function engineUpdateBytes(abi: object, fields: EngineUpdateFields): Uint8Array; +export interface EngineFrameUpdateFields { + readonly sessionId: number; + readonly policyHandle: number; + readonly fontStackHandle: number; + readonly expectedEngineRevision?: number; + readonly consumedPlanRevision?: number; + readonly acknowledgedPublicationGeneration?: number; + readonly textMutation?: { + readonly paragraphId?: number; + readonly start: number; + readonly deleteCount: number; + readonly insert: readonly number[]; + }; + readonly style?: { + readonly paragraphId?: number; + readonly textEnd: number; + readonly fontSize: number; + readonly lineHeight: number; + readonly rasterPixelRatio: number; + }; + readonly geometry?: { + readonly paragraphId?: number; + readonly width: number; + readonly height: number; + readonly maxLines: number; + readonly revision: number; + }; + readonly limits: { + readonly maxParagraphs?: number; + readonly maxClusters: number; + readonly maxLines: number; + readonly maxOutputBytes: number; + }; +} +export function engineFrameUpdateBytes(abi: object, fields: EngineFrameUpdateFields): Uint8Array; +export function copyIntoAllocation( + memory: WebAssembly.Memory, + allocate: (byteLength: number) => number, + bytes: Uint8Array, +): number; diff --git a/packages/text/tests/support/engine-abi.mjs b/packages/text/tests/support/engine-abi.mjs new file mode 100644 index 00000000..9cc1fc0d --- /dev/null +++ b/packages/text/tests/support/engine-abi.mjs @@ -0,0 +1,515 @@ +export function renderPolicyBytes(abi) { + return renderPolicyBytesFromPrograms(abi, [ + { + techniqueId: 1, + programId: 1, + f32InputCount: 1, + u32InputCount: 0, + buffers: [{ id: 1, scalar: abi.policy.scalarTypes.f32, vectorWidth: 1 }], + operations: [ + { opcode: abi.policy.opcodes.loadF32, target: 0, operand0: 0 }, + { opcode: abi.policy.opcodes.storeF32, operand0: 0, immediate0: 1 }, + ], + }, + ]); +} + +export function kernelPolicyBytes(abi) { + const opcodes = abi.policy.opcodes; + return renderPolicyBytesFromPrograms(abi, [ + { + techniqueId: 1, + programId: 1, + f32InputCount: 4, + u32InputCount: 1, + buffers: [ + { id: 1, scalar: abi.policy.scalarTypes.f32, vectorWidth: 4 }, + { id: 2, scalar: abi.policy.scalarTypes.u32, vectorWidth: 1 }, + { id: 3, scalar: abi.policy.scalarTypes.u16, vectorWidth: 1 }, + ], + operations: [ + { opcode: opcodes.loadF32, target: 0, operand0: 0 }, + { opcode: opcodes.loadF32, target: 1, operand0: 1 }, + { opcode: opcodes.addF32, target: 2, operand0: 0, operand1: 1 }, + { opcode: opcodes.loadF32, target: 3, operand0: 2 }, + { opcode: opcodes.multiplyF32, target: 4, operand0: 2, operand1: 3 }, + { opcode: opcodes.loadF32, target: 5, operand0: 3 }, + { opcode: opcodes.lessThanF32, target: 6, operand0: 5, operand1: 0 }, + { opcode: opcodes.selectF32, target: 7, operand0: 6, operand1: 4, immediate0: 2 }, + { opcode: opcodes.loadU32, target: 8, operand0: 0 }, + { opcode: opcodes.convertU32ToF32, target: 9, operand0: 8 }, + { opcode: opcodes.addF32, target: 10, operand0: 7, operand1: 9 }, + { opcode: opcodes.storeF32, operand0: 0, operand1: 0, immediate0: 1 }, + { opcode: opcodes.storeF32, operand0: 2, operand1: 1, immediate0: 1 }, + { opcode: opcodes.storeF32, operand0: 7, operand1: 2, immediate0: 1 }, + { opcode: opcodes.storeF32, operand0: 10, operand1: 3, immediate0: 1 }, + { opcode: opcodes.storeU32, operand0: 8, operand1: 0, immediate0: 2 }, + { opcode: opcodes.storeU16, operand0: 8, operand1: 0, immediate0: 3 }, + ], + }, + ]); +} + +export function engineUpdateBytes( + abi, + { + sessionId, + policyHandle, + expectedEngineRevision, + consumedPlanRevision, + acknowledgedPublicationGeneration = 0, + textMutations = [], + }, +) { + const layout = abi.layouts.engineUpdateRequest; + const mutationLayout = abi.layouts.engineTextMutation; + const mutationsOffset = textMutations.length === 0 ? 0 : align(layout.size, mutationLayout.alignment); + const recordsEnd = + textMutations.length === 0 ? layout.size : mutationsOffset + textMutations.length * mutationLayout.size; + const payloadOffset = align(recordsEnd, 2); + const payloadLength = textMutations.reduce((total, mutation) => total + mutation.insert.length * 2, 0); + const bytes = new Uint8Array(payloadOffset + payloadLength); + const view = new DataView(bytes.buffer); + view.setUint32(layout.abiVersion, abi.version, true); + view.setUint32(layout.byteLength, bytes.byteLength, true); + view.setUint32(layout.sessionId, sessionId, true); + view.setUint32(layout.expectedEngineRevision, expectedEngineRevision, true); + view.setUint32(layout.consumedPlanRevision, consumedPlanRevision, true); + view.setUint32(layout.acknowledgedPublicationGeneration, acknowledgedPublicationGeneration, true); + view.setUint32(layout.policyHandle, policyHandle, true); + view.setUint32(layout.capabilitySet, 1, true); + for (const field of [ + 'maxParagraphs', + 'maxClusters', + 'maxLines', + 'maxRegions', + 'maxExclusions', + 'maxInlineObjects', + 'maxSlotsPerBand', + ]) { + view.setUint32(layout[field], field === 'maxClusters' ? Math.max(1, textMutations.length) : 1, true); + } + view.setUint32(layout.maxOutputBytes, abi.layouts.engineResult.size, true); + view.setUint32(layout.textMutationsOffset, mutationsOffset, true); + view.setUint32(layout.textMutationCount, textMutations.length, true); + let insertOffset = payloadOffset; + for (const [index, mutation] of textMutations.entries()) { + const record = mutationsOffset + index * mutationLayout.size; + view.setUint8(record + mutationLayout.opcode, abi.engine.textMutationOpcodes.replaceUtf16); + view.setUint8(record + mutationLayout.encoding, abi.engine.textEncodings.utf16Le); + view.setUint32(record + mutationLayout.paragraphId, mutation.paragraphId ?? 1, true); + view.setUint32(record + mutationLayout.textStart, mutation.start, true); + view.setUint32(record + mutationLayout.deleteCount, mutation.deleteCount, true); + if (mutation.insert.length > 0) { + view.setUint32(record + mutationLayout.insertOffset, insertOffset, true); + view.setUint32(record + mutationLayout.insertCount, mutation.insert.length, true); + for (const unit of mutation.insert) { + view.setUint16(insertOffset, unit, true); + insertOffset += 2; + } + } + } + return bytes; +} + +export function engineFrameUpdateBytes( + abi, + { + sessionId, + policyHandle, + fontStackHandle, + expectedEngineRevision = 0, + consumedPlanRevision = 0, + acknowledgedPublicationGeneration = 0, + textMutation, + style, + geometry, + limits, + }, +) { + const request = abi.layouts.engineUpdateRequest; + const paragraphRecord = abi.layouts.engineParagraphMutation; + const textRecord = abi.layouts.engineTextMutation; + const styleRecord = abi.layouts.engineStyleMutation; + const constraint = abi.layouts.engineConstraint; + const region = abi.layouts.engineRegion; + let cursor = request.size; + const hasParagraph = textMutation !== undefined || style !== undefined || geometry !== undefined; + const paragraphRecordOffset = hasParagraph ? align(cursor, paragraphRecord.alignment) : 0; + if (hasParagraph) cursor = paragraphRecordOffset + paragraphRecord.size; + const textRecordOffset = textMutation === undefined ? 0 : cursor; + if (textMutation !== undefined) cursor += textRecord.size; + const styleRecordOffset = style === undefined ? 0 : align(cursor, styleRecord.alignment); + if (style !== undefined) cursor = styleRecordOffset + styleRecord.size; + const constraintOffset = geometry === undefined ? 0 : align(cursor, constraint.alignment); + if (geometry !== undefined) cursor = constraintOffset + constraint.size; + const regionOffset = geometry === undefined ? 0 : align(cursor, region.alignment); + if (geometry !== undefined) cursor = regionOffset + region.size; + const textPayloadOffset = textMutation === undefined || textMutation.insert.length === 0 ? 0 : align(cursor, 2); + const textPayloadLength = textMutation === undefined ? 0 : textMutation.insert.length * 2; + const bytes = new Uint8Array(textPayloadOffset === 0 ? cursor : textPayloadOffset + textPayloadLength); + const view = new DataView(bytes.buffer); + view.setUint32(request.abiVersion, abi.version, true); + view.setUint32(request.byteLength, bytes.byteLength, true); + view.setUint32(request.sessionId, sessionId, true); + view.setUint32(request.expectedEngineRevision, expectedEngineRevision, true); + view.setUint32(request.consumedPlanRevision, consumedPlanRevision, true); + view.setUint32(request.acknowledgedPublicationGeneration, acknowledgedPublicationGeneration, true); + view.setUint32(request.policyHandle, policyHandle, true); + view.setUint32(request.capabilitySet, 1, true); + view.setUint32(request.maxParagraphs, 1, true); + view.setUint32(request.maxClusters, limits.maxClusters, true); + view.setUint32(request.maxLines, limits.maxLines, true); + view.setUint32(request.maxRegions, 1, true); + view.setUint32(request.maxExclusions, 1, true); + view.setUint32(request.maxInlineObjects, 1, true); + view.setUint32(request.maxSlotsPerBand, 1, true); + view.setUint32(request.maxOutputBytes, limits.maxOutputBytes, true); + view.setUint32(request.paragraphMutationsOffset, paragraphRecordOffset, true); + view.setUint32(request.paragraphMutationCount, hasParagraph ? 1 : 0, true); + view.setUint32(request.textMutationsOffset, textRecordOffset, true); + view.setUint32(request.textMutationCount, textMutation === undefined ? 0 : 1, true); + view.setUint32(request.styleMutationsOffset, styleRecordOffset, true); + view.setUint32(request.styleMutationCount, style === undefined ? 0 : 1, true); + view.setUint32(request.constraintsOffset, constraintOffset, true); + view.setUint32(request.constraintCount, geometry === undefined ? 0 : 1, true); + view.setUint32(request.regionsOffset, regionOffset, true); + view.setUint32(request.regionCount, geometry === undefined ? 0 : 1, true); + + if (hasParagraph) { + view.setUint8(paragraphRecordOffset + paragraphRecord.opcode, abi.engine.paragraphMutationOpcodes.upsert); + view.setUint32(paragraphRecordOffset + paragraphRecord.paragraphId, 1, true); + view.setUint32(paragraphRecordOffset + paragraphRecord.order, 0, true); + } + + if (textMutation !== undefined) { + view.setUint8(textRecordOffset + textRecord.opcode, abi.engine.textMutationOpcodes.replaceUtf16); + view.setUint8(textRecordOffset + textRecord.encoding, abi.engine.textEncodings.utf16Le); + view.setUint32(textRecordOffset + textRecord.paragraphId, textMutation.paragraphId ?? 1, true); + view.setUint32(textRecordOffset + textRecord.textStart, textMutation.start, true); + view.setUint32(textRecordOffset + textRecord.deleteCount, textMutation.deleteCount, true); + view.setUint32(textRecordOffset + textRecord.insertOffset, textPayloadOffset, true); + view.setUint32(textRecordOffset + textRecord.insertCount, textMutation.insert.length, true); + for (const [index, unit] of textMutation.insert.entries()) { + view.setUint16(textPayloadOffset + index * 2, unit, true); + } + } + + if (style !== undefined) { + view.setUint8(styleRecordOffset + styleRecord.opcode, abi.engine.styleMutationOpcodes.upsert); + view.setUint8(styleRecordOffset + styleRecord.flags, abi.engine.styleFlags.root); + view.setUint32(styleRecordOffset + styleRecord.paragraphId, style.paragraphId ?? 1, true); + view.setUint32(styleRecordOffset + styleRecord.styleId, 1, true); + view.setUint32( + styleRecordOffset + styleRecord.fieldMask, + abi.engine.styleFields.fontStack | + abi.engine.styleFields.fontSize | + abi.engine.styleFields.lineHeight | + abi.engine.styleFields.rasterPixelRatio, + true, + ); + view.setUint32(styleRecordOffset + styleRecord.textEnd, style.textEnd, true); + view.setUint32(styleRecordOffset + styleRecord.fontStackHandle, fontStackHandle, true); + view.setFloat32(styleRecordOffset + styleRecord.fontSize, style.fontSize, true); + view.setFloat32(styleRecordOffset + styleRecord.lineHeight, style.lineHeight, true); + view.setFloat32(styleRecordOffset + styleRecord.rasterPixelRatio, style.rasterPixelRatio, true); + } + + if (geometry !== undefined) { + view.setUint32(constraintOffset + constraint.paragraphId, geometry.paragraphId ?? 1, true); + view.setUint32(constraintOffset + constraint.flowThreadId, 1, true); + view.setFloat32(constraintOffset + constraint.width, geometry.width, true); + view.setFloat32(constraintOffset + constraint.height, geometry.height, true); + view.setFloat32(constraintOffset + constraint.viewportBlockEnd, geometry.height, true); + view.setUint32(constraintOffset + constraint.maxLines, geometry.maxLines, true); + view.setUint16(constraintOffset + constraint.regionCount, 1, true); + view.setUint8(constraintOffset + constraint.widthMode, abi.engine.axisModes.exact); + view.setUint8(constraintOffset + constraint.heightMode, abi.engine.axisModes.exact); + view.setUint8(constraintOffset + constraint.wrap, abi.engine.wrapModes.word); + view.setUint8(constraintOffset + constraint.align, abi.engine.inlineAlignments.start); + view.setUint8(constraintOffset + constraint.overflow, abi.engine.overflowModes.visible); + view.setUint8(constraintOffset + constraint.blockAlign, abi.engine.blockAlignments.start); + + view.setUint32(regionOffset + region.id, 1, true); + view.setUint32(regionOffset + region.geometryRevision, geometry.revision, true); + view.setUint32(regionOffset + region.transformIndex, 1, true); + view.setUint8(regionOffset + region.shape, abi.engine.flowShapeKinds.rectangle); + view.setUint8(regionOffset + region.writingMode, abi.engine.writingModes.horizontalTb); + view.setUint8(regionOffset + region.textOrientation, abi.engine.textOrientations.mixed); + view.setFloat32(regionOffset + region.inlineEnd, geometry.width, true); + view.setFloat32(regionOffset + region.blockEnd, geometry.height, true); + view.setFloat32(regionOffset + region.clipInlineEnd, geometry.width, true); + view.setFloat32(regionOffset + region.clipBlockEnd, geometry.height, true); + } + return bytes; +} + +export function copyIntoAllocation(memory, allocate, bytes) { + const pointer = allocate(bytes.byteLength); + if (pointer === 0) throw new Error('Wasm request allocation failed'); + new Uint8Array(memory.buffer, pointer, bytes.byteLength).set(bytes); + return pointer; +} + +export function fontBindingBytes( + abi, + { + techniqueId, + programVariant = 0, + glyphCount, + strikes, + resources, + resourceIndices, + glyphF32 = [], + glyphU32 = [], + strikeF32 = [], + strikeU32 = [], + resourceF32 = [], + resourceU32 = [], + }, +) { + const request = abi.layouts.fontBindingRequest; + const strikeLayout = abi.layouts.fontBindingStrike; + const resourceLayout = abi.layouts.fontBindingResource; + const strikeRows = glyphCount * strikes.length; + const fields = [ + ['glyphF32', glyphF32, glyphCount], + ['glyphU32', glyphU32, glyphCount], + ['strikeF32', strikeF32, strikeRows], + ['strikeU32', strikeU32, strikeRows], + ['resourceF32', resourceF32, resources.length], + ['resourceU32', resourceU32, resources.length], + ]; + for (const [name, lanes, rows] of fields) { + if (lanes.some((lane) => lane.length !== rows)) throw new RangeError(`${name} rows do not match the binding`); + } + if (resourceIndices.length !== strikeRows) throw new RangeError('resource index rows do not match the binding'); + + let length = request.size; + const allocateTable = (count, stride, alignment) => { + if (count === 0) return 0; + const offset = align(length, alignment); + length = offset + count * stride; + return offset; + }; + const strikesOffset = allocateTable(strikes.length, strikeLayout.size, strikeLayout.alignment); + const resourcesOffset = allocateTable(resources.length, resourceLayout.size, resourceLayout.alignment); + const resourceIndicesOffset = allocateTable(resourceIndices.length, 4, 4); + const fieldOffsets = fields.map(([, lanes, rows]) => allocateTable(lanes.length * rows, 4, 4)); + const bytes = new Uint8Array(length); + const view = new DataView(bytes.buffer); + view.setUint32(request.abiVersion, abi.version, true); + view.setUint32(request.byteLength, bytes.byteLength, true); + view.setUint32(request.techniqueId, techniqueId, true); + view.setUint16(request.programVariant, programVariant, true); + view.setUint32(request.glyphCount, glyphCount, true); + view.setUint32(request.strikeCount, strikes.length, true); + view.setUint32(request.resourceCount, resources.length, true); + for (const [index, [name, lanes]] of fields.entries()) { + view.setUint8(request[`${name}FieldCount`], lanes.length); + view.setUint32(request[`${name}Offset`], fieldOffsets[index], true); + } + view.setUint32(request.strikesOffset, strikesOffset, true); + view.setUint32(request.resourcesOffset, resourcesOffset, true); + view.setUint32(request.resourceIndicesOffset, resourceIndicesOffset, true); + + for (const [index, ppem] of strikes.entries()) { + view.setUint32(strikesOffset + index * strikeLayout.size + strikeLayout.ppem, ppem, true); + } + for (const [index, resource] of resources.entries()) { + const offset = resourcesOffset + index * resourceLayout.size; + view.setUint32(offset + resourceLayout.id, resource.id, true); + view.setUint32(offset + resourceLayout.generation, resource.generation, true); + view.setUint16(offset + resourceLayout.kind, resource.kind, true); + view.setUint32(offset + resourceLayout.reference, resource.reference, true); + } + for (const [index, value] of resourceIndices.entries()) { + view.setUint32(resourceIndicesOffset + index * 4, value, true); + } + for (const [fieldIndex, [, lanes]] of fields.entries()) { + let offset = fieldOffsets[fieldIndex]; + const f32 = fieldIndex === 0 || fieldIndex === 2 || fieldIndex === 4; + for (const lane of lanes) { + for (const value of lane) { + if (f32) view.setFloat32(offset, value, true); + else view.setUint32(offset, value, true); + offset += 4; + } + } + } + return bytes; +} + +function align(value, alignment) { + return Math.ceil(value / alignment) * alignment; +} + +export function renderPolicyBytesFromPrograms(abi, programs) { + const requestLayout = abi.layouts.policyRequest; + const capabilityLayout = abi.layouts.policyCapabilitySet; + const programLayout = abi.layouts.policyProgram; + const bufferLayout = abi.layouts.policyBuffer; + const operationLayout = abi.layouts.policyOperation; + const inputLayout = abi.layouts.policyInput; + const bufferCount = programs.reduce((total, program) => total + program.buffers.length, 0); + const operationCount = programs.reduce((total, program) => total + program.operations.length, 0); + const programInputs = programs.map( + (program) => + program.inputs ?? [ + ...Array.from({ length: program.f32InputCount }, (_, field) => ({ scope: 'semantic', field })), + ...Array.from({ length: program.u32InputCount }, (_, field) => ({ scope: 'semantic', field })), + ], + ); + const inputCount = programInputs.reduce((total, inputs) => total + inputs.length, 0); + const capabilities = [ + { + id: 1, + flags: + abi.policy.capabilityFlags.storageBuffers | + abi.policy.capabilityFlags.orderedDirect | + abi.policy.capabilityFlags.stableIndirect, + maxBufferBytes: 64 * 1024 * 1024, + updateAlignment: 4, + coalesceGapBytes: 128, + rangeCallPenaltyBytes: 256, + maxBuffersPerDraw: 16, + maxResourcesPerDraw: 16, + maxIndirectDraws: 0, + fragmentationBudget: 8, + wholeBufferThresholdBasisPoints: 7_500, + }, + ]; + const capabilitiesOffset = align(requestLayout.size, capabilityLayout.alignment); + const programsOffset = align( + capabilitiesOffset + capabilityLayout.size * capabilities.length, + programLayout.alignment, + ); + const buffersOffset = align(programsOffset + programLayout.size * programs.length, bufferLayout.alignment); + const operationsOffset = align(buffersOffset + bufferLayout.size * bufferCount, operationLayout.alignment); + const inputsOffset = + inputCount === 0 ? 0 : align(operationsOffset + operationLayout.size * operationCount, inputLayout.alignment); + const byteLength = + inputCount === 0 + ? operationsOffset + operationLayout.size * operationCount + : inputsOffset + inputLayout.size * inputCount; + const bytes = new Uint8Array(byteLength); + const view = new DataView(bytes.buffer); + view.setUint32(requestLayout.byteLength, bytes.byteLength, true); + view.setUint32(requestLayout.capabilitySetsOffset, capabilitiesOffset, true); + view.setUint32(requestLayout.capabilitySetCount, capabilities.length, true); + view.setUint32(requestLayout.programsOffset, programsOffset, true); + view.setUint32(requestLayout.programCount, programs.length, true); + view.setUint32(requestLayout.buffersOffset, buffersOffset, true); + view.setUint32(requestLayout.bufferCount, bufferCount, true); + view.setUint32(requestLayout.operationsOffset, operationsOffset, true); + view.setUint32(requestLayout.operationCount, operationCount, true); + view.setUint32(requestLayout.inputsOffset, inputsOffset, true); + view.setUint32(requestLayout.inputCount, inputCount, true); + + for (let index = 0; index < capabilities.length; index += 1) { + const descriptor = capabilities[index]; + const offset = capabilitiesOffset + index * capabilityLayout.size; + view.setUint32(offset + capabilityLayout.id, descriptor.id, true); + view.setUint32(offset + capabilityLayout.flags, descriptor.flags, true); + view.setUint32(offset + capabilityLayout.maxBufferBytes, descriptor.maxBufferBytes, true); + view.setUint32(offset + capabilityLayout.updateAlignment, descriptor.updateAlignment, true); + view.setUint32(offset + capabilityLayout.coalesceGapBytes, descriptor.coalesceGapBytes, true); + view.setUint32(offset + capabilityLayout.rangeCallPenaltyBytes, descriptor.rangeCallPenaltyBytes, true); + view.setUint16(offset + capabilityLayout.maxBuffersPerDraw, descriptor.maxBuffersPerDraw, true); + view.setUint16(offset + capabilityLayout.maxResourcesPerDraw, descriptor.maxResourcesPerDraw, true); + view.setUint16(offset + capabilityLayout.maxIndirectDraws, descriptor.maxIndirectDraws, true); + view.setUint16(offset + capabilityLayout.fragmentationBudget, descriptor.fragmentationBudget, true); + view.setUint16( + offset + capabilityLayout.wholeBufferThresholdBasisPoints, + descriptor.wholeBufferThresholdBasisPoints, + true, + ); + } + + let bufferStart = 0; + let operationStart = 0; + let inputStart = 0; + for (let index = 0; index < programs.length; index += 1) { + const descriptor = programs[index]; + const offset = programsOffset + index * programLayout.size; + view.setUint32(offset + programLayout.techniqueId, descriptor.techniqueId, true); + view.setUint32(offset + programLayout.programId, descriptor.programId, true); + view.setUint32(offset + programLayout.capabilitySetId, descriptor.capabilitySetId ?? 0, true); + view.setUint32(offset + programLayout.resourceKindMask, descriptor.resourceKindMask ?? 1, true); + view.setUint32(offset + programLayout.semanticViewMask, descriptor.semanticViewMask ?? 0, true); + view.setUint32( + offset + programLayout.storageKeyMask, + descriptor.storageKeyMask ?? + abi.policy.batchFields.technique | abi.policy.batchFields.program | abi.policy.batchFields.resource, + true, + ); + view.setUint32( + offset + programLayout.drawKeyMask, + descriptor.drawKeyMask ?? + abi.policy.batchFields.technique | + abi.policy.batchFields.program | + abi.policy.batchFields.resource | + abi.policy.batchFields.order | + abi.policy.batchFields.transform, + true, + ); + view.setUint16(offset + programLayout.variant, descriptor.variant ?? 0, true); + view.setUint8(offset + programLayout.f32InputCount, descriptor.f32InputCount); + view.setUint8(offset + programLayout.u32InputCount, descriptor.u32InputCount); + view.setUint32(offset + programLayout.paintCapabilities, descriptor.paintCapabilities ?? 0, true); + view.setUint32(offset + programLayout.compositingCapabilities, descriptor.compositingCapabilities ?? 0, true); + view.setUint32(offset + programLayout.bufferStart, bufferStart, true); + view.setUint16(offset + programLayout.bufferCount, descriptor.buffers.length, true); + view.setUint32(offset + programLayout.operationStart, operationStart, true); + view.setUint16(offset + programLayout.operationCount, descriptor.operations.length, true); + view.setUint16( + offset + programLayout.allocationStrategy, + descriptor.allocationStrategy ?? abi.policy.allocationStrategies.orderedDirect, + true, + ); + view.setUint32(offset + programLayout.inputStart, inputStart, true); + view.setUint16(offset + programLayout.inputCount, programInputs[index].length, true); + bufferStart += descriptor.buffers.length; + operationStart += descriptor.operations.length; + inputStart += programInputs[index].length; + } + let bufferIndex = 0; + let operationIndex = 0; + let inputIndex = 0; + for (const [programIndex, descriptor] of programs.entries()) { + for (const buffer of descriptor.buffers) { + const offset = buffersOffset + bufferIndex * bufferLayout.size; + view.setUint16(offset + bufferLayout.id, buffer.id, true); + view.setUint8(offset + bufferLayout.scalar, buffer.scalar); + view.setUint8(offset + bufferLayout.vectorWidth, buffer.vectorWidth); + const scalarBytes = buffer.scalar === abi.policy.scalarTypes.u16 ? 2 : 4; + view.setUint16(offset + bufferLayout.alignment, buffer.alignment ?? scalarBytes, true); + view.setUint16(offset + bufferLayout.stride, buffer.stride ?? scalarBytes * buffer.vectorWidth, true); + view.setUint32( + offset + bufferLayout.usage, + buffer.usage ?? abi.policy.bufferUsage.storage | abi.policy.bufferUsage.copyDst, + true, + ); + view.setUint16(offset + bufferLayout.capacityClass, buffer.capacityClass ?? 1, true); + bufferIndex += 1; + } + for (const operation of descriptor.operations) { + const offset = operationsOffset + operationIndex * operationLayout.size; + view.setUint8(offset + operationLayout.opcode, operation.opcode); + view.setUint8(offset + operationLayout.target, operation.target ?? 0); + view.setUint8(offset + operationLayout.operand0, operation.operand0 ?? 0); + view.setUint8(offset + operationLayout.operand1, operation.operand1 ?? 0); + view.setUint32(offset + operationLayout.immediate0, operation.immediate0 ?? 0, true); + view.setUint32(offset + operationLayout.immediate1, operation.immediate1 ?? 0, true); + view.setUint32(offset + operationLayout.immediate2, operation.immediate2 ?? 0, true); + operationIndex += 1; + } + for (const input of programInputs[programIndex]) { + const offset = inputsOffset + inputIndex * inputLayout.size; + view.setUint8(offset + inputLayout.scope, abi.policy.inputScopes[input.scope]); + view.setUint8(offset + inputLayout.field, input.field); + inputIndex += 1; + } + } + return bytes; +} diff --git a/packages/text/tests/types/builtin-raster-techniques-api.test.ts b/packages/text/tests/types/builtin-raster-techniques-api.test.ts index 7a8e54c0..3c8464ed 100644 --- a/packages/text/tests/types/builtin-raster-techniques-api.test.ts +++ b/packages/text/tests/types/builtin-raster-techniques-api.test.ts @@ -1,25 +1,13 @@ -import { - bitmap, - type BitmapBinding, - type BitmapData, - type BitmapGlyphBatchStorage, -} from '../../src/raster/bitmap-technique.js'; -import { msdf, type MsdfBinding, type MsdfData, type MsdfGlyphBatchStorage } from '../../src/raster/msdf.js'; -import { slug, type SlugBinding, type SlugData, type SlugGlyphBatchStorage } from '../../src/raster/slug-technique.js'; -import type { GlyphBatchStorageOf, RasterBindingOf, RasterDataOf } from '../../src/index.js'; +import { bitmap, type BitmapData, type BitmapTechniqueOptions } from '../../src/raster/bitmap-technique.js'; +import { msdf, type MsdfData } from '../../src/raster/msdf.js'; +import { slug, type SlugData } from '../../src/raster/slug-technique.js'; +import type { RasterDataOf, RasterOptionsOf } from '../../src/index.js'; type Equal = (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 ? true : false; type Expect = Value; type _BitmapData = Expect, BitmapData>>; -type _BitmapBinding = Expect, BitmapBinding>>; -type _BitmapStorage = Expect, BitmapGlyphBatchStorage>>; - +type _BitmapOptions = Expect, BitmapTechniqueOptions>>; type _MsdfData = Expect, MsdfData>>; -type _MsdfBinding = Expect, MsdfBinding>>; -type _MtsdfStorage = Expect, MsdfGlyphBatchStorage>>; - type _SlugData = Expect, SlugData>>; -type _SlugBinding = Expect, SlugBinding>>; -type _SlugStorage = Expect, SlugGlyphBatchStorage>>; diff --git a/packages/text/tests/types/public-api.test.ts b/packages/text/tests/types/public-api.test.ts index 05dccd4b..0e093f74 100644 --- a/packages/text/tests/types/public-api.test.ts +++ b/packages/text/tests/types/public-api.test.ts @@ -1,45 +1,29 @@ import { - defineRaster, - defineRasterBatchStage, - defineRasterBaker, defineFont, - createParagraphEngine, - createRuntimeShaper, + defineRasterBaker, + defineRasterTechnique, FontLoader, FontRegistry, rasterBake, - type AnyRasterModule, - type BidiAnalysisViews, + type AnyRasterTechnique, type FontInputOf, - type FontRasterModuleOf, - type GlyphPaint, - type RasterKey, - type RasterBatchOf, - type RasterCoverage, + type FontRasterTechniqueOf, type RasterBakeDescriptorOf, type RasterBakeRequest, + type RasterCoverage, + type RasterDataOf, + type RasterKey, type RasterKindOf, - type RasterObjectDrawBatch, type RasterOptionsOf, - type RasterResourceOf, type RasterResourceSource, - type RasterRuntime, type RasterSource, type RegisteredFont, type RegisteredRaster, - type RuntimeShaper, type Sha256Hex, - type ShapeBatchRequest, - type ShapedBatchViews, - type LayoutParagraph, - type ParagraphConstraints, - type ParagraphMeasurement, } from '../../src/index.js'; -import type { Object3D } from 'three/webgpu'; type Equal = (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 ? true : false; - type Expect = Value; const resolverRasterSource: RasterSource = { type: 'external' }; @@ -63,110 +47,31 @@ const fontLoader = new FontLoader({ development: false, }); const registeredPromise: Promise = fontLoader.load('/fonts/Inter.ttf'); -const shaperPromise: Promise = createRuntimeShaper({ registry: fontRegistry }); -declare const fontHandle: RegisteredFont['handle']; -const shapeRequest: ShapeBatchRequest = { - textUtf16: Uint16Array.of(0x41), - features: [], - runs: [ - { - font: fontHandle, - textStart: 0, - textEnd: 1, - direction: 'ltr', - script: 'Latn', - language: 'en', - clusterLevel: 0, - flags: 0x40, - featureStart: 0, - featureCount: 0, - }, - ], -}; -const shapedPromise: Promise = shaperPromise.then((shaper) => shaper.shapeBatch(shapeRequest)); -const bidiPromise: Promise = shaperPromise.then((shaper) => - shaper.analyzeBidi(Uint16Array.of(0x05d0), 'auto'), -); -const preparedParagraph: Promise = shaperPromise.then((shaper) => - createParagraphEngine({ shaper }).create({ text: 'Hello', font: fontHandle }), -); void registeredPromise; -void shaperPromise; -void shapedPromise; -void bidiPromise; -void preparedParagraph; interface MsdfResource { readonly texture: unknown; } -interface MsdfBatch { - readonly instances: number; - readonly object: Object3D; - dispose(): void; -} - -declare const rasterObject: Object3D; -const objectDrawBatch: RasterObjectDrawBatch = { - object: rasterObject, - setRenderOrderBase() {}, - dispose() {}, -}; -void objectDrawBatch; - -const msdf = defineRaster({ +const msdf = defineRasterTechnique({ + id: 'test.msdf', kind: 'msdf', extension: 'PMNDRS_font_distance_field', version: 0, descriptor() { - return { encoding: 'mtsdf' }; + return { encoding: 'mtsdf' } as const; }, - async decode(_font, _raster): Promise { + async decode(): Promise { return { texture: {} }; }, - async prepare() {}, - stageBatch(previous): import('../../src/index.js').RasterBatchStage { - const batch = previous ?? { instances: 0, object: rasterObject, dispose() {} }; - return defineRasterBatchStage( - batch, - () => undefined, - () => { - if (previous === undefined) batch.dispose(); - }, - ); - }, - dispose(_resource) {}, + dispose() {}, }); type _MsdfKind = Expect, 'msdf'>>; -type _MsdfResource = Expect, MsdfResource>>; -type _MsdfBatch = Expect, MsdfBatch>>; +type _MsdfResource = Expect, MsdfResource>>; -const external = defineRaster({ - kind: 'studio.custom-raster', - extension: 'STUDIO_font_custom', - version: 0, - descriptor() { - return {}; - }, - async decode() { - return { custom: true as const }; - }, - async prepare() {}, - stageBatch(previous) { - const batch = previous ?? { draws: 1, dispose() {} }; - return defineRasterBatchStage( - batch, - () => undefined, - () => { - if (previous === undefined) batch.dispose(); - }, - ); - }, - dispose() {}, -}); - -const configurable = defineRaster({ +const configurable = defineRasterTechnique({ + id: 'studio.configurable-raster', kind: 'studio.configurable-raster', extension: 'STUDIO_font_configurable', version: 0, @@ -176,106 +81,38 @@ const configurable = defineRaster({ async decode() { return { configured: true as const }; }, - async prepare() {}, - stageBatch(previous) { - const batch = previous ?? { draws: 1, object: rasterObject, dispose() {} }; - return defineRasterBatchStage( - batch, - () => undefined, - () => { - if (previous === undefined) batch.dispose(); - }, - ); - }, dispose() {}, }); type _ConfigurableOptions = Expect, { readonly quality: 'low' | 'high' }>>; -const acceptsExternal: AnyRasterModule = external; +const acceptsExternal: AnyRasterTechnique = configurable; void acceptsExternal; declare const font: RegisteredFont; -declare const runtime: RasterRuntime; declare const slugArtifact: RegisteredRaster<'slug'>; -declare const glyphPaint: GlyphPaint; -void glyphPaint; void slugArtifact.extensionData; const slugBytes: Uint8Array = slugArtifact.view(0); void slugBytes; -const loaded = runtime.load(font, { module: msdf }); -type _LoadedKind = Expect['artifact']['kind'], 'msdf'>>; -type _LoadedResource = Expect['resource'], MsdfResource>>; - -const loadedConfigured = runtime.load(font, { - module: configurable, - options: { quality: 'low' }, -}); -type _LoadedConfiguredKind = Expect< - Equal['artifact']['kind'], 'studio.configurable-raster'> ->; - -// @ts-expect-error Runtime loading retains required raster options. -runtime.load(font, { module: configurable }); - // @ts-expect-error An MSDF decoder cannot consume a Slug artifact. msdf.decode(font, slugArtifact); -declare const paragraph: LayoutParagraph; - -const naturalMeasurement: ParagraphMeasurement = paragraph.measure(); -const constrainedMeasurement = paragraph.measure({ - width: { mode: 'at-most', size: 320 }, -}); -const intrinsicParagraph = paragraph.layout(); -const constrainedParagraph = paragraph.layout({ - width: { mode: 'at-most', size: 320 }, - height: { mode: 'unconstrained' }, - wrap: 'word', -}); -const committedParagraph = paragraph.layout({ - width: { mode: 'exactly', size: 280 }, -}); -const hostLayoutConstraints: ParagraphConstraints = { - width: { mode: 'at-most', size: 320 }, -}; -void intrinsicParagraph; -void naturalMeasurement; -void constrainedMeasurement; -void constrainedParagraph; -void committedParagraph; -void hostLayoutConstraints; - -// @ts-expect-error Measurement deliberately omits positioned glyph arrays. -void naturalMeasurement.glyphIds; - -// @ts-expect-error At-most constraints require an available size. -paragraph.layout({ width: { mode: 'at-most' } }); - -// @ts-expect-error Measurement and layout use the same constraint contract. -paragraph.measure({ height: { mode: 'exactly' } }); - -// @ts-expect-error Unconstrained axes do not carry a meaningless size. -paragraph.layout({ width: { mode: 'unconstrained', size: 320 } }); - const titleFont = defineFont('/fonts/Inter-Regular.ttf', msdf); type _TitleInput = Expect, '/fonts/Inter-Regular.ttf'>>; -type _TitleRaster = Expect, typeof msdf>>; +type _TitleRaster = Expect, typeof msdf>>; const configuredFont = defineFont('/fonts/Inter-Regular.ttf', { - module: configurable, + technique: configurable, options: { quality: 'high' }, }); void configuredFont; -// @ts-expect-error A configurable raster module requires its options. +// @ts-expect-error A configurable raster technique requires its options. defineFont('/fonts/Inter-Regular.ttf', configurable); - // @ts-expect-error A configured raster request cannot omit its options. -defineFont('/fonts/Inter-Regular.ttf', { module: configurable }); - +defineFont('/fonts/Inter-Regular.ttf', { technique: configurable }); // @ts-expect-error Raster package option literals remain package-owned. -defineFont('/fonts/Inter-Regular.ttf', { module: configurable, options: { quality: 'ultra' } }); +defineFont('/fonts/Inter-Regular.ttf', { technique: configurable, options: { quality: 'ultra' } }); const relocatedFont = defineFont( { @@ -294,16 +131,11 @@ type _RelocatedInput = Expect< > >; -const bakedOnlyFont = defineFont({ baked: '/fonts/Inter.font.glb' }, msdf); -void bakedOnlyFont; - +void defineFont({ baked: '/fonts/Inter.font.glb' }, msdf); declare const sourceUrl: URL; -const urlFont = defineFont(sourceUrl, msdf); -void urlFont; - +void defineFont(sourceUrl, msdf); // @ts-expect-error A font input requires either source or baked bytes. defineFont({}, msdf); - // @ts-expect-error An optional forbidden source cannot be supplied as undefined. defineFont({ baked: '/fonts/Inter.font.glb', source: undefined }, msdf); @@ -319,7 +151,6 @@ void font.loadRaster( ); declare const registeredRaster: RegisteredRaster; void registeredRaster.resource(externalRasterResource); - // @ts-expect-error A kind is not a stable raster selection when options can differ. font.loadRaster({ kind: 'msdf' }); @@ -337,12 +168,7 @@ const msdfBaker = defineRasterBaker({ extension: 'PMNDRS_font_distance_field', version: 0, artifacts: [], - report: { - metadataBytes: 0, - serializedBytes: 0, - gpuBytes: 0, - pages: [], - }, + report: { metadataBytes: 0, serializedBytes: 0, gpuBytes: 0, pages: [] }, }; }, }); @@ -357,43 +183,20 @@ const nestedDescriptorBaker = defineRasterBaker({ descriptor(options: { readonly language: string }) { return { formatVersion: 0, - settings: { - language: options.language, - scripts: ['Latn', 'Hani'], - fallback: null, - }, + settings: { language: options.language, scripts: ['Latn', 'Hani'], fallback: null }, } as const; }, async bake(request) { - type _RequestDescriptor = Expect< - Equal< - typeof request.descriptor, - { - readonly formatVersion: 0; - readonly settings: { - readonly language: string; - readonly scripts: readonly ['Latn', 'Hani']; - readonly fallback: null; - }; - } - > - >; return { rasterKey: request.rasterKey, kind: 'nested-json', extension: 'PMNDRS_font_nested_json', version: 0, artifacts: [], - report: { - metadataBytes: 0, - serializedBytes: 0, - gpuBytes: 0, - pages: [], - }, + report: { metadataBytes: 0, serializedBytes: 0, gpuBytes: 0, pages: [] }, }; }, }); - type _NestedDescriptor = Expect< Equal< RasterBakeDescriptorOf, @@ -410,16 +213,12 @@ type _NestedDescriptor = Expect< // @ts-expect-error Raster descriptors cannot contain undefined. type _UndefinedDescriptor = RasterBakeRequest<{ readonly invalid: undefined }>; - // @ts-expect-error Raster descriptors cannot contain functions. type _FunctionDescriptor = RasterBakeRequest<{ readonly invalid: () => void }>; - // @ts-expect-error Raster descriptors cannot contain bigint values. type _BigIntDescriptor = RasterBakeRequest<{ readonly invalid: bigint }>; - // @ts-expect-error Raster descriptors cannot contain Date objects. type _DateDescriptor = RasterBakeRequest<{ readonly invalid: Date }>; - // @ts-expect-error Raster descriptors cannot contain Map objects. type _MapDescriptor = RasterBakeRequest<{ readonly invalid: Map }>; @@ -441,15 +240,3 @@ const proseCoverage: RasterCoverage = { glyphIds: [0, 43], }; void proseCoverage; - -declare const dynamicStrike: number; -declare const dynamicStrikes: number[]; - -// @ts-expect-error Bitmap strikes must be statically known numeric literals. -bitmap({ strikes: [dynamicStrike] }); - -// @ts-expect-error Bitmap strikes must be a non-empty tuple. -bitmap({ strikes: [] }); - -// @ts-expect-error A broad array cannot describe bake-time bitmap payloads. -bitmap({ strikes: dynamicStrikes }); diff --git a/packages/text/tests/types/r3f-v1-api.test.ts b/packages/text/tests/types/r3f-v1-api.test.ts index 57afb1e7..1e8c04e1 100644 --- a/packages/text/tests/types/r3f-v1-api.test.ts +++ b/packages/text/tests/types/r3f-v1-api.test.ts @@ -2,15 +2,17 @@ import { createElement } from 'react'; import type { LoadedFont } from '../../src/index.js'; import { Text, TextGroup, useFont } from '../../src/r3f.js'; +import type { ThreeTextMaterial } from '../../src/three.js'; import { bitmap } from '../../src/raster/bitmap-technique.js'; import { msdf } from '../../src/raster/msdf.js'; declare const bitmapFont: LoadedFont; declare const mtsdfFont: LoadedFont; +declare const material: ThreeTextMaterial; const inline = createElement(Text, { paint: { color: '#ff00ff' } }, 'span'); -const label = createElement(Text, { font: bitmapFont }, 'Typed ', inline); -const labels = createElement(TextGroup, { technique: bitmap }, label); +const label = createElement(Text, { font: bitmapFont, material }, 'Typed ', inline); +const labels = createElement(TextGroup, { compositing: 'independent', material }, label); function FontConsumer(): null { const loaded: LoadedFont = useFont({ diff --git a/packages/text/tests/types/raster-technique-api.test.ts b/packages/text/tests/types/raster-technique-api.test.ts index 698a311b..cb2e1dd4 100644 --- a/packages/text/tests/types/raster-technique-api.test.ts +++ b/packages/text/tests/types/raster-technique-api.test.ts @@ -2,19 +2,15 @@ import { defineRasterResourceId, defineRasterTechnique, type AnyRasterTechnique, - type GlyphBatchStorage, - type GlyphBatchStorageOf, - type RasterBindingOf, type RasterDataOf, - type RasterGlyphWriteInput, type RasterOptionsOf, type RasterTechniqueDescriptorOf, type RasterTechniqueId, + type RasterTechniqueRequest, } from '../../src/index.js'; type Equal = (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 ? true : false; - type Expect = Value; type IsAny = 0 extends 1 & Value ? true : false; @@ -22,16 +18,8 @@ interface TestData { readonly records: Uint16Array; } -interface TestBinding { - readonly page: number; -} - -interface TestStorage { - readonly origins: Float32Array; - readonly glyphs: Uint16Array; -} - const page = defineRasterResourceId('test/page/0'); +void page; const technique = defineRasterTechnique({ id: 'test.msdf', @@ -44,16 +32,6 @@ const technique = defineRasterTechnique({ async decode(): Promise { return { records: new Uint16Array() }; }, - select() { - return { resource: page, pipelineVariant: 0, binding: { page: 0 } as TestBinding }; - }, - createStorage(capacity): TestStorage { - return { - origins: new Float32Array(capacity * 2), - glyphs: new Uint16Array(capacity), - }; - }, - writeStorage() {}, dispose() {}, }); @@ -63,39 +41,29 @@ type _Descriptor = Expect< Equal, { readonly quality: 'small' | 'large' }> >; type _Data = Expect, TestData>>; -type _Binding = Expect, TestBinding>>; -type _Storage = Expect, TestStorage>>; -declare const writeInput: RasterGlyphWriteInput; -const writeOrigin: number = writeInput.glyphs[0]!.originX + writeInput.glyphs[0]!.originY; -const writePage: number = writeInput.binding.page; -void writeOrigin; -void writePage; +const request: RasterTechniqueRequest = { technique, options: { quality: 'small' } }; +void request; +// @ts-expect-error Required technique options cannot be omitted. +const missingOptions: RasterTechniqueRequest = { technique }; +void missingOptions; const erased: AnyRasterTechnique = technique; void erased; type _ErasedDataIsUnknown = Expect, unknown>>; -type _ErasedStorage = Expect, GlyphBatchStorage>>; type _ErasedDataIsNotAny = Expect>, false>>; defineRasterTechnique({ - id: 'test.invalid-storage', + id: 'test.invalid-descriptor', kind: 'test-invalid', extension: 'TEST_invalid', version: 0, + // @ts-expect-error Technique descriptors must remain JSON values. descriptor() { - return {}; + return { invalid: () => undefined }; }, async decode() { return {}; }, - select() { - return { resource: page, pipelineVariant: 0, binding: {} }; - }, - // @ts-expect-error Canonical storage fields must all be ArrayBufferView values. - createStorage() { - return { invalid: 1 }; - }, - writeStorage() {}, dispose() {}, }); diff --git a/packages/text/tests/types/text-runtime-api.test.ts b/packages/text/tests/types/text-runtime-api.test.ts index 7afc65bc..b907a00a 100644 --- a/packages/text/tests/types/text-runtime-api.test.ts +++ b/packages/text/tests/types/text-runtime-api.test.ts @@ -1,58 +1,10 @@ -import { - createFontStack, - createTextRuntime, - span, - txt, - type LoadedFont, - type Paragraph, - type TextRuntime, -} from '../../src/index.js'; +import { createTextRuntime, type TextRuntime } from '../../src/index.js'; import { bitmap } from '../../src/raster/bitmap-technique.js'; import { msdf } from '../../src/raster/msdf.js'; import { slug } from '../../src/raster/slug-technique.js'; declare const runtime: TextRuntime; -declare const bitmapFont: LoadedFont; -declare const bitmapFallback: LoadedFont; -declare const mtsdfFont: LoadedFont; - -const uiFont = createFontStack(bitmapFont, bitmapFallback); - -// @ts-expect-error A FontStack cannot mix raster techniques. -createFontStack(bitmapFont, mtsdfFont); - -const labels = runtime.createParagraphBatch({ - technique: bitmap, - capacity: { size: 128, policy: 'fixed' }, - renderVariant: 'plain' as 'plain' | 'warning', -}); - -const warning = span(bitmapFallback, { color: '#ff00ff', fontSize: 18 }); -const label: Paragraph = labels.add({ - font: uiFont, - text: txt`A ${warning`fallback`} label`, - renderVariant: 'warning', -}); - -label.text = 'Plain text'; -label.set({ order: 4, contentBox: { width: { mode: 'at-most', size: 320 }, wrap: 'word' } }); -labels.setCapacity({ size: 256, policy: 'fixed' }); - -runtime.updateAsync({ - priority: 'urgent', - onProgress(progress) { - progress.preparedParagraphs satisfies number; - progress.totalParagraphs satisfies number; - progress.stagedGlyphs satisfies number; - }, -}); -runtime.updateAsync((result) => { - if (result.ok && result.value.status === 'published') - result.value.value satisfies import('../../src/index.js').TextRuntimeRevision; -}); - -// @ts-expect-error A paragraph batch cannot accept a font from another technique. -labels.add({ font: mtsdfFont, text: 'wrong technique' }); +runtime.registry satisfies TextRuntime['registry']; async function loadTargetV1Fonts(): Promise { const created = await createTextRuntime(); @@ -70,5 +22,4 @@ async function loadTargetV1Fonts(): Promise { }); } -void label; void loadTargetV1Fonts; diff --git a/packages/text/tests/types/three-shader-api.test.ts b/packages/text/tests/types/three-shader-api.test.ts index 00cd6f24..72c75c14 100644 --- a/packages/text/tests/types/three-shader-api.test.ts +++ b/packages/text/tests/types/three-shader-api.test.ts @@ -4,6 +4,7 @@ import type { Node } from 'three/webgpu'; import { bitmapShader, + defineTextMaterial, msdfShader, slugShader, type ThreeBitmapInstanceNodes, @@ -25,6 +26,13 @@ const bitmapOutput = bitmapShader(bitmapInstance, bitmapResources); const mtsdfOutput = msdfShader(mtsdfInstance, mtsdfResources); const slugOutput = slugShader(slugInstance, slugResources); +const customMaterial = defineTextMaterial((context) => { + const material = context.createDefaultMaterial(); + material.depthTest = true; + if (context.technique === 'pmndrs.slug') material.opacityNode = context.shader.opacity; + return material; +}); + // Each technique publishes its coverage as a float a custom program may weight or threshold itself. const bitmapCoverage: Node<'float'> = bitmapOutput.coverage; const mtsdfOutlineCoverage: Node<'float'> = mtsdfOutput.outlineCoverage; @@ -53,10 +61,14 @@ const wrongColor: Node<'float'> = bitmapOutput.color; // @ts-expect-error Slug addresses are unsigned integers; a float storage read cannot stand in for one. slugShader({ ...slugInstance, curveBaseTexel: slugOutput.coverage }, slugResources); +// @ts-expect-error Material factories must return a Three NodeMaterial. +defineTextMaterial(() => ({})); + void bitmapCoverage; void mtsdfOutlineCoverage; void slugCoverage; void material; void bitmapMaterial; void wrongColor; +void customMaterial; void mtsdfOutput; diff --git a/packages/text/tests/types/three-v1-api.test.ts b/packages/text/tests/types/three-v1-api.test.ts index 7382fe4f..b5880137 100644 --- a/packages/text/tests/types/three-v1-api.test.ts +++ b/packages/text/tests/types/three-v1-api.test.ts @@ -8,14 +8,19 @@ declare const mtsdfFont: LoadedFont; const emphasis = span(bitmapFont, { color: '#ff00ff' }); const label = new Text({ font: bitmapFont, text: txt`Typed ${emphasis`span`}` }); -const labels = new TextGroup({ technique: bitmap }); +const labels = new TextGroup({ compositing: 'independent' }); +const compositing: 'ordered' | 'independent' = labels.compositing; labels.add(label); label.text = 'Updated'; +label.insertText(7, '!'); +label.deleteText(7, 8); +label.replaceText(0, 7, 'Replaced'); label.setCapacity({ size: 64, policy: 'grow' }); +const measurement = label.measureLayout(); +void measurement?.contentWidth; labels.setCapacity({ size: 4_096, policy: 'chunk' }); -// @ts-expect-error Directly adding another technique is rejected statically. -labels.add(new Text({ font: mtsdfFont, text: 'Wrong technique' })); +labels.add(new Text({ font: mtsdfFont, text: 'Mixed technique' })); const loader = new FontLoader(); const loaded = loader.loadAsync({ @@ -24,3 +29,4 @@ const loaded = loader.loadAsync({ }); void loaded; void labels; +void compositing; diff --git a/packages/text/tests/types/typegpu-v1-api.test.ts b/packages/text/tests/types/typegpu-v1-api.test.ts deleted file mode 100644 index 816f24eb..00000000 --- a/packages/text/tests/types/typegpu-v1-api.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { TgpuRoot } from 'typegpu'; - -import type { PreparedParagraphBatchRevision } from '../../src/paragraph-batch.js'; -import { bitmap } from '../../src/raster/bitmap-technique.js'; -import { - createTypeGpuTextEngine, - defineTypeGpuRasterProgram, - defineTypeGpuRasterShader, - type TypeGpuParagraphBatchTargetRevision, - type TypeGpuRasterProgram, -} from '../../src/typegpu.js'; - -interface TintVariant { - readonly tint: readonly [number, number, number, number]; -} - -interface BitmapDraw { - readonly instanceCount: number; -} - -interface BitmapRevision extends TypeGpuParagraphBatchTargetRevision { - readonly sourceRevision: number; -} - -const shader = defineTypeGpuRasterShader({ - technique: bitmap, - vertex: { input: 'bitmap-instance', output: 'clip-position' }, - fragment: { input: 'bitmap-sample', output: 'color' }, - resources: { atlas: 'texture-2d-array' }, -}); - -const bitmapProgram = defineTypeGpuRasterProgram({ - technique: bitmap, - createTarget(options) { - return { - root: options.root, - technique: bitmap, - setParagraphState() {}, - stage(_previous: BitmapRevision | undefined, next: PreparedParagraphBatchRevision) { - return { - status: 'ready' as const, - stage: { - sourceRevision: next.revision, - commit: () => ({ sourceRevision: next.revision, draws: [], dispose() {} }), - abort() {}, - }, - }; - }, - encode() {}, - dispose() {}, - }; - }, - dispose() {}, -} satisfies TypeGpuRasterProgram); - -declare const root: TgpuRoot; - -async function useTypeGpuApi(): Promise { - const engine = await createTypeGpuTextEngine({ root, colorFormat: 'rgba8unorm' }); - const batch = engine.createParagraphBatch({ technique: bitmap, program: bitmapProgram }); - - // The concrete program carries its exact variant into paragraphs. - batch.renderVariant = { tint: [1, 0, 0, 1] }; - - // @ts-expect-error The retained variant is not an untyped effect bag. - batch.renderVariant = { opacity: 0.5 }; - - batch.dispose(); - engine.dispose(); -} - -void shader; -void useTypeGpuApi; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8dfb1ff..73e20194 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,6 +135,64 @@ importers: specifier: 0.1.17 version: 0.1.17(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)) + apps/r3f-hello-world: + dependencies: + '@pmndrs/text': + specifier: workspace:* + version: link:../../packages/text + '@react-three/fiber': + specifier: 10.0.0-alpha.2 + version: 10.0.0-alpha.2(patch_hash=0f6d2085306bbd109ac98a3051bebfeff94f5c97b04d32917f436bb4a9498ef8)(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(three@0.185.1) + react: + specifier: 19.2.8 + version: 19.2.8 + react-dom: + specifier: 19.2.8 + version: 19.2.8(react@19.2.8) + three: + specifier: 0.185.1 + version: 0.185.1 + devDependencies: + '@babel/core': + specifier: 8.0.1 + version: 8.0.1 + '@rolldown/plugin-babel': + specifier: 0.2.3 + version: 0.2.3(@babel/core@8.0.1)(@babel/runtime@7.29.7)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)) + '@types/node': + specifier: 24.13.3 + version: 24.13.3 + '@types/react': + specifier: 19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: 19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@types/three': + specifier: 0.185.1 + version: 0.185.1(patch_hash=a5d0add8a1d5e65aab9c242df680720a8962e6d8970c0e1a2c69bc66983f9010) + '@vitejs/plugin-react': + specifier: 6.0.1 + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@8.0.1)(@babel/runtime@7.29.7)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)) + babel-plugin-react-compiler: + specifier: 1.0.0 + version: 1.0.0 + oxfmt: + specifier: 0.35.0 + version: 0.35.0 + oxlint: + specifier: 1.75.0 + version: 1.75.0 + typescript: + specifier: 7.0.2 + version: 7.0.2 + vite: + specifier: 8.1.5 + version: 8.1.5(@types/node@24.13.3)(jiti@2.7.0) + vitexec: + specifier: 0.1.17 + version: 0.1.17(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)) + packages/font-baker: dependencies: ajv: @@ -233,9 +291,6 @@ importers: three: specifier: 0.185.1 version: 0.185.1 - typegpu: - specifier: 0.11.9 - version: 0.11.9 unicode-property-value-aliases: specifier: 3.9.0 version: 3.9.0 @@ -2796,10 +2851,6 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyest@0.3.2: - resolution: {integrity: sha512-1wqSt97RLezWzeogLSVXHr+E3jpYO8jxyaK2AIdJeGoZZTCubwNxQ9IqU5gbQpJVcxlbPugPAsII+m9/gqpPSA==} - engines: {node: '>=12.20.0'} - tinyexec@1.2.4: resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} engines: {node: '>=18'} @@ -2834,9 +2885,6 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsover-runtime@0.0.7: - resolution: {integrity: sha512-FHHwMJzZbnP23+fU1PxXljy7j0RRRosL2r1/CRUNTqfW+l7m35JsUkM615x/cAzw4GqRdXPIl3axKMj9qzpZtQ==} - tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} @@ -2848,14 +2896,6 @@ packages: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} - typed-binary@4.3.3: - resolution: {integrity: sha512-W2hLsSzt3k/tg38gDE4Fn/QiwcoqGuUHBc2cb3mXuH7KcYxe/GM57vzW14s2/bawB4R5knGgGq8Xb57vsaJ4Sg==} - - typegpu@0.11.9: - resolution: {integrity: sha512-AqBV6P9lW2jA7pEVDeiMD7Qoza9doPgueQbwyUyf09u4ndb9jOttHwpZ18c+BEzwLHaPlV50jw0hx5QsmVJGNg==} - engines: {node: '>=12.20.0'} - hasBin: true - typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} @@ -5371,8 +5411,6 @@ snapshots: tinybench@2.9.0: {} - tinyest@0.3.2: {} - tinyexec@1.2.4: {} tinyglobby@0.2.17: @@ -5403,8 +5441,6 @@ snapshots: tslib@2.8.1: {} - tsover-runtime@0.0.7: {} - tw-animate-css@1.4.0: {} type-check@0.4.0: @@ -5417,14 +5453,6 @@ snapshots: media-typer: 1.1.1 mime-types: 3.0.2 - typed-binary@4.3.3: {} - - typegpu@0.11.9: - dependencies: - tinyest: 0.3.2 - tsover-runtime: 0.0.7 - typed-binary: 4.3.3 - typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2