diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ea88d22..71dad79c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,11 @@ jobs: - name: Install locked dependencies run: pnpm install --frozen-lockfile + - name: Stage package-size presentation + run: | + cp apps/benchmarks/src/benchmark/package-size-summary.ts "$RUNNER_TEMP/package-size-summary.ts" + cp apps/benchmarks/src/benchmark/size-limit-report.mts "$RUNNER_TEMP/size-limit-report.mts" + - name: Compare package delivery sizes with the PR base uses: andresz1/size-limit-action@94bc357df29c36c8f8d50ea497c3e225c3c95d1d # v1.8.0 with: @@ -45,12 +50,11 @@ jobs: 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. + # The action checks out both revisions, so stage the head revision's + # presentation adapter outside the worktree before it switches bases. 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))})\"" + node \"$RUNNER_TEMP/size-limit-report.mts\"" check: name: Check @@ -115,8 +119,10 @@ jobs: with: name: wasm-failure-evidence path: | - packages/font-baker/dist/font_baker.wasm + packages/text/dist/font_baker.wasm packages/text/dist/bitmap_baker.wasm + packages/text/dist/mtsdf_baker.wasm + packages/text/dist/slug_baker.wasm packages/text/dist/text_shaper.wasm if-no-files-found: error retention-days: 7 diff --git a/AGENTS.md b/AGENTS.md index 02dc9c48..b25ec890 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,11 @@ Use the repository-local `tsl` skill before implementing or reviewing Three.js S Use the repository-local `claude-review` skill when invoking Claude Code for an adversarial or external-model review. Keep reviews read-only, stream visible progress, and retain the complete trace in the ignored repository cache instead of launching an opaque buffered subprocess. +Use the repository-local `gh-stack` skill for every dependent branch or pull-request workflow. Create, adopt, navigate, +rebase, push, submit, sync, link, and merge stacks through non-interactive `gh stack` commands; ordinary `git push`, +`gh pr create`, and `gh pr merge` are not substitutes for GitHub Stack state. Always use `gh stack submit --auto` and +`gh stack view --json`, and provide every branch or checkout argument explicitly as required by the skill. + Consult the repository-local `evidence-first` skill as the default style guidance for human-facing engineering communication, including chat updates and final answers, reports, reviews, handoffs, PR and issue prose, READMEs, and technical documentation. It offers situational cues rather than a fixed template. Domain skills still determine the work and valid evidence, `open-knowledge-format` governs bundle structure and provenance, and `diataxis-docs` governs the purpose and top-level structure of reader-facing documentation. Use these canonical sources instead of creating shadow plans or duplicate status prose: @@ -19,7 +24,7 @@ Use these canonical sources instead of creating shadow plans or duplicate status Update affected canonical documentation in the same change as source. Package source or configuration changes require reviewing the matching package concept, regenerating its `source_digest`, and running `pnpm docs:check`. -Use the exact root toolchain pins through mise. Agent commands must enter that environment explicitly with `mise exec -- pnpm ...` or `mise exec -- ...`; do not depend on `mise activate` surviving across non-interactive commands. Mise owns tool selection, while pnpm remains the only repository workflow surface. Install workload-scoped mise tools only when their documented pnpm workflow requires them. The dated nightly under `packages/font-baker/fuzz` is isolated to cargo-fuzz. Verify narrowly first, then run the relevant package and repository checks. Keep tests deterministic; do not use sleeps, timer cushions, arbitrary retries, or regenerated goldens as correctness mechanisms. +Use the exact root toolchain pins through mise. Agent commands must enter that environment explicitly with `mise exec -- pnpm ...` or `mise exec -- ...`; do not depend on `mise activate` surviving across non-interactive commands. Mise owns tool selection, while pnpm remains the only repository workflow surface. Install workload-scoped mise tools only when their documented pnpm workflow requires them. The dated nightly under `packages/text/rust/font-baker-fuzz` is isolated to cargo-fuzz. Verify narrowly first, then run the relevant package and repository checks. Keep tests deterministic; do not use sleeps, timer cushions, arbitrary retries, or regenerated goldens as correctness mechanisms. Exercise repository workflows through named `pnpm` scripts from the workspace root. Prefer a short root alias for a maintainer-facing application workflow. When a repeatable build, test, profile, capture, generation, or development command is missing, add the package-owned script and root alias before running it; do not leave the working procedure as an agent-only shell recipe or temporary probe. diff --git a/README.md b/README.md index 1cd4039d..10f3acb4 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ engine integrations below are implemented and pass their portability gates. ## Render text with React Three Fiber ```tsx -import { Text, TextGroup, useFont } from '@pmndrs/text/r3f'; +import { Text, TextGroup, useFont } from '@pmndrs/text/react'; import { mtsdf } from '@pmndrs/text/raster/mtsdf'; function Labels() { @@ -253,11 +253,31 @@ await bakeFont({ Baking creates font metrics, glyph records, and technique resources before the application runs. Development fallback can perform the same work in a Worker. Loading remains explicit either way. +For a known local font, the CLI exposes the same path without a project-discovery module: + +```sh +pnpm exec text bake --input Inter-Regular.ttf --output Inter.font.glb --bitmap 32 --msdf --slug +``` + +Add `--unicodes U+0020-007E` to subset the shaping font through the package-owned baker Wasm, or `--check` to rebuild temporarily and +require byte-identical output. + +Use the font's retained `post`/CFF glyph names to find icon code points or produce a bake-ready Unicode set: + +```sh +pnpm exec text glyphs fa-solid-900.ttf --name globe --json +pnpm exec text glyphs fa-solid-900.ttf --name globe --name earth-americas --unicode-set +``` + +`text glyphs` uses the package-owned baker Wasm; fonts without authored names still report exact glyph IDs. + ### Load, shape, and render ```ts import { createFontStack, createTextRuntime } from '@pmndrs/text'; +import { bitmap } from '@pmndrs/text/raster/bitmap'; import { mtsdf } from '@pmndrs/text/raster/mtsdf'; +import { slug } from '@pmndrs/text/raster/slug'; const runtime = await createTextRuntime({ async: { @@ -274,6 +294,11 @@ const Noto = await runtime.loadFont({ raster: { technique: mtsdf }, }); +const [InterBitmap, InterMsdf, InterSlug] = await runtime.loadFont({ + input: { baked: '/fonts/Inter.font.glb' }, + rasters: [{ technique: bitmap, options: { strikes: [32] } }, { technique: mtsdf }, { technique: slug }], +}); + const UiFont = createFontStack(Inter, Noto); const paragraphs = runtime.createParagraphBatch({ diff --git a/apps/benchmarks/fixtures/fonts/inter-v4.1/manifest.json b/apps/benchmarks/fixtures/fonts/inter-v4.1/manifest.json index 44d8d608..5db308f1 100644 --- a/apps/benchmarks/fixtures/fonts/inter-v4.1/manifest.json +++ b/apps/benchmarks/fixtures/fonts/inter-v4.1/manifest.json @@ -23,8 +23,8 @@ "formatVersion": 0, "descriptorHash": "f1c0e6c2ead13ceab41f2e39aa883e139fcecfb59c08bee7818242615170331d", "expectedCore": { - "artifactBytes": 172156, - "artifactSha256": "af7bfb85f04a6a63c6462735a6e8ec6d739576adb354c07ca51e744814db2f7b", + "artifactBytes": 172144, + "artifactSha256": "edf896923f38c9e6080e176540699a7b96b7cd15606b0522447750e7595170b5", "shapingHash": "6a96d9c6f9e59fd6aeb51848413bd4dd8711730a5479a7d004979d80f3b3cd09", "shapingSfntBytes": 147192, "extentsBytes": 23496, diff --git a/apps/benchmarks/fixtures/rendering/inter-bitmap-16.font.glb b/apps/benchmarks/fixtures/rendering/inter-bitmap-16.font.glb index 249b5bd4..4c04ea78 100644 Binary files a/apps/benchmarks/fixtures/rendering/inter-bitmap-16.font.glb and b/apps/benchmarks/fixtures/rendering/inter-bitmap-16.font.glb differ diff --git a/apps/benchmarks/fixtures/rendering/showcase-raster-fixtures-v0.json b/apps/benchmarks/fixtures/rendering/showcase-raster-fixtures-v0.json index f28b4789..983bc58f 100644 --- a/apps/benchmarks/fixtures/rendering/showcase-raster-fixtures-v0.json +++ b/apps/benchmarks/fixtures/rendering/showcase-raster-fixtures-v0.json @@ -9,8 +9,8 @@ { "fontFixture": "inter", "file": "inter-bitmap-16.font.glb", - "bytes": 927164, - "sha256": "55e0f03fcd9cec9312b03f58de112d299982e82db368d9a666db120ef8a4f471", + "bytes": 927152, + "sha256": "6ae4795a1398c3a31aaa10d941fa08e5a1545a3ea86e392bc3f641fd1c1f2d9c", "raster": { "decodedGpuBytes": 695296, "pages": [ diff --git a/apps/benchmarks/fixtures/results/bake-host-baseline-v0.json b/apps/benchmarks/fixtures/results/bake-host-baseline-v0.json index e325ec9b..9161831c 100644 --- a/apps/benchmarks/fixtures/results/bake-host-baseline-v0.json +++ b/apps/benchmarks/fixtures/results/bake-host-baseline-v0.json @@ -7,8 +7,8 @@ "browser": "149.0.7827.55" }, "artifact": { - "bytes": 172156, - "sha256": "af7bfb85f04a6a63c6462735a6e8ec6d739576adb354c07ca51e744814db2f7b" + "bytes": 172144, + "sha256": "edf896923f38c9e6080e176540699a7b96b7cd15606b0522447750e7595170b5" }, "offline": { "coldMedianMs": 4.155290999999977, diff --git a/apps/benchmarks/package.json b/apps/benchmarks/package.json index 3f2f3549..f8482688 100644 --- a/apps/benchmarks/package.json +++ b/apps/benchmarks/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "pnpm --filter @pmndrs/text-font-baker build && vite", + "dev": "pnpm --filter @pmndrs/text build && vite", "build": "node ./scripts/build.mts", "test": "node ./scripts/test.mts", "check": "node ./scripts/check.mts" @@ -13,7 +13,6 @@ "@base-ui/react": "1.6.0", "@fontsource-variable/geist": "5.3.0", "@pmndrs/text": "workspace:*", - "@pmndrs/text-font-baker": "workspace:*", "@pmndrs/text-glyph-example-raster": "workspace:*", "@react-three/fiber": "10.0.0-alpha.2", "class-variance-authority": "0.7.1", diff --git a/apps/benchmarks/scripts/generate-paragraph-cjk-contract.mts b/apps/benchmarks/scripts/generate-paragraph-cjk-contract.mts index 4547dde2..ea4a9fc5 100644 --- a/apps/benchmarks/scripts/generate-paragraph-cjk-contract.mts +++ b/apps/benchmarks/scripts/generate-paragraph-cjk-contract.mts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'; import { readFile, writeFile } from 'node:fs/promises'; import type { ParagraphStyle } from '@pmndrs/text'; -import { createFontBaker } from '@pmndrs/text-font-baker'; +import { createFontBaker } from '@pmndrs/text/bake'; import { paragraphLayoutContract } from '../src/benchmark/paragraph-layout-digest.ts'; import { @@ -27,7 +27,7 @@ const coverage = Object.values(retained.cases) .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', import.meta.url)), + readFile(new URL('../../../packages/text/dist/font_baker.wasm', import.meta.url)), ]); const baker = await createFontBaker(bakerWasm); const artifact = baker.bake({ source, descriptor: { formatVersion: 0, fontFaceIndex: 0 } }).artifacts[0]; diff --git a/apps/benchmarks/scripts/measure-package-sizes.mts b/apps/benchmarks/scripts/measure-package-sizes.mts index 03213a03..60d528e4 100644 --- a/apps/benchmarks/scripts/measure-package-sizes.mts +++ b/apps/benchmarks/scripts/measure-package-sizes.mts @@ -10,7 +10,7 @@ interface MeasuredEntry { readonly id: string; readonly label: string; readonly status: 'measured'; - readonly format: 'javascript' | 'wasm' | 'font-asset' | 'aggregate'; + readonly format: 'javascript' | 'wasm' | 'font-asset'; readonly sha256: string; readonly rawBytes: number; readonly minifiedBytes: number; @@ -78,7 +78,7 @@ async function bundle( } } } - if (!changed || (!id.includes('/packages/text/') && !id.includes('/packages/font-baker/'))) return; + if (!changed || !id.includes('/packages/text/')) return; return transformed; }, }, @@ -274,21 +274,6 @@ async function measureFontAsset( }; } -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( @@ -332,9 +317,9 @@ async function measureAdmittedMsdfGenerator(): Promise { }; } -const browserCore = await measureJavaScript( +const coreJavaScript = await measureJavaScript( 'browser-core', - 'Renderer-neutral core JS (peers and Wasm external)', + 'Core JS', new URL('../size-entries/text-core.ts', import.meta.url), false, true, @@ -344,27 +329,27 @@ const browserCore = await measureJavaScript( excludedInitial: [ '/packages/text/dist/runtime-bake.js', '/packages/text/dist/runtime-bake-worker.js', - '/packages/text/dist/r3f.js', + '/packages/text/dist/react.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', + '/packages/text/dist/font-baker/index.js', + '/packages/text/dist/font-baker/validator.js', + '/packages/text/dist/font-baker/wasm-url.js', ], }, ); const textShaperWasm = await measureWasm( 'text-shaper-wasm', - 'Text engine Wasm', + 'Shaper 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)', + 'Three.js adapter JS', new URL('../size-entries/three-runtime.ts', import.meta.url), false, true, @@ -372,104 +357,66 @@ const threeRuntime = await measureJavaScript( ); const interBitmap = await measureFontAsset( 'font-inter-bitmap-16-32', - 'Inter 4.1 Bitmap font asset (16 + 32 ppem)', + 'Inter font · Bitmap', 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', + 'Inter font · MTSDF', 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', + 'Inter font · Slug', 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)', + 'Font Awesome icons · Bitmap', 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', + 'Font Awesome icons · MTSDF', 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', + 'Font Awesome icons · Slug', new URL('../fixtures/rendering/font-awesome-free-6.7.2-slug.font.glb.gz', import.meta.url), 'gzip', ); const entries: SizeEntry[] = [ - browserCore, + coreJavaScript, 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', + 'Font validator JS', new URL('../size-entries/font-validator.ts', import.meta.url), ), await measureJavaScript( 'runtime-baker-host-js', - 'Runtime baker host JS', + 'Runtime bake host JS', new URL('../size-entries/runtime-bake.ts', import.meta.url), ), await measureJavaScript( 'runtime-baker-worker-js', - 'Runtime baker Worker JS', + 'Runtime bake Worker JS', new URL('../../../packages/text/dist/runtime-bake-worker.js', import.meta.url), - true, + false, true, ), await measureJavaScript( @@ -498,12 +445,12 @@ const entries: SizeEntry[] = [ ), await measureWasm( 'bitmap-baker-wasm', - 'Bitmap fixed baker Wasm', + 'Bitmap baker Wasm', new URL('../../../packages/text/dist/bitmap_baker.wasm', import.meta.url), ), await measureJavaScript( 'bitmap-baker-js', - 'Bitmap fixed baker host JS', + 'Bitmap baker JS', new URL('../size-entries/bitmap-baker.ts', import.meta.url), false, true, @@ -519,12 +466,12 @@ const entries: SizeEntry[] = [ await measureAdmittedMsdfGenerator(), await measureWasm( 'mtsdf-baker-wasm', - 'MSDF fixed baker Wasm', + 'MTSDF baker Wasm', new URL('../../../packages/text/dist/mtsdf_baker.wasm', import.meta.url), ), await measureJavaScript( 'mtsdf-baker-js', - 'MSDF fixed baker host JS', + 'MTSDF baker JS', new URL('../size-entries/mtsdf-baker.ts', import.meta.url), false, true, @@ -534,12 +481,12 @@ const entries: SizeEntry[] = [ ), await measureWasm( 'slug-baker-wasm', - 'Slug fixed baker Wasm', + 'Slug baker Wasm', new URL('../../../packages/text/dist/slug_baker.wasm', import.meta.url), ), await measureJavaScript( 'slug-baker-js', - 'Slug fixed baker host JS', + 'Slug baker JS', new URL('../size-entries/slug-baker.ts', import.meta.url), false, true, @@ -549,13 +496,14 @@ const entries: SizeEntry[] = [ ), await measureJavaScript( 'portable-baker-js', - 'Portable baker JS', + 'Font baker JS', new URL('../size-entries/font-baker.ts', import.meta.url), + false, ), await measureWasm( 'portable-baker-wasm', - 'Portable baker Wasm', - new URL('../../../packages/font-baker/dist/font_baker.wasm', import.meta.url), + 'Font baker Wasm', + new URL('../../../packages/text/dist/font_baker.wasm', import.meta.url), ), await measureJavaScript( 'unicode-analysis-js', @@ -574,31 +522,7 @@ const report = { }; const output = new URL('../src/generated/package-sizes.json', import.meta.url); const serialized = `${JSON.stringify(report, null, 2)}\n`; -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')) { +if (process.argv.includes('--check')) { const committed = await readFile(output, 'utf8'); assertPackageSizeReportFresh(JSON.parse(committed) as PackageSizeReport, report); } else { diff --git a/apps/benchmarks/scripts/provision-harfbuzz.mts b/apps/benchmarks/scripts/provision-harfbuzz.mts index 0abb6ffa..8d7eff58 100644 --- a/apps/benchmarks/scripts/provision-harfbuzz.mts +++ b/apps/benchmarks/scripts/provision-harfbuzz.mts @@ -17,8 +17,13 @@ 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'); +const infoExecutable = resolve(cacheDirectory, 'build/util/hb-info'); -if ((await isPinnedExecutable(executable, 'hb-shape')) && (await isPinnedExecutable(subsetExecutable, 'hb-subset'))) { +if ( + (await isPinnedExecutable(executable, 'hb-shape')) && + (await isPinnedExecutable(subsetExecutable, 'hb-subset')) && + (await isPinnedExecutable(infoExecutable, 'hb-info')) +) { process.stdout.write(`${executable}\n`); process.exit(0); } @@ -67,8 +72,8 @@ try { '-Dvector=disabled', '-Dintrospection=disabled', ]); - await run('meson', ['compile', '-C', buildDirectory, 'hb-shape', 'hb-subset']); - for (const utility of ['hb-shape', 'hb-subset'] as const) { + await run('meson', ['compile', '-C', buildDirectory, 'hb-shape', 'hb-subset', 'hb-info']); + for (const utility of ['hb-shape', 'hb-subset', 'hb-info'] as const) { if (!(await isPinnedExecutable(resolve(buildDirectory, `util/${utility}`), utility))) { throw new Error(`built ${utility} did not identify itself as HarfBuzz ${version}`); } @@ -79,7 +84,7 @@ try { } process.stdout.write(`${executable}\n`); -async function isPinnedExecutable(path: string, utility: 'hb-shape' | 'hb-subset'): Promise { +async function isPinnedExecutable(path: string, utility: 'hb-shape' | 'hb-subset' | 'hb-info'): Promise { try { return (await capture(path, ['--version'])).trim() === `${utility} (HarfBuzz) ${version}`; } catch { diff --git a/apps/benchmarks/scripts/run-packed-consumer.mts b/apps/benchmarks/scripts/run-packed-consumer.mts index d80dbe30..41d43fba 100644 --- a/apps/benchmarks/scripts/run-packed-consumer.mts +++ b/apps/benchmarks/scripts/run-packed-consumer.mts @@ -28,7 +28,6 @@ let server: ViteDevServer | undefined; let browser: Browser | undefined; try { await Promise.all([ - packAndExtract('packages/font-baker', 'pmndrs-text-font-baker-0.0.0.tgz', 'text-font-baker'), packAndExtract('packages/text', 'pmndrs-text-0.0.0.tgz', 'text'), copyFile( join(appDirectory, 'fixtures/fonts/inter-v4.1/Inter-Regular.ttf'), @@ -60,7 +59,7 @@ try { server = await createServer({ root: consumerDirectory, logLevel: 'silent', - optimizeDeps: { exclude: ['@pmndrs/text', '@pmndrs/text-font-baker'] }, + optimizeDeps: { exclude: ['@pmndrs/text'] }, server: { host: '127.0.0.1', port: 5183, strictPort: true }, }); await server.listen(); diff --git a/apps/benchmarks/scripts/support/command-cli.mts b/apps/benchmarks/scripts/support/command-cli.mts index d5e77b73..31b35688 100644 --- a/apps/benchmarks/scripts/support/command-cli.mts +++ b/apps/benchmarks/scripts/support/command-cli.mts @@ -18,7 +18,6 @@ export async function runVitexec(arguments_: readonly string[]): Promise { export async function buildRuntimePackages(): Promise { await runPnpm(['--filter', '@pmndrs/text', 'build']); - await runPnpm(['--filter', '@pmndrs/text-font-baker', 'build']); } export async function runPnpm(arguments_: readonly string[]): Promise { diff --git a/apps/benchmarks/scripts/workflows.mts b/apps/benchmarks/scripts/workflows.mts index bafc9720..377bb14e 100644 --- a/apps/benchmarks/scripts/workflows.mts +++ b/apps/benchmarks/scripts/workflows.mts @@ -23,7 +23,6 @@ const workspaceRoot = fileURLToPath(new URL('../../..', import.meta.url)); const roots = [ 'apps/benchmarks/scripts', 'apps/benchmarks/vitexec', - 'packages/font-baker/scripts', 'packages/text/scripts', ]; const workflowPattern = /\/\* @workflow\s+(\{[\s\S]*?\})\s+\*\//g; diff --git a/apps/benchmarks/size-entries/font-baker.ts b/apps/benchmarks/size-entries/font-baker.ts index a89f3561..f781cb05 100644 --- a/apps/benchmarks/size-entries/font-baker.ts +++ b/apps/benchmarks/size-entries/font-baker.ts @@ -1 +1,5 @@ -export * from '@pmndrs/text-font-baker'; +export { + createFontBaker, + createFontBakerFromInstance, + fontBakerAbi, +} from '../../../packages/text/dist/font-baker/index.js'; diff --git a/apps/benchmarks/size-entries/font-validator.ts b/apps/benchmarks/size-entries/font-validator.ts index c3487528..047858d3 100644 --- a/apps/benchmarks/size-entries/font-validator.ts +++ b/apps/benchmarks/size-entries/font-validator.ts @@ -1 +1 @@ -export * from '@pmndrs/text-font-baker/validate'; +export { validateFontArtifact } from '../../../packages/text/dist/font-baker/validator.js'; diff --git a/apps/benchmarks/src/benchmark/fixtures.test.ts b/apps/benchmarks/src/benchmark/fixtures.test.ts index f083348b..92a5d9a5 100644 --- a/apps/benchmarks/src/benchmark/fixtures.test.ts +++ b/apps/benchmarks/src/benchmark/fixtures.test.ts @@ -222,7 +222,7 @@ describe('canonical Noto Sans CJK fixtures', () => { const inspectorArguments = [ 'run', '--manifest-path', - fileURLToPath(new URL('../../../../packages/font-baker/rust/Cargo.toml', import.meta.url)), + fileURLToPath(new URL('../../../../packages/text/rust/font-baker/Cargo.toml', import.meta.url)), '--bin', 'inspect-font-fixture', '--features', diff --git a/apps/benchmarks/src/benchmark/package-size-budgets.ts b/apps/benchmarks/src/benchmark/package-size-budgets.ts index ae2cb4cf..18618b65 100644 --- a/apps/benchmarks/src/benchmark/package-size-budgets.ts +++ b/apps/benchmarks/src/benchmark/package-size-budgets.ts @@ -12,43 +12,30 @@ export const packageSizeBudgets = { brotliBytes: 113_500, }, 'runtime-baker-host-js': { - rawBytes: 11_500, - minifiedBytes: 9_600, - gzipBytes: 3_900, - brotliBytes: 3_500, + rawBytes: 18_000, + minifiedBytes: 16_000, + gzipBytes: 6_000, + brotliBytes: 5_500, }, 'runtime-baker-worker-js': { - rawBytes: 14_000, - minifiedBytes: 9_600, - gzipBytes: 3_200, - brotliBytes: 2_850, + rawBytes: 790_000, + minifiedBytes: 620_000, + gzipBytes: 148_000, + brotliBytes: 122_000, }, - // 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. + // Complete Rust shaping, layout, policy, and command-plan publication. 'text-shaper-wasm': { 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, @@ -85,42 +72,6 @@ export const packageSizeBudgets = { 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, minifiedBytes: 325_000, @@ -188,16 +139,16 @@ export const packageSizeBudgets = { brotliBytes: 4_000, }, 'portable-baker-js': { - rawBytes: 10_100, - minifiedBytes: 6_700, - gzipBytes: 2_360, - brotliBytes: 2_080, + rawBytes: 12_000, + minifiedBytes: 8_500, + gzipBytes: 2_700, + brotliBytes: 2_400, }, 'portable-baker-wasm': { - rawBytes: 434_285, - minifiedBytes: 434_285, - gzipBytes: 168_326, - brotliBytes: 137_100, + rawBytes: 1_105_000, + minifiedBytes: 1_105_000, + gzipBytes: 395_000, + brotliBytes: 308_000, }, // Raw and minified rose for the allocation-free grapheme script resolution; the growth is comment-dominated, at // +3,010 raw against +298 Brotli, because the parallel-array form needs its reasoning recorded next to it. diff --git a/apps/benchmarks/src/benchmark/package-size-summary.ts b/apps/benchmarks/src/benchmark/package-size-summary.ts new file mode 100644 index 00000000..5319aa78 --- /dev/null +++ b/apps/benchmarks/src/benchmark/package-size-summary.ts @@ -0,0 +1,67 @@ +interface SummaryDefinition { + readonly id: string; + readonly label: string; +} + +export interface PackageSizeSummaryEntry { + readonly id: string; + readonly label: string; + readonly gzipBytes: number; +} + +const summaryDefinitions = [ + { id: 'browser-core', label: 'Core JS' }, + { id: 'text-shaper-wasm', label: 'Shaper Wasm' }, + { id: 'three-runtime-js', label: 'Three.js adapter JS' }, + { id: 'font-inter-bitmap-16-32', label: 'Inter font · Bitmap' }, + { id: 'font-inter-mtsdf', label: 'Inter font · MTSDF' }, + { id: 'font-inter-slug', label: 'Inter font · Slug' }, + { id: 'font-icons-bitmap-16-32', label: 'Font Awesome icons · Bitmap' }, + { id: 'font-icons-mtsdf', label: 'Font Awesome icons · MTSDF' }, + { id: 'font-icons-slug', label: 'Font Awesome icons · Slug' }, + { id: 'font-validator-js', label: 'Font validator JS' }, + { id: 'runtime-baker-host-js', label: 'Runtime bake host JS' }, + { id: 'runtime-baker-worker-js', label: 'Runtime bake Worker JS' }, + { id: 'portable-baker-js', label: 'Font baker JS' }, + { id: 'portable-baker-wasm', label: 'Font baker Wasm' }, + { id: 'bitmap-baker-js', label: 'Bitmap baker JS' }, + { id: 'bitmap-baker-wasm', label: 'Bitmap baker Wasm' }, + { id: 'mtsdf-baker-js', label: 'MTSDF baker JS' }, + { id: 'mtsdf-baker-wasm', label: 'MTSDF baker Wasm' }, + { id: 'slug-baker-js', label: 'Slug baker JS' }, + { id: 'slug-baker-wasm', label: 'Slug baker Wasm' }, +] as const satisfies readonly SummaryDefinition[]; + +export function summarizePackageSizes(report: unknown): readonly PackageSizeSummaryEntry[] { + if (!isNonArrayObject(report) || !Array.isArray(report.entries)) { + throw new Error('package-size summary requires a report with entries'); + } + const entries = new Map(); + for (const entry of report.entries) { + if (!isNonArrayObject(entry) || typeof entry.id !== 'string') continue; + entries.set(entry.id, { status: entry.status, gzipBytes: entry.gzipBytes }); + } + return summaryDefinitions.map(({ id, label }) => { + const entry = entries.get(id); + if ( + entry?.status !== 'measured' || + typeof entry.gzipBytes !== 'number' || + !Number.isSafeInteger(entry.gzipBytes) || + entry.gzipBytes <= 0 + ) { + throw new Error(`package-size summary requires a positive measured gzip size for ${id}`); + } + return { id, label, gzipBytes: entry.gzipBytes }; + }); +} + +export function sizeLimitRows(report: unknown): readonly { readonly name: string; readonly size: number }[] { + return summarizePackageSizes(report).map(({ label, gzipBytes }) => ({ + name: `${label} (gzip)`, + size: gzipBytes, + })); +} + +function isNonArrayObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/apps/benchmarks/src/benchmark/package-sizes.test.ts b/apps/benchmarks/src/benchmark/package-sizes.test.ts index 744791b8..d86d2538 100644 --- a/apps/benchmarks/src/benchmark/package-sizes.test.ts +++ b/apps/benchmarks/src/benchmark/package-sizes.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import report from '../generated/package-sizes.json'; import { packageSizeBudgets } from './package-size-budgets'; import { assertPackageSizeReportFresh } from './package-size-report'; +import { sizeLimitRows, summarizePackageSizes } from './package-size-summary'; describe('independent package-size report', () => { it('identifies every measured payload by SHA-256', () => { @@ -27,9 +28,7 @@ describe('independent package-size report', () => { 'runtime-baker-host-js', 'runtime-baker-worker-js', 'text-shaper-wasm', - 'renderer-neutral-core-total', 'three-runtime-js', - 'three-renderer-total', 'bitmap-runtime-js', 'mtsdf-runtime-js', 'slug-runtime-js', @@ -67,39 +66,39 @@ describe('independent package-size report', () => { } }); - 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 [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('projects the useful gzip measurements for people and pull requests', () => { + const summary = summarizePackageSizes(report); + expect(summary.map(({ label }) => label)).toEqual([ + 'Core JS', + 'Shaper Wasm', + 'Three.js adapter JS', + 'Inter font · Bitmap', + 'Inter font · MTSDF', + 'Inter font · Slug', + 'Font Awesome icons · Bitmap', + 'Font Awesome icons · MTSDF', + 'Font Awesome icons · Slug', + 'Font validator JS', + 'Runtime bake host JS', + 'Runtime bake Worker JS', + 'Font baker JS', + 'Font baker Wasm', + 'Bitmap baker JS', + 'Bitmap baker Wasm', + 'MTSDF baker JS', + 'MTSDF baker Wasm', + 'Slug baker JS', + 'Slug baker Wasm', + ]); + expect(sizeLimitRows(report)).toEqual( + summary.map(({ label, gzipBytes }) => ({ name: `${label} (gzip)`, size: gzipBytes })), + ); + }); - 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]); - } + it('rejects incomplete package-size summaries instead of publishing misleading rows', () => { + const incomplete = structuredClone(report); + incomplete.entries = incomplete.entries.filter(({ id }) => id !== 'text-shaper-wasm'); + expect(() => summarizePackageSizes(incomplete)).toThrow(/text-shaper-wasm/); }); it('keeps the lazy validator out of the initial browser-core measurement', () => { diff --git a/apps/benchmarks/src/benchmark/size-limit-report.mts b/apps/benchmarks/src/benchmark/size-limit-report.mts new file mode 100644 index 00000000..439c9cd4 --- /dev/null +++ b/apps/benchmarks/src/benchmark/size-limit-report.mts @@ -0,0 +1,5 @@ +import { sizeLimitRows } from './package-size-summary.ts'; + +let input = ''; +for await (const chunk of process.stdin) input += String(chunk); +process.stdout.write(JSON.stringify(sizeLimitRows(JSON.parse(input) as unknown))); diff --git a/apps/benchmarks/src/benchmark/targets/product/react-text.ts b/apps/benchmarks/src/benchmark/targets/product/react-text.ts index 1401352c..6813787c 100644 --- a/apps/benchmarks/src/benchmark/targets/product/react-text.ts +++ b/apps/benchmarks/src/benchmark/targets/product/react-text.ts @@ -4,7 +4,7 @@ import * as THREE from 'three/webgpu'; import type { LoadedFont, ParagraphLayout } from '@pmndrs/text'; import { bitmap } from '@pmndrs/text/three/bitmap'; -import { Text, useFont } from '@pmndrs/text/r3f'; +import { Text, useFont } from '@pmndrs/text/react'; import type { LoadedFontRequest, ParagraphContentBox, Text as CoreText } from '@pmndrs/text/three'; import canonicalParagraphLayout from '../../../../fixtures/contracts/paragraph-layout-v0.json'; @@ -76,6 +76,9 @@ export function createReactTextTarget(): BenchmarkTarget { if (state.kind !== 'ready') return; const resources = state.resources; state = { kind: 'empty' }; + // R3F schedules host disposal at idle priority. Release the paragraph lease explicitly before the target-owned + // font so teardown remains deterministic; the later host disposal is intentionally idempotent. + resources.reference.current?.dispose(); flushSync(() => resources.root.unmount()); resources.font.dispose(); useFont.clear(fontRequest); @@ -194,9 +197,8 @@ async function renderCommittedText( accent = '#ff8a00', ): Promise<{ readonly core: BitmapTextObject; readonly store: RootStore }> { const committed = deferred(); - // Target v1 constructs its Three object in a layout effect and publishes it on the following render, so a parent - // effect would still observe an empty ref. The ref callback is the first point where the object exists, and it is - // composed here rather than inside the component so the component never writes through a prop. + // The host ref is the causal signal that R3F committed the Three object. Compose it here rather than making the + // component write through a ref prop while React may still replay the surrounding StrictMode commit. const publish = (object: BitmapTextObject | null): void => { reference.current = object; if (object !== null) committed.resolve(); @@ -205,8 +207,7 @@ async function renderCommittedText( flushSync(() => { store = root.render(renderText(publish, failures, width, accent)); }); - // The commit only signals that an object reached the ref; StrictMode may remount before the flush settles, so the - // retained object is always read back from the ref rather than captured at the first commit. + // StrictMode may remount after the first host ref callback, so always read the retained object back from the ref. await committed.promise; const core = requiredCoreText(reference); if (store === undefined) throw new Error('R3F did not publish its root store'); diff --git a/apps/benchmarks/src/benchmark/targets/shared/direct-wasm.ts b/apps/benchmarks/src/benchmark/targets/shared/direct-wasm.ts index b225be7e..5b7b2cfd 100644 --- a/apps/benchmarks/src/benchmark/targets/shared/direct-wasm.ts +++ b/apps/benchmarks/src/benchmark/targets/shared/direct-wasm.ts @@ -5,7 +5,7 @@ * after an operator chooses an ABI-level benchmark. Product demos use public loader surfaces. */ export interface DirectWasmDependencies { - readonly createFontBaker: typeof import('@pmndrs/text-font-baker').createFontBaker; + readonly createFontBaker: typeof import('@pmndrs/text/bake').createFontBaker; readonly bakerWasmUrl: string; readonly shaperWasmUrl: string; } @@ -14,8 +14,8 @@ export type DirectFontBaker = Awaited { const [bakerModule, bakerWasmModule, shaperWasmModule] = await Promise.all([ - import('@pmndrs/text-font-baker'), - import('@pmndrs/text-font-baker/font-baker.wasm?url'), + import('@pmndrs/text/bake'), + import('@pmndrs/text/font-baker.wasm?url'), import('@pmndrs/text/text-shaper.wasm?url'), ]); return { diff --git a/apps/benchmarks/src/components/report.tsx b/apps/benchmarks/src/components/report.tsx index f9a20bef..0d7e3d60 100644 --- a/apps/benchmarks/src/components/report.tsx +++ b/apps/benchmarks/src/components/report.tsx @@ -1,10 +1,13 @@ import { Fragment } from 'react'; import type { BenchmarkSummary } from '../benchmark/contracts'; import { BENCHMARK_FONT_LABELS } from '../benchmark/font-fixtures'; +import { summarizePackageSizes } from '../benchmark/package-size-summary'; import type { LiveBenchmarkCapture } from '../benchmark/product-result'; import packageSizes from '../generated/package-sizes.json'; import { Metric } from './ui'; +const packageSizeSummary = summarizePackageSizes(packageSizes); + function ms(value: number | undefined): string { return value === undefined ? '—' : `${value.toFixed(2)} ms`; } @@ -61,17 +64,13 @@ export function Report({ )}
-

Independent package-size lane

+

Package sizes · gzip

- {packageSizes.entries.map((entry) => ( + {packageSizeSummary.map((entry) => (
- + {entry.label} - - {entry.status === 'measured' - ? `${bytes(entry.rawBytes)} raw · ${bytes(entry.brotliBytes)} br` - : 'not landed'} - + {bytes(entry.gzipBytes)}
))}
diff --git a/apps/benchmarks/src/generated/package-sizes.json b/apps/benchmarks/src/generated/package-sizes.json index 6ebd34d2..db6d41cc 100644 --- a/apps/benchmarks/src/generated/package-sizes.json +++ b/apps/benchmarks/src/generated/package-sizes.json @@ -7,18 +7,18 @@ "entries": [ { "id": "browser-core", - "label": "Renderer-neutral core JS (peers and Wasm external)", + "label": "Core JS", "status": "measured", "format": "javascript", - "sha256": "e0c1574d31cee981f61c20ff9685cc64523c91dec320cafcc0860189856c5e7a", - "rawBytes": 88398, - "minifiedBytes": 64157, - "gzipBytes": 17846, - "brotliBytes": 15469 + "sha256": "52179471cc1b1dda923640656a0ce3cb95b877cf9f0fd0fc6ca7e56a652c28ae", + "rawBytes": 92550, + "minifiedBytes": 66839, + "gzipBytes": 18659, + "brotliBytes": 16177 }, { "id": "text-shaper-wasm", - "label": "Text engine Wasm", + "label": "Shaper Wasm", "status": "measured", "format": "wasm", "sha256": "f74f96a6214532271296c8165738d14f71c0642aca4af9050a0363aed2a4d576", @@ -27,42 +27,20 @@ "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)", + "label": "Three.js adapter JS", "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 + "sha256": "8e031b5e3d8bdea99bcbcd428a4275d6021e5b84ba982fb12507bb38a25ecd2d", + "rawBytes": 334488, + "minifiedBytes": 220463, + "gzipBytes": 57253, + "brotliBytes": 48250 }, { "id": "font-inter-bitmap-16-32", - "label": "Inter 4.1 Bitmap font asset (16 + 32 ppem)", + "label": "Inter font · Bitmap", "status": "measured", "format": "font-asset", "sha256": "b8143dc39a49199c934f4cee493ddb6acbbfff34716f5332afd4e8b9cb8e785f", @@ -73,7 +51,7 @@ }, { "id": "font-inter-mtsdf", - "label": "Inter 4.1 MTSDF font asset", + "label": "Inter font · MTSDF", "status": "measured", "format": "font-asset", "sha256": "1e1980e2b20341c5e7f531e970e37cb879da2907d5c84e9603414717bcf495c0", @@ -84,7 +62,7 @@ }, { "id": "font-inter-slug", - "label": "Inter 4.1 Slug font asset", + "label": "Inter font · Slug", "status": "measured", "format": "font-asset", "sha256": "1c3f8fea27d1f404f77e47c4dd4929c122e12091a3d91cd92c30a6b9d6ece094", @@ -95,7 +73,7 @@ }, { "id": "font-icons-bitmap-16-32", - "label": "Font Awesome Free 6.7.2 Bitmap icon asset (16 + 32 ppem)", + "label": "Font Awesome icons · Bitmap", "status": "measured", "format": "font-asset", "sha256": "95550c860396470f8583a990a8e2081289a6a87781836c17b488527c8c4eca45", @@ -106,7 +84,7 @@ }, { "id": "font-icons-mtsdf", - "label": "Font Awesome Free 6.7.2 MTSDF icon asset", + "label": "Font Awesome icons · MTSDF", "status": "measured", "format": "font-asset", "sha256": "75ef82e7ee28d6ee6135634fc13b68ba8fc8fe808c8ec3d8428d8e33cf37c3b6", @@ -117,7 +95,7 @@ }, { "id": "font-icons-slug", - "label": "Font Awesome Free 6.7.2 Slug icon asset", + "label": "Font Awesome icons · Slug", "status": "measured", "format": "font-asset", "sha256": "dda2ea68d49f45cdfbf48c3a505718eb62931753834e1d175270ae40d2b40b1d", @@ -126,170 +104,104 @@ "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", - "label": "Lazy font validator JS", + "label": "Font validator JS", "status": "measured", "format": "javascript", - "sha256": "acdb803764e9ab7c5b7f4d070a0065d9354d4206b0cc5a52ceb1eb6acb306552", - "rawBytes": 740645, - "minifiedBytes": 584479, - "gzipBytes": 137637, - "brotliBytes": 112898 + "sha256": "bb300da7e73b9ca0302d36fac9ef87b419f7c26fec62fa84c3bacc6f410f11ae", + "rawBytes": 738797, + "minifiedBytes": 583452, + "gzipBytes": 137404, + "brotliBytes": 112750 }, { "id": "runtime-baker-host-js", - "label": "Runtime baker host JS", + "label": "Runtime bake host JS", "status": "measured", "format": "javascript", - "sha256": "1c2517a9ac99ebe7c73791cdf28761693602f34e0f4e90232dc2ccf746351f1e", - "rawBytes": 11437, - "minifiedBytes": 9524, - "gzipBytes": 3826, - "brotliBytes": 3435 + "sha256": "44674ca37ad692a7e7bcc1677e63e7f7e7b40fc4f5b6efdd172a21a1620a22a5", + "rawBytes": 16593, + "minifiedBytes": 14291, + "gzipBytes": 5506, + "brotliBytes": 4884 }, { "id": "runtime-baker-worker-js", - "label": "Runtime baker Worker JS", + "label": "Runtime bake Worker JS", "status": "measured", "format": "javascript", - "sha256": "31f464e98aade52c1c0814a77b563d018364269769db4220f0e21a805f980bb3", - "rawBytes": 12777, - "minifiedBytes": 8880, - "gzipBytes": 2982, - "brotliBytes": 2644 + "sha256": "4353d9d232ac8c23c53336c5546639498531cb055c110232c6365e93a57c7ec4", + "rawBytes": 781531, + "minifiedBytes": 613017, + "gzipBytes": 145805, + "brotliBytes": 119644 }, { "id": "bitmap-runtime-js", "label": "Bitmap runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "f6e44e8ed5751eafabc0bafcc1d20aa95e9d774a2365907349b3585e86acc0d5", - "rawBytes": 319734, - "minifiedBytes": 210635, - "gzipBytes": 54492, - "brotliBytes": 45949 + "sha256": "e4bbe1fea2567da4b932ca31600af4903820491839fa4a7905275ed84eee360f", + "rawBytes": 323726, + "minifiedBytes": 213191, + "gzipBytes": 55273, + "brotliBytes": 46691 }, { "id": "mtsdf-runtime-js", "label": "MSDF runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "b238e093d562cc7d85bc9b802f1db5aadaa10042534c946750e6fd25b65ced1e", - "rawBytes": 319730, - "minifiedBytes": 210639, - "gzipBytes": 54494, - "brotliBytes": 45983 + "sha256": "07239142ac34ced5da988b0aea3c7aff91ae80398acce8e1136a3ee36d9cd7ea", + "rawBytes": 323722, + "minifiedBytes": 213185, + "gzipBytes": 55264, + "brotliBytes": 46622 }, { "id": "slug-runtime-js", "label": "Slug runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "7aeb11852c0962a9eec22d75fcebd62d5d863f922a9834e124fc3ccf49626201", - "rawBytes": 319732, - "minifiedBytes": 210634, - "gzipBytes": 54431, - "brotliBytes": 45956 + "sha256": "ecfbb2a9e6fa985f0b45ddbfd08b440c20b16f7e12d1a36980fcd478ab5e561b", + "rawBytes": 323724, + "minifiedBytes": 213190, + "gzipBytes": 55206, + "brotliBytes": 46665 }, { "id": "bitmap-baker-wasm", - "label": "Bitmap fixed baker Wasm", + "label": "Bitmap baker Wasm", "status": "measured", "format": "wasm", - "sha256": "bdcf6215905e47be09d8a1a8e5122e95c07c4e4b93509bf8f4a74d043e0438a8", + "sha256": "fb2abdcb7f85eef80218b3bcdb134bb05deeef95d4f23725946bb0e40bdfa1bc", "rawBytes": 626940, "minifiedBytes": 626940, - "gzipBytes": 234735, - "brotliBytes": 180620 + "gzipBytes": 234736, + "brotliBytes": 180717 }, { "id": "bitmap-baker-js", - "label": "Bitmap fixed baker host JS", + "label": "Bitmap baker JS", "status": "measured", "format": "javascript", - "sha256": "1cf2e408a937594f0a460ff48bd72751ccf229b072e07e6556973493502b1531", - "rawBytes": 23026, - "minifiedBytes": 15585, - "gzipBytes": 4773, - "brotliBytes": 4233 + "sha256": "b9bd6ee44067f865b8a0c0d6c076e67121d8af728098c34204185695c727dfb1", + "rawBytes": 22996, + "minifiedBytes": 15561, + "gzipBytes": 4767, + "brotliBytes": 4228 }, { "id": "mtsdf-generator-js", "label": "MSDF generator host JS", "status": "measured", "format": "javascript", - "sha256": "e750b13e84c567ab5fb2c875b66432e5a48fa48a8310ec760817616ba5b8b060", - "rawBytes": 11374, - "minifiedBytes": 8418, - "gzipBytes": 2657, - "brotliBytes": 2357 + "sha256": "db2bfcf9fecf60c033b0fc748833b9ed6674297ad37fe45a89ad010cbf72eacb", + "rawBytes": 11368, + "minifiedBytes": 8412, + "gzipBytes": 2652, + "brotliBytes": 2353 }, { "id": "mtsdf-generator-wasm", @@ -304,80 +216,80 @@ }, { "id": "mtsdf-baker-wasm", - "label": "MSDF fixed baker Wasm", + "label": "MTSDF baker Wasm", "status": "measured", "format": "wasm", - "sha256": "ec6eb1640d587ba8ce9b614aa334c7a93b4a5c36a6c12ee1dba725d7adce7de8", + "sha256": "1ba27cd121447ca271e7040e07ba681fe016e90e41c4810ff485bfd7da6ad792", "rawBytes": 552025, "minifiedBytes": 552025, - "gzipBytes": 215030, - "brotliBytes": 168758 + "gzipBytes": 215027, + "brotliBytes": 169341 }, { "id": "mtsdf-baker-js", - "label": "MSDF fixed baker host JS", + "label": "MTSDF baker JS", "status": "measured", "format": "javascript", - "sha256": "b12564a24fab591641209abd8d98a141e05b7fb9dbd1723caa0fec4517bbf9b4", - "rawBytes": 26861, - "minifiedBytes": 19076, - "gzipBytes": 5522, - "brotliBytes": 4901 + "sha256": "f5288872000d7a7d2157f132850ace5d652cd398eb3e59f2d1873481951cc5cb", + "rawBytes": 26831, + "minifiedBytes": 19052, + "gzipBytes": 5516, + "brotliBytes": 4894 }, { "id": "slug-baker-wasm", - "label": "Slug fixed baker Wasm", + "label": "Slug baker Wasm", "status": "measured", "format": "wasm", - "sha256": "38d461e1ccfd9be05cccff46c6ee4993c785602ba3cafe170abf4eefdecd8f94", + "sha256": "68d7cdb6a5c593765674c36d62390392174ad5e780b109d8c923a4d78985f65c", "rawBytes": 465031, "minifiedBytes": 465031, - "gzipBytes": 186665, - "brotliBytes": 146606 + "gzipBytes": 186664, + "brotliBytes": 146682 }, { "id": "slug-baker-js", - "label": "Slug fixed baker host JS", + "label": "Slug baker JS", "status": "measured", "format": "javascript", - "sha256": "f8f145266df728d067218477258eb9f19dba063f1fe22064d529099f77de731e", - "rawBytes": 18641, - "minifiedBytes": 12877, - "gzipBytes": 4116, - "brotliBytes": 3667 + "sha256": "4528ac56defde4d59e560a1eecc9ed6eb1c21f9c2992b9843e16d9b9594d166d", + "rawBytes": 18611, + "minifiedBytes": 12853, + "gzipBytes": 4111, + "brotliBytes": 3665 }, { "id": "portable-baker-js", - "label": "Portable baker JS", + "label": "Font baker JS", "status": "measured", "format": "javascript", - "sha256": "ac0207d7b5092cb2ded432094535f5f44a65884ae0c9ef8bb397adb793b99a09", - "rawBytes": 8877, - "minifiedBytes": 6017, - "gzipBytes": 2157, - "brotliBytes": 1913 + "sha256": "da99e3d28d19d37ff69ebaf4b57ae7a4c2c4b6a460d9eb2c4456b54a55525889", + "rawBytes": 11526, + "minifiedBytes": 7940, + "gzipBytes": 2471, + "brotliBytes": 2214 }, { "id": "portable-baker-wasm", - "label": "Portable baker Wasm", + "label": "Font baker Wasm", "status": "measured", "format": "wasm", - "sha256": "3edf10a1efc4ad2e7c6a5e5842845c74d17f7e88a72efda4de47ca191b402272", - "rawBytes": 422538, - "minifiedBytes": 422538, - "gzipBytes": 164319, - "brotliBytes": 134012 + "sha256": "0bf72177166ecd2666d72bf21f5ae5e164100681fe56972b227c70eebbf203bc", + "rawBytes": 1097702, + "minifiedBytes": 1097702, + "gzipBytes": 391576, + "brotliBytes": 304817 }, { "id": "unicode-analysis-js", "label": "Unicode 17 analysis JS", "status": "measured", "format": "javascript", - "sha256": "7b4320ddbb5d713a92337daa13f762ef9f56ba3e2bb0ffd3ef2354a702d8a1d7", - "rawBytes": 167796, - "minifiedBytes": 141127, - "gzipBytes": 42406, - "brotliBytes": 31287 + "sha256": "7d9c59e19c774fe61109c88315feb1e6614957f7db0f212462b2206fb6386a62", + "rawBytes": 167712, + "minifiedBytes": 141103, + "gzipBytes": 42399, + "brotliBytes": 31307 } ] } diff --git a/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts b/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts index 2980fcf6..3688e785 100644 --- a/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts +++ b/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts @@ -620,8 +620,8 @@ async function createComparisonWorkloadRuntime( workloadChanged ? 0 : performance.now() - animationEpoch, options.textLadderSpecimen, nextCompanionFonts.map(({ loaded }) => loaded), - initialIconWindow?.scrollX ?? (workloadChanged ? 0 : -scene.position.x), - initialIconWindow?.scrollY ?? (workloadChanged ? 0 : scene.position.y), + initialIconWindow?.scrollX ?? (workloadChanged ? 0 : (iconGridInstance?.view().scrollX ?? 0)), + initialIconWindow?.scrollY ?? (workloadChanged ? 0 : (iconGridInstance?.view().scrollY ?? 0)), ); const nextRoot = reuseBatchRoot ? previousRoot : createBatchRoot(next.workload); const scheduledAt = performance.now(); @@ -650,10 +650,13 @@ async function createComparisonWorkloadRuntime( configuration = next; committedContentWidth = comparisonWorkloadContentWidth(next, width); if (workloadChanged) { - // Scene transforms belong to the outgoing workload. Text Ladder exits by translating the shared scene, - // while Icon Grid pans it; every newly mounted workload must start from its own explicit view defaults. - scene.position.set(-(initialIconWindow?.scrollX ?? 0), initialIconWindow?.scrollY ?? 0, 0); + // Scene transforms belong to the outgoing workload. Every newly mounted workload starts with an identity + // content transform; Icon Grid scrolls its camera so retained Text transforms remain stable. + scene.position.set(0, 0, 0); camera = nextCamera; + if (next.workload === 'icon-grid' && nextIconGridInstance !== undefined) { + applyIconGridCamera(camera, nextIconGridInstance.view()); + } animationEpoch = performance.now(); zoomAnimationState.phraseIndex = 0; zoomAnimationState.phraseRevision = 0; @@ -677,6 +680,7 @@ async function createComparisonWorkloadRuntime( }); if (next.workload === 'icon-grid') { iconGridInstance?.settle(next, { height, width }, scene); + if (iconGridInstance !== undefined) applyIconGridCamera(camera, iconGridInstance.view()); } } catch (error) { if (reuseBatchRoot) { @@ -733,6 +737,7 @@ async function createComparisonWorkloadRuntime( ) { if (iconGridInstance === undefined) throw new Error('icon grid retained update lost its workload instance'); await iconGridInstance.reconfigure(configuration, next, { height, width }, scene); + applyIconGridCamera(camera, iconGridInstance.view()); configuration = next; committedContentWidth = undefined; revision += 1; @@ -858,6 +863,7 @@ async function createComparisonWorkloadRuntime( animationRate(configuration), onError, ); + if (iconGridInstance !== undefined) applyIconGridCamera(camera, iconGridInstance.view()); } if ( renderScene && @@ -910,7 +916,11 @@ async function createComparisonWorkloadRuntime( const activeZoomEntry = configuration.workload === 'zoom-text' ? entries[zoomAnimationState.phraseIndex] : undefined; const zoomScale = activeZoomEntry?.node.scale.x ?? 1; - measureVisibleEntries(entries, batchRoot, zoomScale, visibleEntryMetrics, visibleGeometryScratch); + if (configuration.workload === 'icon-grid') { + measureIconGridRenderMetrics(entries, batchRoot, visibleEntryMetrics, visibleGeometryScratch); + } else { + 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; @@ -1057,7 +1067,10 @@ async function createComparisonWorkloadRuntime( panBy(deltaX, deltaY) { if (closing || disposed) return; if (configuration.workload === 'icon-grid') { - return iconGridInstance?.panBy(configuration, { height, width }, scene, deltaX, deltaY, onError); + if (iconGridInstance === undefined) return; + const applied = iconGridInstance.panBy(configuration, { height, width }, scene, deltaX, deltaY, onError); + applyIconGridCamera(camera, iconGridInstance.view()); + return applied; } const horizontal = finite(deltaX, 'workload horizontal pan'); const vertical = finite(deltaY, 'workload vertical pan'); @@ -1067,6 +1080,7 @@ async function createComparisonWorkloadRuntime( resetView() { if (configuration.workload === 'icon-grid') { iconGridInstance?.resetView(configuration, { height, width }, scene, onError); + if (iconGridInstance !== undefined) applyIconGridCamera(camera, iconGridInstance.view()); } else { scene.position.set(0, 0, 0); } @@ -1616,6 +1630,27 @@ function measureVisibleEntries( } } +function measureIconGridRenderMetrics( + entries: readonly WorkloadEntry[], + batchRoot: THREE.Object3D, + metrics: MutableVisibleEntryMetrics, + geometries: Set, +): void { + metrics.drawCount = 0; + metrics.glyphCount = 0; + metrics.layoutHeight = 0; + metrics.layoutWidth = 0; + metrics.lineCount = 0; + metrics.missingGlyphCount = 0; + metrics.sourceTextLength = 0; + geometries.clear(); + measureVisibleObject(batchRoot, metrics, geometries); + for (const entry of entries) { + if (!entry.node.visible) continue; + metrics.sourceTextLength += entry.sourceText.length; + } +} + function measureVisibleObject( object: THREE.Object3D, metrics: MutableVisibleEntryMetrics, @@ -1669,6 +1704,17 @@ function createWorkloadCamera( return camera; } +function applyIconGridCamera( + camera: THREE.OrthographicCamera | THREE.PerspectiveCamera, + view: { readonly scrollX: number; readonly scrollY: number }, +): void { + if (!(camera instanceof THREE.OrthographicCamera)) { + throw new TypeError('icon grid requires an orthographic camera'); + } + camera.position.x = view.scrollX; + camera.position.y = -view.scrollY; +} + function resizeWorkloadCamera( camera: THREE.OrthographicCamera | THREE.PerspectiveCamera, width: number, diff --git a/apps/benchmarks/src/workloads/icon-grid/scene.ts b/apps/benchmarks/src/workloads/icon-grid/scene.ts index f6072693..665d1ff8 100644 --- a/apps/benchmarks/src/workloads/icon-grid/scene.ts +++ b/apps/benchmarks/src/workloads/icon-grid/scene.ts @@ -5,7 +5,6 @@ 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 { - committedTextMetrics, exactWidth, paintColor, publishWorkloadTexts, @@ -28,6 +27,8 @@ const ICON_GRID_MAX_FRAME_DELTA_MULTIPLIER = 2; const ICON_GRID_FONT_UNITS_PER_EM = 512; const ICON_GRID_MAX_ADVANCE = 640; const ICON_GRID_MAX_ADVANCE_EM = ICON_GRID_MAX_ADVANCE / ICON_GRID_FONT_UNITS_PER_EM; +const iconGridTimingsEnabled = + typeof location !== 'undefined' && new URLSearchParams(location.search).get('textTimings') === '1'; export const ICON_GRID_ITEMS = fontAwesomeIcons.icons; const ICON_GRID_CONTENT = ICON_GRID_ITEMS.map((icon) => { const glyph = String.fromCodePoint(icon.codePoint); @@ -160,13 +161,14 @@ export function positionIconGridEntry( row: number, iconSize: number, ): void { - const iconLayout = committedTextMetrics(entry.text); entry.node.position.set( layout.inset + column * (layout.cellWidth + layout.gap), -(layout.inset + row * (layout.cellHeight + layout.gap)), 0, ); - entry.text.position.set((layout.cellWidth - iconLayout.width) / 2, 0, 0); + // Keep virtualized scrolling independent from synchronous layout queries. Font Awesome's authored icons target a + // one-em visual cell, so center that nominal cell while the wider grid cell preserves its documented 1.25-em max. + entry.text.position.set((layout.cellWidth - iconSize) / 2, 0, 0); entry.labelText?.position.set( (layout.cellWidth - ICON_GRID_LABEL_WIDTH) / 2, -(iconSize * LIVE_TEXT_LINE_HEIGHT + ICON_GRID_LABEL_GAP), @@ -340,6 +342,7 @@ export interface IconGridWorkloadInstance { ): void; settle(configuration: ComparisonWorkloadConfiguration, viewport: IconGridViewport, scene: THREE.Scene): void; suspend(): void; + view(): { readonly scrollX: number; readonly scrollY: number }; } export function createIconGridWorkloadInstance( @@ -437,14 +440,13 @@ class RetainedIconGridWorkload implements IconGridWorkloadInstance { if (!configuration.animationEnabled || window === undefined) return; advanceIconGridAutoPan( this.#autoPan, - -scene.position.x, - scene.position.y, + this.#autoPan.scrollX, + this.#autoPan.scrollY, window.maximumScrollX, window.maximumScrollY, smoothIconGridFrameDelta(this.#frameDelta, elapsedMs), ICON_GRID_AUTO_PAN_PX_PER_SECOND * animationRate, ); - scene.position.set(-this.#autoPan.scrollX, this.#autoPan.scrollY, 0); updateIconGridEntryVisibility( this.#pool.entries(), window.layout, @@ -470,8 +472,8 @@ class RetainedIconGridWorkload implements IconGridWorkloadInstance { this.#iconSize, viewport.width, viewport.height, - -scene.position.x, - scene.position.y, + this.#autoPan.scrollX, + this.#autoPan.scrollY, ); let assignedCount = 0; let renderVisibleCount = 0; @@ -511,13 +513,13 @@ class RetainedIconGridWorkload implements IconGridWorkloadInstance { this.#assertLive(); const horizontal = finite(deltaX, 'workload horizontal pan'); const vertical = finite(deltaY, 'workload vertical pan'); - const previousX = scene.position.x; - const previousY = scene.position.y; - scene.position.x += horizontal; - scene.position.y -= vertical; - this.#clampScene(configuration.fontSize, viewport, scene); + const previousX = this.#autoPan.scrollX; + const previousY = this.#autoPan.scrollY; + this.#autoPan.scrollX -= horizontal; + this.#autoPan.scrollY += vertical; + this.#clampScroll(configuration.fontSize, viewport); this.requestRefresh(configuration, viewport, scene, onError); - return { deltaX: scene.position.x - previousX, deltaY: previousY - scene.position.y }; + return { deltaX: previousX - this.#autoPan.scrollX, deltaY: this.#autoPan.scrollY - previousY }; } async reconfigure( @@ -533,15 +535,15 @@ class RetainedIconGridWorkload implements IconGridWorkloadInstance { nextAutoPan !== undefined ? [nextAutoPan.scrollX, nextAutoPan.scrollY] : next.fontSize === previous.fontSize - ? [-scene.position.x, scene.position.y] + ? [this.#autoPan.scrollX, this.#autoPan.scrollY] : iconGridCenteredScroll( ICON_GRID_ITEMS.length, previous.fontSize, next.fontSize, viewport.width, viewport.height, - -scene.position.x, - scene.position.y, + this.#autoPan.scrollX, + this.#autoPan.scrollY, ); const nextWindow = iconGridVirtualWindow( ICON_GRID_ITEMS.length, @@ -559,7 +561,8 @@ class RetainedIconGridWorkload implements IconGridWorkloadInstance { this.#frameDelta.smoothedElapsedMs = undefined; } this.#iconSize = next.fontSize; - scene.position.set(-nextWindow.scrollX, nextWindow.scrollY, 0); + this.#autoPan.scrollX = nextWindow.scrollX; + this.#autoPan.scrollY = nextWindow.scrollY; this.#applyWindow(nextWindow, next.fontSize, viewport, scene); } @@ -570,8 +573,8 @@ class RetainedIconGridWorkload implements IconGridWorkloadInstance { onError: (error: unknown) => void, ): void { if (!this.#isLive()) return; - this.#requestScrollX = -scene.position.x; - this.#requestScrollY = scene.position.y; + this.#requestScrollX = this.#autoPan.scrollX; + this.#requestScrollY = this.#autoPan.scrollY; if (this.#suspended) { this.#refreshDeferred = true; return; @@ -615,7 +618,6 @@ class RetainedIconGridWorkload implements IconGridWorkloadInstance { this.#autoPan.scrollY = 0; this.#autoPanTimestamp = undefined; this.#frameDelta.smoothedElapsedMs = undefined; - scene.position.set(0, 0, 0); this.requestRefresh(configuration, viewport, scene, onError); } @@ -638,6 +640,10 @@ class RetainedIconGridWorkload implements IconGridWorkloadInstance { this.#refreshDeferred = true; } + view(): { readonly scrollX: number; readonly scrollY: number } { + return { scrollX: this.#autoPan.scrollX, scrollY: this.#autoPan.scrollY }; + } + settle(configuration: ComparisonWorkloadConfiguration, viewport: IconGridViewport, scene: THREE.Scene): void { this.#iconSize = configuration.fontSize; this.#settleWindow( @@ -646,8 +652,8 @@ class RetainedIconGridWorkload implements IconGridWorkloadInstance { configuration.fontSize, viewport.width, viewport.height, - -scene.position.x, - scene.position.y, + this.#autoPan.scrollX, + this.#autoPan.scrollY, ), viewport, scene, @@ -655,6 +661,7 @@ class RetainedIconGridWorkload implements IconGridWorkloadInstance { } #applyWindow(window: IconGridVirtualWindow, iconSize: number, viewport: IconGridViewport, scene: THREE.Scene): void { + const updateStarted = iconGridTimingsEnabled ? performance.now() : 0; const entries = this.#pool.entries(); if (window.poolCapacity !== entries.length) { throw new Error('icon grid pool capacity changed without a scene rebuild'); @@ -694,9 +701,7 @@ class RetainedIconGridWorkload implements IconGridWorkloadInstance { entry.labelText?.set({ text: iconGridLabel(iconIndex) }); this.#pendingEntries.push(entry); } - // Recycled tiles share the workload's batch, so one publication commits every reassigned Text at once. - publishWorkloadTexts(scene, this.#pendingEntries); - if (!this.#isLive()) return; + const updateAndPositionStarted = iconGridTimingsEnabled ? performance.now() : 0; for (const [poolIndex, entry] of this.#pendingEntries.entries()) { if (entry.disposed) continue; const iconIndex = this.#missingIndices[poolIndex]!; @@ -707,6 +712,17 @@ class RetainedIconGridWorkload implements IconGridWorkloadInstance { const row = Math.floor(iconIndex / window.layout.columns); positionIconGridEntry(entry, window.layout, column, row, iconSize); } + if (iconGridTimingsEnabled) { + performance.measure('@pmndrs/benchmark icon-grid.update-and-position', { start: updateAndPositionStarted }); + } + if (!this.#isLive()) return; + const publishStarted = iconGridTimingsEnabled ? performance.now() : 0; + // The nominal icon-cell alignment above needs no synchronous layout query. Publish every staged string and its + // already-final transform through one retained update. + publishWorkloadTexts(scene, this.#pendingEntries); + if (iconGridTimingsEnabled) { + performance.measure('@pmndrs/benchmark icon-grid.publish', { start: publishStarted }); + } for (let index = this.#missingIndices.length; index < this.#availableEntries.length; index += 1) { const entry = this.#availableEntries[index]!; entry.node.visible = false; @@ -714,18 +730,19 @@ class RetainedIconGridWorkload implements IconGridWorkloadInstance { } this.#recycleCount += this.#missingIndices.length; this.#settleWindow(window, viewport, scene); + if (iconGridTimingsEnabled) performance.measure('@pmndrs/benchmark icon-grid.update', { start: updateStarted }); } #assertLive(): void { if (!this.#isLive()) throw new DOMException('The Icon Grid workload instance is disposed', 'AbortError'); } - #clampScene(iconSize: number, viewport: IconGridViewport, scene: THREE.Scene): void { + #clampScroll(iconSize: number, viewport: IconGridViewport): void { const layout = iconGridLayout(ICON_GRID_ITEMS.length, iconSize, viewport.width); const maximumScrollX = Math.max(0, layout.width - viewport.width); const maximumScrollY = Math.max(0, layout.height - viewport.height); - scene.position.x = Math.min(0, Math.max(-maximumScrollX, scene.position.x)); - scene.position.y = Math.min(maximumScrollY, Math.max(0, scene.position.y)); + this.#autoPan.scrollX = Math.min(maximumScrollX, Math.max(0, this.#autoPan.scrollX)); + this.#autoPan.scrollY = Math.min(maximumScrollY, Math.max(0, this.#autoPan.scrollY)); } #isLive(): boolean { @@ -740,7 +757,13 @@ class RetainedIconGridWorkload implements IconGridWorkloadInstance { ) { throw new Error('icon grid cannot publish a window before every assignment is coherent'); } - updateIconGridEntryVisibility(this.#pool.entries(), window.layout, -scene.position.x, scene.position.y, viewport); + updateIconGridEntryVisibility( + this.#pool.entries(), + window.layout, + this.#autoPan.scrollX, + this.#autoPan.scrollY, + viewport, + ); this.#settledWindow = window; this.#assignmentSignature = JSON.stringify(assignments); this.#windowRevision += 1; @@ -895,10 +918,7 @@ export function iconGridLayout(itemCount: number, iconSize: number, viewportWidt } positive(iconSize, 'icon grid icon size'); positive(viewportWidth, 'icon grid viewport width'); - const cellWidth = Math.max( - ICON_GRID_MIN_CELL_WIDTH, - iconSize * ICON_GRID_MAX_ADVANCE_EM + ICON_GRID_ICON_PADDING * 2, - ); + const cellWidth = iconGridCellWidth(iconSize); const cellHeight = (iconSize + ICON_GRID_LABEL_SIZE) * LIVE_TEXT_LINE_HEIGHT + ICON_GRID_LABEL_GAP; const columns = Math.ceil(Math.sqrt(itemCount)); const rows = Math.ceil(itemCount / columns); @@ -914,6 +934,10 @@ export function iconGridLayout(itemCount: number, iconSize: number, viewportWidt }; } +function iconGridCellWidth(iconSize: number): number { + return Math.max(ICON_GRID_MIN_CELL_WIDTH, iconSize * ICON_GRID_MAX_ADVANCE_EM + ICON_GRID_ICON_PADDING * 2); +} + export function iconGridVirtualWindow( itemCount: number, iconSize: number, diff --git a/apps/benchmarks/vitest.global-setup.ts b/apps/benchmarks/vitest.global-setup.ts index 2eca1b93..42f3defd 100644 --- a/apps/benchmarks/vitest.global-setup.ts +++ b/apps/benchmarks/vitest.global-setup.ts @@ -9,7 +9,7 @@ export default async function prepareBenchmarkUnitTests(): Promise { await executeFile('cargo', [ 'build', '--manifest-path', - fileURLToPath(new URL('../../packages/font-baker/rust/Cargo.toml', import.meta.url)), + fileURLToPath(new URL('../../packages/text/rust/font-baker/Cargo.toml', import.meta.url)), '--bin', 'inspect-font-fixture', '--features', diff --git a/apps/r3f-hello-world/README.md b/apps/r3f-hello-world/README.md index 08d5d444..c1215fce 100644 --- a/apps/r3f-hello-world/README.md +++ b/apps/r3f-hello-world/README.md @@ -2,6 +2,11 @@ This is the smallest product-shaped `@pmndrs/text` example in the workspace. It renders `Hello world` through the public React Three Fiber API, resolves the globe from a Font Awesome fallback font, and switches between Bitmap, MSDF, and Slug using controls rendered inside the canvas. +The complete scene lives in `src/app.tsx`. Its local technique state reveals one of three pre-rendered React `Activity` +branches; each branch contains a `TextGroup` and `Text` whose font stack carries the technique binding. The UI controls +use their own `TextGroup` so their labels batch explicitly. The controls are centered across the top of the canvas and +the world copy is centered in the viewport. + ```sh mise exec -- pnpm --filter @pmndrs/text-r3f-hello-world dev ``` @@ -11,9 +16,17 @@ The app uses React 19, the React Compiler, the WebGPU R3F entry point, and Three - `inter-latin.font.glb` is a true Basic Latin source subset (`U+0020–U+007E`). - `font-awesome-world.font.glb` contains only six globe/earth variants. -Regeneration requires exactly HarfBuzz 14.2.0. The check performs fresh source subsets and complete Bitmap/MSDF/Slug bakes, then requires byte-identical GLBs and manifest hashes. +Both checked assets are produced directly through the published CLI through `pnpm exec text bake`, with `--input`, +`--output`, `--unicodes`, `--bitmap`, `--msdf`, and `--slug`. Unicode subsetting uses the package-owned baker Wasm; +no platform font binary is required. The check +uses the same commands with `--check`, which rebuilds into temporary storage and requires a byte-identical GLB without +rewriting the checked asset. ```sh -mise exec -- pnpm --filter @pmndrs/text-r3f-hello-world assets:check +mise exec -- pnpm --filter @pmndrs/text-r3f-hello-world bake +mise exec -- pnpm --filter @pmndrs/text-r3f-hello-world bake:check mise exec -- pnpm --filter @pmndrs/text-r3f-hello-world check ``` + +`bake:inter` and `bake:icons` regenerate one asset each; `bake:check:inter` and `bake:check:icons` verify them +independently. Each font-specific command still embeds all three raster techniques in one GLB. diff --git a/apps/r3f-hello-world/assets/font-awesome-world.font.glb b/apps/r3f-hello-world/assets/font-awesome-world.font.glb index 006f08d6..428a862d 100644 Binary files a/apps/r3f-hello-world/assets/font-awesome-world.font.glb and b/apps/r3f-hello-world/assets/font-awesome-world.font.glb differ diff --git a/apps/r3f-hello-world/assets/inter-latin.font.glb b/apps/r3f-hello-world/assets/inter-latin.font.glb index ec5e03a6..fe616122 100644 Binary files a/apps/r3f-hello-world/assets/inter-latin.font.glb and b/apps/r3f-hello-world/assets/inter-latin.font.glb differ diff --git a/apps/r3f-hello-world/assets/manifest.json b/apps/r3f-hello-world/assets/manifest.json deleted file mode 100644 index 3284f896..00000000 --- a/apps/r3f-hello-world/assets/manifest.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "schemaVersion": 0, - "harfBuzzVersion": "14.2.0", - "assets": [ - { - "asset": "inter-latin.font.glb", - "bytes": 2356064, - "sha256": "fc41c275008a3ad10fc3bd2c4b429eb3ce41274016f726c80fbf556510bab31f", - "source": "apps/benchmarks/fixtures/fonts/inter-v4.1/Inter-Regular.ttf", - "unicodes": "U+0020-007E", - "outputs": [ - { - "role": "font", - "bytes": 2356064, - "sha256": "fc41c275008a3ad10fc3bd2c4b429eb3ce41274016f726c80fbf556510bab31f" - } - ] - }, - { - "asset": "font-awesome-world.font.glb", - "bytes": 184948, - "sha256": "d439d85fba5852b47f3638b36a33a81535f5620ca8d3fd01edddba6cd114bea5", - "source": "apps/benchmarks/fixtures/fonts/font-awesome-free-6.7.2/fa-solid-900.ttf", - "unicodes": "U+E47B,U+F0AC,U+F57C,U+F57D,U+F57E,U+F7A2", - "outputs": [ - { - "role": "font", - "bytes": 184948, - "sha256": "d439d85fba5852b47f3638b36a33a81535f5620ca8d3fd01edddba6cd114bea5" - } - ] - } - ] -} diff --git a/apps/r3f-hello-world/package.json b/apps/r3f-hello-world/package.json index 89ac0f2f..623fab8a 100644 --- a/apps/r3f-hello-world/package.json +++ b/apps/r3f-hello-world/package.json @@ -4,10 +4,14 @@ "private": true, "type": "module", "scripts": { - "assets:generate": "node ./scripts/generate-fonts.mts", - "assets:check": "node ./scripts/generate-fonts.mts --check", + "bake": "pnpm bake:inter && pnpm bake:icons", + "bake:inter": "pnpm exec text bake --input ../benchmarks/fixtures/fonts/inter-v4.1/Inter-Regular.ttf --output ./assets/inter-latin.font.glb --unicodes U+0020-007E --bitmap 32 --msdf --slug", + "bake:icons": "pnpm exec text bake --input ../benchmarks/fixtures/fonts/font-awesome-free-6.7.2/fa-solid-900.ttf --output ./assets/font-awesome-world.font.glb --unicodes U+E47B,U+F0AC,U+F57C,U+F57D,U+F57E,U+F7A2 --bitmap 32 --msdf --slug", + "bake:check": "pnpm bake:check:inter && pnpm bake:check:icons", + "bake:check:inter": "pnpm bake:inter --check", + "bake:check:icons": "pnpm bake:icons --check", "build": "vite build", - "check": "pnpm typecheck && pnpm lint && pnpm format:check && pnpm assets:check && pnpm build && pnpm live:check", + "check": "pnpm typecheck && pnpm lint && pnpm format:check && pnpm bake:check && pnpm build && pnpm live:check", "dev": "vite", "format:check": "oxfmt --check .", "lint": "oxlint --deny-warnings .", diff --git a/apps/r3f-hello-world/scripts/generate-fonts.mts b/apps/r3f-hello-world/scripts/generate-fonts.mts deleted file mode 100644 index b8e11b1b..00000000 --- a/apps/r3f-hello-world/scripts/generate-fonts.mts +++ /dev/null @@ -1,104 +0,0 @@ -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 index 9dfd82a2..90e0fee3 100644 --- a/apps/r3f-hello-world/scripts/live-check.probe.ts +++ b/apps/r3f-hello-world/scripts/live-check.probe.ts @@ -1,17 +1,19 @@ export {}; +const { _roots } = await import('@react-three/fiber/webgpu'); + const canvas = await waitForCanvas(); -for (const [technique, clientX] of [ - ['msdf', 224], - ['bitmap', 96], - ['slug', 352], +for (const [technique, centerOffset] of [ + ['msdf', 0], + ['bitmap', -128], + ['slug', 128], ] as const) { - if (canvas.dataset.exampleTechnique !== technique) clickCanvas(canvas, clientX, 200); - await waitForTechnique(canvas, technique); - if (canvas.dataset.exampleDraws !== '2' || canvas.dataset.exampleRecords !== '11') { + clickCanvas(canvas, canvas.getBoundingClientRect().width / 2 + centerOffset, 48); + const counts = await waitForTechnique(canvas, technique); + if (counts.draws !== 2 || counts.records !== 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`, + `${technique} rendered ${String(counts.records)} records in ` + + `${String(counts.draws)} draws; expected 11 records in two Rust-planned draws`, ); } } @@ -27,9 +29,27 @@ async function waitForCanvas(): Promise { throw new Error('R3F hello-world did not create a canvas'); } -async function waitForTechnique(targetCanvas: HTMLCanvasElement, technique: string): Promise { +async function waitForTechnique( + targetCanvas: HTMLCanvasElement, + technique: string, +): Promise<{ readonly draws: number; readonly records: number }> { for (let frame = 0; frame < 600; frame += 1) { - if (targetCanvas.dataset.exampleTechnique === technique && targetCanvas.dataset.exampleReady === 'true') return; + const root = _roots.get(targetCanvas); + const scene = root?.store.getState().scene; + const worldLayer = scene?.getObjectByName('world-text'); + let draws = 0; + let records = 0; + worldLayer?.traverse((object) => { + if (object.userData.pmndrsTextRunStart === undefined || !('geometry' in object)) return; + const geometry = object.geometry; + if (typeof geometry !== 'object' || geometry === null || !('instanceCount' in geometry)) return; + const instanceCount = geometry.instanceCount; + if (typeof instanceCount !== 'number') return; + draws += 1; + records += instanceCount; + }); + const selected = worldLayer?.getObjectByName(`world-${technique}`); + if (selected?.visible === true && draws === 6 && records === 33) return { draws: draws / 3, records: records / 3 }; await nextFrame(); } throw new Error(`R3F hello-world did not settle the ${technique} technique`); diff --git a/apps/r3f-hello-world/src/app.tsx b/apps/r3f-hello-world/src/app.tsx index 027a0ddd..8e347f83 100644 --- a/apps/r3f-hello-world/src/app.tsx +++ b/apps/r3f-hello-world/src/app.tsx @@ -1,22 +1,141 @@ -import { Canvas } from '@react-three/fiber/webgpu'; -import { Suspense, useState } from 'react'; +import { createFontStack, type LoadedFont } from '@pmndrs/text'; +import { Text, TextGroup, useFont } from '@pmndrs/text/react'; +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 { Activity, useMemo, useState } from 'react'; +import { float, fwidth, smoothstep, uv, vec2 } from 'three/tsl'; -import { TechniqueScene, type Technique } from './technique-scene'; +import iconFontUrl from '../assets/font-awesome-world.font.glb?url'; +import latinFontUrl from '../assets/inter-latin.font.glb?url'; + +type Technique = 'bitmap' | 'msdf' | 'slug'; + +const WORLD_ICON = '\uf0ac'; +const techniques = ['bitmap', 'msdf', 'slug'] as const; +const button = { gap: 16, height: 44, labelSize: 16, topInset: 48, width: 112 } as const; + +const latinRequest = { + input: { baked: latinFontUrl }, + rasters: [{ technique: bitmap, options: { strikes: [32] } }, { technique: msdf }, { technique: slug }], +} as const; + +const iconRequest = { + input: { baked: iconFontUrl }, + rasters: [{ technique: bitmap, options: { strikes: [32] } }, { technique: msdf }, { technique: slug }], +} as const; export function App() { + const viewport = useThree((state) => state.viewport); const [technique, setTechnique] = useState('msdf'); + const [bitmapLatin, msdfLatin, slugLatin] = useFont(latinRequest); + const [bitmapIcons, msdfIcons, slugIcons] = useFont(iconRequest); + const fonts = useMemo( + () => ({ + bitmap: createFontStack(bitmapLatin, bitmapIcons), + msdf: createFontStack(msdfLatin, msdfIcons), + slug: createFontStack(slugLatin, slugIcons), + }), + [bitmapIcons, bitmapLatin, msdfIcons, msdfLatin, slugIcons, slugLatin], + ); + return ( + <> + + {techniques.map((worldTechnique) => ( + + + + Hello world {WORLD_ICON} + + + + ))} + + + {techniques.map((buttonTechnique, index) => ( +
} - flat - orthographic - > - - - - - + + ) => { + event.stopPropagation(); + onClick(); + }} + onPointerEnter={() => { + setHovered(true); + document.body.style.cursor = 'pointer'; + }} + onPointerLeave={() => { + setHovered(false); + document.body.style.cursor = 'default'; + }} + position={[0, 0, -1]} + > + + + + + {technique.toUpperCase()} + + ); } + +function pillNode(width: number, height: number) { + const point = uv().sub(0.5).mul(vec2(width, height)); + const distance = vec2( + point.x + .abs() + .sub((width - height) / 2) + .max(0), + point.y, + ) + .length() + .sub(height / 2); + const edge = fwidth(distance); + return float(1).sub(smoothstep(edge.negate(), edge, distance)); +} diff --git a/apps/r3f-hello-world/src/main.tsx b/apps/r3f-hello-world/src/main.tsx index bc5968d1..9e04b9c8 100644 --- a/apps/r3f-hello-world/src/main.tsx +++ b/apps/r3f-hello-world/src/main.tsx @@ -1,4 +1,6 @@ +import { Canvas } from '@react-three/fiber/webgpu'; import { StrictMode } from 'react'; +import { Suspense } from 'react'; import { createRoot } from 'react-dom/client'; import shaperWasmUrl from '@pmndrs/text/text-shaper.wasm?url'; @@ -18,6 +20,16 @@ document.head.append(shaperPreload); createRoot(root).render( - + WebGPU or WebGL2 is required.} + flat + orthographic + > + + + + + , ); diff --git a/apps/r3f-hello-world/src/technique-scene.tsx b/apps/r3f-hello-world/src/technique-scene.tsx deleted file mode 100644 index f9192ed4..00000000 --- a/apps/r3f-hello-world/src/technique-scene.tsx +++ /dev/null @@ -1,177 +0,0 @@ -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/docs/engineering/code-style.md b/docs/engineering/code-style.md index bea05ca5..4f664f9f 100644 --- a/docs/engineering/code-style.md +++ b/docs/engineering/code-style.md @@ -9,10 +9,10 @@ sources: resource: ../../.agents/skills/maintainability-review/SKILL.md title: Maintainability review workflow - id: font-baker-wasm - resource: ../../packages/font-baker/rust/src/wasm.rs + resource: ../../packages/text/rust/font-baker/src/wasm.rs title: Portable font-baker Wasm boundary - id: font-baker-typescript - resource: ../../packages/font-baker/src/index.ts + resource: ../../packages/text/src/font-baker/index.ts title: Portable font-baker TypeScript boundary - id: runtime-protocol resource: ../../packages/text/src/internal/runtime-bake-protocol.ts diff --git a/docs/log.md b/docs/log.md index 3c2e7716..8ca2c603 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,71 @@ ## 2026-08-10 +- **Standardized the React entry and polished the R3F example** — Renamed the sole declarative package entry from the + stale `@pmndrs/text/r3f` path to the originally specified `@pmndrs/text/react` path; React Three Fiber remains the + internal host reconciler and an optional peer rather than part of the public name. The hello-world controls now use a + local `Button` component with a memoized TSL capsule-distance node over plane geometry, active and hover colors, and + Inter uppercase labels with tracked spacing. Each label is vertically centered by a shaped 44-unit line box whose + extra leading is divided around the font metrics, rather than by an arbitrary visual offset. A clean WebGPU browser + run switches Bitmap, MSDF, and Slug without shader errors or warnings. + +- **Removed redundant warm transform scans** — `TextGroup` now consumes Three's completed scene traversal and tracks + transform changes below the shared draw root. Camera or group motion leaves indexed transform storage untouched; + actual text, nested-parent, visibility, reparenting, and manual-matrix changes patch only their paragraph IDs. A + compiled-Wasm integration regression proves shared-root motion performs zero forced per-text world updates and no GPU + attribute version change, while a direct child move still updates its retained slot. + +- **Stabilized retained Icon Grid recycling** — Corrected the engine host's aggregate/per-paragraph limit split so 684 + paragraphs no longer each reserve line scratch for the entire batch. A deterministic 200-cycle regression replaces + the former 17-update, 4.29 GB status-7 failure. Icon Grid now scrolls through its camera, avoids layout queries in + renderer telemetry and recycling, publishes each recycled window once, and leaves Bitmap pixel snapping opt-in. + Clean Chrome samples on the 120 Hz development display held roughly 116–120 FPS across Bitmap, MSDF, and Slug. + +- **Restored retained paragraph scaling and Bitmap CPU batching** — Metric-only style mutations now refresh shaping-run + typography before rebuilding cluster advances while retaining the HarfRust glyph result. Optimized-Wasm tests prove a + 2× font-size change produces a 2× inline advance. Bitmap strikes now bind all atlas pages as one texture array with a + per-glyph layer lane, collapsing multi-page prose to one ordered draw. Clean Chrome Paragraph Stress verification kept + Bitmap, MSDF, and Slug correctly positioned at 96 px and during animated intermediate sizes; Bitmap CPU sampled at + 0.47–1.3 ms instead of the reproduced roughly 80 ms failure, while GPU remained independently around 1–5 ms. + +- **Centered and simplified the R3F hello-world scene** — The public `Text` component now infers a runtime-selected + Bitmap/MSDF/Slug font-stack union without `Text`. The example removes its probe-only effect, ref, + frame callback, canvas attributes, redundant button-row group, and unnecessary independent-compositing declarations. + `Text` and `TextGroup` now construct through R3F host commits, so three React `Activity` branches pre-render complete + hidden technique layers instead of initializing them after the first click. Vitexec reads named R3F layers directly, + proves all six hidden planned meshes exist before switching, and verifies 2 draws / 11 visible records per revealed + technique. The controls form a centered top row and the world copy remains centered in the viewport. The benchmark's + exact React reconciliation target retains hash `bb15bbcc`, natural/narrow layout oracles, object identity, span paints, + and submitted draws; its explicit teardown releases the live paragraph before its target-owned font because R3F host + disposal runs at idle priority. The example's root `bake` and `bake:check` scripts now compose separate Inter and icon + commands, keeping each multi-technique GLB independently regenerable and verifiable. Two independent current bakes + reproduced each asset exactly. The icon migration changes only generator/source-provenance metadata; every binary view + is unchanged. Inter's extents and Bitmap/MSDF/Slug views are unchanged, while the package-owned subsetter serializes + same-length `GPOS`/`GSUB` tables differently; a full Basic Latin plus ligature/kerning stress pass produced identical + glyphs, clusters, positions, advances, lines, and measurements through both artifacts. + +- **Removed product HarfBuzz subprocesses** — `text glyphs`, `text bake --unicodes`, and programmatic + `@pmndrs/text/bake` now use the packaged Fontations/Skera baker Wasm. One normalized prepared source feeds core + shaping and every requested raster technique; HarfBuzz remains internal test-oracle tooling only. + +- **Package-owned font preparation** — Added generated `prepare` and `inspect` Wasm exports backed by Skera and Skrifa. The optional baker alone enables `std`; the same Rust source still passes its `wasm32 --no-default-features` compatibility build, and an ASCII subset is inspected and rebaked through the packaged direct-memory bridge. Measured `opt-level = "z"` plus Binaryen `-Oz` wins for this graph at 1,097,710 raw / 391,557 gzip bytes. + +- **Single-package bake ownership** — Folded the portable font-baker Rust/Wasm source, TypeScript bridge, validator, schemas, tests, and build tooling into `@pmndrs/text`. `@pmndrs/text/bake` is now the sole programmatic product surface, while package-boundary tests prove the ordinary root import retains no eager edge to baker Wasm, `std`-enabled subsetting dependencies, Ajv, or glTF Validator. + +- **Made multi-technique fonts one authored load and one CLI bake** — Direct `text bake` arguments now accept a + known input/output, shaping-font Unicode subsetting, Bitmap strikes, MSDF, Slug, and byte-exact check mode. The R3F + hello-world example deletes its custom baker script and invokes only that published CLI for both checked assets. Its + runtime surface now declares the three raster requests once per GLB and receives a position-preserving typed tuple; + the artifact is fetched and registered once while each technique retains its exact option and decoded-data type. The + package now exposes one `text` executable with command-specific help and version output; `text glyphs` surfaces real + font glyph names as JSON or a bake-ready Unicode set while omitting synthetic `gidN` labels. + +- **Simplified publishing-size evidence** — The benchmark UI and Size Limit pull-request comment now share one strict + nine-row projection: gzip for Core JS, Shaper Wasm, Three.js adapter JS, and Inter plus Font Awesome across Bitmap, + MTSDF, and Slug. Confusing arithmetic runtime/delivery totals and alternate compression columns no longer enter the + human report. The canonical record retains detailed independent measurements and budgets used by internal telemetry + and regression checks; Three.js, React, and R3F remain external peers. + - **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 diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index 1603874e..17164ce4 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:4e29ddcc1d3eab0c460a7243984bffe40418ce6255d30ea3bc139161e660d50d' +source_digest: 'sha256:717f23a2e506c3340e541e0ff2517cb59f8fec86d286247ddbc01a37d4269162' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest @@ -205,7 +205,7 @@ sources: title: Realtime comparison product probe generated: by: openai-codex/gpt-5.6 - at: '2026-08-10T02:40:43Z' + at: '2026-08-10T22:52:27Z' --- # Package reference: `@pmndrs/text-benchmarks` @@ -370,7 +370,9 @@ without splitting the shared draw or rerunning layout. Two samples per backend r 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. -Icon Grid auto-pan integrates a bounded exponential average of observed frame deltas so a delayed display frame does not become a visually abrupt catch-up jump. This affects motion only: the virtual window still traverses the complete 1,402-glyph catalog, retains its overscanned pool, and requests content reassignment after crossing a complete cell pitch. Its second timed appearance, after Text Ladder, starts at a different catalog position and reverses both axes. Content changes use the generic `Text` update contract and retained raster capacity; catalog glyph/label strings, assignment epochs, recyclable-entry lists, visible-entry metrics, geometry deduplication, font totals, and bitmap atlas-page reports retain caller-owned storage instead of allocating on every frame or recycle. Pool growth remains genuinely cold and detached until ready, then publishes size, view position, and assignments in one continuation. A 20-second WebGPU/Bitmap trace traversed 660 recycled glyph assignments across 31 coherent windows at 60.05 average FPS, 18.60 ms p95, 18.64 ms maximum, and zero frames over 20 ms; 20 minor and five major GC events each remained below 2.6 ms and produced no visible cadence miss. Renderer-wide batching across separate `Text` objects remains outside this milestone. +Icon Grid auto-pan integrates a bounded exponential average of observed frame deltas so a delayed display frame does not become a visually abrupt catch-up jump. This affects motion only: the virtual window still traverses the complete 1,402-glyph catalog, retains its overscanned pool, and requests content reassignment after crossing a complete cell pitch. Its second timed appearance, after Text Ladder, starts at a different catalog position and reverses both axes. The orthographic camera owns scrolling, leaving retained text transforms stable instead of invalidating every world transform through a moving scene root. Recycled strings are staged together and published once; nominal one-em icon-cell alignment avoids a synchronous layout query. Icon Grid telemetry reads draw and glyph counts from realized command-buffer geometry and never calls `measureLayout`, so observing the demo cannot trigger a second full-batch semantic query. Query-gated DevTools measures expose update, publication, and renderer-submit phases without entering customer builds by default. + +Clean Chrome/WebGPU samples on the 120 Hz development display, after a server restart, reported Bitmap at roughly 116 FPS with 9.17 ms p95 and 16.59 ms p99 frame intervals, MSDF at 120 FPS with 9.23/16.65 ms p95/p99, and Slug at 120 FPS with 9.07/9.27 ms p95/p99. Median CPU submit time was 2.67/2.78/1.27 ms and median GPU time 0.85/1.05/3.74 ms for Bitmap/MSDF/Slug. These are host observations rather than CI thresholds. The core 684-paragraph, 200-cycle regression owns the sustained-retention gate that prevents the former status-7 failure and multi-gigabyte line-scratch amplification from returning. Rapid Presentation controls compose against a requested-location revision rather than the last React commit, so a workload selection made while a technique preload is pending retains both requested changes and only the newest preload may commit. Retained Bitmap, MSDF, and Slug controls may arrive before cold scene activation; an activation gate releases them to a latest-value serialized queue, preventing animated text from starving layout completion. Bitmap's exact glyph assertion is committed with the workload instead of leaking from Benchmark Ipsum into Advanced Shaping, and zero-glyph intermediate generations are hidden from GPU submission. @@ -402,9 +404,9 @@ Every measured call receives its actual zero-based sample index; warmups remain GitHub CI uses the Ubuntu runner's rolling system Chromium as a deliberate compatibility canary instead of downloading Playwright's pinned browser. The workflow discovers an executable, prints its version, fails if none exists, and exports only `PMNDRS_TEXT_CHROMIUM_EXECUTABLE_PATH`. One shared launcher conditionally supplies that exact path to all five direct Playwright launch sites; without the variable, local commands retain Playwright's managed executable. Every launch reports `browser.version()` to stderr, and headless benchmark summaries retain that exact value as `browserVersion` beside the browser-provided user-agent string, whose Chromium minor/build components may be reduced. The packed-tarball consumer declares a data-URL favicon so its synthetic document makes no browser-implicit network request; browser error diagnostics retain console source locations and failed HTTP status, resource type, and URL. Historical fixture filenames and goldens remain tied to their recorded captures rather than being relabeled by this rolling lane. -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 canonical package-size lane measures the initial public JavaScript graph, lazy font validator, runtime Worker boundary, baker and shaper JavaScript/Wasm, Unicode 17 analysis, and representative font artifacts without zero-byte placeholders. Static entry closures and dynamic chunks are separated from Rollup metadata rather than conflated; Core JS externalizes the package's declared Three.js, React, and R3F peers, and package-owned Wasm URLs are externalized from JavaScript measurements regardless of their owning package. The detailed record retains its measurement platform, architecture, SHA-256 payload identities, and reviewed raw/minified/gzip/Brotli ceilings because those fields enforce reproducibility and detect internal regressions. The human summary is deliberately smaller: the benchmark UI and Size Limit pull-request comment use one fail-closed projection containing only gzip for Core JS, Shaper Wasm, Three.js adapter JS, Inter plus Font Awesome across Bitmap, MTSDF, and Slug, and each optional validator, runtime-bake, font-baker, and raster-baker JS/Wasm payload. It publishes neither arithmetic runtime/delivery totals nor alternate compression columns. This keeps each displayed number attributable to one emitted payload and avoids presenting external peers or a chosen font combination as a universal application total. The inspector's runtime and resource cards remain separate workload telemetry rather than inputs to the pull-request size summary. -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 current Darwin arm64 record reports a 66,839 minified / 18,659 gzip / 16,177 Brotli peer-externalized browser graph and an independently measured 141,103 / 42,399 / 31,307 Unicode analysis graph. The validator, runtime host, initial runtime Worker, font-baker host, font-baker Wasm, and shaper Wasm report 583,452 minified, 14,291 minified, 613,017 minified, 7,940 minified, 1,097,702 raw, and 1,159,317 raw bytes respectively. Their gzip sizes are 137,404, 5,506, 145,805, 2,471, 391,576, and 442,284 bytes. Bitmap, MTSDF, and Slug baker hosts measure 4,767, 5,516, and 4,111 gzip bytes; their Wasm modules measure 234,736, 215,027, and 186,664 gzip bytes. Static Node project discovery is excluded from the direct font-baker host graph, and dynamic raster modules are excluded from the initial Worker graph because each has its own independently visible row. 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. @@ -513,9 +515,9 @@ The package-size lane measures the item 8.1 MTSDF kernel separately from the cov 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 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 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 the 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-tool identity. The provisioner includes `hb-info` beside `hb-shape` and `hb-subset`, so the shipped `text glyphs` command is tested against the same authenticated font-inspection tool used to discover font-provided names. The checks then require those ignored caches, rebuild their subsets in temporary storage, and compare the font, license, and manifest exactly. Upstream creates the utilities 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, subset, or inspection 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. +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. Teardown explicitly disposes that retained paragraph before its target-owned font because R3F defers ordinary host disposal to idle priority; the later host disposal is idempotent. 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. The initial deterministic browser probe is admitted with a checked-in record: 100 executions across 10 fresh GPU-friendly Chromium/Vite lifecycles, zero retries/failures, unique causal completion identities, and wrong-expectation plus withheld-completion negative controls. Probe exit status and every parsed lifecycle/environment field are validated before publication. Browser scripts navigate only through DOM readiness and then wait on the product's own completion promise or visible state; they do not use network-idle heuristics. Exact contract comparison rejects non-finite numbers, exotic objects, key-order differences, and missing or additional fields without JSON coercion. The current live probe executes the exact TSL graph on asserted WebGPU and forced WebGL2 backends before paragraph measurement, positioned-layout, bidi/policy, CJK, and mobile Playwright flows. This proves a real GPU shader workload while reserving the rendered-font claim for item 6.1. @@ -538,7 +540,7 @@ The size lane is also a package-graph gate. Its consumer builds inspect emitted The V0 autoresearch baseline is a fail-closed control artifact, not an active optimizer. Its generated evidence list authenticates the current package sizes, admitted harness, shaping, paragraph, bidi, CJK, and advanced-shaping conformance records at the exact root toolchain pins. A discriminated campaign state remains `disabled`; tests reject malformed evidence and prove that an enabled manifest cannot cross the campaign guard without a later explicit maintainer decision. -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 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,144-byte artifact and SHA-256 `edf896923f38c9e6080e176540699a7b96b7cd15606b0522447750e7595170b5`. 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 diff --git a/docs/packages/font-baker.md b/docs/packages/font-baker.md deleted file mode 100644 index 727b65a6..00000000 --- a/docs/packages/font-baker.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -type: Workspace Package -title: '@pmndrs/text-font-baker' -description: Implements the internal portable Rust/Wasm shaping-resource bake core and direct-memory TypeScript wrapper. -resource: ../../packages/font-baker -workspace_package: '@pmndrs/text-font-baker' -documentation_type: reference -source_digest: 'sha256:d85b56b06a26945e220248f487e87465027376929238e1a8206a78c5880d1700' -tags: [package, rust, wasm, baking, internal] -sources: - - id: manifest - resource: ../../packages/font-baker/package.json - title: Package manifest - - id: implementation-status - resource: ../planning/font-baker-implementation.md - title: Portable font baker implementation evidence - - id: validator - resource: ../../packages/font-baker/src/validator.ts - title: Core font artifact validator - - id: wasm-url - resource: ../../packages/font-baker/src/wasm-url.ts - title: Canonical optimized Wasm URL - - id: fontations - resource: https://github.com/googlefonts/fontations - title: Fontations -generated: - by: openai-codex/gpt-5.6 - at: '2026-08-04T17:42:34Z' ---- - -# Package reference: `@pmndrs/text-font-baker` - -Status: ✅ portable shaping-data core complete; shared by offline and runtime hosts; Latin, Arabic, and CJK conformance proven - -This package keeps the Rust crate, `no_std + alloc` Wasm build, compiler-derived ABI contract, direct-linear-memory TypeScript wrapper, core artifact validator, vendored schema bundle, and tiered tests together. It emits a deterministic shaping-only core GLB. A build-only Rust generator emits both portable JSON and an exact typed `as const` TypeScript module from the same compiler facts. The generated contract also carries the exact baker, font-format, HarfRust, HarfBuzz, Unicode, glTF schema, validator, and Binaryen pins consumed by provenance and fixtures. Its contract-only subpath exposes the baker and format versions shared by the bridge, validator, and public loader without importing Wasm host code. - -Build and typecheck commands invoke the repository-pinned TypeScript compiler directly. The previous native-process memory guard was removed after the patched `@types/three` declaration graph eliminated the checker expansion; this package does not import TSL but shares the same ordinary workspace compiler path. - -Build-only command capture waits for the producer's stdout stream to close before parsing compiler-derived ABI JSON. Child-process exit alone is not treated as output completion: a causal integration regression keeps inherited stdout open beyond producer exit and proves the complete JSON payload is retained before parsing. - -The separate `@pmndrs/text-font-baker/validate` ESM entry treats every baked asset as untrusted. It enforces exact GLB framing and padding, retains the pinned Khronos 2.0.0-dev.3.10 report with only exact unsupported-extension and extension-buffer informational messages admitted, evaluates the canonical Draft-04 extension schema with Ajv 6.15.0 against the vendored Khronos revision, and checks buffer ranges, versions, reciprocal raster identity, reduced-SFNT checksums/metrics, dense extents, zero padding, and the domain-separated shaping hash. URI-addressed external raster entries require a lowercase SHA-256 artifact hash; resolver-only entries may omit both URI and hash. Its exact Khronos allowlist accepts open package-owned extension names while semantic validation remains with their packages. Closed-profile SFNT tags are compared as their four raw directory bytes, so non-ASCII hostile tags fail through the same structured issue contract instead of escaping through UTF-8 decoding. It exports the strict framing, report, and generic extension-schema primitives used by companion validators without moving companion semantics into core. Node `Buffer` inputs are explicitly copied before the temporary checksum-adjustment normalization, and repeat-validation tests prove the validator never mutates their bytes. The main baker entry has no static edge to either validation engine. - -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 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. - -Font interpretation is library-owned: Fontations `read-fonts` parses SFNT/TTC tables and `skrifa` supplies metrics and glyph bounds.[^fontations] Project code owns the accepted table policy, reduced-SFNT serialization, V0 extent encoding, hashes, reports, ABI, and GLB contract. Provenance requires the selected collection face index alongside the descriptor hash so later runtime raster baking cannot silently fall back to face zero; the schema, validator, loader, and generated artifacts all enforce that single unpublished V0 contract. A source `STAT` table alone is not evidence of variation axes and no longer rejects an otherwise static font; actual axis/delta tables still reject V0 input, and `STAT` remains omitted from the reduced static payload. - -The portable bake path does not run HarfRust, shape Unicode, generate a bitmap, discover application fonts, or provide a filesystem/Worker host. The public Node host now wraps it from `@pmndrs/text/bake`; the Worker and runtime shaper remain separate packages. Its host-only `generate-shaping-oracle` binary uses pinned HarfRust 0.12.0 to produce deterministic UTF-16 fixture JSON and is not linked into the `no_std` Wasm artifact. The oracle-only `inspect-font-fixture` binary uses Fontations rather than a project parser to emit deterministic glyph/table/cmap facts. Mandatory package E2E lanes authenticate Inter 4.1, Amiri 1.002, and Noto Sans CJK JP 2.004 before exercising the compiled Wasm API; none can skip based on the environment. Noto proves the 65,535-glyph boundary, `cmap` formats 12/14, supplementary/variation mappings, exact source/reduced HarfRust and HarfBuzz equality, and exact retention of source `BASE`, `VORG`, `vhea`, and `vmtx` without fabricating absent tables or implementing vertical layout. - -The isolated nightly fuzz workspace also hosts the repository-owned MTSDF outline target because cargo-fuzz remains centralized under one pinned exception. That target depends on the non-shipping admission adapter, not on a product dependency or font parser, and mutates bounded contour commands through core generation. Its separate command, corpus, and artifact directory keep shaping-font and geometry failures attributable. - -## Package scripts - -| Script | Purpose | -| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| `build` | Derive the ABI contract, compile and optimize the `no_std` Wasm module, and emit the package. CI checks that committed generated output is fresh. | -| `check` | Run the complete package test and type-check gates. | -| `test` | Build and run Rust unit/integration, compiled Wasm/TypeScript, deterministic fuzz-smoke, and licensed real-font end-to-end tests. | - -Run `pnpm scripts list font-baker` from the workspace root to discover shaping-oracle, inspection, validator-fuzz, mutation-fuzz, and isolated nightly cargo-fuzz workflows. - -See the [implementation evidence](../planning/font-baker-implementation.md) for package-owned proof; the roadmap owns cross-package milestone status.[^implementation-status] - -[^fontations]: The package does not maintain a parallel OpenType parser or outline geometry engine. - -[^implementation-status]: The implementation-status concept records the executable evidence and next canonical gate. diff --git a/docs/packages/index.md b/docs/packages/index.md index fa62704a..bb86c12c 100644 --- a/docs/packages/index.md +++ b/docs/packages/index.md @@ -1,7 +1,6 @@ # Workspace packages - [`@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. diff --git a/docs/packages/r3f-hello-world.md b/docs/packages/r3f-hello-world.md index 02e3e21d..c8396ded 100644 --- a/docs/packages/r3f-hello-world.md +++ b/docs/packages/r3f-hello-world.md @@ -5,52 +5,62 @@ description: Demonstrates the public React Three Fiber API with Bitmap, MSDF, Sl resource: ../../apps/r3f-hello-world workspace_package: '@pmndrs/text-r3f-hello-world' documentation_type: reference -source_digest: 'sha256:e13744eb1f7b218f5bf3bbc2efc3dd0bdb87eea3f3cc859a01bd97523234f897' +source_digest: 'sha256:e9a02cae665c10851ca8592c4482696d599d31bd88f0ae8c23665d0e1e31d0f4' 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 + resource: ../../apps/r3f-hello-world/src/app.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' + at: '2026-08-11T01:48:53Z' --- # 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. +`Hello world` through the public `@pmndrs/text/react` `Text` component and resolves a Font Awesome globe through an ordered +font stack. One `App` component owns the font loads, technique state, three React `Activity` branches, and its in-canvas +MSDF controls. Each hidden branch pre-renders a complete `TextGroup` and world `Text`; changing technique reveals the +already committed Bitmap, MSDF, or Slug branch rather than initializing one after the click. The example does not retain +a second renderer path or manually pack glyph data. The UI `TextGroup` batches its labels explicitly. The controls sit +in one centered row at the top of the viewport, while the world copy remains centered in the available canvas. One local +`Button` component owns each transform group, plane mesh, hover state, and text label. Its memoized `pillNode()` graph +rounds the plane without tessellated shape geometry. Inter labels use a 44-unit shaped line box, centered font metrics, +and tracked uppercase text. Neither text layer opts into independent compositing because authored order is the honest +default for this small scene. 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. +Each GLB embeds Bitmap, MSDF, and Slug raster resources for its subset. The package manifest invokes only the published +CLI through `pnpm exec text bake`: direct input/output arguments select all three rasters, `--unicodes` delegates +shaping-font subsetting to the package-owned baker Wasm, and `--check` rebakes into temporary storage before requiring +byte-identical output. +The example loads each GLB once with one typed raster tuple and receives exact Bitmap, MSDF, and Slug `LoadedFont` values; +it does not repeat the input URL per technique. 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 bake +mise exec -- pnpm --filter @pmndrs/text-r3f-hello-world bake:check 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. +`bake:inter` and `bake:icons` own the two output GLBs, and the root `bake` command composes them. The corresponding +`bake:check:inter`, `bake:check:icons`, and root `bake:check` commands preserve the same per-asset boundary in byte-exact +check mode. The complete 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, reads the named R3F world layer directly through Vitexec, and first requires all three +hidden `Activity` branches to own their two Rust-planned meshes. Every revealed branch contains 13 laid-out glyphs—11 +visible records plus two spaces—with one mesh for Latin and one for the icon fallback resource. The teaching component +carries no probe-only effect, ref, frame callback, or canvas data attributes. diff --git a/docs/packages/text.md b/docs/packages/text.md index 9136d78e..3928215b 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:61242870023c632bd2ce25324a2d961b249c6de523013d9935cc1ec1f543319c' +source_digest: 'sha256:5d4c053b1ebf9efe975e79a8ea6ba3f4521a628eab60281e537005c673f946f1' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest @@ -17,6 +17,15 @@ sources: - id: runtime resource: ../../packages/text/src/text-runtime.ts title: Font and Rust-runtime ownership + - id: node-cli + resource: ../../packages/text/src/node/cli.ts + title: Project-discovery and direct font-bake CLI + - id: font-baker + resource: ../../packages/text/rust/font-baker + title: Optional portable font-baker Wasm + - id: bake-api + resource: ../../packages/text/src/node/bake.ts + title: Programmatic bake subpath - id: text-properties resource: ../../packages/text/src/text-properties.ts title: Paragraph input contract @@ -41,8 +50,8 @@ sources: - 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 + - id: react + resource: ../../packages/text/src/react.ts title: React Three Fiber adapter - id: engine-design resource: ../planning/rust-layout-engine.md @@ -55,7 +64,7 @@ sources: title: Three.js text API reference generated: by: openai-codex/gpt-5.6 - at: '2026-08-10T03:47:15Z' + at: '2026-08-10T22:52:27Z' --- # Package reference: `@pmndrs/text` @@ -72,9 +81,11 @@ The package owns five runtime layers: | 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. | +| React integration | `@pmndrs/text/react` | Reconcile React values into the same imperative `Text` and `TextGroup` objects. | -Rust remains `no_std + alloc` with the package allocator contract. It uses the existing compile-time direct-memory mapping +Runtime Rust and all shared Rust code remain `no_std + alloc` compatible with the package allocator contract. The optional +font-baker Wasm alone enables a feature-gated `std` adapter for Fontations subsetting; the same crate continues to +pass its `wasm32-unknown-unknown --no-default-features` build. The text engine 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. @@ -87,14 +98,60 @@ TypeScript does not independently shape, lay out, or pack paragraphs. | `@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/react` | React ``, ``, and `useFont`, reconciled through React Three Fiber. | +| `@pmndrs/text/bake` | Node programmatic font baking, glyph selection, and font inspection used by the `text` CLI. | +| `@pmndrs/text/runtime-bake` | Explicit browser Worker host for optional runtime baking. | | `@pmndrs/text/raster/*` | Renderer-neutral Bitmap, MSDF, and Slug decoding and raster-technique contracts. | | `@pmndrs/text/bakers/*` | Optional portable raster bakers and validators. | +The font-baker Rust source, direct-memory wrapper, schemas, tests, build pipeline, optimized Wasm, and generated ABI are +owned by this package. There is no separately published font-baker package. The root entry has no static edge to the +baker, its `std`-enabled dependencies, Ajv, glTF Validator, or the baker Wasm; only explicit bake/runtime-bake surfaces can +load those bytes. + `@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. +The package-owned `text` executable is available through `pnpm exec`; its `bake` command supports both project discovery +and a direct known-font mode. Its stable packaged shim delegates to the built Node CLI, so workspace installs can link the +executable before `dist` exists. Direct mode accepts one input/output pair, a collection face, optional shaping-font +Unicode subsetting through the package-owned Fontations/Skera baker Wasm, and independently selected embedded Bitmap, +MSDF, and Slug rasters. The prepared source bytes feed the core shaping bake and every selected raster bake; neither the +CLI nor the programmatic `@pmndrs/text/bake` path invokes a platform font tool. `--check` +publishes only to temporary storage and compares the complete GLB byte-for-byte with the requested output. It calls the +same `bakeFont` host as programmatic consumers rather than maintaining an example-only composition path. + +The `text glyphs` command uses the same package-owned baker Wasm and Skrifa to enumerate Unicode mappings, exact glyph +IDs, and names retained in a font's `post` or CFF data. Exact repeatable `--name` filters can emit structured JSON or a +compressed `--unicode-set` accepted by `text bake --unicodes`. Fonts without authored names still expose exact IDs rather +than invented semantic labels. Rich vendor labels and aliases remain external catalog data. + +The R3F `Text` component infers the technique union from a required outer font selection, including a font stack chosen +from runtime state. Callers do not widen dynamic selections to `AnyRasterTechnique`. A nested `Text` may omit `font` +because it is flattened into an inline span and inherits from its outer text; a rendered outer `Text` without a font is +invalid. `TextGroup` owns batching and compositing policy, never font inheritance. Both components register their Three +objects with the R3F host and are constructed during its commit rather than in a layout effect. React `Activity` can +therefore pre-render a hidden text or whole text group, while R3F retains visibility and eventual disposal ownership. + +One baked GLB may expose several raster techniques without repeating its input identity. `TextRuntime.loadFont()` and +R3F `useFont()` accept a nonempty `rasters` tuple and return a position-preserving tuple of `LoadedFont` values. The +artifact is fetched, validated, registered with the shaper, and retained once; each requested technique still derives +its exact descriptor, resolves and decodes its own raster resource, and retains its associated data type. A mapped tuple +keeps required Bitmap options and custom third-party technique types enforceable at every position. + +When runtime baking is required, one Worker request normalizes the Unicode ranges, prepares the selected source once, +and feeds those exact prepared bytes to the shaping bake and every requested Bitmap, MSDF, or Slug bake. The Worker +composes and validates one canonical GLB before transferring it. Its `asset.generator` is the publishing package identity +`@pmndrs/text`, independent of whether the producer was the CLI, Node API, or runtime Worker. + +The Worker caches only that final validated GLB in `CacheStorage`; partial preparation and raster outputs never become +cache entries. Identity covers source bytes, face, normalized ranges, ordered raster descriptors and keys, and all +relevant format/baker versions. Persistence is inherited from the source response: `no-store`, `no-cache`, missing +freshness metadata, and already-expired responses remain memory-only, while `max-age` or `Expires` supplies the exact +derived-artifact expiration. Browser quota eviction owns storage pressure. Cache absence, quota rejection, privacy +restrictions, and storage corruption are transparent misses followed by the same canonical bake. + ## Retained frame transaction One `TextGroup` owns one Rust engine session. A traversal sends only changed paragraph sections: @@ -105,6 +162,13 @@ One `TextGroup` owns one Rust engine session. A traversal sends only changed par - transform and visibility changes update Three's renderer-local sidecar without calling Wasm; - an empty or normalized-equal update sends nothing. +Three's ordinary scene traversal owns world-matrix composition. `TextGroup` tracks local matrices, visibility, and +parent identity only below its shared draw root, then gives the executor the paragraph IDs whose relative transform +path changed. Camera and `TextGroup` motion therefore move the shared draw without forcing every `Text` world matrix a +second time, multiplying every relative matrix, or scheduling transform-table uploads. Direct `Text` motion, nested +ancestor motion, visibility, reparenting, and manual matrix changes still patch the affected renderer-local slots and do +not enter Wasm. + Rust publishes one revision containing: - engine and plan revision headers; @@ -114,6 +178,12 @@ Rust publishes one revision containing: - ordered draw commands with technique/program, resource, material, transform, and clip identity; - optional semantic measurement or inspection sections only when explicitly demanded. +Metric-only style changes refresh retained shaping-run typography before cluster aggregation but reuse the HarfRust glyph +result. Font size, letter spacing, word spacing, line height, and baseline changes therefore rebuild advances and +positioning without treating glyph identities as newly shaped content. A public optimized-Wasm regression doubles a +paragraph's font size and proves its retained inline advance doubles; the live Paragraph Stress scene additionally keeps +correct spacing through intermediate animated sizes for Bitmap, MSDF, and Slug. + 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. @@ -135,6 +205,13 @@ 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. +Bitmap atlas pages within one strike are renderer layers, not independent draw resources. The font binding exposes one +strike resource, the Rust policy writes the selected page as one u32 instance lane, and Three uploads the strike as one +R8 texture array. This preserves authored glyph order while preventing page transitions inside ordinary prose from +splitting a paragraph into hundreds of draws. The multi-page integration fixture asserts one ordered draw and a live +Chrome run reduced the sampled Paragraph Stress CPU frame from roughly 80 ms before the correction to 0.47–1.3 ms after +it; the sampled GPU frame remained a separate 1–5 ms concern. + ## Font fallback and techniques `createFontStack()` accepts fonts from one runtime in explicit fallback order. Members may use different techniques. The @@ -169,6 +246,17 @@ The host pins request/result staging views and re-pins after any `memory.grow()` Growth is permitted only at the `text_update` boundary. Result capacity is negotiated and retried without publishing a partial revision. +Batch and paragraph capacities are intentionally separate. Request/result arenas scale with aggregate `TextGroup` +content, while Rust line and text scratch are bounded by the longest paragraph. Feeding aggregate text length into the +per-paragraph line bound multiplied retained scratch by paragraph count: a 684-paragraph recycling regression grew Wasm +memory from roughly 2.07 GB to the 4.29 GB address ceiling in 17 updates. The corrected bound completes 200 update cycles +and settles near 105 MB for that deliberately larger 8,000-glyph fixture. This regression also guards against forwarding +aggregate glyph capacity as one paragraph's text reservation. + +Bitmap vertex pixel snapping is an explicit immutable Three/R3F option and defaults off. The unsnapped graph uses the +ordinary model-view-projection position so shared-root or camera animation preserves subpixel movement; callers targeting +a pixel-art presentation can opt in without changing shaping, layout, or render-plan records. + 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. @@ -236,20 +324,20 @@ 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 | +| Core JavaScript plus shaper Wasm | 1,251,867 B | 460,943 B | 364,027 B | +| Three adapter plus core and shaper Wasm | 1,493,805 B | 499,537 B | 396,100 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 +92,550 raw / 18,659 gzip / 16,177 Brotli, and the complete Three JavaScript graph is 334,488 raw / 57,253 gzip / +48,250 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. +Three graph measured 460,130 and 498,606 gzip bytes. The current source-response cache policy and publishing changes +measure 460,943 and 499,537 gzip bytes respectively. 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 diff --git a/docs/planning/architecture.md b/docs/planning/architecture.md index a7ba4ef5..e46a6356 100644 --- a/docs/planning/architecture.md +++ b/docs/planning/architecture.md @@ -248,7 +248,7 @@ The React integration owns no shaping, line-breaking, baking, raster decoding, s ```mermaid flowchart LR - React["@pmndrs/text/r3f"] --> Three["@pmndrs/text/three"] --> Core["@pmndrs/text"] + React["@pmndrs/text/react"] --> Three["@pmndrs/text/three"] --> Core["@pmndrs/text"] TypeGPU["@pmndrs/text/typegpu"] --> Core Gpucat["@pmndrs/text-gpucat"] --> Core Core --> Registry["asset validator / registry"] @@ -380,7 +380,11 @@ V0 requires: - broad-run, line-shape, paragraph-analysis, and width-layout caches; - GPU resources by font, raster, logical page, selected variant, and device. -Persistent runtime-bake caching is deferred, but the key shape is reserved for source hash, descriptor hash, format/baker/generator versions, and selected raster. +Runtime baking persists only the final validated canonical GLB in Worker-owned `CacheStorage`. The exact key includes the +source hash, face, normalized Unicode ranges, ordered raster descriptors and keys, and format/baker/generator versions. +Persistence and expiration inherit the source response's cache policy; responses without reusable freshness, including +`no-store` and `no-cache`, remain memory-only. Browser quota eviction owns storage pressure and storage failures are +transparent misses. Preparation intermediates and partial raster results are never cached. ## Failure and warning model diff --git a/docs/planning/benchmark-plan.md b/docs/planning/benchmark-plan.md index 8ac22195..f94c1dbf 100644 --- a/docs/planning/benchmark-plan.md +++ b/docs/planning/benchmark-plan.md @@ -73,7 +73,7 @@ Status key: ✅ specified or available · 🟡 partial or conditional · ⬜ not | Harness gate | Status | Evidence required to advance | | -------------------------------------------- | :----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Canonical architecture and scenario contract | ✅ | This plan owns one target registry, one scenario registry, and one runner contract for interactive and headless surfaces. | -| Portable baker target | ✅ | `packages/font-baker` and the app run immutable Inter 4.1 bytes through the direct-memory Wasm API with deterministic GLB evidence. | +| Portable baker target | ✅ | `packages/text/rust/font-baker` and the app run immutable Inter 4.1 bytes through the direct-memory Wasm API with deterministic GLB evidence. | | Lab shell under `apps/benchmarks` | ✅ | The responsive token/component shell defaults to the human-facing live benchmark with mode, technique, backend, and workload URL state; finite visual conformance is separate. Fixed histories report renderer-callback CPU time, FPS, and real WebGPU/WebGL2 GPU timestamps when supported, while capture/export snapshots the live contract on demand. Causal product checks own label fit, control density, horizontal overflow, and mobile/tablet/desktop flow at 390, 1,024, and 1,280 CSS pixels. | | Headless product E2E | 🟡 | A browser CLI, Vitexec, and Playwright call the same strict registry execution module. The bounded CI-safe conformance suite includes synthetic, forced-WebGL2 TSL and bitmap rendering, public React `Text` reconciliation, direct-baker, loader/Worker, HarfRust, paragraph, bidi/policy/uikit, and item-5.4 CJK lanes. Hardware-WebGPU and pending-Suspense probes remain maintainer-local, and Milestone 6 awaits its closure review. | | Package-size lane | ✅ | Independent library-mode entries produce nonzero raw/minified/gzip/Brotli initial-core, Unicode 17 analysis, lazy-validator, runtime-host, runtime-Worker, baker, and shaper JavaScript sizes plus raw/gzip/Brotli Wasm. Rollup static closures exclude dynamic chunks; the browser-core lane externalizes declared `three`, React, and R3F peers, while Worker and shaper JavaScript exclude separately measured Wasm assets. The record names its measurement host: same-host output stays exact, while every foreign-host entry must satisfy the shared complete reviewed budgets. Unicode analysis is 139,936 bytes minified and the Darwin arm64 shaper record is 32,778 bytes minified JavaScript plus 680,312 bytes optimized Wasm. | diff --git a/docs/planning/core-api.md b/docs/planning/core-api.md index f316567e..6af51c0d 100644 --- a/docs/planning/core-api.md +++ b/docs/planning/core-api.md @@ -39,7 +39,7 @@ query result types. Rust owns shaping, bidi, line composition, positioning, inst command buffer. A renderer integration owns synchronization and GPU realization. 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. +`@pmndrs/text/react`; they do not drive the Rust engine directly. ## Runtime and font loading @@ -232,4 +232,4 @@ The following experimental V0 surfaces are not part of the current API: - `@pmndrs/text/typegpu` and its duplicate batch executor. 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. +[Three.js API](three-api.md) for the maintained renderer and `@pmndrs/text/react` for React. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index a8e0357a..f9212eea 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -77,7 +77,7 @@ Implementation and passing fixtures are evidence, not approval. A proposed row c | D-067 | `defineFont(input, raster)` is the recommended reusable token; equivalent string, URL, and object inputs deduplicate by normalized request and validated shaping identities rather than object identity. | Accepted | | D-068 | Raster identity is the RFC 8785 canonical package descriptor's SHA-256 `rasterKey`; callers never provide arbitrary raster IDs. | Accepted | | D-086 | Raster-baker descriptors must satisfy `JsonValue` in the public TypeScript contract and are still validated as untrusted plugin output; each descriptor/key pair is resolved once and reused for ordering, packaging, and baking. | Accepted | -| D-087 | `@pmndrs/text-font-baker` solely owns the optimized font-baker Wasm and its canonical URL; offline and runtime hosts share dependency-light bake policy but retain platform-specific I/O, and runtime size ceilings forbid heavy graph drift. | Accepted | +| D-087 | `@pmndrs/text` solely owns the optimized font-baker Wasm, Rust source, tooling, and canonical URL. `@pmndrs/text/bake` is its Node programmatic surface and `@pmndrs/text/runtime-bake` is its explicit browser host; the root graph cannot reach baker or validator code. No second font-baker package is published. | Accepted | | D-016 | The root `rust-toolchain.toml` is the sole product Rust authority. The isolated coverage-fuzz workspace has one exact dated nightly authority because libFuzzer needs unstable compiler instrumentation. Root and nested mise configurations consume their contextual idiomatic files instead of duplicating Rust versions; pnpm and Cargo remain the normal command interface. | Settled for V0 | D-004/005 follow the established uikit split: the core owns every feature and React only reconciles lifecycle and props. Nested text follows React Native's attributed-text model; direct props and Suspense match uikit/Drei conventions. D-006 makes the short string form canonical while preserving explicit source/baked overrides and preload identity. D-007 fixes native ESM, explicit subpath exports, module workers, and `import()`-based lazy boundaries as package invariants. D-008/009 adopt Koota's value-oriented inference at raster/plugin boundaries without applying type-level models to runtime binary data. See the [API contract](api-shapes.md). @@ -323,6 +323,10 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | 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 | +| D-238 | Human-facing package-size evidence reports independent gzip measurements: Core JavaScript, shaper Wasm, Three.js adapter JavaScript, the Inter and Font Awesome artifacts for Bitmap, MTSDF, and Slug, and the optional font validator, runtime-bake host/Worker, core font baker, and Bitmap/MTSDF/Slug baker JavaScript and Wasm modules. It does not publish arithmetic runtime totals, delivery totals, raw bytes, or Brotli rows. The detailed canonical record may retain additional independently budgeted implementation measurements and payload telemetry, but the benchmark UI and Size Limit pull-request comment share one fail-closed projection so internal accounting names do not become the product vocabulary. Three.js, React, and R3F remain external peers. | Accepted | +| D-239 | A known local font is directly expressible through `text bake --input --output` without authoring a discovery module or custom baking script. The package exposes one `text` executable with command-specific help and version output. First-party `--bitmap`, `--msdf`, and `--slug` flags select embedded raster resources; `--unicodes` invokes the package-owned Fontations/Skera baker Wasm before the shared `bakeFont` path so one prepared source feeds the shaping font and every raster; and `--check` performs a temporary byte-exact rebuild. `text glyphs` uses the same Wasm and Skrifa to surface Unicode mappings, exact glyph IDs, and retained `post`/CFF names as JSON or a bake-ready Unicode set without inventing semantic names. Product baking has no HarfBuzz executable dependency; pinned HarfBuzz remains an internal correctness oracle only. Runtime and R3F loading accept one nonempty tuple of raster requests for one input and return a position-preserving typed tuple of `LoadedFont` values. The font artifact is fetched and registered once while each declared raster still performs its required independent decode. Required per-technique options remain compile-time enforced. | Accepted | +| D-240 | CLI, Node, and runtime Worker baking share one prepare-once pipeline. A runtime request carries normalized Unicode ranges and the complete ordered raster plan; the Worker feeds the exact prepared source to the shaping bake and every selected first-party raster, composes one canonical GLB, validates it, and transfers one artifact. Only that final artifact is eligible for Worker-owned `CacheStorage`, keyed by source, face, ranges, exact raster descriptors/keys, and contract versions. Persistence inherits the source response's reusable freshness (`max-age` or `Expires`); `no-store`, `no-cache`, missing freshness, and expired responses remain memory-only. Browser quota eviction owns storage pressure, and storage failures remain transparent misses. Every GLB producer records `asset.generator` as the publishing package identity `@pmndrs/text`. | Accepted | +| D-241 | The package exposes its React integration as `@pmndrs/text/react`, matching the original public API, roadmap, and ecosystem convention. React Three Fiber remains the internal reconciler and a peer dependency, but is not encoded into the public subpath name. The stale `/r3f` export and generated entry are removed rather than retained as a second alias before publication. | 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. @@ -350,4 +354,4 @@ The [benchmark plan](benchmark-plan.md), [conformance plan](conformance-plan.md) 2. ✅ HarfRust, HarfBuzz, Unicode, glTF schema, validator, ABI, format, and initial generator versions are fixed in the [version contract](version-contract.md). 3. ⬜ Assign an authorized maintainer to submit the accepted provisional `PMNDRS` prefix request. 4. ✅ Inter Regular 4.1 and the target Chromium/GPU matrix are pinned; Amiri and Noto CJK add complex-script and universality evidence without changing the first rendering fixture. -5. ✅ D-123–D-138 and D-140–D-144, the code-first README, separate core/Three/TypeGPU specifications, prepared-revision handoff, raster shader/program split, render variants, engine ownership boundaries, and maintained `/three`, `/r3f`, and `/typegpu` subpaths are accepted for the extraction PR. D-139 and D-145 remain experiments pending complete-stage bridge evidence, and the external gpucat fixture remains an implementation fitness gate rather than a package-topology decision. +5. ✅ D-123–D-138 and D-140–D-144, the code-first README, separate core/Three/TypeGPU specifications, prepared-revision handoff, raster shader/program split, render variants, and engine ownership boundaries were accepted for the extraction PR. D-234 later removed the first-generation TypeGPU path, and D-241 restores `/react` as the sole React entry instead of `/r3f`. D-139 and D-145 remain experiments pending complete-stage bridge evidence, and the external gpucat fixture remains an implementation fitness gate rather than a package-topology decision. diff --git a/docs/planning/font-baker-allocator.md b/docs/planning/font-baker-allocator.md index 9e61b71a..4b97392f 100644 --- a/docs/planning/font-baker-allocator.md +++ b/docs/planning/font-baker-allocator.md @@ -2,7 +2,7 @@ type: Performance Experiment title: Wasm allocator experiment description: Defines the allocator candidates, workloads, measurements, and selection gate for the no_std font baker. -resource: ../../packages/font-baker/rust/Cargo.toml +resource: ../../packages/text/rust/font-baker/Cargo.toml tags: [baking, wasm, allocator, performance] sources: - id: 'citation-1' diff --git a/docs/planning/font-baker-implementation.md b/docs/planning/font-baker-implementation.md index 7b0294e5..4ff319d9 100644 --- a/docs/planning/font-baker-implementation.md +++ b/docs/planning/font-baker-implementation.md @@ -2,7 +2,7 @@ type: Implementation Evidence title: Portable font baker implementation evidence description: Records package-owned evidence for the portable Rust bake core, generated Wasm ABI, TypeScript wrapper, and validator. -resource: ../../packages/font-baker +resource: ../../packages/text/rust/font-baker tags: [baking, rust, wasm, typescript, implementation] sources: - id: 'citation-1' @@ -15,16 +15,16 @@ sources: resource: '../roadmap/roadmap.md' title: 'Canonical implementation roadmap' - id: 'citation-4' - resource: '../../packages/font-baker' - title: '`packages/font-baker`' + resource: '../../packages/text/rust/font-baker' + title: '`packages/text/rust/font-baker`' - id: 'citation-5-1' - resource: '../../packages/font-baker/rust/src/abi_contract.rs' + resource: '../../packages/text/rust/font-baker/src/abi_contract.rs' title: 'Compiler-derived ABI layouts' - id: 'citation-5-2' - resource: '../../packages/font-baker/rust/build.rs' + resource: '../../packages/text/rust/font-baker/build.rs' title: 'compile-time generator' - id: 'citation-6' - resource: '../../packages/font-baker/src/validator.ts' + resource: '../../packages/text/src/font-baker/validator.ts' title: 'Core font artifact validator' - id: 'fontations' resource: 'https://github.com/googlefonts/fontations' @@ -37,17 +37,18 @@ generated: # Portable font baker implementation evidence -This page records evidence owned by `packages/font-baker`. It does not repeat program-wide milestone status: the [canonical roadmap](../roadmap/roadmap.md) owns that checklist, while the [bake API contract](api-shapes.md#shared-bake-core) and [shaping data contract](shaping-data-contract.md) own behavior. +This page records evidence owned by `packages/text/rust/font-baker`. It does not repeat program-wide milestone status: the [canonical roadmap](../roadmap/roadmap.md) owns that checklist, while the [bake API contract](api-shapes.md#shared-bake-core) and [shaping data contract](shaping-data-contract.md) own behavior. Status key: ✅ complete for the declared slice · 🟡 in progress · ⬜ not started · ⛔ blocked | Area | Status | Current evidence | Next gate | | ------------------------ | :----: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| Package placement | ✅ | Rust, Wasm, TypeScript, build support, and tests live together in `packages/font-baker`; no new implementation workspace is rooted at the repository top level. | Keep future baker artifacts inside package directories. | +| Package placement | ✅ | Rust/Wasm lives in `packages/text/rust/font-baker`; its TypeScript bridge, build support, and tests live in the matching `packages/text` ownership tree. `@pmndrs/text/bake` is the sole programmatic product surface and no second font-baker package is published. | Keep future baker artifacts inside `@pmndrs/text` or a baker-only source crate. | | Portable Rust core | ✅ | Delegates SFNT/TTC and typed-table parsing to Fontations `read-fonts`, and metrics/bounds interpretation to `skrifa`; public fixtures cover container/table policy, face selection, deterministic reduction, dense extents, shaping identity, and exact Inter 4.1 output.[^fontations] | Keep every new policy branch paired with a focused regression. | +| Source preparation | ✅ | Feature-gated Skera 0.5.1 prepares canonical Unicode subsets and Skrifa enumerates exact cmap/glyph-name facts through generated `prepare` and `inspect` ABI exports. CLI, Node, and runtime Worker hosts share one functional pipeline: one normalized preparation result feeds the shaping bake and every requested Bitmap, MSDF, and Slug bake before one canonical GLB is composed and validated. An actual Inter ASCII Worker integration proves its output byte-identical to Node, and a request-boundary regression proves all three raster plans cross once. The capability is confined to baker paths: runtime/shared crates remain `no_std`, while baker-only shared crates may reuse it. | Keep new producer paths on this shared pipeline and preserve byte-identical Node/Worker output. | | Stable Wasm ABI | ✅ | Fixed-width `#[repr(C)]` types are the sole layout authority. Build-only Rust generation derives size, alignment, and offsets with `size_of`/`align_of`/`offset_of!`, publishes portable JSON, and emits an exact typed `as const` TypeScript module. CI rejects stale generated source; production Wasm embeds no contract and exports no ABI bootstrap. | Keep compiler-derived JSON/TypeScript identity and absent-Wasm-contract checks mandatory as the ABI evolves. | -| Wasm allocator | ✅ | The `no_std + alloc` build uses pinned ABI-private dynamic Talc. Module-owned allocation registries cap caller-controlled requests at 64 MiB, reserve fallibly, retain actual `Vec` ownership, require exact pointer/length pairs, and check response sizes; forged and repeated releases have regression coverage. The optimized four-module corpus saves 46,610 raw, 15,121 gzip, and 12,121 Brotli bytes relative to `dlmalloc`. A 128 MiB global arena saved no meaningful transfer bytes while raising initial memory to about 129 MiB, so it is rejected. One fixed small `WasmState` still uses infallible `Box::new` once per instance because stable Rust lacks the proportionate fallible API. | Consider a request-local scratch arena only after phase profiling proves a bounded shared lifetime outside persistent Worker state. | -| TypeScript wrapper | ✅ | Implements the accepted `FontBakeRequestV0 → FontBakeResultV0` boundary, instantiates the raw Wasm module, reads generated ABI JSON, transfers bytes through linear memory, returns typed bytes/reports, and maps structured errors. Its package owns the sole optimized Wasm artifact and canonical URL consumed by both the offline Node host and item-3.2 Worker. | Preserve exact offline/Worker output parity and one-copy artifact ownership. | +| Wasm allocator | ✅ | Both the `no_std` compatibility build and optional `std` baker artifact use pinned ABI-private dynamic Talc. Module-owned allocation registries cap caller-controlled requests at 64 MiB, reserve fallibly, retain actual `Vec` ownership, require exact pointer/length pairs, and check response sizes; forged and repeated releases have regression coverage. A 128 MiB global arena saved no meaningful transfer bytes while raising initial memory to about 129 MiB, so it is rejected. One fixed small `WasmState` still uses infallible `Box::new` once per instance because stable Rust lacks the proportionate fallible API. | Consider a request-local scratch arena only after phase profiling proves a bounded shared lifetime outside persistent Worker state. | +| TypeScript wrapper | ✅ | Implements bake, source preparation, and font inspection over one direct-memory envelope, instantiates the raw Wasm module, consumes the generated ABI constant, returns typed bytes/reports, and maps structured errors. Its package owns the sole optimized Wasm artifact and canonical URL consumed by both the offline Node host and Worker. | Preserve exact offline/Worker output parity and one-copy artifact ownership. | | Unit verification | ✅ | Rust unit tests isolate checksum padding, outward V0 bounds encoding, and GLB alignment behavior. | Add a focused regression with every internal defect or policy branch. | | Package integration | ✅ | Public Rust tests validate ABI fields, source/container/table policy, TTC face selection, and structured errors. Compiled-Wasm tests validate the pinned optimized module, zero imports, generated/published ABI identity, direct-memory behavior, exact and forged release metadata, and recovery. The reusable validation entry adds strict GLB framing, exact Khronos-report admission, Draft-04 required/union coverage, schema-copy identity, semantic identity, hostile payload mutation tests, and repeatable non-mutating Node `Buffer` validation. | Reuse the same hostile-input discipline at loader, shaping, paragraph, and renderer boundaries. | | Fuzz verification | ✅ | CI runs deterministic arbitrary-byte Rust bake smoke and artifact-mutation validation smoke with seed `0x504d4e44`. Longer source/artifact mutation drivers remain stable-toolchain tools. The isolated coverage target uses mise-owned `nightly-2026-06-01`, cargo-fuzz 0.13.2, and libfuzzer-sys 0.4.13 against the same public bake boundary, seeded from pinned Inter without copying fixture bytes. Minimized failures must enter the malformed corpus. | Add package-owned targets whenever bitmap, loader, shaping, layout, or renderer trust boundaries arrive. | @@ -56,4 +57,11 @@ Status key: ✅ complete for the declared slice · 🟡 in progress · ⬜ not s The portable TypeScript package remains intentionally internal. The public `@pmndrs/text/bake` Node subpath wraps it without exposing the raw allocation protocol; the runtime path remains a dynamically imported Worker host over the same core. +Following the Rust/Wasm code-size guidance, the subsetting build measures `opt-level = "s"` and `"z"` rather than +assuming either result, retains LTO and one codegen unit, strips symbols, and runs Binaryen `-Oz`. The measured winner is +`"z"`. The current prepare/inspect artifact is 1,097,702 raw bytes and 391,576 bytes with Node best-level gzip, versus +the preceding 422,538 raw / 163,865 gzip artifact. The +675,164 raw / +227,711 gzip capability cost is isolated from the +root and shaper graphs. Preserved Twiggy evidence attributes 506,797 raw bytes to the subset entry and its retained writer, +parser, and collection graph; `wasm-snip` remains an experiment, not a license to remove malformed-font error handling. + [^fontations]: `read-fonts` provides checked zero-allocation OpenType table access and `skrifa` provides maintained glyph metadata and bounds. HarfRust is the separately owned runtime shaping engine and is not linked into the portable bake core. diff --git a/docs/planning/gpucat-integration.md b/docs/planning/gpucat-integration.md index 73fb24fa..8ec9a62b 100644 --- a/docs/planning/gpucat-integration.md +++ b/docs/planning/gpucat-integration.md @@ -85,7 +85,7 @@ test: ```txt @pmndrs/text core, loading, shaping, layout, paragraph batches, target protocol @pmndrs/text/three package-owned Three.js integration -@pmndrs/text/r3f package-owned React Three Fiber integration over /three +@pmndrs/text/react package-owned React integration over /three through React Three Fiber @pmndrs/text/typegpu package-owned TypeGPU programs and direct engine @pmndrs/text-gpucat external gpucat objects, resources, programs, and target ``` diff --git a/docs/planning/three-api.md b/docs/planning/three-api.md index fb8acca1..7dc79c59 100644 --- a/docs/planning/three-api.md +++ b/docs/planning/three-api.md @@ -54,7 +54,12 @@ material implementation. const loader = new FontLoader(); const font = await loader.loadAsync({ input: { baked: '/fonts/inter-msdf.font.glb' }, - raster: { technique: msdf, options: { /* technique options */ } }, + raster: { + technique: msdf, + options: { + /* technique options */ + }, + }, }); ``` @@ -115,11 +120,11 @@ application asserts that blending order is irrelevant. Capacity policy controls the instance arena: -| Policy | Behavior | -| --- | --- | -| `grow` | Grow retained storage to fit the group. | +| 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. | +| `fixed` | Reject an update that exceeds the declared capacity. | 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. @@ -250,7 +255,7 @@ are moved into another group. ## React Three Fiber -`@pmndrs/text/r3f` exports ``, ``, and `useFont`. Components preserve the Three ownership and batching +`@pmndrs/text/react` 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. diff --git a/docs/planning/tooling-fixtures.md b/docs/planning/tooling-fixtures.md index 073cad78..b5dce256 100644 --- a/docs/planning/tooling-fixtures.md +++ b/docs/planning/tooling-fixtures.md @@ -49,7 +49,7 @@ Use one statically selected, redistributable OpenType font for the first complet | Exact source, version, license, and SHA-256 | ✅ | The checked-in manifest binds the upstream release/commit, archive member, OFL-1.1 text, byte sizes, and hashes. | | Portable baker local real-font lane | ✅ | The package E2E verifies the canonical bytes and cannot skip or substitute an environment font. | | Required pull-request real-font lane | ✅ | The checked-in font and license run without network access or ambient machine state. | -| Benchmark-app product scenario | ✅ | Interactive and browser-headless paths run the canonical bytes through `@pmndrs/text-font-baker`; local upload is an explicit override. | +| Benchmark-app product scenario | ✅ | Interactive and browser-headless paths run the canonical bytes through `@pmndrs/text/bake`; local upload is an explicit override. | | Font-baker fuzzing | ✅ | Fixed-seed Rust and validator-mutation smoke tests run hermetically; longer mutation drivers and pinned cargo-fuzz/libFuzzer exercise the public boundaries, with minimized failures promoted into the checked-in malformed corpus. | | GLB-to-HarfRust shaping | ✅ | Canonical Inter is independently validated, registered through `FontRegistry`, contributes exactly its retained 147,192-byte SFNT, 23,496-byte extents, and 368-byte availability views, and matches every pinned HarfRust field through both public batch calls. | | Complex-script source/GLB equivalence | ✅ | Amiri Regular 1.002 is pinned by immutable Google Fonts and upstream commits. Source HarfRust equals GLB-extracted reduced-SFNT HarfRust exactly; pinned HarfBuzz 13 independently agrees on every Arabic/Latin glyph field. | @@ -209,7 +209,7 @@ The executable package-integration suite additionally covers statically visible Runs pinned `hb-shape` and HarfRust over the same source bytes and cases. Stores glyph IDs, clusters, advances, offsets, flags, feature settings, segment properties, and engine versions. -`packages/font-baker` owns `generate-shaping-oracle`, the HarfRust 0.12.0 producer. The benchmark package owns `generate:harfbuzz-oracle`, which refuses any `hb-shape` version other than 13.0.0. Both normalize clusters to UTF-16 and emit deterministic JSON. The Inter differential records three explicit unsafe-to-concat flag deltas without weakening either independent oracle. Amiri is stricter: HarfRust and HarfBuzz agree on every glyph ID, UTF-16 cluster, advance, offset, and flag. The font-baker E2E then reruns the HarfRust producer over the reduced SFNT extracted from the validated Amiri GLB and requires the complete document to equal the source-font oracle. +`packages/text/rust/font-baker` owns `generate-shaping-oracle`, the HarfRust 0.12.0 producer. The benchmark package owns `generate:harfbuzz-oracle`, which refuses any `hb-shape` version other than 13.0.0. Both normalize clusters to UTF-16 and emit deterministic JSON. The Inter differential records three explicit unsafe-to-concat flag deltas without weakening either independent oracle. Amiri is stricter: HarfRust and HarfBuzz agree on every glyph ID, UTF-16 cluster, advance, offset, and flag. The font-baker E2E then reruns the HarfRust producer over the reduced SFNT extracted from the validated Amiri GLB and requires the complete document to equal the source-font oracle. The runtime conformance path must begin with the generated GLB, pass through the public validator and `FontRegistry`, and then register only those extracted views with the Wasm shaper. Item 4.2 compares every resulting runtime batch field against `harfrust.json` bit-for-bit; a test that gives the shaper the original TTF or baker-internal buffers does not prove the artifact contract. diff --git a/docs/planning/version-contract.md b/docs/planning/version-contract.md index 8c4943a2..a295a8eb 100644 --- a/docs/planning/version-contract.md +++ b/docs/planning/version-contract.md @@ -54,7 +54,7 @@ sources: resource: https://www.npmjs.com/package/ajv/v/6.15.0 title: Ajv 6.15.0 - id: abi-source - resource: ../../packages/font-baker/rust/src/abi_contract.rs + resource: ../../packages/text/rust/font-baker/src/abi_contract.rs title: Generated ABI and version-contract source - id: shaper-abi-source resource: ../../packages/text/rust/shaper/src/abi_contract.rs @@ -119,7 +119,7 @@ GLib development metadata is a native build-host prerequisite for the HarfBuzz b ## Generated contract -The Rust ABI sources generate all three published JSON contracts at build time. Their `versions` objects carry the applicable baker, bitmap generator, format, shaper, oracle, Unicode, glTF schema, validator, and Binaryen pins; Rust provenance and the TypeScript direct-memory shims consume those sources rather than relying on a hand-authored contract artifact. Rust package/generator versions derive from Cargo metadata. The contract-only `@pmndrs/text-font-baker/contract` subpath gives the TypeScript loader, validator, and direct-memory bridge one version authority without pulling the bridge into the browser graph. +The Rust ABI sources generate all three published JSON contracts at build time. Their `versions` objects carry the applicable baker, bitmap generator, format, shaper, oracle, Unicode, glTF schema, validator, and Binaryen pins; Rust provenance and the TypeScript direct-memory shims consume those sources rather than relying on a hand-authored contract artifact. Rust package/generator versions derive from Cargo metadata. The package-internal contract module gives the TypeScript loader, validator, and direct-memory bridge one version authority without exposing another package or pulling the bridge into the root browser graph. Every raster generator stamps its exact owning package semantic version into its canonical descriptor and artifact provenance. A generator upgrade changes its descriptor hash and therefore its raster key. The bitmap generator begins at `0.0.0`; additional raster generators receive their own exact pins when their packages enter the roadmap. diff --git a/docs/roadmap/roadmap.md b/docs/roadmap/roadmap.md index 2442e21d..1f42a313 100644 --- a/docs/roadmap/roadmap.md +++ b/docs/roadmap/roadmap.md @@ -97,68 +97,68 @@ flowchart LR These rows replace the former separate backlog. Each is intended to become one focused issue or a short, explicitly linked PR sequence. -| ID | Status | Work | Size | Depends on | -| ----- | :----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :--: | ---------- | -| 0.1 | ✅ | Accept public core/React APIs, typed raster capabilities, URL resolution, and ESM-only exports. | S | — | -| 0.2 | ✅ | Make the initial `@pmndrs/text` contract shim preserve font/raster literals and pass positive/negative composition fixtures. | S | 0.1 | -| 0.3 | ✅ | Accept identity, GLB, Worker, and version contracts. | S | 0.2 | -| 1.1 | ✅ | Build shared benchmark target/scenario/result contracts and a deterministic synthetic smoke target. | M | 0.3 | -| 1.2 | ✅ | Add the interactive lab, headless runner, raw result export, and package-size lane over the same registry. | M | 1.1 | -| 1.3 | ✅ | Pin the source font, HarfRust/HarfBuzz shaping oracles, and browser HTML/CSS visual reference as harness fixtures. | M | 1.2 | -| 2.1 | ✅ | Implement static `defineFont` discovery, literal raster extraction, and conservative local source resolution. | M | 1.3 | -| 2.2 | ✅ | Implement the host-independent font bake request/result core. | M | 2.1 | -| 2.3 | ✅ | Emit/validate the core font and declared package-owned bitmap strikes. | M | 2.2 | -| 2.4 | ✅ | Add the Node API, CLI, deterministic bytes, and report. | M | 2.3 | -| 3.1 | ✅ | Implement baked probing, validation, and registration. | M | 2.4 | -| 3.2 | ✅ | Add the dynamically imported Worker bake path. | M | 3.1 | -| 3.3 | ✅ | Prove Node/Worker parity, cancellation, and import isolation. | M | 3.2 | -| 4.1 | ✅ | Register fonts and cache HarfRust data/plans in Wasm. | M | 2.2 | -| 4.2 | ✅ | Implement batched shape/reshape ABI and conformance fixtures. | M | 4.1 | -| 5.1 | ✅ | Build paragraph analysis, measured clusters, greedy breaks, and allocation-light `measure`. | M | 4.2 | -| 5.2 | ✅ | Add final positioned `layout`, reflow caches, and batched boundary reshaping. | M | 5.1 | -| 5.3 | ✅ | Add alignment, clipping, max-lines, ellipsis, bidi, and current-uikit adapter fixtures. | M | 5.2 | -| 5.4 | ✅ | Pin one redistributable pan-CJK face and prove source/reduced HarfRust, HarfBuzz, horizontal paragraph layout, fuzz, and Node/Chromium/Vitexec evidence without renderer or paging work. | L | 5.3 | -| 6.0 | ✅ | Establish the current-repository TSL compiler, shader, and live WebGPU/WebGL2 baseline without broad type erasure. | S | 3.3, 5.4 | -| 6.1 | ✅ | Upload/render bitmap records and textures as the harness's first real raster target on WebGPU/WebGL2. | M | 6.0 | -| 6.2 | ✅ | Implement the Three.js `Text` object over the bitmap proof. | M | 6.1 | -| 6.3 | ✅ | Implement `@pmndrs/text/react` as a thin reconciliation layer. | M | 6.2 | -| 6.4 | ✅ | Rework the harness into a benchmark-first human control plane with a separate visual conformance mode. | M | 6.1–6.3 | -| 7.1 | ✅ | Harden lifecycle, invalid input, limits, and package graphs. | M | 1–6 | -| 7.2 | ✅ | Ship the advanced-shaping showcase and record end-to-end conformance/performance baselines. | M | 7.1 | -| 8.1 | ✅ | Implement the repository-owned deterministic `no_std` Rust MTSDF core and pass panic, scalar/SIMD, Wasm, size, fuzz, and native-msdfgen quality gates. | L | 7.2 | -| 8.2 | ✅ | Implement the fixed MTSDF baker, canonical 20-byte records, linear RGBA8 KTX2 payload, and embedded/external parity. | XL | 8.1 | -| 8.3 | ✅ | Implement the optional MSDF runtime module, strict validation, one resource/batch family, paint effects, and disposal. | L | 8.2 | -| 8.4 | ✅ | Implement one version-matched TSL MTSDF graph for WebGPU and WebGL2 with resize, transform, base-level minification, and effects scenes. | L | 8.3 | -| 8.5 | ✅ | Record visual-error, atlas, upload, memory, bundle-isolation, and steady-state rendering evidence. | XL | 8.4 | -| 8.6 | ✅ | Add configurable MTSDF quality, bounded runtime-atlas options, compiler-derived Wasm ABI layouts, and measured baker performance hardening before closing Milestone 8. | XL | 8.5 | -| 9.1 | ✅ | Port Slug outline conversion, exact normalization/bands, compact packing, deterministic baker, validator, and embedded/external resources. | XL | 7.2 | -| 9.2 | ✅ | Copy and adapt the version-matched analytic TSL fill runtime, batching, lifecycle, fail-closed paint boundary, and public `Text` integration. | XL | 9.1 | -| 9.3 | ✅ | Integrate Slug into the shared benchmark/conformance product, raster-role scenes, source-outline matrix, and complete two-axis icon-font grid. | XL | 9.2 | -| 9.4 | ✅ | Reproduce the applicable prior-fork performance baseline, evaluate retained challengers, and close payload, residency, frame-time, and bundle-isolation gates. | XL | 9.3 | -| 10.1 | ✅ | Replace the optional Three-shaped plugin seam with one required renderer-neutral transactional raster lifecycle and retain Three.js as an adapter. | L | 8.6, 9.4 | -| 10.2 | ✅ | Publish warm shaping, layout, paint planning, and raster staging through the Three.js object-update lifecycle without consumer `ready` waits. | L | 10.1 | -| 10.3 | ✅ | Add bounded glyph-capacity slack, complete in-place field replacement, authoritative shrink counts, overflow replacement, and coalesced dirty uploads to all three rasters. | XL | 10.2 | -| 10.4 | ✅ | Prove the public extension boundary with a private workspace raster/baker package that owns a new kind, artifact, adapter, retained updates, overflow, abort, and disposal. | L | 10.1, 10.3 | -| 10.5 | ✅ | Remove benchmark recycling workarounds and prove Icon Grid plus every Presentation workload through sequential, timed, allocation, cadence, dual-backend, and React Doctor gates. | XL | 10.2–10.4 | -| 10.6 | ✅ | Complete raster switching, v0 conformance, public API review, recommendations, plugin authoring guidance, package-size evidence, and signed stacked merge. | L | 10.5 | -| 11.1 | ✅ | Freeze the accepted README/API fixtures and capture current Three.js behavior, package graphs, rendering, allocation, and shaping baselines. | M | 10.6 | -| 11.2 | ✅ | Split portable raster decoding/bindings/packing from GPU realization; export reusable backend `RasterShader` algorithms and exact-typed programs, retaining native TSL and reusable TypeGPU paths. | L | 11.1 | -| 11.3 | ✅ | Implement `TextRuntime`, same-technique `FontStack`, batch-owned `Paragraph` handles, desired snapshots/font leases, typed `txt`/`span`, opaque batch/paragraph/span render variants, capacity, and origin overrides. | XL | 11.2 | -| 11.4 | ✅ | Implement dirty-channel coalescing plus per-call `update()` and Promise/callback `updateAsync()` synchronization with cross-batch atomic publication, cancellation, and supersession. | XL | 11.3 | -| 11.5 | ✅ | Move raster-resource partitioning, typed bindings, stable slots, overflow chunks, canonical CPU storage, dirty/live ranges, attachments, resolved variants, and ordered `PreparedGlyphRun` values into core. | XL | 11.3–11.4 | -| 11.6 | ✅ | Rebuild Bitmap, MTSDF, and Slug behind `FontLoader` → `TextGroup` → `Text`, including program-selected variants, reusable canonical shaders, optional TSL effects, late binding, native ordering, and renderer isolation. | XL | 11.5 | -| 11.7 | ✅ | Rebuild React Three Fiber over the same retained `TextGroup`/`Text` lifecycle, letting Three synchronize once per batch during render while preserving nested spans. | L | 11.6 | -| 11.8 | 🟡 | Run the TypeGPU-first capability gate, then implement reusable complete-stage TypeGPU raster programs and only the minimal direct pass encoder needed to prove the same public core batches/runs through TypeGPU and Wayfare. | XL | 11.5 | -| 11.9 | ⬜ | Prove TypeGPU-authored Bitmap/MTSDF/Slug through pinned `@typegpu/three`, including real textures, dependent loads, loops, vertex work, generated shaders, forced WebGPU/WebGL2 capability, pixels, and isolated cost; retain native TSL unless every promised backend passes. | L | 11.6, 11.8 | -| 11.10 | ⬜ | Prove an external gpucat package against public core and technique exports, including ordering limits, partial uploads, lifetime, TypeGPU/WGSL reuse, and an explicit GLSL companion or WebGPU-only scope, without a core change or private import. | L | 11.5, 11.8 | -| 11.11 | 🟡 | Reconcile implementation against the authoritative README and engine contract, remove the merged v0 surface, update package concepts/digests, and close package, browser, GPU, size, and OKF gates before declaring v1. | L | 11.6–11.10 | -| 11.12 | ⬜ | Bake underline position/thickness and strikeout position/size into font metrics without implementing decoration rendering, so text decoration becomes an additive renderer feature instead of an artifact version bump and a re-bake of every shipped font. | S | 11.6 | -| 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 | +| ID | Status | Work | Size | Depends on | +| ----- | :----: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--: | ----------- | +| 0.1 | ✅ | Accept public core/React APIs, typed raster capabilities, URL resolution, and ESM-only exports. | S | — | +| 0.2 | ✅ | Make the initial `@pmndrs/text` contract shim preserve font/raster literals and pass positive/negative composition fixtures. | S | 0.1 | +| 0.3 | ✅ | Accept identity, GLB, Worker, and version contracts. | S | 0.2 | +| 1.1 | ✅ | Build shared benchmark target/scenario/result contracts and a deterministic synthetic smoke target. | M | 0.3 | +| 1.2 | ✅ | Add the interactive lab, headless runner, raw result export, and package-size lane over the same registry. | M | 1.1 | +| 1.3 | ✅ | Pin the source font, HarfRust/HarfBuzz shaping oracles, and browser HTML/CSS visual reference as harness fixtures. | M | 1.2 | +| 2.1 | ✅ | Implement static `defineFont` discovery, literal raster extraction, and conservative local source resolution. | M | 1.3 | +| 2.2 | ✅ | Implement the host-independent font bake request/result core. | M | 2.1 | +| 2.3 | ✅ | Emit/validate the core font and declared package-owned bitmap strikes. | M | 2.2 | +| 2.4 | ✅ | Add the Node API, CLI, deterministic bytes, and report. | M | 2.3 | +| 3.1 | ✅ | Implement baked probing, validation, and registration. | M | 2.4 | +| 3.2 | ✅ | Add the dynamically imported Worker bake path. | M | 3.1 | +| 3.3 | ✅ | Prove Node/Worker parity, cancellation, and import isolation. | M | 3.2 | +| 4.1 | ✅ | Register fonts and cache HarfRust data/plans in Wasm. | M | 2.2 | +| 4.2 | ✅ | Implement batched shape/reshape ABI and conformance fixtures. | M | 4.1 | +| 5.1 | ✅ | Build paragraph analysis, measured clusters, greedy breaks, and allocation-light `measure`. | M | 4.2 | +| 5.2 | ✅ | Add final positioned `layout`, reflow caches, and batched boundary reshaping. | M | 5.1 | +| 5.3 | ✅ | Add alignment, clipping, max-lines, ellipsis, bidi, and current-uikit adapter fixtures. | M | 5.2 | +| 5.4 | ✅ | Pin one redistributable pan-CJK face and prove source/reduced HarfRust, HarfBuzz, horizontal paragraph layout, fuzz, and Node/Chromium/Vitexec evidence without renderer or paging work. | L | 5.3 | +| 6.0 | ✅ | Establish the current-repository TSL compiler, shader, and live WebGPU/WebGL2 baseline without broad type erasure. | S | 3.3, 5.4 | +| 6.1 | ✅ | Upload/render bitmap records and textures as the harness's first real raster target on WebGPU/WebGL2. | M | 6.0 | +| 6.2 | ✅ | Implement the Three.js `Text` object over the bitmap proof. | M | 6.1 | +| 6.3 | ✅ | Implement `@pmndrs/text/react` as a thin reconciliation layer. | M | 6.2 | +| 6.4 | ✅ | Rework the harness into a benchmark-first human control plane with a separate visual conformance mode. | M | 6.1–6.3 | +| 7.1 | ✅ | Harden lifecycle, invalid input, limits, and package graphs. | M | 1–6 | +| 7.2 | ✅ | Ship the advanced-shaping showcase and record end-to-end conformance/performance baselines. | M | 7.1 | +| 8.1 | ✅ | Implement the repository-owned deterministic `no_std` Rust MTSDF core and pass panic, scalar/SIMD, Wasm, size, fuzz, and native-msdfgen quality gates. | L | 7.2 | +| 8.2 | ✅ | Implement the fixed MTSDF baker, canonical 20-byte records, linear RGBA8 KTX2 payload, and embedded/external parity. | XL | 8.1 | +| 8.3 | ✅ | Implement the optional MSDF runtime module, strict validation, one resource/batch family, paint effects, and disposal. | L | 8.2 | +| 8.4 | ✅ | Implement one version-matched TSL MTSDF graph for WebGPU and WebGL2 with resize, transform, base-level minification, and effects scenes. | L | 8.3 | +| 8.5 | ✅ | Record visual-error, atlas, upload, memory, bundle-isolation, and steady-state rendering evidence. | XL | 8.4 | +| 8.6 | ✅ | Add configurable MTSDF quality, bounded runtime-atlas options, compiler-derived Wasm ABI layouts, and measured baker performance hardening before closing Milestone 8. | XL | 8.5 | +| 9.1 | ✅ | Port Slug outline conversion, exact normalization/bands, compact packing, deterministic baker, validator, and embedded/external resources. | XL | 7.2 | +| 9.2 | ✅ | Copy and adapt the version-matched analytic TSL fill runtime, batching, lifecycle, fail-closed paint boundary, and public `Text` integration. | XL | 9.1 | +| 9.3 | ✅ | Integrate Slug into the shared benchmark/conformance product, raster-role scenes, source-outline matrix, and complete two-axis icon-font grid. | XL | 9.2 | +| 9.4 | ✅ | Reproduce the applicable prior-fork performance baseline, evaluate retained challengers, and close payload, residency, frame-time, and bundle-isolation gates. | XL | 9.3 | +| 10.1 | ✅ | Replace the optional Three-shaped plugin seam with one required renderer-neutral transactional raster lifecycle and retain Three.js as an adapter. | L | 8.6, 9.4 | +| 10.2 | ✅ | Publish warm shaping, layout, paint planning, and raster staging through the Three.js object-update lifecycle without consumer `ready` waits. | L | 10.1 | +| 10.3 | ✅ | Add bounded glyph-capacity slack, complete in-place field replacement, authoritative shrink counts, overflow replacement, and coalesced dirty uploads to all three rasters. | XL | 10.2 | +| 10.4 | ✅ | Prove the public extension boundary with a private workspace raster/baker package that owns a new kind, artifact, adapter, retained updates, overflow, abort, and disposal. | L | 10.1, 10.3 | +| 10.5 | ✅ | Remove benchmark recycling workarounds and prove Icon Grid plus every Presentation workload through sequential, timed, allocation, cadence, dual-backend, and React Doctor gates. | XL | 10.2–10.4 | +| 10.6 | ✅ | Complete raster switching, v0 conformance, public API review, recommendations, plugin authoring guidance, package-size evidence, and signed stacked merge. | L | 10.5 | +| 11.1 | ✅ | Freeze the accepted README/API fixtures and capture current Three.js behavior, package graphs, rendering, allocation, and shaping baselines. | M | 10.6 | +| 11.2 | ✅ | Split portable raster decoding/bindings/packing from GPU realization; export reusable backend `RasterShader` algorithms and exact-typed programs, retaining native TSL and reusable TypeGPU paths. | L | 11.1 | +| 11.3 | ✅ | Implement `TextRuntime`, same-technique `FontStack`, batch-owned `Paragraph` handles, desired snapshots/font leases, typed `txt`/`span`, opaque batch/paragraph/span render variants, capacity, and origin overrides. | XL | 11.2 | +| 11.4 | ✅ | Implement dirty-channel coalescing plus per-call `update()` and Promise/callback `updateAsync()` synchronization with cross-batch atomic publication, cancellation, and supersession. | XL | 11.3 | +| 11.5 | ✅ | Move raster-resource partitioning, typed bindings, stable slots, overflow chunks, canonical CPU storage, dirty/live ranges, attachments, resolved variants, and ordered `PreparedGlyphRun` values into core. | XL | 11.3–11.4 | +| 11.6 | ✅ | Rebuild Bitmap, MTSDF, and Slug behind `FontLoader` → `TextGroup` → `Text`, including program-selected variants, reusable canonical shaders, optional TSL effects, late binding, native ordering, and renderer isolation. | XL | 11.5 | +| 11.7 | ✅ | Rebuild React Three Fiber over the same retained `TextGroup`/`Text` lifecycle, letting Three synchronize once per batch during render while preserving nested spans. | L | 11.6 | +| 11.8 | 🟡 | Run the TypeGPU-first capability gate, then implement reusable complete-stage TypeGPU raster programs and only the minimal direct pass encoder needed to prove the same public core batches/runs through TypeGPU and Wayfare. | XL | 11.5 | +| 11.9 | ⬜ | Prove TypeGPU-authored Bitmap/MTSDF/Slug through pinned `@typegpu/three`, including real textures, dependent loads, loops, vertex work, generated shaders, forced WebGPU/WebGL2 capability, pixels, and isolated cost; retain native TSL unless every promised backend passes. | L | 11.6, 11.8 | +| 11.10 | ⬜ | Prove an external gpucat package against public core and technique exports, including ordering limits, partial uploads, lifetime, TypeGPU/WGSL reuse, and an explicit GLSL companion or WebGPU-only scope, without a core change or private import. | L | 11.5, 11.8 | +| 11.11 | 🟡 | Reconcile implementation against the authoritative README and engine contract, remove the merged v0 surface, update package concepts/digests, and close package, browser, GPU, size, and OKF gates before declaring v1. | L | 11.6–11.10 | +| 11.12 | ⬜ | Bake underline position/thickness and strikeout position/size into font metrics without implementing decoration rendering, so text decoration becomes an additive renderer feature instead of an artifact version bump and a re-bake of every shipped font. | S | 11.6 | +| 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 @@ -307,7 +307,8 @@ Item 2.3 is closed. Exact goldens bind split, combined-embedded, combined-extern - [x] `@pmndrs/text/bake` exports the filesystem-oriented `bakeFont` and discovery-oriented `bakeProject` Node APIs without adding Node built-ins to browser-safe entry points. - [x] The generic `bakeFont` tuple preserves each selected raster package's exact option and packaging types; compile-only fixtures reject an empty bitmap strike tuple and unsupported packaging. - [x] `bakeProject` consumes the canonical TypeScript discovery report, groups and deduplicates one source deterministically, and dynamically imports only each already-verified ESM baker entry. -- [x] The thin native-ESM `pmndrs-text-bake` command covers conventional project defaults, repeatable entry/asset-root options, mirrored output roots, human output, JSON output, help, malformed arguments, and diagnostic exit status. +- [x] The thin native-ESM `text bake` command covers conventional project defaults, repeatable entry/asset-root options, mirrored output roots, human output, JSON output, command-specific help, malformed arguments, and diagnostic exit status. +- [x] `text glyphs` exposes Unicode mappings and retained `post`/CFF names through pinned `hb-info`, filters exact names, and emits either JSON or a compressed set accepted by `text bake --unicodes`; synthetic `gidN` labels are never promoted to semantic names. - [x] Exact Inter embedded/external goldens, mixed embedded/external raster composition, and repeated project runs prove authoritative byte and output-report determinism. - [x] Writes use same-directory exclusive temporary files, file synchronization, atomic rename, cancellation cleanup, source/output overlap checks, unique targets, and single-filename artifact IDs. - [x] The completed report records phase and total timing, before/after RSS, explicitly labeled process-lifetime peak RSS, output paths/roles/bytes/hashes, container bytes, and raw/gzip/Brotli transport bytes. @@ -352,7 +353,7 @@ Item 3.1 is closed. Item 3.2 is active and replaces the injected fallback seam's - [x] A baked miss dynamically imports `@pmndrs/text/runtime-bake`; the initial browser graph contains only the import boundary and cannot construct a Worker or reach the bake wrapper/Wasm. - [x] The standard host creates a named module Worker lazily, queues concurrent requests behind one active bake, reuses that instance within the burst, copies only the source transfer buffer needed to preserve loader provenance, and transfers the returned artifact buffer. -- [x] The Worker imports the exact portable `@pmndrs/text-font-baker` wrapper, lazily instantiates the same optimized `font_baker.wasm`, accepts only the versioned face descriptor, and serializes structured failures. +- [x] The Worker imports the exact portable `@pmndrs/text/bake` wrapper, lazily instantiates the same optimized `font_baker.wasm`, accepts only the versioned face descriptor, and serializes structured failures. - [x] The loader routes standard fallback output through the same provenance and hostile-input validator used for baked hits before registration. - [x] Canonical Inter integration tests exercise the public host, default loader path, transfer lists, Worker entry, and exact portable-core artifact bytes; package tests prove the runtime host/Worker/Wasm remain outside the static entry graph. - [x] Independent size lanes report the runtime host, Worker JavaScript, and portable Wasm separately instead of folding lazy code or Wasm into the initial core. diff --git a/package.json b/package.json index 31e7328a..6da18c91 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "author": "Justin Walsh (https://github.com/thejustinwalsh)", "type": "module", "scripts": { - "bake": "pnpm --filter @pmndrs/text... build && node packages/text/dist/node/cli.js", + "bake": "pnpm --filter @pmndrs/text... build && node packages/text/bin/text.js bake", "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", diff --git a/packages/font-baker/README.md b/packages/font-baker/README.md deleted file mode 100644 index b5a128bf..00000000 --- a/packages/font-baker/README.md +++ /dev/null @@ -1,70 +0,0 @@ -# @pmndrs/text-font-baker - -Internal portable bake core for `pmndrs/text`. The package keeps the Rust crate, -its Wasm boundary, TypeScript wrapper, build support, and tests together under -the monorepo package tree. - -It ships one `wasm32-unknown-unknown` module built with `no_std + alloc` and an -ABI-private dynamic Talc allocator. There are no platform-native binaries, WASI imports, -Embind bindings, or generated binding runtime. Rust generates the versioned ABI -JSON at compile time from `src/abi_contract.rs`; the Wasm embeds those exact -bytes, and the `generate-abi` program emits the compiled contract for the package -build. The TypeScript shim uses it to access exported functions and response -offsets in linear memory. The package build then runs pinned Binaryen 129.0.0 -with `-Oz` over the Rust release module while preserving only the bulk-memory -and nontrapping float-to-int features emitted by the pinned Rust target. - -The current slice accepts source-font bytes and a V0 face descriptor, emits one -shaping-only `PMNDRS_font` GLB, and returns byte-accounting data and structured -diagnostics. Its separate `@pmndrs/text-font-baker/validate` entry validates the -complete artifact through strict GLB parsing, pinned Khronos glTF validation, -Draft-04 extension schemas, project semantics, and embedded shaping payloads. -Keeping that entry separate prevents the Ajv and Khronos engines from loading -with the small direct-memory baker wrapper. The package does not implement -project discovery, filesystem output, bitmap baking, Worker orchestration, or -runtime shaping. - -```ts -import { createFontBaker } from '@pmndrs/text-font-baker'; -import { fontBakerWasmUrl } from '@pmndrs/text-font-baker/wasm-url'; - -const wasm = await fetch(fontBakerWasmUrl).then((response) => response.arrayBuffer()); -const baker = await createFontBaker(wasm); -const result = baker.bake({ - source: sourceBytes, - descriptor: { formatVersion: 0, fontFaceIndex: 0 }, -}); -``` - -Validate untrusted baked bytes before registration: - -```ts -import { validateFontArtifact } from '@pmndrs/text-font-baker/validate'; - -const validated = await validateFontArtifact(result.artifacts[0].bytes); -``` - -Build and verify the workspace from the repository root: - -```sh -pnpm build -pnpm test -``` - -The test command keeps four lanes explicit: Rust unit tests, public Rust and -compiled Wasm/package/schema/malformed-input integration tests, deterministic -fixed-seed fuzz smoke, and a real-font vertical-slice test. -The real-font lane never substitutes generated font bytes for product evidence; -it always verifies and bakes the checked-in, licensed, hash-pinned Inter 4.1 -fixture. The resulting reduced SFNT is validated structurally and shaped through -the complete checked-in corpus with HarfRust 0.12.0. - -Discover longer seeded validator and source-font mutation campaigns with `pnpm scripts list font-baker`. Run the primary -coverage-guided lane with `pnpm scripts run font-baker:fuzz-rust`; its nested mise configuration isolates exact -`nightly-2026-06-01`, -cargo-fuzz 0.13.2, and libfuzzer-sys 0.4.13 from the stable product toolchain. -Any minimized finding must become a checked-in malformed fixture and ordinary -stable-toolchain regression test. - -The package remains an internal portable core. The public Node surface is `@pmndrs/text/bake`, which orchestrates this -package without exposing its Wasm memory protocol. diff --git a/packages/font-baker/package.json b/packages/font-baker/package.json deleted file mode 100644 index 5c127509..00000000 --- a/packages/font-baker/package.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "name": "@pmndrs/text-font-baker", - "version": "0.0.0", - "private": true, - "license": "MIT", - "author": "Justin Walsh (https://github.com/thejustinwalsh)", - "files": [ - "dist" - ], - "type": "module", - "sideEffects": false, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - }, - "./validate": { - "types": "./dist/validator.d.ts", - "import": "./dist/validator.js" - }, - "./contract": { - "types": "./dist/contract.d.ts", - "import": "./dist/contract.js" - }, - "./wasm-url": { - "types": "./dist/wasm-url.d.ts", - "import": "./dist/wasm-url.js" - }, - "./font-baker.wasm": "./dist/font_baker.wasm", - "./abi.json": "./dist/font-baker-abi-v0.json", - "./package.json": "./package.json" - }, - "scripts": { - "build": "node ./scripts/build.mjs", - "test": "node ./scripts/test.mts", - "check": "node ./scripts/check.mts" - }, - "dependencies": { - "ajv": "6.15.0", - "gltf-validator": "2.0.0-dev.3.10" - }, - "devDependencies": { - "binaryen": "129.0.0", - "typescript": "7.0.2" - } -} diff --git a/packages/font-baker/scripts/build.mjs b/packages/font-baker/scripts/build.mjs deleted file mode 100644 index 8b2d217e..00000000 --- a/packages/font-baker/scripts/build.mjs +++ /dev/null @@ -1,88 +0,0 @@ -import { copyFile, mkdir, writeFile } from 'node:fs/promises'; -import { spawn } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; - -import { captureCommand } from './capture-command.mjs'; -import { reproducibleRustEnvironment } from './reproducible-rust-env.mjs'; -import { writeGeneratedTypescriptAbi } from './generated-typescript-abi.mjs'; - -const packageRoot = fileURLToPath(new URL('../', import.meta.url)); -const workspaceRoot = fileURLToPath(new URL('../../../', import.meta.url)); -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 wasmOpt = fileURLToPath( - new URL( - process.platform === 'win32' ? '../node_modules/.bin/wasm-opt.CMD' : '../node_modules/.bin/wasm-opt', - import.meta.url, - ), -); -const rustWasm = fileURLToPath( - new URL('../rust/target/wasm32-unknown-unknown/release/pmndrs_text_font_baker.wasm', import.meta.url), -); -const distributedWasm = fileURLToPath(new URL('../dist/font_baker.wasm', import.meta.url)); - -const abiJson = await runCapture('cargo', [ - 'run', - '--manifest-path', - 'rust/Cargo.toml', - '--bin', - 'generate-abi', - '--locked', - '--quiet', -]); -await writeGeneratedTypescriptAbi( - new URL('../src/generated/font-baker-abi.ts', import.meta.url), - 'fontBakerAbi', - abiJson, - { check: process.env.CI === 'true' }, -); -await run( - 'cargo', - [ - 'build', - '--manifest-path', - 'rust/Cargo.toml', - '--target', - 'wasm32-unknown-unknown', - '--release', - '--locked', - '--no-default-features', - ], - rustEnvironment, -); -await run(tsc, ['-p', 'tsconfig.build.json']); -await mkdir(new URL('../dist/', import.meta.url), { recursive: true }); -await copyFile( - new URL('../src/schemas/KHRONOS-SPEC-LICENSE.txt', import.meta.url), - new URL('../dist/schemas/KHRONOS-SPEC-LICENSE.txt', import.meta.url), -); -await copyFile( - new URL('../src/schemas/README.md', import.meta.url), - new URL('../dist/schemas/README.md', import.meta.url), -); -await run(wasmOpt, [ - '--enable-bulk-memory', - '--enable-nontrapping-float-to-int', - '-Oz', - rustWasm, - '-o', - distributedWasm, -]); -await writeFile(new URL('../dist/font-baker-abi-v0.json', import.meta.url), abiJson); - -function run(command, args, environment = process.env) { - return new Promise((resolve, reject) => { - const child = spawn(command, args, { 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}`)); - }); - }); -} - -function runCapture(command, args) { - return captureCommand(command, args, { cwd: packageRoot }); -} diff --git a/packages/font-baker/scripts/check.mts b/packages/font-baker/scripts/check.mts deleted file mode 100644 index 11926a1e..00000000 --- a/packages/font-baker/scripts/check.mts +++ /dev/null @@ -1,14 +0,0 @@ -import { isMainModule, runNode } from './support/command.mts'; -import { runFontBakerTest } from './test.mts'; - -export async function runFontBakerCheck(): Promise { - await runFontBakerTest(); - await runNode('node_modules/typescript/bin/tsc', ['-p', 'tsconfig.json', '--noEmit']); -} - -if (isMainModule(import.meta.url)) { - runFontBakerCheck().catch((error: unknown) => { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exitCode = 2; - }); -} diff --git a/packages/font-baker/scripts/support/command.mts b/packages/font-baker/scripts/support/command.mts deleted file mode 100644 index 043d0298..00000000 --- a/packages/font-baker/scripts/support/command.mts +++ /dev/null @@ -1,47 +0,0 @@ -import { spawn } from 'node:child_process'; -import { globSync } from 'node:fs'; -import { fileURLToPath, pathToFileURL } from 'node:url'; - -const packageRoot = fileURLToPath(new URL('../..', import.meta.url)); - -export function isMainModule(metaUrl: string): boolean { - const entryPath = process.argv[1]; - return entryPath !== undefined && metaUrl === pathToFileURL(entryPath).href; -} - -export function commandArguments( - arguments_: readonly string[], - name: string, -): { - readonly command: string; - readonly rest: readonly string[]; -} { - const command = arguments_[0]; - if (command === undefined) throw new Error(`${name} requires a command; run pnpm run ${name} --help`); - return { command, rest: arguments_.slice(1) }; -} - -export async function run(command: string, arguments_: readonly string[]): Promise { - await new Promise((resolveRun, reject) => { - const child = spawn(command, arguments_, { cwd: packageRoot, stdio: 'inherit' }); - child.once('error', reject); - child.once('close', (code) => { - if (code === 0) resolveRun(); - else reject(new Error(`${command} exited with ${String(code)}`)); - }); - }); -} - -export function runNode(script: string, arguments_: readonly string[] = []): Promise { - return run(process.execPath, [script, ...arguments_]); -} - -export function runNodeTests(patterns: readonly string[]): Promise { - const files = patterns.flatMap((pattern) => globSync(pattern)).sort(); - if (files.length === 0) throw new Error(`No test files matched: ${patterns.join(', ')}`); - return run(process.execPath, ['--test', ...files]); -} - -export function runCargo(arguments_: readonly string[]): Promise { - return run(process.platform === 'win32' ? 'cargo.exe' : 'cargo', arguments_); -} diff --git a/packages/font-baker/scripts/test.mts b/packages/font-baker/scripts/test.mts deleted file mode 100644 index 3c63fdb1..00000000 --- a/packages/font-baker/scripts/test.mts +++ /dev/null @@ -1,16 +0,0 @@ -import { isMainModule, runCargo, runNode, runNodeTests } from './support/command.mts'; - -export async function runFontBakerTest(): Promise { - await runNode('scripts/build.mjs'); - await runCargo(['test', '--manifest-path', 'rust/Cargo.toml', '--locked']); - await runNodeTests(['tests/integration/*.test.mjs']); - await runNodeTests(['tests/fuzz/*.test.mjs']); - await runNodeTests(['tests/e2e/*.test.mjs']); -} - -if (isMainModule(import.meta.url)) { - runFontBakerTest().catch((error: unknown) => { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exitCode = 2; - }); -} diff --git a/packages/font-baker/src/wasm-url.ts b/packages/font-baker/src/wasm-url.ts deleted file mode 100644 index ef26d311..00000000 --- a/packages/font-baker/src/wasm-url.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Canonical browser-safe URL for the optimized font baker Wasm artifact. */ -export const fontBakerWasmUrl: string = new URL('./font_baker.wasm', import.meta.url).href; diff --git a/packages/font-baker/tsconfig.build.json b/packages/font-baker/tsconfig.build.json deleted file mode 100644 index bf09f57a..00000000 --- a/packages/font-baker/tsconfig.build.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ES2025", "DOM"], - "outDir": "dist", - "resolveJsonModule": true, - "rootDir": "src", - "tsBuildInfoFile": "dist/.tsbuildinfo" - }, - "include": ["src/**/*.ts", "src/**/*.json"] -} diff --git a/packages/font-baker/tsconfig.json b/packages/font-baker/tsconfig.json deleted file mode 100644 index c98048b4..00000000 --- a/packages/font-baker/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ES2025", "DOM"], - "noEmit": true, - "resolveJsonModule": true, - "rootDir": "." - }, - "include": ["src/**/*.ts", "src/**/*.json"] -} diff --git a/packages/font-baker/LICENSE b/packages/text/LICENSE similarity index 100% rename from packages/font-baker/LICENSE rename to packages/text/LICENSE diff --git a/packages/text/bin/text.js b/packages/text/bin/text.js new file mode 100755 index 00000000..e648e8ca --- /dev/null +++ b/packages/text/bin/text.js @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import { runCli } from '../dist/node/cli.js'; + +process.exitCode = await runCli(process.argv.slice(2)); diff --git a/packages/text/package.json b/packages/text/package.json index 5ce13c33..ca13558c 100644 --- a/packages/text/package.json +++ b/packages/text/package.json @@ -5,9 +5,11 @@ "license": "MIT", "author": "Justin Walsh (https://github.com/thejustinwalsh)", "bin": { - "pmndrs-text-bake": "./dist/node/cli.js" + "text": "./bin/text.js" }, "files": [ + "LICENSE", + "bin", "dist", "!dist/.tsbuildinfo", "!dist/internal/raster-baker-profile.d.ts", @@ -36,9 +38,9 @@ "types": "./dist/three/slug.d.ts", "import": "./dist/three/slug.js" }, - "./r3f": { - "types": "./dist/r3f.d.ts", - "import": "./dist/r3f.js" + "./react": { + "types": "./dist/react.d.ts", + "import": "./dist/react.js" }, "./raster/bitmap": { "types": "./dist/raster/bitmap-technique.d.ts", @@ -92,6 +94,8 @@ "./mtsdf-abi.json": "./dist/mtsdf-baker-abi-v1.json", "./slug-baker.wasm": "./dist/slug_baker.wasm", "./slug-abi.json": "./dist/slug-baker-abi-v0.json", + "./font-baker.wasm": "./dist/font_baker.wasm", + "./font-baker-abi.json": "./dist/font-baker-abi-v0.json", "./package.json": "./package.json" }, "scripts": { @@ -101,7 +105,8 @@ }, "dependencies": { "@cto.af/linebreak": "4.0.3", - "@pmndrs/text-font-baker": "workspace:*", + "ajv": "6.15.0", + "gltf-validator": "2.0.0-dev.3.10", "ktx-parse": "1.1.0", "typescript": "7.0.2", "unicode-segmenter": "0.15.0" diff --git a/packages/font-baker/fuzz/.gitignore b/packages/text/rust/font-baker-fuzz/.gitignore similarity index 100% rename from packages/font-baker/fuzz/.gitignore rename to packages/text/rust/font-baker-fuzz/.gitignore diff --git a/packages/font-baker/fuzz/Cargo.lock b/packages/text/rust/font-baker-fuzz/Cargo.lock similarity index 100% rename from packages/font-baker/fuzz/Cargo.lock rename to packages/text/rust/font-baker-fuzz/Cargo.lock diff --git a/packages/font-baker/fuzz/Cargo.toml b/packages/text/rust/font-baker-fuzz/Cargo.toml similarity index 68% rename from packages/font-baker/fuzz/Cargo.toml rename to packages/text/rust/font-baker-fuzz/Cargo.toml index 7e753039..664eb9b7 100644 --- a/packages/font-baker/fuzz/Cargo.toml +++ b/packages/text/rust/font-baker-fuzz/Cargo.toml @@ -9,8 +9,8 @@ cargo-fuzz = true [dependencies] libfuzzer-sys = "=0.4.13" -pmndrs-text-font-baker = { path = "../rust", default-features = false, features = ["std"] } -pmndrs-text-mtsdf-admission = { path = "../../text/rust/mtsdf-admission", features = ["fuzzing"] } +pmndrs-text-font-baker = { path = "../font-baker", default-features = false, features = ["std"] } +pmndrs-text-mtsdf-admission = { path = "../mtsdf-admission", features = ["fuzzing"] } [[bin]] name = "bake_font" diff --git a/packages/font-baker/fuzz/README.md b/packages/text/rust/font-baker-fuzz/README.md similarity index 82% rename from packages/font-baker/fuzz/README.md rename to packages/text/rust/font-baker-fuzz/README.md index b960385e..bd96c964 100644 --- a/packages/font-baker/fuzz/README.md +++ b/packages/text/rust/font-baker-fuzz/README.md @@ -10,15 +10,15 @@ The nested mise configuration consumes this directory's `rust-toolchain.toml` an `cargo-fuzz` 0.13.2. From the repository root: ```sh -pnpm --filter @pmndrs/text-font-baker run fuzz rust -pnpm --filter @pmndrs/text-font-baker run fuzz mtsdf +pnpm --filter @pmndrs/text/bake run fuzz rust +pnpm --filter @pmndrs/text/bake run fuzz mtsdf ``` For a bounded verification run: ```sh -pnpm --filter @pmndrs/text-font-baker run fuzz rust -- -runs=1000 -max_len=1048576 -pnpm --filter @pmndrs/text-font-baker run fuzz mtsdf -- -runs=1000 -max_len=1666 +pnpm --filter @pmndrs/text/bake run fuzz rust -- -runs=1000 -max_len=1048576 +pnpm --filter @pmndrs/text/bake run fuzz mtsdf -- -runs=1000 -max_len=1666 ``` The nightly exception is confined to this workspace and never builds the distributed Wasm or product diff --git a/packages/font-baker/fuzz/fuzz_targets/bake_font.rs b/packages/text/rust/font-baker-fuzz/fuzz_targets/bake_font.rs similarity index 100% rename from packages/font-baker/fuzz/fuzz_targets/bake_font.rs rename to packages/text/rust/font-baker-fuzz/fuzz_targets/bake_font.rs diff --git a/packages/font-baker/fuzz/fuzz_targets/mtsdf_outline.rs b/packages/text/rust/font-baker-fuzz/fuzz_targets/mtsdf_outline.rs similarity index 100% rename from packages/font-baker/fuzz/fuzz_targets/mtsdf_outline.rs rename to packages/text/rust/font-baker-fuzz/fuzz_targets/mtsdf_outline.rs diff --git a/packages/font-baker/fuzz/mise.toml b/packages/text/rust/font-baker-fuzz/mise.toml similarity index 100% rename from packages/font-baker/fuzz/mise.toml rename to packages/text/rust/font-baker-fuzz/mise.toml diff --git a/packages/font-baker/fuzz/rust-toolchain.toml b/packages/text/rust/font-baker-fuzz/rust-toolchain.toml similarity index 100% rename from packages/font-baker/fuzz/rust-toolchain.toml rename to packages/text/rust/font-baker-fuzz/rust-toolchain.toml diff --git a/packages/font-baker/rust/Cargo.lock b/packages/text/rust/font-baker/Cargo.lock similarity index 92% rename from packages/font-baker/rust/Cargo.lock rename to packages/text/rust/font-baker/Cargo.lock index 5ca3dc64..7db48b95 100644 --- a/packages/font-baker/rust/Cargo.lock +++ b/packages/text/rust/font-baker/Cargo.lock @@ -169,6 +169,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hybrid-array" version = "0.4.13" @@ -205,6 +211,12 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + [[package]] name = "memchr" version = "2.8.3" @@ -239,6 +251,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "skera", "skrifa", "talc", "thiserror", @@ -352,6 +365,18 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "skera" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a07f3ab79d1b30fdfc72a4637ec055f1013040f8791e4a521d6f3e364ee6eb4" +dependencies = [ + "hashbrown", + "skrifa", + "thiserror", + "write-fonts", +] + [[package]] name = "skrifa" version = "0.45.1" @@ -433,6 +458,17 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "write-fonts" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b082e0c3b0c5565b4f7bb1e0bbb4efbc8afc5a9441ade74fadc6502f9d406091" +dependencies = [ + "font-types", + "log", + "read-fonts 0.42.1", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/packages/font-baker/rust/Cargo.toml b/packages/text/rust/font-baker/Cargo.toml similarity index 93% rename from packages/font-baker/rust/Cargo.toml rename to packages/text/rust/font-baker/Cargo.toml index bd80e7d1..b7a5a66d 100644 --- a/packages/font-baker/rust/Cargo.toml +++ b/packages/text/rust/font-baker/Cargo.toml @@ -33,6 +33,7 @@ required-features = ["fuzzing"] [features] default = ["compression"] std = ["read-fonts/std", "serde/std", "serde_json/std", "skrifa/std"] +subsetting = ["std", "dep:skera"] compression = ["std", "dep:brotli", "dep:flate2"] fuzzing = ["std"] oracle = ["std", "dep:harfrust"] @@ -46,6 +47,7 @@ read-fonts = { version = "0.42.1", default-features = false, features = ["libm"] serde = { version = "1.0.229", default-features = false, features = ["alloc", "derive"] } serde_json = { version = "1.0.145", default-features = false, features = ["alloc"] } sha2 = { version = "0.11.0", default-features = false } +skera = { version = "=0.5.1", default-features = false, optional = true } skrifa = { version = "0.45.1", default-features = false, features = ["libm"] } thiserror = { version = "2.0.19", default-features = false } @@ -56,6 +58,7 @@ serde_json = "1.0.145" talc = "=5.0.4" [profile.release] +opt-level = "z" lto = true codegen-units = 1 panic = "abort" diff --git a/packages/font-baker/rust/build.rs b/packages/text/rust/font-baker/build.rs similarity index 81% rename from packages/font-baker/rust/build.rs rename to packages/text/rust/font-baker/build.rs index b7ec14a1..b7b86d58 100644 --- a/packages/font-baker/rust/build.rs +++ b/packages/text/rust/font-baker/build.rs @@ -47,6 +47,26 @@ fn main() { ], "result": "responsePointer", }, + "prepare": { + "export": abi_contract::PREPARE_EXPORT, + "parameters": [ + "sourcePointer", + "sourceByteLength", + "selectionPointer", + "selectionByteLength", + ], + "result": "responsePointer", + }, + "inspect": { + "export": abi_contract::INSPECT_EXPORT, + "parameters": [ + "sourcePointer", + "sourceByteLength", + "descriptorPointer", + "descriptorByteLength", + ], + "result": "responsePointer", + }, "responseByteLength": { "export": abi_contract::RESULT_LEN_EXPORT, "parameters": [], diff --git a/packages/font-baker/rust/src/abi_contract.rs b/packages/text/rust/font-baker/src/abi_contract.rs similarity index 93% rename from packages/font-baker/rust/src/abi_contract.rs rename to packages/text/rust/font-baker/src/abi_contract.rs index c1b636f6..476eb438 100644 --- a/packages/font-baker/rust/src/abi_contract.rs +++ b/packages/text/rust/font-baker/src/abi_contract.rs @@ -6,6 +6,8 @@ pub const MEMORY_EXPORT: &str = "memory"; pub const ALLOC_EXPORT: &str = "pmndrs_font_baker_alloc"; pub const DEALLOC_EXPORT: &str = "pmndrs_font_baker_dealloc"; pub const BAKE_EXPORT: &str = "pmndrs_font_baker_bake"; +pub const PREPARE_EXPORT: &str = "pmndrs_font_baker_prepare"; +pub const INSPECT_EXPORT: &str = "pmndrs_font_baker_inspect"; pub const RESULT_LEN_EXPORT: &str = "pmndrs_font_baker_result_len"; pub const RESPONSE_HEADER_BYTES: u32 = size_of::() as u32; diff --git a/packages/font-baker/rust/src/bin/fuzz-bake.rs b/packages/text/rust/font-baker/src/bin/fuzz-bake.rs similarity index 100% rename from packages/font-baker/rust/src/bin/fuzz-bake.rs rename to packages/text/rust/font-baker/src/bin/fuzz-bake.rs diff --git a/packages/font-baker/rust/src/bin/generate-abi.rs b/packages/text/rust/font-baker/src/bin/generate-abi.rs similarity index 100% rename from packages/font-baker/rust/src/bin/generate-abi.rs rename to packages/text/rust/font-baker/src/bin/generate-abi.rs diff --git a/packages/font-baker/rust/src/bin/generate-shaping-oracle.rs b/packages/text/rust/font-baker/src/bin/generate-shaping-oracle.rs similarity index 100% rename from packages/font-baker/rust/src/bin/generate-shaping-oracle.rs rename to packages/text/rust/font-baker/src/bin/generate-shaping-oracle.rs diff --git a/packages/font-baker/rust/src/bin/inspect-font-fixture.rs b/packages/text/rust/font-baker/src/bin/inspect-font-fixture.rs similarity index 99% rename from packages/font-baker/rust/src/bin/inspect-font-fixture.rs rename to packages/text/rust/font-baker/src/bin/inspect-font-fixture.rs index 988fdca3..19322564 100644 --- a/packages/font-baker/rust/src/bin/inspect-font-fixture.rs +++ b/packages/text/rust/font-baker/src/bin/inspect-font-fixture.rs @@ -274,7 +274,7 @@ mod tests { use super::*; const INTER: &[u8] = include_bytes!( - "../../../../../apps/benchmarks/fixtures/fonts/inter-v4.1/Inter-Regular.ttf" + "../../../../../../apps/benchmarks/fixtures/fonts/inter-v4.1/Inter-Regular.ttf" ); #[test] diff --git a/packages/font-baker/rust/src/error.rs b/packages/text/rust/font-baker/src/error.rs similarity index 95% rename from packages/font-baker/rust/src/error.rs rename to packages/text/rust/font-baker/src/error.rs index e90842e9..8edc8226 100644 --- a/packages/font-baker/rust/src/error.rs +++ b/packages/text/rust/font-baker/src/error.rs @@ -6,6 +6,7 @@ use thiserror::Error; #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum BakeErrorCode { InvalidDescriptor, + InvalidSelection, InvalidFont, UnsupportedContainer, UnsupportedVariableFont, @@ -15,6 +16,7 @@ pub enum BakeErrorCode { InvalidGlyphExtents, IntegerOverflow, SerializationFailed, + SubsettingFailed, } #[derive(Debug, Error, Serialize)] diff --git a/packages/font-baker/rust/src/glb.rs b/packages/text/rust/font-baker/src/glb.rs similarity index 98% rename from packages/font-baker/rust/src/glb.rs rename to packages/text/rust/font-baker/src/glb.rs index ed930462..ec725dbb 100644 --- a/packages/font-baker/rust/src/glb.rs +++ b/packages/text/rust/font-baker/src/glb.rs @@ -32,7 +32,7 @@ pub(crate) fn build_font_glb( .copy_from_slice(&shaping.extents_availability); let document = json!({ - "asset": { "version": "2.0", "generator": "@pmndrs/text-font-baker" }, + "asset": { "version": "2.0", "generator": "@pmndrs/text" }, "extensionsUsed": ["PMNDRS_font"], "extensionsRequired": ["PMNDRS_font"], "extensions": { "PMNDRS_font": { diff --git a/packages/font-baker/rust/src/lib.rs b/packages/text/rust/font-baker/src/lib.rs similarity index 93% rename from packages/font-baker/rust/src/lib.rs rename to packages/text/rust/font-baker/src/lib.rs index 24b9d57c..3d705288 100644 --- a/packages/font-baker/rust/src/lib.rs +++ b/packages/text/rust/font-baker/src/lib.rs @@ -13,7 +13,10 @@ mod glb; mod report; mod sfnt; -#[cfg(all(target_arch = "wasm32", not(feature = "std")))] +#[cfg(feature = "subsetting")] +mod source_font; + +#[cfg(target_arch = "wasm32")] mod wasm; pub use error::{BakeError, BakeErrorCode}; @@ -22,6 +25,11 @@ pub use report::{ ContainerPayloadReport, FontMetricsV0, ProvenanceV0, ShapingPayloadReportV0, TablePayloadReport, TransportPayloadReport, }; +#[cfg(feature = "subsetting")] +pub use source_font::{ + FontInspectionV0, FontSelectionV0, GlyphInspectionV0, PreparedFontReportV0, PreparedFontV0, + UnicodeRangeV0, inspect_font, prepare_font, +}; /// Return the compile-time-generated C ABI description embedded in this build. pub fn abi_json() -> &'static str { diff --git a/packages/font-baker/rust/src/report.rs b/packages/text/rust/font-baker/src/report.rs similarity index 100% rename from packages/font-baker/rust/src/report.rs rename to packages/text/rust/font-baker/src/report.rs diff --git a/packages/font-baker/rust/src/sfnt.rs b/packages/text/rust/font-baker/src/sfnt.rs similarity index 99% rename from packages/font-baker/rust/src/sfnt.rs rename to packages/text/rust/font-baker/src/sfnt.rs index 1722d85d..2c4f9689 100644 --- a/packages/font-baker/rust/src/sfnt.rs +++ b/packages/text/rust/font-baker/src/sfnt.rs @@ -349,8 +349,9 @@ fn overflow() -> BakeError { mod tests { use super::*; - const INTER: &[u8] = - include_bytes!("../../../../apps/benchmarks/fixtures/fonts/inter-v4.1/Inter-Regular.ttf"); + const INTER: &[u8] = include_bytes!( + "../../../../../apps/benchmarks/fixtures/fonts/inter-v4.1/Inter-Regular.ttf" + ); #[test] fn checksum_pads_partial_words() { diff --git a/packages/text/rust/font-baker/src/source_font.rs b/packages/text/rust/font-baker/src/source_font.rs new file mode 100644 index 00000000..96afac25 --- /dev/null +++ b/packages/text/rust/font-baker/src/source_font.rs @@ -0,0 +1,286 @@ +use read_fonts::{ + FontRef, + collections::IntSet, + tables::name::NameId, + types::{GlyphId, Tag}, +}; +use serde::{Deserialize, Serialize}; +use skera::{DEFAULT_DROP_TABLES, DEFAULT_LAYOUT_FEATURES, Plan, SubsetFlags}; +use skrifa::{GlyphNameSource, MetadataProvider}; +use std::{string::String, string::ToString, vec::Vec}; + +use crate::{BakeError, BakeErrorCode, hex_sha256}; + +const MAX_UNICODE_RANGES: usize = 4_096; +const MAX_UNICODE: u32 = 0x10_ffff; +const SURROGATE_START: u32 = 0xd800; +const SURROGATE_END: u32 = 0xdfff; + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UnicodeRangeV0 { + pub start: u32, + pub end: u32, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct FontSelectionV0 { + pub format_version: u8, + pub font_face_index: u32, + pub unicode_ranges: Vec, +} + +#[derive(Debug)] +pub struct PreparedFontV0 { + pub bytes: Vec, + pub report: PreparedFontReportV0, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PreparedFontReportV0 { + pub format_version: u8, + pub source_bytes: usize, + pub prepared_bytes: usize, + pub font_face_index: u32, + pub glyph_count: u32, + pub sha256: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FontInspectionV0 { + pub format_version: u8, + pub font_face_index: u32, + pub glyph_count: u32, + pub glyph_name_source: GlyphInspectionNameSource, + pub glyphs: Vec, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum GlyphInspectionNameSource { + Post, + Cff, + None, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GlyphInspectionV0 { + pub code_point: u32, + pub glyph_id: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +pub fn prepare_font( + source: &[u8], + selection: FontSelectionV0, +) -> Result { + let unicode_ranges = validate_selection(&selection)?; + let font = parse_font(source, selection.font_face_index)?; + let mut unicodes = IntSet::::empty(); + for range in unicode_ranges { + insert_scalar_range(&mut unicodes, range); + } + + let glyphs = IntSet::::empty(); + let drop_tables = DEFAULT_DROP_TABLES.iter().copied().collect::>(); + let mut layout_scripts = IntSet::::empty(); + layout_scripts.invert(); + let layout_features = DEFAULT_LAYOUT_FEATURES + .iter() + .copied() + .collect::>(); + let mut name_ids = IntSet::::empty(); + name_ids.insert_range(NameId::from(0)..=NameId::from(6)); + let mut name_languages = IntSet::::empty(); + name_languages.insert(0x0409); + let plan = Plan::new( + &glyphs, + &unicodes, + &font, + SubsetFlags::default(), + &drop_tables, + &layout_scripts, + &layout_features, + &name_ids, + &name_languages, + ); + let bytes = skera::subset_font(&font, &plan).map_err(|error| { + BakeError::new( + BakeErrorCode::SubsettingFailed, + format!("font subsetting failed: {error}"), + ) + })?; + let prepared_font = parse_font(&bytes, 0)?; + let glyph_count = prepared_font.glyph_names().num_glyphs(); + let report = PreparedFontReportV0 { + format_version: 0, + source_bytes: source.len(), + prepared_bytes: bytes.len(), + font_face_index: 0, + glyph_count, + sha256: hex_sha256(&bytes), + }; + Ok(PreparedFontV0 { bytes, report }) +} + +pub fn inspect_font(source: &[u8], font_face_index: u32) -> Result { + let font = parse_font(source, font_face_index)?; + let names = font.glyph_names(); + let glyph_name_source = match names.source() { + GlyphNameSource::Post => GlyphInspectionNameSource::Post, + GlyphNameSource::Cff => GlyphInspectionNameSource::Cff, + GlyphNameSource::Synthesized => GlyphInspectionNameSource::None, + }; + let mut glyphs = Vec::new(); + for (code_point, glyph_id) in font.charmap().mappings() { + let name = names + .get(glyph_id) + .filter(|value| !value.is_synthesized()) + .map(|value| value.as_str().to_string()); + glyphs.push(GlyphInspectionV0 { + code_point, + glyph_id: glyph_id.to_u32(), + name, + }); + } + Ok(FontInspectionV0 { + format_version: 0, + font_face_index, + glyph_count: names.num_glyphs(), + glyph_name_source, + glyphs, + }) +} + +fn parse_font(source: &[u8], font_face_index: u32) -> Result, BakeError> { + FontRef::from_index(source, font_face_index).map_err(|error| { + BakeError::new( + BakeErrorCode::InvalidFont, + format!("failed to parse font face {font_face_index}: {error}"), + ) + }) +} + +fn validate_selection(selection: &FontSelectionV0) -> Result<&[UnicodeRangeV0], BakeError> { + if selection.format_version != 0 { + return Err(BakeError::new( + BakeErrorCode::InvalidSelection, + format!( + "unsupported font selection format version {}", + selection.format_version + ), + )); + } + if selection.unicode_ranges.is_empty() { + return Err(BakeError::new( + BakeErrorCode::InvalidSelection, + "font selection requires at least one Unicode range", + )); + } + if selection.unicode_ranges.len() > MAX_UNICODE_RANGES { + return Err(BakeError::new( + BakeErrorCode::InvalidSelection, + format!("font selection exceeds {MAX_UNICODE_RANGES} Unicode ranges"), + )); + } + let mut previous_end = None; + for range in &selection.unicode_ranges { + if range.start > range.end || range.end > MAX_UNICODE { + return Err(BakeError::new( + BakeErrorCode::InvalidSelection, + "Unicode ranges must be ordered inclusive values within U+10FFFF", + )); + } + if previous_end.is_some_and(|end| range.start <= end) { + return Err(BakeError::new( + BakeErrorCode::InvalidSelection, + "Unicode ranges must be sorted and non-overlapping", + )); + } + previous_end = Some(range.end); + } + Ok(&selection.unicode_ranges) +} + +fn insert_scalar_range(unicodes: &mut IntSet, range: &UnicodeRangeV0) { + if range.start < SURROGATE_START { + unicodes.insert_range(range.start..=range.end.min(SURROGATE_START - 1)); + } + if range.end > SURROGATE_END { + unicodes.insert_range(range.start.max(SURROGATE_END + 1)..=range.end); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const INTER: &[u8] = include_bytes!( + "../../../../../apps/benchmarks/fixtures/fonts/inter-v4.1/Inter-Regular.ttf" + ); + + #[test] + fn prepares_a_shaping_complete_ascii_subset() { + let result = prepare_font( + INTER, + FontSelectionV0 { + format_version: 0, + font_face_index: 0, + unicode_ranges: vec![UnicodeRangeV0 { + start: 0x20, + end: 0x7e, + }], + }, + ) + .expect("ASCII subset"); + + assert!(result.bytes.len() < INTER.len()); + assert!(result.report.glyph_count < 200); + let font = FontRef::new(&result.bytes).expect("subset font"); + assert!(font.charmap().map('A').is_some()); + assert!(font.charmap().map('é').is_none()); + } + + #[test] + fn inspection_reports_exact_nominal_glyph_ids() { + let inspection = inspect_font(INTER, 0).expect("inspect Inter"); + let capital_a = inspection + .glyphs + .iter() + .find(|glyph| glyph.code_point == u32::from('A')) + .expect("A mapping"); + + assert_eq!(capital_a.glyph_id, 2); + assert_eq!(capital_a.name.as_deref(), Some("A")); + assert!(inspection.glyph_count > 2_000); + } + + #[test] + fn selection_rejects_overlapping_ranges_before_font_parsing() { + let error = prepare_font( + &[], + FontSelectionV0 { + format_version: 0, + font_face_index: 0, + unicode_ranges: vec![ + UnicodeRangeV0 { + start: 0x20, + end: 0x7e, + }, + UnicodeRangeV0 { + start: 0x7e, + end: 0xff, + }, + ], + }, + ) + .expect_err("overlapping selection"); + + assert_eq!(error.code, BakeErrorCode::InvalidSelection); + } +} diff --git a/packages/font-baker/rust/src/wasm.rs b/packages/text/rust/font-baker/src/wasm.rs similarity index 72% rename from packages/font-baker/rust/src/wasm.rs rename to packages/text/rust/font-baker/src/wasm.rs index ca21dbde..f0312f4f 100644 --- a/packages/font-baker/rust/src/wasm.rs +++ b/packages/text/rust/font-baker/src/wasm.rs @@ -4,6 +4,8 @@ use std::{boxed::Box, string::ToString, vec::Vec}; use serde::Serialize; use crate::{BakeDescriptorV0, BakeResultV0, bake_font}; +#[cfg(feature = "subsetting")] +use crate::{FontSelectionV0, inspect_font, prepare_font}; const MAX_REQUEST_ALLOCATION_BYTES: u32 = 64 * 1024 * 1024; const MAX_RESPONSE_BYTES: usize = MAX_REQUEST_ALLOCATION_BYTES as usize; @@ -13,6 +15,7 @@ static STATE: AtomicUsize = AtomicUsize::new(0); #[global_allocator] static ALLOCATOR: talc::wasm::WasmDynamicTalc = talc::wasm::new_wasm_dynamic_allocator(); +#[cfg(not(feature = "std"))] #[panic_handler] fn panic(_info: &core::panic::PanicInfo<'_>) -> ! { core::arch::wasm32::unreachable() @@ -72,6 +75,71 @@ pub unsafe extern "C" fn pmndrs_font_baker_bake( leak_response(encode_response(result)) } +#[cfg(feature = "subsetting")] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pmndrs_font_baker_prepare( + source_pointer: u32, + source_len: u32, + selection_pointer: u32, + selection_len: u32, +) -> u32 { + let result = with_state(|state| { + let Some(source) = state.owned_bytes(source_pointer, source_len) else { + return Err(crate::BakeError::new( + crate::BakeErrorCode::InvalidSelection, + "font preparation source range is not an active module allocation", + )); + }; + let Some(selection_bytes) = state.owned_bytes(selection_pointer, selection_len) else { + return Err(crate::BakeError::new( + crate::BakeErrorCode::InvalidSelection, + "font selection range is not an active module allocation", + )); + }; + serde_json::from_slice::(selection_bytes) + .map_err(|error| { + crate::BakeError::new(crate::BakeErrorCode::InvalidSelection, error.to_string()) + }) + .and_then(|selection| prepare_font(source, selection)) + }); + leak_response(encode_value_response( + result.map(|prepared| (prepared.report, prepared.bytes)), + )) +} + +#[cfg(feature = "subsetting")] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pmndrs_font_baker_inspect( + source_pointer: u32, + source_len: u32, + descriptor_pointer: u32, + descriptor_len: u32, +) -> u32 { + let result = with_state(|state| { + let Some(source) = state.owned_bytes(source_pointer, source_len) else { + return Err(crate::BakeError::new( + crate::BakeErrorCode::InvalidDescriptor, + "font inspection source range is not an active module allocation", + )); + }; + let Some(descriptor_bytes) = state.owned_bytes(descriptor_pointer, descriptor_len) else { + return Err(crate::BakeError::new( + crate::BakeErrorCode::InvalidDescriptor, + "font inspection descriptor range is not an active module allocation", + )); + }; + serde_json::from_slice::(descriptor_bytes) + .map_err(|error| { + crate::BakeError::new(crate::BakeErrorCode::InvalidDescriptor, error.to_string()) + }) + .and_then(|descriptor| descriptor.validate()) + .and_then(|descriptor| inspect_font(source, descriptor.font_face_index)) + }); + leak_response(encode_value_response( + result.map(|inspection| (inspection, Vec::new())), + )) +} + #[unsafe(no_mangle)] pub extern "C" fn pmndrs_font_baker_result_len() -> u32 { with_state(|state| state.result_len) @@ -116,6 +184,34 @@ fn encode_response(result: Result) -> Vec { Vec::new(), ), }; + encode_envelope(status, metadata, artifact) +} + +#[cfg(feature = "subsetting")] +fn encode_value_response( + result: Result<(Metadata, Vec), crate::BakeError>, +) -> Vec { + match result { + Ok((metadata, artifact)) => encode_envelope( + crate::abi_contract::RESPONSE_SUCCESS_STATUS, + serde_json::to_vec(&metadata).unwrap_or_else(|_| { + b"{\"code\":\"SERIALIZATION_FAILED\",\"message\":\"failed to serialize result\"}" + .to_vec() + }), + artifact, + ), + Err(error) => encode_envelope( + 1, + serde_json::to_vec(&error).unwrap_or_else(|_| { + b"{\"code\":\"SERIALIZATION_FAILED\",\"message\":\"failed to serialize error\"}" + .to_vec() + }), + Vec::new(), + ), + } +} + +fn encode_envelope(status: u32, metadata: Vec, artifact: Vec) -> Vec { let header_len = crate::abi_contract::RESPONSE_HEADER_BYTES as usize; let Ok(metadata_len) = u32::try_from(metadata.len()) else { return encode_response(Err(crate::BakeError::new( diff --git a/packages/font-baker/rust/tests/fuzz_smoke.rs b/packages/text/rust/font-baker/tests/fuzz_smoke.rs similarity index 100% rename from packages/font-baker/rust/tests/fuzz_smoke.rs rename to packages/text/rust/font-baker/tests/fuzz_smoke.rs diff --git a/packages/font-baker/rust/tests/public_api.rs b/packages/text/rust/font-baker/tests/public_api.rs similarity index 94% rename from packages/font-baker/rust/tests/public_api.rs rename to packages/text/rust/font-baker/tests/public_api.rs index 0a7da46a..e376d954 100644 --- a/packages/font-baker/rust/tests/public_api.rs +++ b/packages/text/rust/font-baker/tests/public_api.rs @@ -1,7 +1,7 @@ use pmndrs_text_font_baker::{BakeDescriptorV0, BakeErrorCode, abi_json, bake_font}; const INTER: &[u8] = - include_bytes!("../../../../apps/benchmarks/fixtures/fonts/inter-v4.1/Inter-Regular.ttf"); + include_bytes!("../../../../../apps/benchmarks/fixtures/fonts/inter-v4.1/Inter-Regular.ttf"); #[test] fn generated_abi_is_valid_and_names_the_public_exports() { @@ -20,6 +20,14 @@ fn generated_abi_is_valid_and_names_the_public_exports() { assert_eq!(abi["versions"]["binaryen"], "129.0.0"); assert_eq!(abi["pointerWidth"], 32); assert_eq!(abi["functions"]["bake"]["export"], "pmndrs_font_baker_bake"); + assert_eq!( + abi["functions"]["prepare"]["export"], + "pmndrs_font_baker_prepare" + ); + assert_eq!( + abi["functions"]["inspect"]["export"], + "pmndrs_font_baker_inspect" + ); assert_eq!(abi["response"]["payloadOffset"], 16); } diff --git a/packages/text/rust/shaper/src/engine/cluster_state.rs b/packages/text/rust/shaper/src/engine/cluster_state.rs index 94e75554..39dd6c9d 100644 --- a/packages/text/rust/shaper/src/engine/cluster_state.rs +++ b/packages/text/rust/shaper/src/engine/cluster_state.rs @@ -256,16 +256,7 @@ impl ClusterArena { 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.assign_cluster_ownership(cluster, *shaped_run)?; self.shaped[cluster] = 1; self.glyph_counts[cluster] = self.glyph_counts[cluster] .checked_add(1) @@ -275,6 +266,9 @@ impl ClusterArena { self.advances[cluster] += f64::from(shape.x_advances[glyph].unsigned_abs()) * scale; } } + for shaped_run in shape.runs.iter().filter(|run| run.source_run == source_run) { + self.fill_glyphless_run_ownership(runs, *shaped_run, cluster_start, cluster_end)?; + } 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]) @@ -520,9 +514,9 @@ impl ClusterArena { let mut cluster = 0usize; for offset in 0..=text_length { while self - .starts + .ends .get(cluster) - .is_some_and(|start| *start < offset as u32) + .is_some_and(|end| *end <= offset as u32) { cluster += 1; } @@ -566,19 +560,7 @@ impl ClusterArena { .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.assign_cluster_ownership(cluster_index, *shaped_run)?; self.shaped[cluster_index] = 1; self.glyph_counts[cluster_index] = self.glyph_counts[cluster_index] .checked_add(1) @@ -599,6 +581,9 @@ impl ClusterArena { ) * scale; } } + for shaped_run in &shape.runs { + self.fill_glyphless_run_ownership(runs, *shaped_run, 0, self.starts.len())?; + } let mut glyph_start = 0_u32; for index in 0..self.glyph_starts.len() { self.glyph_starts[index] = glyph_start; @@ -647,6 +632,68 @@ impl ClusterArena { Ok(()) } + fn assign_cluster_ownership( + &mut self, + cluster: usize, + shaped_run: super::shaping_state::ShapedRun, + ) -> Result<(), EngineError> { + let source_slot = &mut self.source_runs[cluster]; + let binding_slot = &mut self.binding_handles[cluster]; + let font_slot = &mut self.font_handles[cluster]; + 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); + } + Ok(()) + } + + fn fill_glyphless_run_ownership( + &mut self, + runs: &[ShapingRun], + shaped_run: super::shaping_state::ShapedRun, + allowed_start: usize, + allowed_end: usize, + ) -> Result<(), EngineError> { + let source_index = + usize::try_from(shaped_run.source_run).map_err(|_| EngineError::InvalidRequest)?; + let source = runs.get(source_index).ok_or(EngineError::InvalidRequest)?; + if shaped_run.text_start < source.text_start + || shaped_run.text_end > source.text_end + || shaped_run.text_start >= shaped_run.text_end + { + return Err(EngineError::InvalidRequest); + } + let cluster_start = self + .ends + .partition_point(|end| *end <= shaped_run.text_start); + let cluster_end = self + .starts + .partition_point(|start| *start < shaped_run.text_end); + if cluster_start < allowed_start + || cluster_end > allowed_end + || cluster_start >= cluster_end + { + return Err(EngineError::InvalidRequest); + } + for cluster in cluster_start..cluster_end { + let source_slot = &mut self.source_runs[cluster]; + let binding_slot = &mut self.binding_handles[cluster]; + let font_slot = &mut self.font_handles[cluster]; + if *source_slot == NO_SOURCE_RUN { + *source_slot = shaped_run.source_run; + *binding_slot = shaped_run.binding_handle; + *font_slot = shaped_run.font_handle; + } + } + Ok(()) + } + fn apply_break_flags(&mut self, unicode: &UnicodeAnalysis) -> Result<(), EngineError> { for line_break in unicode.line_breaks() { let end = line_break.position; @@ -680,7 +727,12 @@ impl ClusterArena { .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) { + if !self + .starts + .get(index) + .zip(self.ends.get(index)) + .is_some_and(|(start, end)| *start <= offset && offset < *end) + { return Err(EngineError::InvalidRequest); } Ok(index) @@ -880,6 +932,139 @@ mod tests { assert_eq!(index.capacities(), capacities); } + #[test] + fn glyphless_ligature_continuation_inherits_shape_run_ownership() { + let text: Vec = "ff".encode_utf16().collect(); + let mut unicode = UnicodeAnalysis::default(); + unicode.analyze(&text).unwrap(); + let style = ResolvedStyle::test_typography(16.0, 0.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 shape = ShapeArena { + runs: vec![ShapedRun { + source_run: 0, + binding_handle: 19, + font_handle: 9, + text_start: 0, + text_end: 2, + glyph_start: 0, + glyph_count: 1, + }], + glyph_ids: vec![42], + clusters: vec![0], + x_advances: vec![1_000], + y_advances: vec![0], + x_offsets: vec![0], + y_offsets: vec![0], + glyph_flags: vec![GLYPH_UNSAFE_TO_BREAK], + }; + let mut clusters = ClusterArena::default(); + clusters + .build( + ClusterBuildInput { + text: &text, + text_unit_ids: &[1, 2], + unicode: &unicode, + styles: &styles, + runs: &runs, + shape: &shape, + }, + |_| { + Some(FontMetrics { + units_per_em: 1_000, + ascender: 800, + descender: -200, + line_gap: 0, + }) + }, + ) + .unwrap(); + + assert_eq!(clusters.source_runs, [0, 0]); + assert_eq!(clusters.binding_handles, [19, 19]); + assert_eq!(clusters.font_handles, [9, 9]); + assert_eq!(clusters.glyph_counts, [1, 0]); + assert_eq!(clusters.advances, [16.0, 0.0]); + assert_eq!(clusters.flags[1] & CLUSTER_SAFE_BEFORE, 0); + } + + #[test] + fn reordered_glyph_offset_maps_to_its_containing_grapheme() { + let text: Vec = "त्ये".encode_utf16().collect(); + let mut unicode = UnicodeAnalysis::default(); + unicode.analyze(&text).unwrap(); + assert_eq!(unicode.grapheme_boundaries(), &[0, 4]); + let style = ResolvedStyle::test_typography(16.0, 0.0, 0.0); + let styles = [StyleSegment { + text_start: 0, + text_end: 4, + style, + }]; + let runs = [ShapingRun { + text_start: 0, + text_end: 4, + script: u32::from_be_bytes(*b"Deva"), + direction: 4, + bidi_level: 0, + style, + }]; + let shape = ShapeArena { + runs: vec![ShapedRun { + source_run: 0, + binding_handle: 19, + font_handle: 9, + text_start: 0, + text_end: 4, + glyph_start: 0, + glyph_count: 1, + }], + glyph_ids: vec![42], + clusters: vec![2], + x_advances: vec![1_000], + y_advances: vec![0], + x_offsets: vec![0], + y_offsets: vec![0], + glyph_flags: vec![GLYPH_UNSAFE_TO_BREAK], + }; + 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, + }, + |_| { + Some(FontMetrics { + units_per_em: 1_000, + ascender: 800, + descender: -200, + line_gap: 0, + }) + }, + ) + .unwrap(); + + assert_eq!(clusters.index_at, [0, 0, 0, 0, 1]); + assert_eq!(clusters.glyph_counts, [1]); + assert_eq!(clusters.glyph_indices, [0]); + assert_eq!(clusters.source_runs, [0]); + } + #[test] fn retained_source_run_rebuild_matches_the_cold_cluster_oracle() { let old_text: Vec = "ab".encode_utf16().collect(); diff --git a/packages/text/rust/shaper/src/engine/flow_composition.rs b/packages/text/rust/shaper/src/engine/flow_composition.rs index 59043d91..4cc58da0 100644 --- a/packages/text/rust/shaper/src/engine/flow_composition.rs +++ b/packages/text/rust/shaper/src/engine/flow_composition.rs @@ -6,7 +6,7 @@ use super::{ EngineError, cluster_state::{CLUSTER_HARD_BREAK, ClusterArena}, flow_geometry::{FlowGeometryArena, InlineSlotArena}, - frame::{OVERFLOW_ELLIPSIS, OVERFLOW_VISIBLE, WRITING_HORIZONTAL_TB}, + frame::{OVERFLOW_CLIP, OVERFLOW_ELLIPSIS, WRITING_HORIZONTAL_TB}, line_composition::{ComposedLine, LineCursor, layout_next_line}, style_state::StyleSegment, }; @@ -164,10 +164,10 @@ impl FlowLayoutArena { constraint.flow_thread_id, region.record.id, region.record.transform_index, - if constraint.overflow == OVERFLOW_VISIBLE { - 0 - } else { + if constraint.overflow == OVERFLOW_CLIP { region.record.id + } else { + 0 }, clusters, styles, @@ -731,7 +731,8 @@ mod tests { 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, + OVERFLOW_CLIP, OVERFLOW_ELLIPSIS, OVERFLOW_VISIBLE, SHAPE_RECTANGLE, WRAP_CHARACTER, + WRAP_NONE, }, semantic_wire::{FlowConstraint, FlowExclusion, FlowRegion}, style_state::ResolvedStyle, @@ -885,6 +886,67 @@ mod tests { assert_eq!(layout.fragments.last().unwrap().line.cluster_end, 4); } + #[test] + fn only_clip_overflow_assigns_a_clip_id_to_lines() { + let clusters = ClusterArena { + starts: vec![0, 1, 2], + ends: vec![1, 2, 3], + advances: vec![2.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 metrics = |_| { + Some(FontMetrics { + units_per_em: 1_000, + ascender: 800, + descender: -200, + line_gap: 0, + }) + }; + + for (overflow, expected_clip_id) in [ + (OVERFLOW_VISIBLE, 0), + (OVERFLOW_ELLIPSIS, 0), + (OVERFLOW_CLIP, 7), + ] { + let mut flow = constraint(); + flow.overflow = overflow; + let mut flow_region = region(); + flow_region.exclusion_count = 0; + let geometry = FlowGeometryArena { + constraints: vec![flow], + regions: vec![RetainedRegion { + record: flow_region, + vertex_start: 0, + }], + ..FlowGeometryArena::default() + }; + let mut layout = FlowLayoutArena::default(); + layout + .build( + &geometry, + &clusters, + &styles, + &mut InlineSlotArena::default(), + 8, + 1, + metrics, + |_| Some(1), + ) + .unwrap(); + assert_eq!(layout.lines[0].clip_id, expected_clip_id); + } + } + #[test] fn localized_edit_recomposes_one_line_and_reuses_converged_prefix_and_suffix() { let make_clusters = |advances: Vec| ClusterArena { @@ -1246,6 +1308,7 @@ mod tests { ) .unwrap(); assert_eq!(layout.fragments[0].line.cluster_end, 3); + assert_eq!(layout.lines[0].clip_id, 0); assert_eq!(layout.ellipsis_threads(), [constraint.flow_thread_id]); } diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index fea5fcbb..b2d28d98 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -1786,7 +1786,11 @@ impl ParagraphState { fn prepare_shaping_runs(&mut self) -> Result<(), EngineError> { self.abort_shaping_runs(); - if !self.text_prepared && !self.style_invalidation.shaping && !self.bidi_prepared { + if !self.text_prepared + && !self.style_invalidation.shaping + && !self.style_invalidation.metrics + && !self.bidi_prepared + { return Ok(()); } let text = if self.text_prepared { @@ -1839,7 +1843,12 @@ impl ParagraphState { font_bindings: &[RegisteredFontBinding], ) -> Result<(), EngineError> { self.abort_shape(); - if !self.shaping_runs_prepared { + // Metric-only styles must refresh the retained run values consumed by cluster aggregation, but the underlying + // HarfRust result remains valid. Keeping those two invalidations distinct avoids reshaping on size, tracking, + // word-spacing, line-height, or baseline changes while still rebuilding advances from the new run styles. + if !self.shaping_runs_prepared + || (!self.text_prepared && !self.style_invalidation.shaping && !self.bidi_prepared) + { return Ok(()); } if self.try_prepare_incremental_shape(shaper)? { diff --git a/packages/text/scripts/benchmark-rust-layout-engine.mjs b/packages/text/scripts/benchmark-rust-layout-engine.mjs index bffd5885..0682fa34 100644 --- a/packages/text/scripts/benchmark-rust-layout-engine.mjs +++ b/packages/text/scripts/benchmark-rust-layout-engine.mjs @@ -1,14 +1,14 @@ /* @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.", + "requirements": "Built @pmndrs/text and @pmndrs/text/bake 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 { validateFontArtifact } from '@pmndrs/text/bake'; import { validateBitmapArtifact } from '@pmndrs/text/bakers/bitmap/validate'; import { validateMsdfArtifact } from '@pmndrs/text/bakers/msdf/validate'; import { validateSlugArtifact } from '@pmndrs/text/bakers/slug/validate'; diff --git a/packages/text/scripts/build-engine-kernel-lab.mjs b/packages/text/scripts/build-engine-kernel-lab.mjs index 5cc1f348..f7fdb9ed 100644 --- a/packages/text/scripts/build-engine-kernel-lab.mjs +++ b/packages/text/scripts/build-engine-kernel-lab.mjs @@ -2,7 +2,7 @@ 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'; +import { reproducibleRustEnvironment } from './support/reproducible-rust-env.mjs'; const packageRoot = fileURLToPath(new URL('../', import.meta.url)); const workspaceRoot = fileURLToPath(new URL('../../../', import.meta.url)); diff --git a/packages/text/scripts/build.mjs b/packages/text/scripts/build.mjs index 0100638d..d13a2666 100644 --- a/packages/text/scripts/build.mjs +++ b/packages/text/scripts/build.mjs @@ -1,11 +1,11 @@ import { spawn } from 'node:child_process'; -import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { chmod, copyFile, 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'; -import { reproducibleRustEnvironment } from '../../font-baker/scripts/reproducible-rust-env.mjs'; -import { writeGeneratedTypescriptAbi } from '../../font-baker/scripts/generated-typescript-abi.mjs'; +import { captureCommand } from './support/capture-command.mjs'; +import { writeGeneratedTypescriptAbi } from './support/generated-typescript-abi.mjs'; +import { reproducibleRustEnvironment } from './support/reproducible-rust-env.mjs'; const packageRoot = fileURLToPath(new URL('../', import.meta.url)); const workspaceRoot = fileURLToPath(new URL('../../../', import.meta.url)); @@ -52,8 +52,12 @@ const mtsdfWasm = join(mtsdfArtifactTargetDirectory, 'wasm32-unknown-unknown/rel const distributedMtsdfWasm = fileURLToPath(new URL('../dist/mtsdf_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 fontBakerWasm = fileURLToPath( + new URL('../rust/font-baker/target/wasm32-unknown-unknown/release/pmndrs_text_font_baker.wasm', import.meta.url), +); +const distributedFontBakerWasm = fileURLToPath(new URL('../dist/font_baker.wasm', import.meta.url)); -const [bitmapAbiJson, shaperAbiJson, mtsdfAbiJson, slugAbiJson] = await Promise.all([ +const [bitmapAbiJson, shaperAbiJson, mtsdfAbiJson, slugAbiJson, fontBakerAbiJson] = await Promise.all([ runCapture('cargo', [ 'run', '--manifest-path', @@ -90,6 +94,15 @@ const [bitmapAbiJson, shaperAbiJson, mtsdfAbiJson, slugAbiJson] = await Promise. '--locked', '--quiet', ]), + runCapture('cargo', [ + 'run', + '--manifest-path', + 'rust/font-baker/Cargo.toml', + '--bin', + 'generate-abi', + '--locked', + '--quiet', + ]), ]); await Promise.all([ writeGeneratedTypescriptAbi( @@ -116,6 +129,12 @@ await Promise.all([ slugAbiJson, { check: process.env.CI === 'true' }, ), + writeGeneratedTypescriptAbi( + new URL('../src/font-baker/generated/font-baker-abi.ts', import.meta.url), + 'fontBakerAbi', + fontBakerAbiJson, + { check: process.env.CI === 'true' }, + ), ]); await run( @@ -179,6 +198,22 @@ await run( ], shaperRustEnvironment, ); +await run( + 'cargo', + [ + 'build', + '--manifest-path', + 'rust/font-baker/Cargo.toml', + '--target', + 'wasm32-unknown-unknown', + '--release', + '--locked', + '--no-default-features', + '--features', + 'subsetting', + ], + rustEnvironment, +); await rm(new URL('../dist/', import.meta.url), { recursive: true, force: true }); await mkdir(new URL('../dist/', import.meta.url), { recursive: true }); await run(tsc, ['-p', 'tsconfig.build.json']); @@ -215,6 +250,14 @@ await run(wasmOpt, [ '-o', distributedSlugWasm, ]); +await run(wasmOpt, [ + '--enable-bulk-memory', + '--enable-nontrapping-float-to-int', + '-Oz', + fontBakerWasm, + '-o', + distributedFontBakerWasm, +]); await Promise.all([ assertMtsdfArtifactBakerExports(distributedMtsdfWasm, mtsdfAbiJson), assertSlugArtifactBakerExports(distributedSlugWasm, slugAbiJson), @@ -223,6 +266,16 @@ await writeFile(new URL('../dist/bitmap-baker-abi-v0.json', import.meta.url), bi 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); await writeFile(new URL('../dist/slug-baker-abi-v0.json', import.meta.url), slugAbiJson); +await writeFile(new URL('../dist/font-baker-abi-v0.json', import.meta.url), fontBakerAbiJson); +await mkdir(new URL('../dist/font-baker/schemas/', import.meta.url), { recursive: true }); +await copyFile( + new URL('../src/font-baker/schemas/KHRONOS-SPEC-LICENSE.txt', import.meta.url), + new URL('../dist/font-baker/schemas/KHRONOS-SPEC-LICENSE.txt', import.meta.url), +); +await copyFile( + new URL('../src/font-baker/schemas/README.md', import.meta.url), + new URL('../dist/font-baker/schemas/README.md', import.meta.url), +); if (process.platform !== 'win32') { await chmod(new URL('../dist/node/cli.js', import.meta.url), 0o755); } diff --git a/packages/text/scripts/fixtures.mts b/packages/text/scripts/fixtures.mts index f6ef631a..701bc73d 100644 --- a/packages/text/scripts/fixtures.mts +++ b/packages/text/scripts/fixtures.mts @@ -2,7 +2,7 @@ { "name": "text:bitmap-fixture:generate", "summary": "Regenerate the canonical Bitmap artifact through the public baker.", - "requirements": "Stable Rust, Binaryen, and the font-baker workspace package.", + "requirements": "Stable Rust, Binaryen, and the @pmndrs/text package build.", "writes": "The checked-in canonical Bitmap fixture.", "args": ["bitmap"] } @@ -13,7 +13,6 @@ import { commandArguments, isMainModule, runNode, runPnpm } from './support/comm export async function runFixtures(arguments_: readonly string[]): Promise { const { command, rest } = commandArguments(arguments_, 'fixtures'); if (command !== 'bitmap') throw new Error(`Unknown fixture command: ${command}`); - await runPnpm(['--filter', '@pmndrs/text-font-baker', 'build']); await runPnpm(['run', 'build']); await runNode('scripts/generate-bitmap-fixture.mjs', rest); } diff --git a/packages/font-baker/scripts/font-fixtures.mts b/packages/text/scripts/font-baker/font-fixtures.mts similarity index 92% rename from packages/font-baker/scripts/font-fixtures.mts rename to packages/text/scripts/font-baker/font-fixtures.mts index 1c45fcc8..a450dc87 100644 --- a/packages/font-baker/scripts/font-fixtures.mts +++ b/packages/text/scripts/font-baker/font-fixtures.mts @@ -17,7 +17,7 @@ } */ -import { commandArguments, isMainModule, runCargo } from './support/command.mts'; +import { commandArguments, isMainModule, runCargo } from '../support/command.mts'; export async function runFontFixtures(arguments_: readonly string[]): Promise { const { command, rest } = commandArguments(arguments_, 'font-fixtures'); @@ -31,7 +31,7 @@ export async function runFontFixtures(arguments_: readonly string[]): Promise { const { command, rest } = commandArguments(arguments_, 'fuzz'); switch (command) { case 'validator': await runNode('scripts/build.mjs'); - await runNode('scripts/fuzz-validator.mjs', rest); + await runNode('scripts/font-baker/fuzz-validator.mjs', rest); return; case 'rust': - await runNode('scripts/fuzz-rust.mjs', rest); + await runNode('scripts/font-baker/fuzz-rust.mjs', rest); return; case 'mtsdf': - await runNode('scripts/fuzz-rust.mjs', ['mtsdf_outline', ...rest]); + await runNode('scripts/font-baker/fuzz-rust.mjs', ['mtsdf_outline', ...rest]); return; case 'mutation': - await runNode('scripts/fuzz-rust-mutation.mjs', rest); + await runNode('scripts/font-baker/fuzz-rust-mutation.mjs', rest); return; default: throw new Error(`Unknown fuzz command: ${command}`); diff --git a/packages/text/scripts/generate-bitmap-fixture.mjs b/packages/text/scripts/generate-bitmap-fixture.mjs index 102cd623..600ab5d6 100644 --- a/packages/text/scripts/generate-bitmap-fixture.mjs +++ b/packages/text/scripts/generate-bitmap-fixture.mjs @@ -1,8 +1,8 @@ import { createHash } from 'node:crypto'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { createFontBaker } from '@pmndrs/text-font-baker'; -import { fontBakerWasmUrl } from '@pmndrs/text-font-baker/wasm-url'; +import { createFontBaker } from '@pmndrs/text/bake'; +import { fontBakerWasmUrl } from '@pmndrs/text/bake'; import { bitmapBakerFromCore, createBitmapBaker } from '../dist/bakers/bitmap.js'; import { validateBitmapArtifact } from '../dist/bakers/bitmap-validator.js'; diff --git a/packages/text/scripts/profile-mtsdf-baker.mjs b/packages/text/scripts/profile-mtsdf-baker.mjs index 34434f97..d6bc0b67 100644 --- a/packages/text/scripts/profile-mtsdf-baker.mjs +++ b/packages/text/scripts/profile-mtsdf-baker.mjs @@ -18,7 +18,7 @@ import { performance } from 'node:perf_hooks'; import { fileURLToPath } from 'node:url'; import { Worker as NodeWorker } from 'node:worker_threads'; -import { reproducibleRustEnvironment } from '../../font-baker/scripts/reproducible-rust-env.mjs'; +import { reproducibleRustEnvironment } from './support/reproducible-rust-env.mjs'; const packageRoot = fileURLToPath(new URL('../', import.meta.url)); const workspaceRoot = fileURLToPath(new URL('../../../', import.meta.url)); diff --git a/packages/font-baker/scripts/capture-command.mjs b/packages/text/scripts/support/capture-command.mjs similarity index 100% rename from packages/font-baker/scripts/capture-command.mjs rename to packages/text/scripts/support/capture-command.mjs diff --git a/packages/font-baker/scripts/generated-typescript-abi.mjs b/packages/text/scripts/support/generated-typescript-abi.mjs similarity index 100% rename from packages/font-baker/scripts/generated-typescript-abi.mjs rename to packages/text/scripts/support/generated-typescript-abi.mjs diff --git a/packages/text/scripts/support/render-technique-proof.mjs b/packages/text/scripts/support/render-technique-proof.mjs index 26744e68..b326a835 100644 --- a/packages/text/scripts/support/render-technique-proof.mjs +++ b/packages/text/scripts/support/render-technique-proof.mjs @@ -14,13 +14,18 @@ export function techniqueProof(abi, name, raster, allocation = 'ordered') { 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); + const binding = { + width: Math.max(...strike.pages.map((page) => page.width)), + height: Math.max(...strike.pages.map((page) => page.height)), + }; + const fields = denseAtlasFields(view, raster.glyphCount, strike.planeUnitsPerEm, strike.pages, binding); return proof(abi, bitmapProgram(abi, 'strike'), allocation, { glyphCount: raster.glyphCount, strikes: [strike.ppem], - resources: strike.pages.map(resource), - resourceIndices: pageIndices(view, raster.glyphCount), + resources: [resource(undefined, 0)], + resourceIndices: pageIndices(view, raster.glyphCount, true), strikeF32: fields, + strikeU32: [field(raster.glyphCount, (record) => view.getUint16(record + 16, true))], }); } @@ -121,9 +126,10 @@ function proof(abi, descriptor, allocation, binding) { } function bitmapProgram(abi, glyphScope) { - const context = programContext(abi, glyphScope, 8, 0); - const { loadF32, binary, storeF32 } = context; + const context = programContext(abi, glyphScope, 8, 1); + const { loadF32, loadU32, binary, storeF32, storeU32 } = context; loadF32(15); + loadU32(29, 0); binary('multiplyF32', 15, 7, 2); binary('addF32', 16, 0, 15); binary('multiplyF32', 17, 8, 2); @@ -137,7 +143,8 @@ function bitmapProgram(abi, glyphScope) { [4, [13, 14]], [5, [3, 4, 5, 6]], ]); - return program(context, floatBuffers(abi, [2, 2, 2, 2, 4])); + storeU32(6, 0, 29); + return program(context, [...floatBuffers(abi, [2, 2, 2, 2, 4]), ...uintBuffers(abi, [1], 6)]); } function mtsdfProgram(abi) { diff --git a/packages/font-baker/scripts/reproducible-rust-env.mjs b/packages/text/scripts/support/reproducible-rust-env.mjs similarity index 100% rename from packages/font-baker/scripts/reproducible-rust-env.mjs rename to packages/text/scripts/support/reproducible-rust-env.mjs diff --git a/packages/text/scripts/test.mts b/packages/text/scripts/test.mts index 47f86534..0fb09a56 100644 --- a/packages/text/scripts/test.mts +++ b/packages/text/scripts/test.mts @@ -1,9 +1,10 @@ import { isMainModule, runCargo, runNode, runNodeTests } from './support/command.mts'; -const completeRustManifests = [ - 'rust/bitmap-baker/Cargo.toml', - 'rust/shaper/Cargo.toml', - 'rust/slug-baker/Cargo.toml', +const completeRustTargets = [ + { manifest: 'rust/bitmap-baker/Cargo.toml' }, + { manifest: 'rust/font-baker/Cargo.toml', features: 'subsetting' }, + { manifest: 'rust/shaper/Cargo.toml' }, + { manifest: 'rust/slug-baker/Cargo.toml' }, ] as const; const libraryRustManifests = [ @@ -18,8 +19,14 @@ export async function runTextTest(): Promise { await runNode('scripts/unicode.mts', ['check-data']); await runNode('scripts/build.mjs'); await runNode('node_modules/typescript/bin/tsc', ['-p', 'tsconfig.types.json']); - for (const manifest of completeRustManifests) { - await runCargo(['test', '--manifest-path', manifest, '--locked']); + for (const target of completeRustTargets) { + await runCargo([ + 'test', + '--manifest-path', + target.manifest, + '--locked', + ...('features' in target ? ['--features', target.features] : []), + ]); } for (const manifest of libraryRustManifests) { await runCargo(['test', '--manifest-path', manifest, '--lib', '--locked']); @@ -29,6 +36,9 @@ export async function runTextTest(): Promise { await runNode('scripts/sync-unicode-test-data.mjs', ['--check']); await runNodeTests(['tests/package/*.test.mjs', 'tests/integration/*.test.mjs']); await runNodeTests(['tests/fuzz/*.test.mjs']); + await runNodeTests(['tests/font-baker/integration/*.test.mjs']); + await runNodeTests(['tests/font-baker/fuzz/*.test.mjs']); + await runNodeTests(['tests/font-baker/e2e/*.test.mjs']); } if (isMainModule(import.meta.url)) { diff --git a/packages/text/src/bake.ts b/packages/text/src/bake.ts index 18766e82..d40d1bda 100644 --- a/packages/text/src/bake.ts +++ b/packages/text/src/bake.ts @@ -1,7 +1,7 @@ import type { JsonValue } from './raster.js'; import type { RasterKey, Sha256Hex } from './identity.js'; -export type { BakeWarning, SerializedBakeError } from '@pmndrs/text-font-baker'; +export type { BakeWarning, SerializedBakeError } from './font-baker/index.js'; export type BakeProgressPhase = | 'queued' diff --git a/packages/text/src/bakers/bitmap-validator.ts b/packages/text/src/bakers/bitmap-validator.ts index af35ea6d..a41c1576 100644 --- a/packages/text/src/bakers/bitmap-validator.ts +++ b/packages/text/src/bakers/bitmap-validator.ts @@ -5,7 +5,7 @@ import { validateWithKhronos, type KhronosValidationReport, type ParsedGlb, -} from '@pmndrs/text-font-baker/validate'; +} from '../font-baker/validator.js'; import { VK_FORMAT_ASTC_4x4_UNORM_BLOCK, VK_FORMAT_BC4_UNORM_BLOCK, diff --git a/packages/text/src/bakers/msdf-validator.ts b/packages/text/src/bakers/msdf-validator.ts index 7b209335..fb979468 100644 --- a/packages/text/src/bakers/msdf-validator.ts +++ b/packages/text/src/bakers/msdf-validator.ts @@ -5,7 +5,7 @@ import { validateWithKhronos, type KhronosValidationReport, type ParsedGlb, -} from '@pmndrs/text-font-baker/validate'; +} from '../font-baker/validator.js'; import { KHR_DF_CHANNEL_RGBSDA_ALPHA, KHR_DF_CHANNEL_RGBSDA_BLUE, diff --git a/packages/text/src/bakers/slug-validator.ts b/packages/text/src/bakers/slug-validator.ts index fade7ad8..cd8fbf7e 100644 --- a/packages/text/src/bakers/slug-validator.ts +++ b/packages/text/src/bakers/slug-validator.ts @@ -5,7 +5,7 @@ import { validateWithKhronos, type KhronosValidationReport, type ParsedGlb, -} from '@pmndrs/text-font-baker/validate'; +} from '../font-baker/validator.js'; import { KHR_DF_CHANNEL_RGBSDA_ALPHA, KHR_DF_CHANNEL_RGBSDA_BLUE, diff --git a/packages/font-baker/src/contract.ts b/packages/text/src/font-baker/contract.ts similarity index 100% rename from packages/font-baker/src/contract.ts rename to packages/text/src/font-baker/contract.ts diff --git a/packages/font-baker/src/generated/font-baker-abi.ts b/packages/text/src/font-baker/generated/font-baker-abi.ts similarity index 77% rename from packages/font-baker/src/generated/font-baker-abi.ts rename to packages/text/src/font-baker/generated/font-baker-abi.ts index 942e1eff..6f3e2634 100644 --- a/packages/font-baker/src/generated/font-baker-abi.ts +++ b/packages/text/src/font-baker/generated/font-baker-abi.ts @@ -26,6 +26,26 @@ export const fontBakerAbi = { "byteLength" ] }, + "inspect": { + "export": "pmndrs_font_baker_inspect", + "parameters": [ + "sourcePointer", + "sourceByteLength", + "descriptorPointer", + "descriptorByteLength" + ], + "result": "responsePointer" + }, + "prepare": { + "export": "pmndrs_font_baker_prepare", + "parameters": [ + "sourcePointer", + "sourceByteLength", + "selectionPointer", + "selectionByteLength" + ], + "result": "responsePointer" + }, "responseByteLength": { "export": "pmndrs_font_baker_result_len", "parameters": [], diff --git a/packages/font-baker/src/gltf-validator.d.ts b/packages/text/src/font-baker/gltf-validator.d.ts similarity index 100% rename from packages/font-baker/src/gltf-validator.d.ts rename to packages/text/src/font-baker/gltf-validator.d.ts diff --git a/packages/font-baker/src/index.ts b/packages/text/src/font-baker/index.ts similarity index 63% rename from packages/font-baker/src/index.ts rename to packages/text/src/font-baker/index.ts index 80f2bc68..e70c8c0a 100644 --- a/packages/font-baker/src/index.ts +++ b/packages/text/src/font-baker/index.ts @@ -14,6 +14,55 @@ export interface FontBakeRequestV0 { readonly descriptor: FontBakeDescriptorV0; } +export interface UnicodeRangeV0 { + readonly start: number; + readonly end: number; +} + +export interface FontSelectionV0 { + readonly formatVersion: 0; + readonly fontFaceIndex: number; + readonly unicodeRanges: readonly UnicodeRangeV0[]; +} + +export interface FontPrepareRequestV0 { + readonly source: Uint8Array; + readonly selection: FontSelectionV0; +} + +export interface PreparedFontReportV0 { + readonly formatVersion: 0; + readonly sourceBytes: number; + readonly preparedBytes: number; + readonly fontFaceIndex: 0; + readonly glyphCount: number; + readonly sha256: string; +} + +export interface PreparedFontV0 { + readonly bytes: Uint8Array; + readonly report: PreparedFontReportV0; +} + +export interface FontInspectRequestV0 { + readonly source: Uint8Array; + readonly descriptor: FontBakeDescriptorV0; +} + +export interface GlyphInspectionV0 { + readonly codePoint: number; + readonly glyphId: number; + readonly name?: string; +} + +export interface FontInspectionV0 { + readonly formatVersion: 0; + readonly fontFaceIndex: number; + readonly glyphCount: number; + readonly glyphNameSource: 'post' | 'cff' | 'none'; + readonly glyphs: readonly GlyphInspectionV0[]; +} + export interface BakeArtifactV0 { readonly role: 'font'; readonly id: string; @@ -86,6 +135,8 @@ export class FontBakeError extends Error { export interface FontBakeCore { bake(request: FontBakeRequestV0): FontBakeResultV0; + prepare(request: FontPrepareRequestV0): PreparedFontV0; + inspect(request: FontInspectRequestV0): FontInspectionV0; } export type FontBakerWasmSource = BufferSource | WebAssembly.Module; @@ -118,38 +169,24 @@ export function createFontBakerFromInstance(instance: WebAssembly.Instance): Fon return { bake({ source, descriptor }) { - const descriptorBytes = textEncoder.encode(JSON.stringify(descriptor)); - let sourcePointer = 0; - let descriptorPointer = 0; - let responsePointer = 0; - let responseLength = 0; - try { - sourcePointer = copyIntoWasm(exports, source); - descriptorPointer = copyIntoWasm(exports, descriptorBytes); - responsePointer = exports.pmndrs_font_baker_bake( - sourcePointer, - source.byteLength, - descriptorPointer, - descriptorBytes.byteLength, - ); - responseLength = exports.pmndrs_font_baker_result_len(); - const response = new Uint8Array(exports.memory.buffer, responsePointer, responseLength); - return decodeResponse(response, fontBakerAbi); - } finally { - if (sourcePointer !== 0) { - exports.pmndrs_font_baker_dealloc(sourcePointer, source.byteLength); - } - if (descriptorPointer !== 0) { - exports.pmndrs_font_baker_dealloc(descriptorPointer, descriptorBytes.byteLength); - } - if (responsePointer !== 0 && responseLength !== 0) { - exports.pmndrs_font_baker_dealloc(responsePointer, responseLength); - } - } + return invoke(exports, exports.pmndrs_font_baker_bake, source, descriptor, decodeBakeResponse); + }, + prepare({ source, selection }) { + return invoke(exports, exports.pmndrs_font_baker_prepare, source, selection, decodePreparedFont); + }, + inspect({ source, descriptor }) { + return invoke(exports, exports.pmndrs_font_baker_inspect, source, descriptor, decodeFontInspection); }, }; } +type FontBakerOperation = ( + sourcePointer: number, + sourceLength: number, + descriptorPointer: number, + descriptorLength: number, +) => number; + interface FontBakerExports { readonly memory: WebAssembly.Memory; readonly pmndrs_font_baker_alloc: (length: number) => number; @@ -160,6 +197,8 @@ interface FontBakerExports { descriptorPointer: number, descriptorLength: number, ) => number; + readonly pmndrs_font_baker_prepare: FontBakerOperation; + readonly pmndrs_font_baker_inspect: FontBakerOperation; readonly pmndrs_font_baker_result_len: () => number; } @@ -168,25 +207,63 @@ function readExports(exports: WebAssembly.Exports, abi: FontBakerAbiV0): FontBak const alloc = exports[abi.functions.allocate.export]; const dealloc = exports[abi.functions.deallocate.export]; const bake = exports[abi.functions.bake.export]; + const prepare = exports[abi.functions.prepare.export]; + const inspect = exports[abi.functions.inspect.export]; const resultLen = exports[abi.functions.responseByteLength.export]; if ( !(memory instanceof WebAssembly.Memory) || typeof alloc !== 'function' || typeof dealloc !== 'function' || typeof bake !== 'function' || + typeof prepare !== 'function' || + typeof inspect !== 'function' || typeof resultLen !== 'function' ) { - throw new TypeError('invalid @pmndrs/text-font-baker Wasm exports'); + throw new TypeError('invalid @pmndrs/text bake Wasm exports'); } return { memory, pmndrs_font_baker_alloc: alloc as FontBakerExports['pmndrs_font_baker_alloc'], pmndrs_font_baker_dealloc: dealloc as FontBakerExports['pmndrs_font_baker_dealloc'], pmndrs_font_baker_bake: bake as FontBakerExports['pmndrs_font_baker_bake'], + pmndrs_font_baker_prepare: prepare as FontBakerExports['pmndrs_font_baker_prepare'], + pmndrs_font_baker_inspect: inspect as FontBakerExports['pmndrs_font_baker_inspect'], pmndrs_font_baker_result_len: resultLen as FontBakerExports['pmndrs_font_baker_result_len'], }; } +function invoke( + exports: FontBakerExports, + operation: FontBakerOperation, + source: Uint8Array, + descriptor: unknown, + decode: (bytes: Uint8Array, abi: FontBakerAbiV0) => Result, +): Result { + const descriptorBytes = textEncoder.encode(JSON.stringify(descriptor)); + let sourcePointer = 0; + let descriptorPointer = 0; + let responsePointer = 0; + let responseLength = 0; + try { + sourcePointer = copyIntoWasm(exports, source); + descriptorPointer = copyIntoWasm(exports, descriptorBytes); + responsePointer = operation(sourcePointer, source.byteLength, descriptorPointer, descriptorBytes.byteLength); + responseLength = exports.pmndrs_font_baker_result_len(); + const response = new Uint8Array(exports.memory.buffer, responsePointer, responseLength); + return decode(response, fontBakerAbi); + } finally { + if (sourcePointer !== 0) { + exports.pmndrs_font_baker_dealloc(sourcePointer, source.byteLength); + } + if (descriptorPointer !== 0) { + exports.pmndrs_font_baker_dealloc(descriptorPointer, descriptorBytes.byteLength); + } + if (responsePointer !== 0 && responseLength !== 0) { + exports.pmndrs_font_baker_dealloc(responsePointer, responseLength); + } + } +} + function copyIntoWasm(exports: FontBakerExports, bytes: Uint8Array): number { const pointer = exports.pmndrs_font_baker_alloc(bytes.byteLength); if (pointer === 0 && bytes.byteLength !== 0) { @@ -201,7 +278,7 @@ function copyIntoWasm(exports: FontBakerExports, bytes: Uint8Array): number { } } -function decodeResponse(bytes: Uint8Array, abi: FontBakerAbiV0): FontBakeResultV0 { +function decodeEnvelope(bytes: Uint8Array, abi: FontBakerAbiV0): { metadata: unknown; artifact: Uint8Array } { const response = abi.response; if ( bytes.byteLength < response.headerByteLength || @@ -223,13 +300,20 @@ function decodeResponse(bytes: Uint8Array, abi: FontBakerAbiV0): FontBakeResultV if (status !== response.successStatus) { throw new FontBakeError(parseSerializedBakeError(metadata)); } + return { + metadata, + artifact: bytes.slice(response.payloadOffset + metadataLength), + }; +} + +function decodeBakeResponse(bytes: Uint8Array, abi: FontBakerAbiV0): FontBakeResultV0 { + const { metadata, artifact: artifactBytes } = decodeEnvelope(bytes, abi); assertFontResultMetadata(metadata); const result = metadata; const artifact = result.artifacts[0]; if (result.artifacts.length !== 1 || artifact === undefined) { throw new TypeError('V0 font baker must return exactly one core artifact'); } - const artifactBytes = bytes.slice(response.payloadOffset + metadataLength); return { artifacts: [{ ...artifact, bytes: artifactBytes }], report: result.report, @@ -237,6 +321,22 @@ function decodeResponse(bytes: Uint8Array, abi: FontBakerAbiV0): FontBakeResultV }; } +function decodePreparedFont(bytes: Uint8Array, abi: FontBakerAbiV0): PreparedFontV0 { + const { metadata, artifact } = decodeEnvelope(bytes, abi); + if (!isPreparedFontReport(metadata) || artifact.byteLength !== metadata.preparedBytes) { + throw new TypeError('font baker returned invalid prepared font metadata'); + } + return { bytes: artifact, report: metadata }; +} + +function decodeFontInspection(bytes: Uint8Array, abi: FontBakerAbiV0): FontInspectionV0 { + const { metadata, artifact } = decodeEnvelope(bytes, abi); + if (!isFontInspection(metadata) || artifact.byteLength !== 0) { + throw new TypeError('font baker returned invalid font inspection metadata'); + } + return metadata; +} + function assertFontResultMetadata(value: unknown): asserts value is FontResultMetadata { if ( !isNonArrayObject(value) || @@ -330,6 +430,43 @@ function isBakeWarning(value: unknown): value is BakeWarning { ); } +function isPreparedFontReport(value: unknown): value is PreparedFontReportV0 { + return ( + isNonArrayObject(value) && + value.formatVersion === 0 && + isNonnegativeSafeInteger(value.sourceBytes) && + isNonnegativeSafeInteger(value.preparedBytes) && + value.preparedBytes > 0 && + value.fontFaceIndex === 0 && + isNonnegativeSafeInteger(value.glyphCount) && + typeof value.sha256 === 'string' && + /^[0-9a-f]{64}$/.test(value.sha256) + ); +} + +function isFontInspection(value: unknown): value is FontInspectionV0 { + return ( + isNonArrayObject(value) && + value.formatVersion === 0 && + isNonnegativeSafeInteger(value.fontFaceIndex) && + isNonnegativeSafeInteger(value.glyphCount) && + (value.glyphNameSource === 'post' || value.glyphNameSource === 'cff' || value.glyphNameSource === 'none') && + Array.isArray(value.glyphs) && + value.glyphs.every(isGlyphInspection) + ); +} + +function isGlyphInspection(value: unknown): value is GlyphInspectionV0 { + return ( + isNonArrayObject(value) && + isNonnegativeSafeInteger(value.codePoint) && + value.codePoint <= 0x10_ffff && + isNonnegativeSafeInteger(value.glyphId) && + value.glyphId <= 0xffff_ffff && + (value.name === undefined || typeof value.name === 'string') + ); +} + function parseSerializedBakeError(value: unknown): SerializedBakeError { if (!isBakeWarning(value)) throw new TypeError('font baker returned invalid error metadata'); return value; diff --git a/packages/font-baker/src/schemas/KHRONOS-SPEC-LICENSE.txt b/packages/text/src/font-baker/schemas/KHRONOS-SPEC-LICENSE.txt similarity index 100% rename from packages/font-baker/src/schemas/KHRONOS-SPEC-LICENSE.txt rename to packages/text/src/font-baker/schemas/KHRONOS-SPEC-LICENSE.txt diff --git a/packages/font-baker/src/schemas/README.md b/packages/text/src/font-baker/schemas/README.md similarity index 100% rename from packages/font-baker/src/schemas/README.md rename to packages/text/src/font-baker/schemas/README.md diff --git a/packages/font-baker/src/schemas/extensions/glTF.PMNDRS_font.schema.json b/packages/text/src/font-baker/schemas/extensions/glTF.PMNDRS_font.schema.json similarity index 100% rename from packages/font-baker/src/schemas/extensions/glTF.PMNDRS_font.schema.json rename to packages/text/src/font-baker/schemas/extensions/glTF.PMNDRS_font.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/accessor.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/accessor.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/accessor.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/accessor.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/accessor.sparse.indices.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/accessor.sparse.indices.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/accessor.sparse.indices.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/accessor.sparse.indices.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/accessor.sparse.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/accessor.sparse.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/accessor.sparse.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/accessor.sparse.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/accessor.sparse.values.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/accessor.sparse.values.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/accessor.sparse.values.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/accessor.sparse.values.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/animation.channel.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/animation.channel.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/animation.channel.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/animation.channel.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/animation.channel.target.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/animation.channel.target.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/animation.channel.target.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/animation.channel.target.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/animation.sampler.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/animation.sampler.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/animation.sampler.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/animation.sampler.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/animation.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/animation.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/animation.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/animation.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/asset.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/asset.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/asset.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/asset.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/buffer.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/buffer.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/buffer.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/buffer.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/bufferView.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/bufferView.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/bufferView.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/bufferView.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/camera.orthographic.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/camera.orthographic.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/camera.orthographic.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/camera.orthographic.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/camera.perspective.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/camera.perspective.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/camera.perspective.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/camera.perspective.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/camera.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/camera.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/camera.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/camera.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/extension.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/extension.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/extension.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/extension.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/extras.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/extras.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/extras.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/extras.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/glTF.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/glTF.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/glTF.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/glTF.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/glTFChildOfRootProperty.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/glTFChildOfRootProperty.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/glTFChildOfRootProperty.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/glTFChildOfRootProperty.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/glTFProperty.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/glTFProperty.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/glTFProperty.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/glTFProperty.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/glTFid.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/glTFid.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/glTFid.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/glTFid.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/image.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/image.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/image.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/image.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/material.normalTextureInfo.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/material.normalTextureInfo.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/material.normalTextureInfo.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/material.normalTextureInfo.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/material.occlusionTextureInfo.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/material.occlusionTextureInfo.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/material.occlusionTextureInfo.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/material.occlusionTextureInfo.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/material.pbrMetallicRoughness.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/material.pbrMetallicRoughness.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/material.pbrMetallicRoughness.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/material.pbrMetallicRoughness.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/material.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/material.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/material.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/material.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/mesh.primitive.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/mesh.primitive.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/mesh.primitive.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/mesh.primitive.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/mesh.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/mesh.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/mesh.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/mesh.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/node.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/node.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/node.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/node.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/sampler.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/sampler.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/sampler.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/sampler.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/scene.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/scene.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/scene.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/scene.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/skin.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/skin.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/skin.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/skin.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/texture.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/texture.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/texture.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/texture.schema.json diff --git a/packages/font-baker/src/schemas/gltf-2.0/textureInfo.schema.json b/packages/text/src/font-baker/schemas/gltf-2.0/textureInfo.schema.json similarity index 100% rename from packages/font-baker/src/schemas/gltf-2.0/textureInfo.schema.json rename to packages/text/src/font-baker/schemas/gltf-2.0/textureInfo.schema.json diff --git a/packages/font-baker/src/validator.ts b/packages/text/src/font-baker/validator.ts similarity index 100% rename from packages/font-baker/src/validator.ts rename to packages/text/src/font-baker/validator.ts diff --git a/packages/text/src/font-baker/wasm-url.ts b/packages/text/src/font-baker/wasm-url.ts new file mode 100644 index 00000000..4edc41e7 --- /dev/null +++ b/packages/text/src/font-baker/wasm-url.ts @@ -0,0 +1,2 @@ +/** Canonical browser-safe URL for the optimized font baker Wasm artifact. */ +export const fontBakerWasmUrl: string = new URL('../font_baker.wasm', import.meta.url).href; diff --git a/packages/text/src/index.ts b/packages/text/src/index.ts index 0af55fa0..f63d0536 100644 --- a/packages/text/src/index.ts +++ b/packages/text/src/index.ts @@ -135,5 +135,14 @@ export { export type { FontFeature, ResolvedFontFeature } from './font-feature.js'; -export type { LoadedFontInput, LoadedFontRequest, TextRuntime, TextRuntimeOptions } from './text-runtime.js'; +export type { + LoadedFontInput, + LoadedFontRasterRequests, + LoadedFontRequest, + LoadedFontTechniques, + LoadedFonts, + LoadedFontsRequest, + TextRuntime, + TextRuntimeOptions, +} from './text-runtime.js'; export { createTextRuntime } from './text-runtime.js'; diff --git a/packages/text/src/internal/compose-bake.ts b/packages/text/src/internal/compose-bake.ts index d17c5f69..89bdc91c 100644 --- a/packages/text/src/internal/compose-bake.ts +++ b/packages/text/src/internal/compose-bake.ts @@ -1,5 +1,5 @@ -import type { FontBakeResultV0 } from '@pmndrs/text-font-baker'; -import { parseGlb } from '@pmndrs/text-font-baker/validate'; +import type { FontBakeResultV0 } from '../font-baker/index.js'; +import { parseGlb } from '../font-baker/validator.js'; import type { BakeArtifactV0, BakeWarning, FontPayloadReport, RasterBakeArtifact, RasterPackagingV0 } from '../bake.js'; import type { Sha256Hex } from '../identity.js'; diff --git a/packages/text/src/internal/core-bake-policy.ts b/packages/text/src/internal/core-bake-policy.ts index a48daa44..39f04324 100644 --- a/packages/text/src/internal/core-bake-policy.ts +++ b/packages/text/src/internal/core-bake-policy.ts @@ -1,4 +1,4 @@ -import type { FontBakeDescriptorV0 } from '@pmndrs/text-font-baker'; +import type { FontBakeDescriptorV0 } from '../font-baker/index.js'; interface CoreFontArtifact { readonly role: 'font'; diff --git a/packages/text/src/internal/font-bake-pipeline.ts b/packages/text/src/internal/font-bake-pipeline.ts new file mode 100644 index 00000000..8bbe5c0e --- /dev/null +++ b/packages/text/src/internal/font-bake-pipeline.ts @@ -0,0 +1,99 @@ +import type { BakeProgressListener } from '../bake.js'; +import type { FontBakeCore, PreparedFontReportV0 } from '../font-baker/index.js'; +import { validateFontArtifact } from '../font-baker/validator.js'; +import type { Sha256Hex } from '../identity.js'; + +import { composeFontBake, type ComposedFontBakeResultV0 } from './compose-bake.js'; +import { soleCoreFontArtifact } from './core-bake-policy.js'; +import { normalizeUnicodeRanges } from './font-selection.js'; +import type { ResolvedRasterBakePlan } from './raster-bake-plan.js'; + +export interface FontBakePipelineOptions { + readonly fontBaker: FontBakeCore; + readonly source: Uint8Array; + readonly fontFaceIndex: number; + readonly unicodeRanges?: readonly { readonly start: number; readonly end: number }[]; + readonly rasters: readonly ResolvedRasterBakePlan[]; + readonly signal?: AbortSignal; + readonly onProgress?: BakeProgressListener; +} + +export interface FontBakePipelineTimings { + readonly coreBake: number; + readonly rasterBake: number; + readonly compose: number; + readonly validate: number; +} + +export interface FontBakePipelineResult { + readonly composed: ComposedFontBakeResultV0; + readonly preparation?: PreparedFontReportV0; + readonly timings: FontBakePipelineTimings; +} + +/** Prepare once, then feed the exact prepared bytes to the shaping core and every requested raster baker. */ +export async function bakeFontPipeline(options: FontBakePipelineOptions): Promise { + const timings = { coreBake: 0, rasterBake: 0, compose: 0, validate: 0 }; + options.signal?.throwIfAborted(); + + let phase = performance.now(); + const preparation = + options.unicodeRanges === undefined + ? undefined + : options.fontBaker.prepare({ + source: options.source, + selection: { + formatVersion: 0, + fontFaceIndex: options.fontFaceIndex, + unicodeRanges: normalizeUnicodeRanges(options.unicodeRanges), + }, + }); + const source = preparation?.bytes ?? options.source; + const fontFaceIndex = preparation?.report.fontFaceIndex ?? options.fontFaceIndex; + const core = options.fontBaker.bake({ + source, + descriptor: { formatVersion: 0, fontFaceIndex }, + }); + timings.coreBake = performance.now() - phase; + options.signal?.throwIfAborted(); + + phase = performance.now(); + const coreValidation = await validateFontArtifact(soleCoreFontArtifact(core).bytes); + timings.validate += performance.now() - phase; + + phase = performance.now(); + const rasters = []; + for (const plan of options.rasters) { + options.signal?.throwIfAborted(); + const raster = await plan.baker.bake({ + font: { + source, + fontFaceIndex, + glyphCount: coreValidation.glyphCount, + shapingHash: coreValidation.shapingHash as Sha256Hex, + }, + rasterKey: plan.rasterKey, + packaging: plan.packaging, + descriptor: plan.descriptor, + ...(options.signal === undefined ? {} : { signal: options.signal }), + ...(options.onProgress === undefined ? {} : { onProgress: options.onProgress }), + }); + rasters.push({ raster, packaging: plan.packaging }); + } + timings.rasterBake = performance.now() - phase; + + phase = performance.now(); + const composed = await composeFontBake(core, rasters); + timings.compose = performance.now() - phase; + options.signal?.throwIfAborted(); + + phase = performance.now(); + await validateFontArtifact(composed.artifacts[0]!.bytes); + timings.validate += performance.now() - phase; + + return { + composed, + ...(preparation === undefined ? {} : { preparation: preparation.report }), + timings, + }; +} diff --git a/packages/text/src/internal/font-binding-wire.ts b/packages/text/src/internal/font-binding-wire.ts index 16e9730e..a6095a00 100644 --- a/packages/text/src/internal/font-binding-wire.ts +++ b/packages/text/src/internal/font-binding-wire.ts @@ -105,9 +105,13 @@ function compileBitmap( techniqueId: number, identities: RenderWireIdentityRegistry, ): Uint8Array { - const entries = data.strikes.flatMap((strike) => strike.pages.map((page) => page.resource)); + const entries = data.strikes.map((strike) => strike.pages[0]!.resource); const { resources, indexFor } = fontBindingResources(entries, identities); const views = data.strikes.map((strike) => recordView(strike.records)); + const bindings = data.strikes.map((strike) => ({ + width: Math.max(...strike.pages.map((page) => page.width)), + height: Math.max(...strike.pages.map((page) => page.height)), + })); 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); @@ -116,17 +120,14 @@ function compileBitmap( 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]; + return page === ABSENT_PAGE ? 0 : view.getUint16(record + offset, true) / bindings[strike]![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]; + : (view.getUint16(record + end, true) - view.getUint16(record + start, true)) / bindings[strike]![dimension]; }; return compileFontBinding({ techniqueId, @@ -137,7 +138,7 @@ function compileBitmap( 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); + return page === ABSENT_PAGE ? MISSING_RESOURCE : indexFor(data.strikes[strike]!.pages[0]!.resource); }, glyphF32: emptyFontBindingTable(glyphCount), glyphU32: emptyFontBindingTable(glyphCount), @@ -170,7 +171,15 @@ function compileBitmap( (row) => span(row, 10, 14, 'height'), ], }, - strikeU32: emptyFontBindingTable(rows), + strikeU32: { + rows, + fields: [ + (row) => { + const { view, record } = strikeRecord(row); + return view.getUint16(record + 16, true); + }, + ], + }, resourceF32: emptyFontBindingTable(resources.length), resourceU32: emptyFontBindingTable(resources.length), }); diff --git a/packages/text/src/internal/font-selection.ts b/packages/text/src/internal/font-selection.ts new file mode 100644 index 00000000..95ef45fa --- /dev/null +++ b/packages/text/src/internal/font-selection.ts @@ -0,0 +1,34 @@ +import type { UnicodeRangeV0 } from '../font-baker/index.js'; + +const MAX_UNICODE_RANGES = 4_096; +const MAX_UNICODE = 0x10ffff; + +export function normalizeUnicodeRanges(ranges: readonly UnicodeRangeV0[]): readonly UnicodeRangeV0[] { + if (!Array.isArray(ranges) || ranges.length === 0) { + throw new TypeError('font selection requires at least one Unicode range'); + } + if (ranges.length > MAX_UNICODE_RANGES) { + throw new RangeError(`font selection exceeds ${MAX_UNICODE_RANGES} Unicode ranges`); + } + const normalized = ranges.map((range, index) => { + if (typeof range !== 'object' || range === null || Array.isArray(range)) { + throw new TypeError(`Unicode range ${index} must be an object`); + } + const { start, end } = range; + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || start > end || end > MAX_UNICODE) { + throw new RangeError(`Unicode range ${index} must contain ordered integers from U+0000 through U+10FFFF`); + } + return { start, end }; + }); + normalized.sort((left, right) => left.start - right.start || left.end - right.end); + const merged: UnicodeRangeV0[] = []; + for (const range of normalized) { + const previous = merged.at(-1); + if (previous !== undefined && range.start <= previous.end + 1) { + merged[merged.length - 1] = { start: previous.start, end: Math.max(previous.end, range.end) }; + } else { + merged.push(range); + } + } + return merged; +} diff --git a/packages/text/src/internal/raster-artifact-validation.ts b/packages/text/src/internal/raster-artifact-validation.ts index b210d8f8..2907aa5c 100644 --- a/packages/text/src/internal/raster-artifact-validation.ts +++ b/packages/text/src/internal/raster-artifact-validation.ts @@ -1,4 +1,4 @@ -import type { ParsedGlb } from '@pmndrs/text-font-baker/validate'; +import type { ParsedGlb } from '../font-baker/validator.js'; import { RasterKtxValidationError, validateNativeKtx2 as validateNativeKtx2Container, diff --git a/packages/text/src/internal/render-policy-wire.ts b/packages/text/src/internal/render-policy-wire.ts index 229840d9..95f2d1b8 100644 --- a/packages/text/src/internal/render-policy-wire.ts +++ b/packages/text/src/internal/render-policy-wire.ts @@ -164,11 +164,12 @@ function bitmapProgram( transformMode: ThreeTransformMode, allocationMode: ThreeAllocationMode, ): PolicyProgram { - const context = programContext('strike', 8, 0); + const context = programContext('strike', 8, 1); const { loadF32, loadU32, binary, storeF32, storeU32 } = context; loadF32(15); loadU32(31, 0); loadU32(30, 1); + loadU32(29, 2); binary('multiplyF32', 15, 7, 2); binary('addF32', 16, 0, 15); binary('multiplyF32', 17, 8, 2); @@ -184,13 +185,14 @@ function bitmapProgram( ]); if (transformMode === 'indexed') storeU32(FIRST_PARTY_TRANSFORM_BUFFER_ID, 0, 31); storeU32(FIRST_PARTY_STABLE_GLYPH_BUFFER_ID, 0, 30); + storeU32(6, 0, 29); return createProgram( techniqueId, programId, context, transformMode === 'indexed' - ? [...floatBuffers([2, 2, 2, 2, 4]), stableGlyphIdBuffer(), transformIndexBuffer()] - : [...floatBuffers([2, 2, 2, 2, 4]), stableGlyphIdBuffer()], + ? [...floatBuffers([2, 2, 2, 2, 4]), ...u32Buffers([1], 6), stableGlyphIdBuffer(), transformIndexBuffer()] + : [...floatBuffers([2, 2, 2, 2, 4]), ...u32Buffers([1], 6), stableGlyphIdBuffer()], transformMode, allocationMode, ); diff --git a/packages/text/src/internal/runtime-bake-protocol.ts b/packages/text/src/internal/runtime-bake-protocol.ts index e528b0b6..cc4644c7 100644 --- a/packages/text/src/internal/runtime-bake-protocol.ts +++ b/packages/text/src/internal/runtime-bake-protocol.ts @@ -1,10 +1,28 @@ -import type { FontBakeDescriptorV0, SerializedBakeError } from '@pmndrs/text-font-baker'; +import type { FontBakeDescriptorV0, SerializedBakeError } from '../font-baker/index.js'; +import type { RasterKey } from '../identity.js'; +import type { JsonValue } from '../raster.js'; + +export interface RuntimeBakeUnicodeRangeV0 { + readonly start: number; + readonly end: number; +} + +export interface RuntimeBakeRasterV0 { + readonly kind: string; + readonly extension: string; + readonly version: number; + readonly rasterKey: RasterKey; + readonly descriptor: JsonValue; +} export interface RuntimeBakeRequestV0 { readonly type: 'bake-font-v0'; readonly id: number; readonly source: ArrayBuffer; readonly font: FontBakeDescriptorV0; + readonly cache?: { readonly expiresAt: number }; + readonly unicodeRanges?: readonly RuntimeBakeUnicodeRangeV0[]; + readonly rasters?: readonly RuntimeBakeRasterV0[]; } export interface RuntimeBakeSuccessV0 { @@ -64,10 +82,65 @@ export function isRuntimeBakeRequestV0(value: unknown): value is RuntimeBakeRequ value.font.formatVersion === 0 && typeof value.font.fontFaceIndex === 'number' && Number.isSafeInteger(value.font.fontFaceIndex) && - value.font.fontFaceIndex >= 0 + value.font.fontFaceIndex >= 0 && + (value.cache === undefined || + (isNonArrayObject(value.cache) && + Number.isSafeInteger(value.cache.expiresAt) && + (value.cache.expiresAt as number) > 0)) && + (value.unicodeRanges === undefined || isUnicodeRanges(value.unicodeRanges)) && + (value.rasters === undefined || isRasters(value.rasters)) + ); +} + +function isUnicodeRanges(value: unknown): value is readonly RuntimeBakeUnicodeRangeV0[] { + return ( + Array.isArray(value) && + value.length > 0 && + value.length <= 4_096 && + value.every( + (range) => + isNonArrayObject(range) && + Number.isSafeInteger(range.start) && + Number.isSafeInteger(range.end) && + (range.start as number) >= 0 && + (range.start as number) <= (range.end as number) && + (range.end as number) <= 0x10ffff, + ) ); } +function isRasters(value: unknown): value is readonly RuntimeBakeRasterV0[] { + return ( + Array.isArray(value) && + value.length <= 256 && + value.every( + (raster) => + isNonArrayObject(raster) && + typeof raster.kind === 'string' && + raster.kind.length > 0 && + typeof raster.extension === 'string' && + raster.extension.length > 0 && + Number.isSafeInteger(raster.version) && + (raster.version as number) >= 0 && + typeof raster.rasterKey === 'string' && + /^[0-9a-f]{64}$/.test(raster.rasterKey) && + isJsonValue(raster.descriptor), + ) + ); +} + +function isJsonValue(value: unknown, seen = new Set(), depth = 0): value is JsonValue { + if (value === null || typeof value === 'boolean' || typeof value === 'string') return true; + if (typeof value === 'number') return Number.isFinite(value); + if (typeof value !== 'object' || depth >= 32 || seen.has(value)) return false; + seen.add(value); + const valid = Array.isArray(value) + ? value.length <= 4_096 && value.every((child) => isJsonValue(child, seen, depth + 1)) + : Object.keys(value).length <= 256 && Object.values(value).every((child) => isJsonValue(child, seen, depth + 1)); + seen.delete(value); + return valid; +} + function isRuntimeBakeArtifactV0(value: unknown): value is RuntimeBakeArtifactV0 { return ( isNonArrayObject(value) && diff --git a/packages/text/src/internal/runtime-font-cache.ts b/packages/text/src/internal/runtime-font-cache.ts new file mode 100644 index 00000000..856f01e1 --- /dev/null +++ b/packages/text/src/internal/runtime-font-cache.ts @@ -0,0 +1,130 @@ +import { FONT_BAKER_VERSION, FONT_FORMAT_VERSION } from '../font-baker/contract.js'; +import type { JsonValue } from '../raster.js'; + +import { copyToOwnedArrayBuffer } from './owned-array-buffer.js'; +import { canonicalJson } from './raster-identity.js'; +import type { RuntimeBakeRequestV0 } from './runtime-bake-protocol.js'; + +const CACHE_NAME = `pmndrs-text-font-bakes-${FONT_FORMAT_VERSION}-${FONT_BAKER_VERSION}`; +const CACHE_PATH = '/.pmndrs-text/font-bakes/'; +const EXPIRES_HEADER = 'x-pmndrs-expires-at'; +const LENGTH_HEADER = 'x-pmndrs-byte-length'; +const ARTIFACT_ID_HEADER = 'x-pmndrs-artifact-id'; +const SHA256_HEADER = 'x-pmndrs-sha256'; + +export interface RuntimeFontCache { + key(source: Uint8Array, request: RuntimeBakeRequestV0): Promise; + match(key: string): Promise; + put(key: string, artifact: CachedFontArtifact, expiresAt: number): Promise; +} + +export interface CachedFontArtifact { + readonly bytes: Uint8Array; + readonly id: string; + readonly sha256: string; +} + +/** CacheStorage owns quota eviction; the source response owns whether and how long the derived artifact persists. */ +export function createRuntimeFontCache(): RuntimeFontCache | undefined { + const storage = (globalThis as { readonly caches?: CacheStorage }).caches; + const origin = (globalThis as { readonly location?: Location }).location?.origin; + if (storage === undefined || origin === undefined || origin === 'null' || !/^https?:\/\//.test(origin)) { + return undefined; + } + return createCache(storage, origin, () => Date.now()); +} + +/** @internal Deterministic environment injection for cache policy tests. */ +export function createCache(storage: CacheStorage, origin: string, now: () => number): RuntimeFontCache { + const requestFor = (key: string): Request => new Request(new URL(`${CACHE_PATH}${key}`, origin)); + return { + async key(source, request) { + const sourceHash = await sha256(source); + const identity = canonicalJson({ + face: request.font.fontFaceIndex, + rasters: request.rasters ?? [], + sourceHash, + unicodeRanges: request.unicodeRanges ?? null, + } as unknown as JsonValue); + return sha256(new TextEncoder().encode(identity)); + }, + async match(key) { + try { + const cache = await storage.open(CACHE_NAME); + const request = requestFor(key); + const response = await cache.match(request); + if (response === undefined) return undefined; + const expiresAt = headerInteger(response, EXPIRES_HEADER); + const byteLength = headerInteger(response, LENGTH_HEADER); + const id = response.headers.get(ARTIFACT_ID_HEADER); + const artifactHash = response.headers.get(SHA256_HEADER); + if ( + expiresAt === undefined || + byteLength === undefined || + byteLength <= 0 || + id === null || + id.length === 0 || + artifactHash === null || + !/^[0-9a-f]{64}$/.test(artifactHash) || + now() >= expiresAt + ) { + await cache.delete(request); + return undefined; + } + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength !== byteLength || (await sha256(bytes)) !== artifactHash) { + await cache.delete(request); + return undefined; + } + return { bytes, id, sha256: artifactHash }; + } catch { + return undefined; + } + }, + async put(key, artifact, expiresAt) { + const { bytes } = artifact; + if (bytes.byteLength === 0 || !Number.isSafeInteger(expiresAt) || expiresAt <= now()) return; + try { + const cache = await storage.open(CACHE_NAME); + await cache.put( + requestFor(key), + new Response(copyToOwnedArrayBuffer(bytes), { + headers: { + 'content-type': 'model/gltf-binary', + [EXPIRES_HEADER]: String(expiresAt), + [LENGTH_HEADER]: String(bytes.byteLength), + [ARTIFACT_ID_HEADER]: artifact.id, + [SHA256_HEADER]: artifact.sha256, + }, + }), + ); + await pruneExpired(cache, now()); + } catch { + // CacheStorage is an optional acceleration. Quota, privacy-mode, and storage failures must not fail baking. + } + }, + }; +} + +async function pruneExpired(cache: Cache, now: number): Promise { + for (const request of await cache.keys()) { + const response = await cache.match(request); + if (response === undefined) continue; + const expiresAt = headerInteger(response, EXPIRES_HEADER); + if (expiresAt === undefined || now >= expiresAt) await cache.delete(request); + } +} + +function headerInteger(response: Response, name: string): number | undefined { + const value = response.headers.get(name); + if (value === null || !/^\d+$/.test(value)) return undefined; + const number = Number(value); + return Number.isSafeInteger(number) ? number : undefined; +} + +async function sha256(bytes: Uint8Array): Promise { + const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', copyToOwnedArrayBuffer(bytes))); + let output = ''; + for (const byte of digest) output += byte.toString(16).padStart(2, '0'); + return output; +} diff --git a/packages/text/src/loader.ts b/packages/text/src/loader.ts index 17ff65d5..9697585d 100644 --- a/packages/text/src/loader.ts +++ b/packages/text/src/loader.ts @@ -1,8 +1,8 @@ -import type { ParsedGlb, ValidatedFontArtifactV0 } from '@pmndrs/text-font-baker/validate'; +import type { ParsedGlb, ValidatedFontArtifactV0 } from './font-baker/validator.js'; import { FONT_BAKER_VERSION as CORE_BAKER_VERSION, FONT_FORMAT_VERSION as CORE_FORMAT_VERSION, -} from '@pmndrs/text-font-baker/contract'; +} from './font-baker/contract.js'; import type { FontInput, FontMetrics, RegisteredFont } from './font.js'; import type { FontHandle, FontKey, RasterHandle, RasterKey, Sha256Hex } from './identity.js'; @@ -24,6 +24,7 @@ import type { RegisteredRaster, } from './raster.js'; import type { BakeProgressListener } from './bake.js'; +import type { RuntimeBakeRasterV0, RuntimeBakeUnicodeRangeV0 } from './internal/runtime-bake-protocol.js'; const DEFAULT_MAX_ARTIFACT_BYTES = 64 * 1024 * 1024; const DEFAULT_MAX_BUFFER_VIEWS = 4_096; @@ -32,7 +33,7 @@ const DEFAULT_MAX_RASTERS = 256; let nextRegistryId = 1; let nextFontHandle = 1; let nextRasterHandle = 1; -let validatorPromise: Promise | undefined; +let validatorPromise: Promise | undefined; let defaultRuntimeBakePromise: Promise | undefined; export interface FontLoadOptions { @@ -50,6 +51,10 @@ export interface RuntimeFontBakeRequest { readonly source: Uint8Array; readonly sourceUrl: string; readonly bakedUrl?: string; + /** Persistent derived-artifact lifetime inherited from the source response. Omitted means memory-only. */ + readonly cache?: { readonly expiresAt: number }; + readonly unicodeRanges?: readonly RuntimeBakeUnicodeRangeV0[]; + readonly rasters?: readonly RuntimeBakeRasterV0[]; readonly signal?: AbortSignal; readonly onProgress?: BakeProgressListener; } @@ -62,6 +67,8 @@ export interface FontLoaderOptions { readonly fetch?: typeof fetch; readonly development?: boolean; readonly runtimeBake?: RuntimeFontBake; + /** @internal A transformed runtime source cannot be authenticated or retained as the baked shaping source. */ + readonly runtimeSourceIdentity?: 'original' | 'transformed'; readonly onDiagnostic?: (diagnostic: FontLoadDiagnostic) => void; readonly onWarning?: (diagnostic: FontLoadDiagnostic) => void; } @@ -374,6 +381,7 @@ export class FontLoader { readonly #baseUrl: URL | undefined; readonly #development: boolean; readonly #runtimeBake: RuntimeFontBake | undefined; + readonly #runtimeSourceIdentity: 'original' | 'transformed'; readonly #onDiagnostic: ((diagnostic: FontLoadDiagnostic) => void) | undefined; readonly #onWarning: ((diagnostic: FontLoadDiagnostic) => void) | undefined; readonly #loads = new Map(); @@ -389,6 +397,7 @@ export class FontLoader { this.#baseUrl = resolveBaseUrl(options.baseUrl); this.#development = options.development ?? defaultDevelopmentMode(); this.#runtimeBake = options.runtimeBake; + this.#runtimeSourceIdentity = options.runtimeSourceIdentity ?? 'original'; this.#onDiagnostic = options.onDiagnostic; this.#onWarning = options.onWarning; } @@ -476,18 +485,20 @@ export class FontLoader { } const runtimeBake = this.#runtimeBake ?? (await loadDefaultRuntimeBake(request.sourceUrl)); signal.throwIfAborted(); - const source = await this.#fetchRequired(request.sourceUrl, 'FONT_SOURCE_FETCH', signal); + const sourceResponse = await this.#fetchRequired(request.sourceUrl, 'FONT_SOURCE_FETCH', signal); + const { bytes: source } = sourceResponse; const baked = await runtimeBake({ source, sourceUrl: request.sourceUrl, ...(request.bakedUrl === undefined ? {} : { bakedUrl: request.bakedUrl }), + ...(sourceResponse.expiresAt === undefined ? {} : { cache: { expiresAt: sourceResponse.expiresAt } }), signal, }); signal.throwIfAborted(); return this.registry._registerAsset(baked, { ...(request.bakedUrl === undefined ? {} : { artifactUrl: request.bakedUrl }), sourceUrl: request.sourceUrl, - sourceBytes: source, + ...(this.#runtimeSourceIdentity === 'original' ? { sourceBytes: source } : {}), fetch: this.#fetch, }); } @@ -548,7 +559,11 @@ export class FontLoader { } } - async #fetchRequired(url: string, code: string, signal: AbortSignal): Promise { + async #fetchRequired( + url: string, + code: string, + signal: AbortSignal, + ): Promise<{ readonly bytes: Uint8Array; readonly expiresAt?: number }> { let response: Response; try { response = await this.#fetch(url, { signal }); @@ -561,7 +576,15 @@ export class FontLoader { url, }); } - return readResponseBytes(response, this.registry._artifactByteLimit(), 'FONT_SOURCE_RESOURCE_LIMIT', url, signal); + const bytes = await readResponseBytes( + response, + this.registry._artifactByteLimit(), + 'FONT_SOURCE_RESOURCE_LIMIT', + url, + signal, + ); + const expiresAt = persistentResponseExpiration(response); + return { bytes, ...(expiresAt === undefined ? {} : { expiresAt }) }; } #warnMissing(url: string): void { @@ -1220,6 +1243,27 @@ function normalizeUrl(value: string | URL, baseUrl: URL | undefined): string { return url.href; } +function persistentResponseExpiration(response: Response, now = Date.now()): number | undefined { + const cacheControl = response.headers.get('cache-control')?.toLowerCase(); + if (cacheControl !== undefined) { + const directives = cacheControl.split(',').map((directive) => directive.trim()); + if (directives.includes('no-store') || directives.includes('no-cache')) return undefined; + const maxAge = directives + .map((directive) => /^max-age=(?:"(\d+)"|(\d+))$/.exec(directive)) + .find((match) => match !== null); + if (maxAge !== undefined) { + const seconds = Number(maxAge[1] ?? maxAge[2]); + if (!Number.isSafeInteger(seconds) || seconds <= 0) return undefined; + const responseDate = Date.parse(response.headers.get('date') ?? ''); + const base = Number.isFinite(responseDate) ? responseDate : now; + const expiresAt = base + seconds * 1_000; + return Number.isSafeInteger(expiresAt) && expiresAt > now ? expiresAt : undefined; + } + } + const expiresAt = Date.parse(response.headers.get('expires') ?? ''); + return Number.isFinite(expiresAt) && expiresAt > now ? expiresAt : undefined; +} + function resolveBaseUrl(value: string | URL | undefined): URL | undefined { if (value !== undefined) return new URL(value); const location = (globalThis as { location?: { href?: string } }).location?.href; @@ -1271,8 +1315,8 @@ function consumeSharedLoad(shared: SharedFontLoad, signal: AbortSignal | undefin }); } -function loadValidator(): Promise { - return (validatorPromise ??= import('@pmndrs/text-font-baker/validate')); +function loadValidator(): Promise { + return (validatorPromise ??= import('./font-baker/validator.js')); } async function readResponseBytes( diff --git a/packages/text/src/node/bake.ts b/packages/text/src/node/bake.ts index f6bcf07e..716c92ab 100644 --- a/packages/text/src/node/bake.ts +++ b/packages/text/src/node/bake.ts @@ -5,23 +5,42 @@ import { performance } from 'node:perf_hooks'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { brotliCompressSync, constants as zlibConstants, gzipSync } from 'node:zlib'; -import { createFontBaker, type FontBakeDescriptorV0 } from '@pmndrs/text-font-baker'; -import { fontBakerWasmUrl } from '@pmndrs/text-font-baker/wasm-url'; -import { validateFontArtifact } from '@pmndrs/text-font-baker/validate'; +import { + createFontBaker, + type FontBakeDescriptorV0, + type FontInspectionV0, + type PreparedFontReportV0, + type UnicodeRangeV0, +} from '../font-baker/index.js'; +import { fontBakerWasmUrl } from '../font-baker/wasm-url.js'; + +export { + FontBakeError, + createFontBaker, + createFontBakerFromInstance, + fontBakerAbi, + type FontBakeCore, + type FontBakeDescriptorV0, + type FontBakeRequestV0, + type FontBakeResultV0, + type FontBakerAbiV0, + type FontBakerWasmSource, + type SerializedBakeError, +} from '../font-baker/index.js'; +export { fontBakerWasmUrl } from '../font-baker/wasm-url.js'; +export * from '../font-baker/validator.js'; import type { AnyRasterBakerModule, BakeArtifactV0, BakeWarning, FontPayloadReport, RasterBakePlan } from '../bake.js'; -import { - discoverProjectFonts, - type DiscoveryDiagnostic, - type DiscoveredFontDefinition, - type DiscoveryOptions, - type ResolvedRasterBaker, +import type { + DiscoveryDiagnostic, + DiscoveredFontDefinition, + DiscoveryOptions, + ResolvedRasterBaker, } from '../discovery.js'; -import { composeFontBake } from '../internal/compose-bake.js'; -import { fontBakeDescriptorV0, soleCoreFontArtifact } from '../internal/core-bake-policy.js'; +import { fontBakeDescriptorV0 } from '../internal/core-bake-policy.js'; +import { bakeFontPipeline } from '../internal/font-bake-pipeline.js'; import { resolveRasterBakePlan, type ResolvedRasterBakePlan } from '../internal/raster-bake-plan.js'; import { cacheSuccessfulPromise } from '../internal/successful-promise-cache.js'; -import type { Sha256Hex } from '../identity.js'; export interface NodeBakeOptions< Rasters extends readonly RasterBakePlan[] = readonly RasterBakePlan[], @@ -29,6 +48,7 @@ export interface NodeBakeOptions< readonly input: string | URL; readonly output: string | URL; readonly font: Omit; + readonly unicodeRanges?: readonly UnicodeRangeV0[]; readonly rasters?: Rasters; readonly signal?: AbortSignal; } @@ -67,9 +87,15 @@ export interface NodeBakeExecutionReport { } export interface NodeFontBakeReport extends FontPayloadReport { + readonly preparation?: PreparedFontReportV0; readonly execution: NodeBakeExecutionReport; } +export interface NodeFontInspectOptions { + readonly input: string | URL; + readonly fontFaceIndex?: number; +} + export interface ProjectBakeOptions extends DiscoveryOptions { readonly outputRoot?: string | URL; } @@ -113,6 +139,15 @@ export async function bakeFont { + const source = new Uint8Array(await readFile(filePath(options.input, 'input'))); + const fontBaker = await defaultFontBaker(); + return fontBaker.inspect({ + source, + descriptor: fontBakeDescriptorV0(options.fontFaceIndex ?? 0), + }); +} + async function bakeFontWithResolvedPlans( options: NodeBakeOptions, preparedRasters?: readonly ResolvedRasterBakePlan[], @@ -134,51 +169,25 @@ async function bakeFontWithResolvedPlans( await assertDistinctInputOutput(input, output); let phase = performance.now(); - const source = new Uint8Array(await readFile(input)); + const originalSource = new Uint8Array(await readFile(input)); timings.read = performance.now() - phase; options.signal?.throwIfAborted(); - phase = performance.now(); const fontBaker = await defaultFontBaker(); - const core = fontBaker.bake({ - source, - descriptor: fontBakeDescriptorV0(options.font.fontFaceIndex), - }); - timings.coreBake = performance.now() - phase; - options.signal?.throwIfAborted(); - - phase = performance.now(); - const coreValidation = await validateFontArtifact(soleCoreFontArtifact(core).bytes); - timings.validate += performance.now() - phase; - phase = performance.now(); - const rasterInputs = []; const rasters = preparedRasters ?? (await Promise.all((options.rasters ?? []).map(resolveRasterBakePlan))); - for (const plan of rasters) { - options.signal?.throwIfAborted(); - const raster = await plan.baker.bake({ - font: { - source, - fontFaceIndex: options.font.fontFaceIndex, - glyphCount: coreValidation.glyphCount, - shapingHash: coreValidation.shapingHash as Sha256Hex, - }, - rasterKey: plan.rasterKey, - packaging: plan.packaging, - descriptor: plan.descriptor, - ...(options.signal === undefined ? {} : { signal: options.signal }), - }); - rasterInputs.push({ raster, packaging: plan.packaging }); - } - timings.rasterBake = performance.now() - phase; - - phase = performance.now(); - const composed = await composeFontBake(core, rasterInputs); - timings.compose = performance.now() - phase; - options.signal?.throwIfAborted(); - - phase = performance.now(); - await validateFontArtifact(composed.artifacts[0]!.bytes); - timings.validate += performance.now() - phase; + const pipeline = await bakeFontPipeline({ + fontBaker, + source: originalSource, + fontFaceIndex: options.font.fontFaceIndex, + ...(options.unicodeRanges === undefined ? {} : { unicodeRanges: options.unicodeRanges }), + rasters, + ...(options.signal === undefined ? {} : { signal: options.signal }), + }); + timings.coreBake = pipeline.timings.coreBake; + timings.rasterBake = pipeline.timings.rasterBake; + timings.compose = pipeline.timings.compose; + timings.validate = pipeline.timings.validate; + const { composed, preparation } = pipeline; phase = performance.now(); const report = finalizeTransport(composed.report, composed.artifacts); timings.transport = performance.now() - phase; @@ -190,6 +199,7 @@ async function bakeFontWithResolvedPlans( const rssAfterBytes = process.memoryUsage.rss(); return { ...report, + ...(preparation === undefined ? {} : { preparation }), execution: { timingsMs: { ...timings, total: performance.now() - started }, memory: { @@ -209,6 +219,7 @@ async function bakeFontWithResolvedPlans( export async function bakeProject(options: ProjectBakeOptions = {}): Promise { options.signal?.throwIfAborted(); + const { discoverProjectFonts } = await import('../discovery.js'); const discovery = await discoverProjectFonts(options); const projectRoot = await canonicalProjectRoot(options.projectRoot); const outputRoot = diff --git a/packages/text/src/node/cli.ts b/packages/text/src/node/cli.ts index d17ad4c0..1e25c336 100644 --- a/packages/text/src/node/cli.ts +++ b/packages/text/src/node/cli.ts @@ -1,9 +1,21 @@ #!/usr/bin/env node -import { resolve } from 'node:path'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { bakeProject, NodeBakeError, type ProjectBakeOptions } from './bake.js'; +import type { AnyRasterBakerModule, RasterBakePlan } from '../bake.js'; +import type { UnicodeRangeV0 } from '../font-baker/index.js'; +import { normalizeUnicodeRanges } from '../internal/font-selection.js'; +import { + bakeFont, + bakeProject, + inspectFont, + NodeBakeError, + type NodeFontBakeReport, + type ProjectBakeOptions, +} from './bake.js'; export interface CliIo { readonly stdout: { write(value: string): unknown }; @@ -14,19 +26,48 @@ export async function runCli( argv: readonly string[], io: CliIo = { stdout: process.stdout, stderr: process.stderr }, ): Promise { - let parsed: ParsedArguments; + const [command, ...commandArguments] = argv; + if (command === undefined || command === '--help' || command === '-h') { + io.stdout.write(topLevelUsage()); + return 0; + } + if (command === '--version' || command === '-v') { + try { + io.stdout.write(`@pmndrs/text ${await packageVersion()}\n`); + return 0; + } catch (error) { + writeFailure(io, error); + return 1; + } + } + if (command === 'glyphs') return runGlyphsCommand(commandArguments, io); + if (command !== 'bake') { + io.stderr.write(`Unknown command: ${command}\n\n${topLevelUsage()}`); + return 2; + } + + let parsed: ParsedBakeArguments; try { - parsed = parseArguments(argv); + parsed = parseBakeArguments(commandArguments); } catch (error) { - io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n\n${usage()}`); + io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n\n${bakeUsage()}`); return 2; } if (parsed.help) { - io.stdout.write(usage()); + io.stdout.write(bakeUsage()); return 0; } try { - const report = await bakeProject(parsed.options); + if (parsed.direct !== undefined) { + const report = await bakeDirect(parsed.direct); + if (parsed.json) io.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + else { + io.stdout.write(`${parsed.direct.input} -> ${parsed.direct.output}\n`); + io.stdout.write(`Baked 1 font in ${report.execution.timingsMs.total.toFixed(2)} ms.\n`); + } + return 0; + } + const report = await bakeProject(parsed.project); if (parsed.json) { io.stdout.write(`${JSON.stringify(report, null, 2)}\n`); } else { @@ -43,26 +84,184 @@ export async function runCli( } return report.diagnostics.length === 0 ? 0 : 1; } catch (error) { - if (error instanceof NodeBakeError) { - io.stderr.write(`${error.code}: ${error.message}${error.path === undefined ? '' : ` (${error.path})`}\n`); + writeFailure(io, error); + return 1; + } +} + +interface GlyphArguments { + readonly input: string; + readonly fontFaceIndex: number; + readonly names: readonly string[]; + readonly json: boolean; + readonly unicodeSet: boolean; + readonly help: boolean; +} + +interface GlyphRecord { + readonly unicode: string; + readonly codePoint: number; + readonly glyphId: number; + readonly name?: string; +} + +async function runGlyphsCommand(argv: readonly string[], io: CliIo): Promise { + let parsed: GlyphArguments; + try { + parsed = parseGlyphArguments(argv); + } catch (error) { + io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n\n${glyphUsage()}`); + return 2; + } + if (parsed.help) { + io.stdout.write(glyphUsage()); + return 0; + } + try { + const glyphs = await inspectGlyphs(parsed); + if (parsed.json) { + io.stdout.write( + `${JSON.stringify({ input: parsed.input, fontFaceIndex: parsed.fontFaceIndex, glyphs }, null, 2)}\n`, + ); + } else if (parsed.unicodeSet) { + io.stdout.write(`${formatUnicodeSet(glyphs.map(({ codePoint }) => codePoint))}\n`); } else { - io.stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`); + for (const glyph of glyphs) { + io.stdout.write(`${glyph.unicode}\t${glyph.glyphId}\t${glyph.name ?? '-'}\n`); + } } + return 0; + } catch (error) { + writeFailure(io, error); return 1; } } -interface ParsedArguments { - readonly options: ProjectBakeOptions; +function parseGlyphArguments(argv: readonly string[]): GlyphArguments { + let input: string | undefined; + let fontFaceIndex = 0; + let fontFaceIndexSet = false; + const names: string[] = []; + let json = false; + let unicodeSet = false; + let help = false; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]!; + if (argument === '--help' || argument === '-h') { + help = true; + } else if (argument === '--font-face-index') { + if (fontFaceIndexSet) throw new TypeError('--font-face-index may be provided only once'); + fontFaceIndexSet = true; + fontFaceIndex = nonnegativeInteger(valueAfter(argv, ++index, argument), argument); + } else if (argument === '--name') { + names.push(valueAfter(argv, ++index, argument)); + } else if (argument === '--json') { + json = true; + } else if (argument === '--unicode-set') { + unicodeSet = true; + } else if (argument.startsWith('-')) { + throw new TypeError(`Unknown glyphs argument: ${argument}`); + } else { + input = uniqueValue(input, argument, 'font input'); + } + } + if (json && unicodeSet) throw new TypeError('--json and --unicode-set cannot be combined'); + if (!help && input === undefined) throw new TypeError('glyph inspection requires a font input'); + return { input: input ?? '', fontFaceIndex, names, json, unicodeSet, help }; +} + +async function inspectGlyphs(options: GlyphArguments): Promise { + let inspection; + try { + inspection = await inspectFont({ input: options.input, fontFaceIndex: options.fontFaceIndex }); + } catch (error) { + if (error instanceof NodeBakeError) throw error; + throw new NodeBakeError( + 'FONT_INSPECTION_FAILED', + `font inspection failed: ${error instanceof Error ? error.message : String(error)}`, + options.input, + ); + } + const requestedNames = new Set(options.names); + const foundNames = new Set(); + const glyphs: GlyphRecord[] = []; + for (const glyph of inspection.glyphs) { + const { codePoint, glyphId, name } = glyph; + if (requestedNames.size !== 0 && (name === undefined || !requestedNames.has(name))) continue; + if (name !== undefined) foundNames.add(name); + glyphs.push({ + unicode: formatCodePoint(codePoint), + codePoint, + glyphId, + ...(name === undefined ? {} : { name }), + }); + } + const missing = options.names.filter((name) => !foundNames.has(name)); + if (missing.length !== 0) { + throw new NodeBakeError( + 'GLYPH_NAME_NOT_FOUND', + `font has no glyph name${missing.length === 1 ? '' : 's'}: ${missing.join(', ')}`, + options.input, + ); + } + return glyphs; +} + +function formatUnicodeSet(codePoints: readonly number[]): string { + if (codePoints.length === 0) return ''; + const sortedCodePoints = [...new Set(codePoints)].sort((left, right) => left - right); + const ranges: string[] = []; + let start = sortedCodePoints[0]!; + let end = start; + for (const codePoint of sortedCodePoints.slice(1)) { + if (codePoint === end + 1) { + end = codePoint; + continue; + } + ranges.push(start === end ? formatCodePoint(start) : `${formatCodePoint(start)}-${formatCodePoint(end)}`); + start = codePoint; + end = codePoint; + } + ranges.push(start === end ? formatCodePoint(start) : `${formatCodePoint(start)}-${formatCodePoint(end)}`); + return ranges.join(','); +} + +function formatCodePoint(codePoint: number): string { + return `U+${codePoint.toString(16).toUpperCase().padStart(4, '0')}`; +} + +interface ParsedBakeArguments { + readonly project: ProjectBakeOptions; + readonly direct?: DirectBakeArguments; readonly json: boolean; readonly help: boolean; } -function parseArguments(argv: readonly string[]): ParsedArguments { +interface DirectBakeArguments { + readonly input: string; + readonly output: string; + readonly fontFaceIndex: number; + readonly bitmapStrikes?: readonly [number, ...number[]]; + readonly msdf: boolean; + readonly slug: boolean; + readonly unicodeRanges?: readonly UnicodeRangeV0[]; + readonly check: boolean; +} + +function parseBakeArguments(argv: readonly string[]): ParsedBakeArguments { let projectRoot: string | undefined; let outputRoot: string | undefined; const entries: string[] = []; const assetRoots: string[] = []; + let input: string | undefined; + let output: string | undefined; + let fontFaceIndex = 0; + let fontFaceIndexSet = false; + let bitmapStrikes: readonly [number, ...number[]] | undefined; + let msdf = false; + let slug = false; + let unicodeRanges: readonly UnicodeRangeV0[] | undefined; + let check = false; let json = false; let help = false; for (let index = 0; index < argv.length; index += 1) { @@ -79,22 +278,150 @@ function parseArguments(argv: readonly string[]): ParsedArguments { entries.push(valueAfter(argv, ++index, argument)); } else if (argument === '--asset-root') { assetRoots.push(valueAfter(argv, ++index, argument)); + } else if (argument === '--input') { + input = uniqueValue(input, valueAfter(argv, ++index, argument), argument); + } else if (argument === '--output') { + output = uniqueValue(output, valueAfter(argv, ++index, argument), argument); + } else if (argument === '--font-face-index') { + if (fontFaceIndexSet) throw new TypeError('--font-face-index may be provided only once'); + fontFaceIndexSet = true; + fontFaceIndex = nonnegativeInteger(valueAfter(argv, ++index, argument), argument); + } else if (argument === '--bitmap') { + if (bitmapStrikes !== undefined) throw new TypeError('--bitmap may be provided only once'); + bitmapStrikes = bitmapStrikeList(valueAfter(argv, ++index, argument)); + } else if (argument === '--msdf') { + msdf = true; + } else if (argument === '--slug') { + slug = true; + } else if (argument === '--unicodes') { + if (unicodeRanges !== undefined) throw new TypeError('--unicodes may be provided only once'); + unicodeRanges = parseUnicodeSet(valueAfter(argv, ++index, argument)); + } else if (argument === '--check') { + check = true; } else { throw new TypeError(`Unknown argument: ${argument}`); } } + const directSelected = + input !== undefined || + output !== undefined || + bitmapStrikes !== undefined || + msdf || + slug || + unicodeRanges !== undefined || + check || + fontFaceIndexSet; + const projectSelected = + projectRoot !== undefined || outputRoot !== undefined || entries.length !== 0 || assetRoots.length !== 0; + if (directSelected && projectSelected) + throw new TypeError('direct font options cannot be mixed with project discovery'); + if (directSelected && (input === undefined || output === undefined)) { + throw new TypeError('direct font baking requires both --input and --output'); + } return { - options: { + project: { ...(projectRoot === undefined ? {} : { projectRoot }), ...(outputRoot === undefined ? {} : { outputRoot }), ...(entries.length === 0 ? {} : { entries }), ...(assetRoots.length === 0 ? {} : { assetRoots }), }, + ...(directSelected + ? { + direct: { + input: input!, + output: output!, + fontFaceIndex, + ...(bitmapStrikes === undefined ? {} : { bitmapStrikes }), + msdf, + slug, + ...(unicodeRanges === undefined ? {} : { unicodeRanges }), + check, + }, + } + : {}), json, help, }; } +function uniqueValue(previous: string | undefined, value: string, option: string): string { + if (previous !== undefined) throw new TypeError(`${option} may be provided only once`); + return value; +} + +function nonnegativeInteger(value: string, option: string): number { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 0) throw new TypeError(`${option} requires a nonnegative integer`); + return parsed; +} + +function bitmapStrikeList(value: string): readonly [number, ...number[]] { + const strikes = value.split(',').map((part) => Number(part)); + if (strikes.length === 0 || strikes.some((strike) => !Number.isSafeInteger(strike) || strike <= 0)) { + throw new TypeError('--bitmap requires a comma-separated list of positive integer ppem strikes'); + } + return strikes as [number, ...number[]]; +} + +function parseUnicodeSet(value: string): readonly UnicodeRangeV0[] { + const ranges = value.split(',').map((part) => { + const match = /^U\+([0-9A-Fa-f]{1,6})(?:-U\+?([0-9A-Fa-f]{1,6})|-([0-9A-Fa-f]{1,6}))?$/u.exec(part.trim()); + if (match === null) throw new TypeError('--unicodes requires comma-separated U+XXXX or U+XXXX-YYYY ranges'); + const start = Number.parseInt(match[1]!, 16); + const end = Number.parseInt(match[2] ?? match[3] ?? match[1]!, 16); + if (start > end || end > 0x10ffff) { + throw new TypeError('--unicodes ranges must be ordered Unicode scalar values at or below U+10FFFF'); + } + return { start, end }; + }); + return normalizeUnicodeRanges(ranges); +} + +async function bakeDirect(options: DirectBakeArguments): Promise { + const temporaryDirectory = await mkdtemp(join(tmpdir(), 'text-bake-')); + try { + const output = options.check ? join(temporaryDirectory, 'checked.font.glb') : options.output; + const report = await bakeFont({ + input: options.input, + output, + font: { fontFaceIndex: options.fontFaceIndex }, + ...(options.unicodeRanges === undefined ? {} : { unicodeRanges: options.unicodeRanges }), + rasters: await directRasterPlans(options), + }); + if (options.check) { + const [expected, actual] = await Promise.all([readFile(options.output), readFile(output)]); + if (!expected.equals(actual)) { + throw new NodeBakeError( + 'STALE_OUTPUT', + 'baked font is not byte-identical to the requested output', + options.output, + ); + } + } + return report; + } finally { + await rm(temporaryDirectory, { force: true, recursive: true }); + } +} + +async function directRasterPlans(options: DirectBakeArguments): Promise[]> { + const packaging = { artifact: 'embedded', pages: 'embedded' } as const; + const rasters: RasterBakePlan[] = []; + if (options.bitmapStrikes !== undefined) { + const { bitmapBaker } = await import('../bakers/bitmap.js'); + rasters.push({ baker: bitmapBaker, packaging, options: { strikes: options.bitmapStrikes } }); + } + if (options.msdf) { + const { msdfBaker } = await import('../bakers/msdf.js'); + rasters.push({ baker: msdfBaker, packaging, options: undefined }); + } + if (options.slug) { + const { slugBaker } = await import('../bakers/slug.js'); + rasters.push({ baker: slugBaker, packaging, options: undefined }); + } + return rasters; +} + function valueAfter(argv: readonly string[], index: number, option: string): string { const value = argv[index]; if (value === undefined || value.startsWith('-')) { @@ -103,16 +430,102 @@ function valueAfter(argv: readonly string[], index: number, option: string): str return value; } -function usage(): string { - return `Usage: pmndrs-text-bake [options] +async function packageVersion(): Promise { + const value: unknown = JSON.parse(await readFile(new URL('../../package.json', import.meta.url), 'utf8')); + if (typeof value !== 'object' || value === null || Array.isArray(value) || !('version' in value)) { + throw new TypeError('@pmndrs/text package metadata has no version'); + } + const version = value.version; + if (typeof version !== 'string' || version.length === 0) { + throw new TypeError('@pmndrs/text package metadata has an invalid version'); + } + return version; +} + +function writeFailure(io: CliIo, error: unknown): void { + if (error instanceof NodeBakeError) { + io.stderr.write(`${error.code}: ${error.message}${error.path === undefined ? '' : ` (${error.path})`}\n`); + } else { + io.stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`); + } +} + +function topLevelUsage(): string { + return `Usage: text [options] + +Portable font baking for @pmndrs/text. + +Commands: + bake Bake font shaping data and optional raster techniques + glyphs List Unicode mappings and font-provided glyph names + +Global options: + -h, --help Show this help + -v, --version Show the installed @pmndrs/text version + +Run "text --help" for command-specific options and examples. +`; +} + +function glyphUsage(): string { + return `Usage: text glyphs [options] + +List the font's Unicode mappings and surface glyph names retained in its post or +CFF data. Fonts without authored names still report exact glyph IDs. Options: + --font-face-index Collection face to inspect (default: 0) + --name Select an exact font-provided glyph name; repeatable + --json Emit structured Unicode, glyph ID, and name records + --unicode-set Emit a compressed set for text bake --unicodes + -h, --help Show this help + +Examples: + text glyphs fa-solid-900.ttf --name globe --json + text glyphs fa-solid-900.ttf --name globe --name earth-americas --unicode-set +`; +} + +function bakeUsage(): string { + return `Usage: + text bake [discovery options] + text bake --input --output [options] + +Bake one known font directly, or discover defineFont() declarations in a project. +Direct options and discovery options cannot be mixed. With no bake options, discovery +scans the current project and writes beside each source asset. + +Direct font options: + --input Source TTF, OTF, TTC, or OTC font + --output Output GLB containing shaping data and selected rasters + --font-face-index Collection face to bake (default: 0) + --unicodes Unicode set used to prepare a smaller source font + Example: U+0020-007E,U+00A0-00FF,U+4E00-9FFF + Selects code points, not raw glyph IDs + +Raster options: + --bitmap Embed Bitmap at positive integer ppem strikes (example: 16,32) + --msdf Embed the default MSDF raster + --slug Embed the default Slug raster + With none selected, emit a shaping-only GLB + +Discovery options: --project-root Project root (default: current directory) --entry Restrict discovery to an entry; repeatable --asset-root Local asset root; repeatable (default: public) --output-root Mirror asset-relative outputs under this directory - --json Print the complete machine-readable report + +Output options: + --check Rebuild in temporary storage and require byte-identical output + --json Print the complete machine-readable bake report -h, --help Show this help + +Examples: + text bake --input Inter-Regular.ttf --output inter.font.glb --bitmap 16,32 --msdf --slug + text bake --input Inter-Regular.ttf --output inter-latin.font.glb --unicodes U+0020-007E --msdf + text bake --project-root . --output-root public/generated + text bake --input Inter-Regular.ttf --output inter.font.glb --msdf --check + `; } diff --git a/packages/text/src/r3f.ts b/packages/text/src/react.ts similarity index 58% rename from packages/text/src/r3f.ts rename to packages/text/src/react.ts index a9988f79..0e74f762 100644 --- a/packages/text/src/r3f.ts +++ b/packages/text/src/react.ts @@ -1,9 +1,9 @@ -import { useThree, type ThreeElements } from '@react-three/fiber/webgpu'; +import { extend, useThree, type ThreeElements } from '@react-three/fiber/webgpu'; import { createElement, + forwardRef, isValidElement, use, - useEffectEvent, useLayoutEffect, useMemo, useRef, @@ -15,10 +15,10 @@ import { } from 'react'; import type { GlyphPaintInput } from './formatted-text.js'; -import type { FontSelection, LoadedFont } from './loaded-font.js'; +import type { FontSelection, FontStack, LoadedFont } from './loaded-font.js'; import type { ParagraphContentBox, ParagraphStyle } from './text-properties.js'; import type { AnyRasterTechnique } from './raster-technique.js'; -import type { LoadedFontRequest } from './text-runtime.js'; +import type { LoadedFontRequest, LoadedFontTechniques, LoadedFonts, LoadedFontsRequest } from './text-runtime.js'; import { FontLoader, Text as ThreeText, @@ -39,8 +39,19 @@ export type R3fTextChild = | ReactElement> | readonly R3fTextChild[]; +type R3fFontSelection = + | FontSelection + | (Technique extends AnyRasterTechnique ? FontSelection : never); + +type FontSelectionTechnique = + Selection extends LoadedFont + ? Technique + : Selection extends FontStack + ? Technique + : never; + export type R3fTextProps = Object3DProps & { - readonly font?: FontSelection; + readonly font?: R3fFontSelection; readonly children?: R3fTextChild; readonly contentBox?: ParagraphContentBox; readonly style?: ParagraphStyle; @@ -48,6 +59,7 @@ export type R3fTextProps = Object3DProps & readonly rasterPixelRatio?: number; readonly material?: ThreeTextMaterial; readonly capacity?: StandaloneTextProperties['capacity']; + readonly pixelSnapping?: boolean; readonly onError?: ((error: unknown) => void) | undefined; readonly ref?: Ref>; }; @@ -71,51 +83,87 @@ interface InlineProperties { readonly material?: ThreeTextMaterial; } +type DesiredR3fTextProperties = Partial> & { + readonly font: FontSelection; + readonly text: string; +}; + interface UseFont { (request: LoadedFontRequest): LoadedFont; + (request: LoadedFontsRequest): LoadedFonts; preload(request: LoadedFontRequest): Promise>; + preload( + request: LoadedFontsRequest, + ): Promise>; clear(request: LoadedFontRequest): void; + clear(request: LoadedFontsRequest): void; } const fontLoader = new FontLoader(); -const fontPromises = new Map>>(); +type AnyLoadedFontResult = LoadedFont | readonly LoadedFont[]; +const fontPromises = new Map>(); const techniqueIds = new WeakMap(); let nextTechniqueId = 1; +const ThreeTextElement = extend(ThreeText); +const ThreeTextGroupElement = extend(ThreeTextGroup); -export function Text(input: R3fTextProps): ReactElement | null { - const { ref: forwardedRef, ...properties } = input; +interface TextComponent { + >( + input: Omit, 'font'> & { + readonly font: Selection & ([FontSelectionTechnique] extends [never] ? never : unknown); + }, + ): ReactElement | null; + (input: R3fTextProps): ReactElement | null; +} + +export const Text = forwardRef(function Text( + properties: Omit, 'ref'>, + forwardedRef: Ref>, +): ReactElement | null { 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 [object, publishObject] = useState | null>(null); + useLayoutEffect(() => assignRef(forwardedRef, object ?? undefined), [forwardedRef, object]); + if (desired.font === undefined) throw new TypeError('an outer R3F Text requires a font'); + return createElement(TextObject, { + key: properties.pixelSnapping === true ? 'pixel-snapped' : 'unsnapped', + desired: desired as DesiredR3fTextProperties, + object: objectProperties(properties), + onError: properties.onError, + publishObject: publishObject as (value: ThreeText | null) => void, + }); +}) as TextComponent; + +function TextObject({ + desired, + object: objectProps, + onError, + publishObject: publishCommittedObject, +}: { + readonly desired: DesiredR3fTextProperties; + readonly object: Object3DProps; + readonly onError: ((error: unknown) => void) | undefined; + readonly publishObject: (value: ThreeText | null) => void; +}): ReactElement { + const [constructorArguments] = useState(() => [desired as StandaloneTextProperties] as const); + const appliedRef = useRef(desired); + const capacityRef = useRef(desired.capacity); + 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); - created.onError = (error: unknown) => onErrorRef.current?.(error); - appliedRef.current = desired; - return created; - }); - - useLayoutEffect(() => { - const created = createObject(); - store.publish(created); - return () => { - store.publish(undefined); - created.dispose(); - }; - }, [store]); - - useLayoutEffect(() => assignRef(forwardedRef, object), [forwardedRef, object]); + const publishObject = useMemo( + () => (value: ThreeText | null) => { + store.publish(value ?? undefined); + publishCommittedObject(value); + }, + [publishCommittedObject, store], + ); useLayoutEffect(() => { - if (object === undefined || desired.font === undefined) return; - const { capacity, ...update } = desired; + if (object === undefined) return; + const { capacity, pixelSnapping: _pixelSnapping, ...update } = desired; if (!sameDesiredText(appliedRef.current, desired)) { - object.set(update as StandaloneTextProperties); + object.set(update); appliedRef.current = desired; } if (capacity !== undefined && !sameCapacity(capacity, capacityRef.current)) object.setCapacity(capacity); @@ -123,74 +171,87 @@ export function Text(input: R3fTextProps { - onErrorRef.current = properties.onError; - }, [properties.onError]); - - if (object === undefined) return null; - return createElement('primitive', { - ...objectProperties(properties), - object, + return createElement(ThreeTextElement, { + ...objectProps, + args: constructorArguments as unknown as readonly [StandaloneTextProperties], + onError, + ref: publishObject, }); } -export function TextGroup(input: R3fTextGroupProps): ReactElement | null { - const { ref: forwardedRef, ...properties } = input; +export const TextGroup: (input: R3fTextGroupProps) => ReactElement | null = forwardRef(function TextGroup( + properties: Omit, + forwardedRef: Ref, +): ReactElement | null { + const [object, publishObject] = useState(null); + useLayoutEffect(() => assignRef(forwardedRef, object ?? undefined), [forwardedRef, object]); + return createElement(TextGroupObject, { + key: `${properties.compositing ?? 'ordered'}:${properties.pixelSnapping === true ? 'pixel-snapped' : 'unsnapped'}`, + object: groupObjectProperties(properties), + options: properties, + publishObject, + }); +}) as (input: R3fTextGroupProps) => ReactElement | null; + +function TextGroupObject({ + object: objectProps, + options, + publishObject: publishCommittedObject, +}: { + readonly object: Object3DProps; + readonly options: Omit; + readonly publishObject: (value: ThreeTextGroup | null) => void; +}): ReactElement { + const [constructorArguments] = useState( + () => + [ + { + ...(options.capacity === undefined ? {} : { capacity: options.capacity }), + ...(options.compositing === undefined ? {} : { compositing: options.compositing }), + ...(options.renderOrder === undefined ? {} : { renderOrder: options.renderOrder }), + ...(options.material === undefined ? {} : { material: options.material }), + ...(options.pixelSnapping === undefined ? {} : { pixelSnapping: options.pixelSnapping }), + }, + ] as const, + ); 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({ - ...(properties.capacity === undefined ? {} : { capacity: properties.capacity }), - ...(properties.compositing === undefined ? {} : { compositing: properties.compositing }), - ...(properties.renderOrder === undefined ? {} : { renderOrder: properties.renderOrder }), - ...(properties.material === undefined ? {} : { material: properties.material }), - }); - created.onError = (error: unknown) => onErrorRef.current?.(error); - return created; - }); - - useLayoutEffect(() => { - const created = createObject(); - store.publish(created); - return () => { - store.publish(undefined); - created.dispose(); - }; - }, [store]); - - useLayoutEffect(() => assignRef(forwardedRef, object), [forwardedRef, object]); + const publishObject = useMemo( + () => (value: ThreeTextGroup | null) => { + store.publish(value ?? undefined); + publishCommittedObject(value); + }, + [publishCommittedObject, store], + ); useLayoutEffect(() => { if (object === undefined) return; - if (properties.capacity !== undefined && !sameCapacity(properties.capacity, object)) - object.setCapacity(properties.capacity); - object.setMaterial(properties.material); + if (options.capacity !== undefined && !sameCapacity(options.capacity, object)) object.setCapacity(options.capacity); + object.setMaterial(options.material); invalidate(); - }, [invalidate, object, properties.capacity, properties.material]); + }, [invalidate, object, options.capacity, options.material]); - useLayoutEffect(() => { - onErrorRef.current = properties.onError; - }, [properties.onError]); - - if (object === undefined) return null; return createElement( - 'primitive', + ThreeTextGroupElement, { - ...groupObjectProperties(properties), - object, + ...objectProps, + args: constructorArguments as unknown as readonly [TextGroupOptions], + onError: options.onError, + ref: publishObject, }, - properties.children, + options.children, ); } -const useFontImplementation = (( - request: LoadedFontRequest, -): LoadedFont => use(preloadFont(request))) as UseFont; +const useFontImplementation = (( + request: LoadedFontRequest | LoadedFontsRequest, +): AnyLoadedFontResult => use(preloadFontInternal(request))) as UseFont; useFontImplementation.preload = preloadFont; -useFontImplementation.clear = (request): void => { +useFontImplementation.clear = ( + request: LoadedFontRequest | LoadedFontsRequest, +): void => { fontPromises.delete(fontRequestKey(request)); }; @@ -231,27 +292,48 @@ function assignRef(ref: Ref | undefined, value: Value | undefined) function preloadFont( request: LoadedFontRequest, -): Promise> { +): Promise>; +function preloadFont( + request: LoadedFontsRequest, +): Promise>; +function preloadFont( + request: LoadedFontRequest | LoadedFontsRequest, +): Promise { + return preloadFontInternal(request); +} + +function preloadFontInternal( + request: LoadedFontRequest | LoadedFontsRequest, +): Promise { const key = fontRequestKey(request); - let promise = fontPromises.get(key) as Promise> | undefined; + let promise = fontPromises.get(key); if (promise !== undefined) return promise; - promise = fontLoader.loadAsync(request).catch((error: unknown) => { + const loaded = 'rasters' in request ? fontLoader.loadFontsAsync(request) : fontLoader.loadAsync(request); + promise = loaded.catch((error: unknown) => { if (fontPromises.get(key) === promise) fontPromises.delete(key); throw error; }); - fontPromises.set(key, promise as Promise>); + fontPromises.set(key, promise); return promise; } -function fontRequestKey(request: LoadedFontRequest): string { - let techniqueId = techniqueIds.get(request.raster.technique); +function fontRequestKey( + request: LoadedFontRequest | LoadedFontsRequest, +): string { + const rasters = 'rasters' in request ? request.rasters : [request.raster]; + const rasterKeys = rasters.map((raster) => [techniqueKey(raster.technique), raster.options ?? null]); + const input = + 'baked' in request.input ? ['baked', String(request.input.baked)] : ['source', String(request.input.source)]; + return JSON.stringify([input, rasterKeys]); +} + +function techniqueKey(technique: AnyRasterTechnique): number { + let techniqueId = techniqueIds.get(technique); if (techniqueId === undefined) { techniqueId = nextTechniqueId++; - techniqueIds.set(request.raster.technique, techniqueId); + techniqueIds.set(technique, techniqueId); } - const input = - 'baked' in request.input ? ['baked', String(request.input.baked)] : ['source', String(request.input.source)]; - return JSON.stringify([input, techniqueId, request.raster.options ?? null]); + return techniqueId; } function flattenText( @@ -319,6 +401,7 @@ function textProperties( ...(properties.rasterPixelRatio === undefined ? {} : { rasterPixelRatio: properties.rasterPixelRatio }), ...(properties.material === undefined ? {} : { material: properties.material }), ...(properties.capacity === undefined ? {} : { capacity: properties.capacity }), + ...(properties.pixelSnapping === undefined ? {} : { pixelSnapping: properties.pixelSnapping }), }); } @@ -333,6 +416,7 @@ function objectProperties(properties: R3fT 'rasterPixelRatio', 'material', 'capacity', + 'pixelSnapping', 'onError', 'ref', ]) @@ -342,7 +426,8 @@ function objectProperties(properties: R3fT function groupObjectProperties(properties: R3fTextGroupProps): Object3DProps { const object = { ...properties } as Record; - for (const key of ['capacity', 'compositing', 'material', 'children', 'onError', 'ref']) delete object[key]; + for (const key of ['capacity', 'compositing', 'material', 'pixelSnapping', 'children', 'onError', 'ref']) + delete object[key]; return object as Object3DProps; } diff --git a/packages/text/src/runtime-bake-worker.ts b/packages/text/src/runtime-bake-worker.ts index a4f80e6e..80e71d09 100644 --- a/packages/text/src/runtime-bake-worker.ts +++ b/packages/text/src/runtime-bake-worker.ts @@ -1,12 +1,17 @@ /// -import { createFontBaker, type FontBakeCore, type SerializedBakeError } from '@pmndrs/text-font-baker'; -import { fontBakerWasmUrl } from '@pmndrs/text-font-baker/wasm-url'; +import { createFontBaker, type FontBakeCore, type SerializedBakeError } from './font-baker/index.js'; +import { fontBakerWasmUrl } from './font-baker/wasm-url.js'; -import { soleCoreFontArtifact } from './internal/core-bake-policy.js'; +import type { AnyRasterBakerModule } from './bake.js'; import { copyToOwnedArrayBuffer } from './internal/owned-array-buffer.js'; +import { bakeFontPipeline } from './internal/font-bake-pipeline.js'; +import type { ResolvedRasterBakePlan } from './internal/raster-bake-plan.js'; +import { deriveRasterKey } from './internal/raster-identity.js'; +import { createRuntimeFontCache, type CachedFontArtifact } from './internal/runtime-font-cache.js'; import { isRuntimeBakeRequestV0, + type RuntimeBakeRasterV0, type RuntimeBakeRequestV0, type RuntimeBakeFailureV0, type RuntimeBakeSuccessV0, @@ -32,37 +37,40 @@ scope.addEventListener('message', (event: MessageEvent) => { async function handleMessage(value: RuntimeBakeRequestV0): Promise { try { + const source = new Uint8Array(value.source); + const cache = + value.cache === undefined || value.cache.expiresAt <= Date.now() ? undefined : createRuntimeFontCache(); + const cacheKey = await cache?.key(source, value); + const cached = cacheKey === undefined ? undefined : await cache?.match(cacheKey); + if (cached !== undefined) { + scope.postMessage(bakeProgressMessage(value.id, 'font', 'complete', 1, 1)); + postSuccess(value.id, cached, { cache: 'hit' }, []); + return; + } scope.postMessage(bakeProgressMessage(value.id, 'font', 'loading', 0, 1)); - const core = await loadCore(); + const [core, rasters] = await Promise.all([loadCore(), resolveRasters(value.rasters ?? [])]); scope.postMessage(bakeProgressMessage(value.id, 'font', 'baking', 0, 1)); - const result = core.bake({ - source: new Uint8Array(value.source), - descriptor: value.font, + const result = await bakeFontPipeline({ + fontBaker: core, + source, + fontFaceIndex: value.font.fontFaceIndex, + ...(value.unicodeRanges === undefined ? {} : { unicodeRanges: value.unicodeRanges }), + rasters, + onProgress(progress) { + scope.postMessage( + bakeProgressMessage(value.id, progress.stage, progress.phase, progress.completed, progress.total), + ); + }, }); - const artifact = soleCoreFontArtifact(result); scope.postMessage(bakeProgressMessage(value.id, 'font', 'packaging', 0, 1)); - const artifacts: RuntimeBakeSuccessV0['artifacts'] = [ - { - role: artifact.role, - id: artifact.id, - bytes: copyToOwnedArrayBuffer(artifact.bytes), - sha256: artifact.sha256, - }, - ]; - const response: RuntimeBakeSuccessV0 = { - type: 'bake-font-result-v0', - id: value.id, - ok: true, - artifacts, - report: result.report, - warnings: result.warnings, - }; + if (result.composed.artifacts.length !== 1 || result.composed.artifacts[0]?.role !== 'font') { + throw new Error('runtime bake must produce exactly one embedded font artifact'); + } + const artifact = result.composed.artifacts[0]; + if (cacheKey !== undefined) await cache?.put(cacheKey, artifact, value.cache!.expiresAt); scope.postMessage(bakeProgressMessage(value.id, 'font', 'transferring', 0, 1)); scope.postMessage(bakeProgressMessage(value.id, 'font', 'complete', 1, 1)); - scope.postMessage( - response, - artifacts.map(({ bytes }) => bytes), - ); + postSuccess(value.id, artifact, result.composed.report, result.composed.warnings); } catch (error) { const response: RuntimeBakeFailureV0 = { type: 'bake-font-result-v0', @@ -74,6 +82,69 @@ async function handleMessage(value: RuntimeBakeRequestV0): Promise { } } +function postSuccess(id: number, artifact: CachedFontArtifact, report: unknown, warnings: readonly unknown[]): void { + const artifacts: RuntimeBakeSuccessV0['artifacts'] = [ + { + role: 'font', + id: artifact.id, + bytes: copyToOwnedArrayBuffer(artifact.bytes), + sha256: artifact.sha256, + }, + ]; + const response: RuntimeBakeSuccessV0 = { + type: 'bake-font-result-v0', + id, + ok: true, + artifacts, + report, + warnings, + }; + scope.postMessage( + response, + artifacts.map(({ bytes }) => bytes), + ); +} + +async function resolveRasters(rasters: readonly RuntimeBakeRasterV0[]): Promise { + return Promise.all(rasters.map(resolveRaster)); +} + +async function resolveRaster(raster: RuntimeBakeRasterV0): Promise { + let baker: AnyRasterBakerModule; + switch (raster.kind) { + case 'bitmap': + baker = (await import('./bakers/bitmap.js')).default; + break; + case 'msdf': + baker = (await import('./bakers/msdf.js')).default; + break; + case 'slug': + baker = (await import('./bakers/slug.js')).default; + break; + default: + throw new Error(`runtime font baker does not support raster kind ${raster.kind}`); + } + if (baker.extension !== raster.extension || baker.version !== raster.version) { + throw new Error(`runtime ${raster.kind} baker identity does not match the requested technique`); + } + const rasterKey = await deriveRasterKey({ + descriptor: raster.descriptor, + extension: raster.extension, + kind: raster.kind, + version: raster.version, + }); + if (rasterKey !== raster.rasterKey) { + throw new Error(`runtime ${raster.kind} descriptor does not match its raster key`); + } + return { + baker, + packaging: { artifact: 'embedded', pages: 'embedded' }, + options: undefined, + descriptor: raster.descriptor, + rasterKey, + }; +} + function serializeError(error: unknown): SerializedBakeError { if (error instanceof Error) { const value = error as Error & { readonly code?: unknown; readonly path?: unknown }; diff --git a/packages/text/src/runtime-bake.ts b/packages/text/src/runtime-bake.ts index b37f3eb9..25ff74ff 100644 --- a/packages/text/src/runtime-bake.ts +++ b/packages/text/src/runtime-bake.ts @@ -1,8 +1,9 @@ -import { FontBakeError } from '@pmndrs/text-font-baker'; +import { FontBakeError } from './font-baker/index.js'; import type { RuntimeFontBake, RuntimeFontBakeRequest } from './loader.js'; import { fontBakeDescriptorV0 } from './internal/core-bake-policy.js'; import { copyToOwnedArrayBuffer } from './internal/owned-array-buffer.js'; +import { normalizeUnicodeRanges } from './internal/font-selection.js'; import { isRuntimeBakeResultV0, type RuntimeBakeRequestV0, @@ -28,6 +29,11 @@ const host = new SerialWorkerHost< id, source, font: fontBakeDescriptorV0(0), + ...(request.cache === undefined ? {} : { cache: request.cache }), + ...(request.unicodeRanges === undefined + ? {} + : { unicodeRanges: normalizeUnicodeRanges(request.unicodeRanges) }), + ...(request.rasters === undefined ? {} : { rasters: request.rasters }), }, transfer: [source], }; diff --git a/packages/text/src/text-runtime.ts b/packages/text/src/text-runtime.ts index bb524319..7be71be8 100644 --- a/packages/text/src/text-runtime.ts +++ b/packages/text/src/text-runtime.ts @@ -9,6 +9,8 @@ import { type RuntimeFontBakeRequest, } from './loader.js'; import { canonicalJson, deriveRasterKey } from './internal/raster-identity.js'; +import { normalizeUnicodeRanges } from './internal/font-selection.js'; +import type { RuntimeBakeRasterV0, RuntimeBakeUnicodeRangeV0 } from './internal/runtime-bake-protocol.js'; import { getRegisteredFontData } from './internal/registered-font.js'; import type { AnyRasterTechnique, @@ -34,13 +36,32 @@ export interface TextRuntimeOptions { export type LoadedFontInput = | { readonly baked: string | URL } - | { readonly source: string | URL; readonly runtimeBake: RuntimeFontBake }; + | { + readonly source: string | URL; + readonly runtimeBake: RuntimeFontBake; + readonly unicodeRanges?: readonly RuntimeBakeUnicodeRangeV0[]; + }; export interface LoadedFontRequest { readonly input: LoadedFontInput; readonly raster: RasterTechniqueRequest; } +export type LoadedFontTechniques = readonly [AnyRasterTechnique, ...AnyRasterTechnique[]]; + +export type LoadedFontRasterRequests = { + readonly [Index in keyof Techniques]: RasterTechniqueRequest; +}; + +export interface LoadedFontsRequest { + readonly input: LoadedFontInput; + readonly rasters: LoadedFontRasterRequests; +} + +export type LoadedFonts = { + readonly [Index in keyof Techniques]: LoadedFont; +}; + export interface TextRuntime { readonly registry: FontRegistry; readonly disposed: boolean; @@ -50,6 +71,11 @@ export interface TextRuntime { options?: { readonly signal?: AbortSignal }, ): Promise>; + loadFont( + request: LoadedFontsRequest, + options?: { readonly signal?: AbortSignal }, + ): Promise>; + dispose(): void; } @@ -79,7 +105,7 @@ class TextRuntimeImpl implements TextRuntime { readonly registry: FontRegistry; readonly #shaper: RuntimeShaper; readonly #defaultLoader: FontLoader; - readonly #sourceLoaders = new Map(); + readonly #sourceLoaders = new Map>(); readonly #loaded = new Map>>>(); readonly #pending = new Map>>(); #disposed = false; @@ -94,45 +120,67 @@ class TextRuntimeImpl implements TextRuntime { return this.#disposed; } - async loadFont( + loadFont( request: LoadedFontRequest, + options?: { readonly signal?: AbortSignal }, + ): Promise>; + loadFont( + request: LoadedFontsRequest, + options?: { readonly signal?: AbortSignal }, + ): Promise>; + async loadFont( + request: LoadedFontRequest | LoadedFontsRequest, options: { readonly signal?: AbortSignal } = {}, - ): Promise> { + ): Promise { this.#assertActive(); options.signal?.throwIfAborted(); - const font = await this.#loadRegisteredFont(request.input, options.signal); + const rasterRequests = 'rasters' in request ? request.rasters : [request.raster]; + const font = await this.#loadRegisteredFont(request.input, rasterRequests, options.signal); this.#assertActive(); options.signal?.throwIfAborted(); this.#shaper.registerFont(font); - const descriptor = techniqueOperations(request.raster.technique).descriptor( - request.raster.options as RasterOptionsArgument>, + if ('rasters' in request) { + return Promise.all( + request.rasters.map((raster) => this.#loadFontRaster(font, raster, options.signal)), + ) as unknown as Promise>; + } + return this.#loadFontRaster(font, request.raster, options.signal); + } + + async #loadFontRaster( + font: RegisteredFont, + raster: RasterTechniqueRequest, + signal: AbortSignal | undefined, + ): Promise> { + const descriptor = techniqueOperations(raster.technique).descriptor( + raster.options as RasterOptionsArgument>, ); const key = canonicalJson(descriptor); - const loaded = this.#loaded.get(font)?.get(request.raster.technique)?.get(key); + const loaded = this.#loaded.get(font)?.get(raster.technique)?.get(key); if (loaded !== undefined && !loaded.disposed) return loaded as LoadedFont; - const pending = this.#pending.get(font)?.get(request.raster.technique)?.get(key); - if (pending !== undefined) return consumePending(pending.promise as Promise>, options.signal); + const pending = this.#pending.get(font)?.get(raster.technique)?.get(key); + if (pending !== undefined) return consumePending(pending.promise as Promise>, signal); const controller = new AbortController(); const entry = {} as PendingTechniqueLoad; - const promise = this.#loadTechnique(font, request, descriptor, controller.signal).then( + const promise = this.#loadTechnique(font, raster, descriptor, controller.signal).then( (value) => { - this.#deletePending(font, request.raster.technique, key, entry); + this.#deletePending(font, raster.technique, key, entry); if (this.#disposed || controller.signal.aborted) { value.dispose(); throw new FontLoadError('TEXT_RUNTIME_DISPOSED', 'text runtime was disposed during font loading'); } - this.#loadedMap(font, request.raster.technique).set(key, value as LoadedFont); + this.#loadedMap(font, raster.technique).set(key, value as LoadedFont); return value; }, (error: unknown) => { - this.#deletePending(font, request.raster.technique, key, entry); + this.#deletePending(font, raster.technique, key, entry); throw error; }, ); Object.assign(entry, { controller, promise }); - this.#pendingMap(font, request.raster.technique).set(key, entry); - return consumePending(promise, options.signal); + this.#pendingMap(font, raster.technique).set(key, entry); + return consumePending(promise, signal); } dispose(): void { @@ -153,24 +201,52 @@ class TextRuntimeImpl implements TextRuntime { this.#shaper.dispose(); } - async #loadRegisteredFont(input: LoadedFontInput, signal: AbortSignal | undefined): Promise { + async #loadRegisteredFont( + input: LoadedFontInput, + rasterRequests: readonly RasterTechniqueRequest[], + signal: AbortSignal | undefined, + ): Promise { if ('baked' in input) return this.#defaultLoader.load({ baked: input.baked }, signal === undefined ? {} : { signal }); - let loader = this.#sourceLoaders.get(input.runtimeBake); + const unicodeRanges = + input.unicodeRanges === undefined ? undefined : normalizeUnicodeRanges(input.unicodeRanges); + const rasters = await Promise.all(rasterRequests.map(runtimeBakeRaster)); + const planKey = canonicalJson( + { + rasters, + unicodeRanges: unicodeRanges ?? null, + } as unknown as import('./raster.js').JsonValue, + ); + let loaders = this.#sourceLoaders.get(input.runtimeBake); + if (loaders === undefined) { + loaders = new Map(); + this.#sourceLoaders.set(input.runtimeBake, loaders); + } + let loader = loaders.get(planKey); if (loader === undefined) { - loader = new FontLoader({ registry: this.registry, runtimeBake: input.runtimeBake }); - this.#sourceLoaders.set(input.runtimeBake, loader); + const runtimeBake: RuntimeFontBake = (request) => + input.runtimeBake({ + ...request, + ...(unicodeRanges === undefined ? {} : { unicodeRanges }), + rasters, + }); + loader = new FontLoader({ + registry: this.registry, + runtimeBake, + ...(unicodeRanges === undefined ? {} : { runtimeSourceIdentity: 'transformed' }), + }); + loaders.set(planKey, loader); } return loader.load({ source: input.source, baked: null }, signal === undefined ? {} : { signal }); } async #loadTechnique( font: RegisteredFont, - request: LoadedFontRequest, + request: RasterTechniqueRequest, descriptor: RasterTechniqueTypesOf['descriptor'], signal: AbortSignal, ): Promise> { - const technique = request.raster.technique; + const technique = request.technique; const rasterKey = await deriveRasterKey({ descriptor, extension: technique.extension, @@ -209,11 +285,11 @@ class TextRuntimeImpl implements TextRuntime { async #runtimeBake( font: RegisteredFont, - request: LoadedFontRequest, + request: RasterTechniqueRequest, rasterKey: Awaited>, signal: AbortSignal, ): Promise>> { - const technique = request.raster.technique; + const technique = request.technique; const loadBaker = techniqueOperations(technique).runtimeBaker; if (loadBaker === undefined) { throw new FontLoadError('RASTER_NOT_FOUND', `${technique.kind} has no baked artifact or runtime baker`); @@ -234,7 +310,7 @@ class TextRuntimeImpl implements TextRuntime { font, fontFaceIndex: registered.fontFaceIndex, rasterKey, - options: request.raster.options as RasterOptionsArgument>, + options: request.options as RasterOptionsArgument>, signal, } as unknown as TechniqueRasterBakeRequest>; const baked = await baker.bake(bakeRequest); @@ -318,6 +394,28 @@ class TextRuntimeImpl implements TextRuntime { } } +async function runtimeBakeRaster( + request: RasterTechniqueRequest, +): Promise { + const { technique } = request; + const descriptor = techniqueOperations(technique).descriptor( + request.options as RasterOptionsArgument>, + ); + const rasterKey = await deriveRasterKey({ + descriptor, + extension: technique.extension, + kind: technique.kind, + version: technique.version, + }); + return { + kind: technique.kind, + extension: technique.extension, + version: technique.version, + rasterKey, + descriptor, + }; +} + async function decodeTechnique( technique: Technique, font: RegisteredFont, diff --git a/packages/text/src/three/bitmap-shader.ts b/packages/text/src/three/bitmap-shader.ts index 425dc1da..9ec631e6 100644 --- a/packages/text/src/three/bitmap-shader.ts +++ b/packages/text/src/three/bitmap-shader.ts @@ -16,6 +16,8 @@ export interface ThreeBitmapInstanceNodes { readonly uvSize: Node<'vec2'>; /** Resolved paint colour with alpha, unpremultiplied. */ readonly color: Node<'vec4'>; + /** Texture-array layer containing this glyph's coverage. */ + readonly pageIndex: Node<'uint'>; } /** The GPU resources one Bitmap glyph batch binds: the single-channel coverage page its strike binding selected. */ @@ -24,13 +26,18 @@ export interface ThreeBitmapShaderResources { readonly page: Texture; } +export interface ThreeBitmapShaderOptions { + /** Snap projected vertices to physical pixels. Disabled by default so animated transforms retain subpixel motion. */ + readonly pixelSnapping?: boolean; +} + /** Everything the canonical Bitmap graph produces, so a program can consume a stage or compose over its final output. */ export interface ThreeBitmapShaderOutput { readonly position: Node<'vec3'>; /** - * Clip-space vertex position with the projected quad edges snapped to the physical pixel grid. Bitmap coverage is - * authored as one atlas texel per device pixel, so a quad landing between pixel centres resamples the strike instead - * of reproducing it. A program must assign this to `material.vertexNode` to inherit that placement. + * Clip-space vertex position selected by the shader options. Pixel snapping is opt-in because it preserves strike + * sharpness at rest but quantizes animated motion. A program must assign this to `material.vertexNode` to inherit the + * selected placement. */ readonly clipPosition: Node<'vec4'>; /** Atlas coordinate the page is sampled at, in the page's own top-down texel space. */ @@ -52,19 +59,20 @@ export interface ThreeBitmapShaderOutput { export function bitmapShader( instance: ThreeBitmapInstanceNodes, resources: ThreeBitmapShaderResources, + options: ThreeBitmapShaderOptions = {}, ): ThreeBitmapShaderOutput { const atlasUv = TSL.vec2( instance.uvOrigin.x.add(TSL.uv().x.mul(instance.uvSize.x)), instance.uvOrigin.y.add(TSL.uv().y.mul(instance.uvSize.y)), ); - const coverage = TSL.texture(resources.page, atlasUv).r; + const coverage = TSL.texture(resources.page, atlasUv).depth(instance.pageIndex).r; return { position: TSL.vec3( instance.origin.x.add(TSL.positionLocal.x.mul(instance.size.x)), instance.origin.y.add(TSL.positionLocal.y.mul(instance.size.y)).negate(), 0, ), - clipPosition: pixelSnappedClipPosition(), + clipPosition: options.pixelSnapping === true ? pixelSnappedClipPosition() : TSL.modelViewProjection, atlasUv, coverage, color: instance.color.rgb, diff --git a/packages/text/src/three/engine-plan-target.ts b/packages/text/src/three/engine-plan-target.ts index 5f4de5f7..6d8ac4e9 100644 --- a/packages/text/src/three/engine-plan-target.ts +++ b/packages/text/src/three/engine-plan-target.ts @@ -5,7 +5,7 @@ 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 { bitmap, type BitmapStrikeData } 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'; @@ -73,6 +73,7 @@ interface RecordAddressing { export interface ThreeTextEnginePlanOwner { readonly drawRoot: THREE.Object3D; + readonly pixelSnapping: boolean; objectForTransform(transformId: number): THREE.Object3D; transformIds(): Iterable; readonly renderOrderBase: number; @@ -85,12 +86,13 @@ export class ThreeTextRenderPlanExecutor { readonly #view = new TextEngineRenderPlanView(); readonly #buffers = new Map(); readonly #resources = new Map(); - readonly #bitmapTextures = 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 #directDrawsByTransform = new Map(); readonly #originRecords = new Map(); readonly #rootInverse = new THREE.Matrix4(); readonly #relativeTransform = new THREE.Matrix4(); @@ -150,6 +152,7 @@ export class ThreeTextRenderPlanExecutor { this.#replaceDraws(plan, draws, primitives, buffers, resources); } this.syncTransforms(); + for (const draw of this.#draws) draw.updateMatrixWorld(false); this.#applyRetirements(plan, retirements); this.#originRecords.clear(); }); @@ -220,57 +223,68 @@ export class ThreeTextRenderPlanExecutor { } /** Upload changed scene transforms without crossing into Wasm or invalidating text layout. */ - syncTransforms(): number { + syncTransforms(transformIds: Iterable = this.#owner.transformIds(), worldMatricesCurrent = false): 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 rootPrepared = false; + let changedTransforms = 0; 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); + for (const transformId of transformIds) { + const indexed = this.#activeTransformIndices.has(transformId); + const directDraws = this.#directDrawsByTransform.get(transformId); + if (!indexed && directDraws === undefined) continue; + if (!rootPrepared) { + if (!worldMatricesCurrent) this.#owner.drawRoot.updateWorldMatrix(true, false); + this.#rootInverse.copy(this.#owner.drawRoot.matrixWorld).invert(); + rootPrepared = true; } - 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); + if (!worldMatricesCurrent) 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; + const visible = visibleBelowRoot(object, this.#owner.drawRoot); + let transformChanged = false; + if (indexed) { + const offset = transformId * 16; + if (visible) { + if (!matrixEquals(target, offset, this.#relativeTransform.elements)) { + target.set(this.#relativeTransform.elements, offset); + transformChanged = true; + } + } else if (!zeroMatrixEquals(target, offset)) { + target.fill(0, offset, offset + 16); + transformChanged = true; + } + if (transformChanged) { + this.#transformAttribute.addUpdateRange(offset, 16); + indexedChanged += 1; + } + } + for (const draw of directDraws ?? []) { + let drawChanged = false; + if (draw.visible !== visible) { + draw.visible = visible; + drawChanged = true; + } + if (!draw.matrix.equals(this.#relativeTransform)) { + draw.matrix.copy(this.#relativeTransform); + draw.matrixWorldNeedsUpdate = true; + drawChanged = true; + } + if (drawChanged) { + draw.updateMatrixWorld(false); + transformChanged = true; + } } - if (drawChanged) changedTransforms.add(transformId); + if (transformChanged) changedTransforms += 1; } - if (changedTransforms.size === 0) return 0; + if (changedTransforms === 0) return 0; if (indexedChanged !== 0) { this.#transformAttribute.needsUpdate = true; invalidatePboTexture(this.#transformAttribute); } - return changedTransforms.size; + return changedTransforms; } dispose(): void { @@ -288,6 +302,7 @@ export class ThreeTextRenderPlanExecutor { this.#buffers.clear(); this.#resources.clear(); this.#activeTransformIndices.clear(); + this.#directDrawsByTransform.clear(); this.#originRecords.clear(); this.#originSegments = []; } @@ -506,6 +521,14 @@ export class ThreeTextRenderPlanExecutor { this.#originSegments = nextOriginSegments; this.#activeTransformIndices.clear(); for (const transformIndex of transformIndices) this.#activeTransformIndices.add(transformIndex); + this.#directDrawsByTransform.clear(); + for (const draw of this.#draws) { + const transformId = directTransformId(draw); + if (transformId === 0) continue; + const transformDraws = this.#directDrawsByTransform.get(transformId) ?? []; + transformDraws.push(draw); + this.#directDrawsByTransform.set(transformId, transformDraws); + } } #restoreOriginTargets(): void { @@ -589,18 +612,18 @@ export class ThreeTextRenderPlanExecutor { 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 strike = bitmapStrike(resolved); + const required = [1, 2, 3, 4, 5, 6].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 + const key = `${resource.id}:${resource.generation}:${materialId}:snap=${String(this.#owner.pixelSnapping)}:${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 texture = this.#bitmapTexture(resource.referenceId, strike); const runStart = TSL.uniform(0, 'uint').onObjectUpdate( ({ object }) => (object?.userData.pmndrsTextRunStart as number | undefined) ?? 0, ); @@ -618,8 +641,12 @@ export class ThreeTextRenderPlanExecutor { .setPBO(true) .element(instance), color: TSL.storage(required[4]!.attribute, 'vec4', required[4]!.attribute.count).setPBO(true).element(instance), + pageIndex: TSL.storage(required[5]!.attribute, 'uint', required[5]!.attribute.count) + .setPBO(true) + .element(instance), }, { page: texture }, + { pixelSnapping: this.#owner.pixelSnapping }, ); const position = transform.kind === 'indexed' @@ -759,10 +786,23 @@ export class ThreeTextRenderPlanExecutor { return material; } - #bitmapTexture(referenceId: number, page: BitmapPageData): THREE.DataTexture { + #bitmapTexture(referenceId: number, strike: BitmapStrikeData): THREE.DataArrayTexture { 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); + const width = Math.max(...strike.pages.map((page) => page.width)); + const height = Math.max(...strike.pages.map((page) => page.height)); + const bytes = new Uint8Array(width * height * strike.pages.length); + for (let layer = 0; layer < strike.pages.length; layer += 1) { + const page = strike.pages[layer]!; + for (let row = 0; row < page.height; row += 1) { + const source = row * page.width; + const target = (layer * height + row) * width; + bytes.set(page.bytes.subarray(source, source + page.width), target); + } + } + texture = new THREE.DataArrayTexture(bytes, width, height, strike.pages.length); + texture.format = THREE.RedFormat; + texture.type = THREE.UnsignedByteType; texture.colorSpace = THREE.NoColorSpace; texture.magFilter = THREE.LinearFilter; texture.minFilter = THREE.LinearFilter; @@ -1116,6 +1156,15 @@ function directTransformId(draw: THREE.Mesh): number { return (draw.userData.pmndrsTextTransformId as number | undefined) ?? 0; } +function visibleBelowRoot(object: THREE.Object3D, root: THREE.Object3D): boolean { + let current: THREE.Object3D | null = object; + while (current !== null && current !== root) { + if (!current.visible) return false; + current = current.parent; + } + return current === root; +} + function bitmapMaterial( shader: Readonly<{ clipPosition: THREE.Node<'vec4'>; @@ -1153,11 +1202,11 @@ function baseTextMaterial(): THREE.MeshBasicNodeMaterial { }); } -function bitmapPage(resource: ThreeTextEngineResource): BitmapPageData { - if (resource.technique !== bitmap.id || !('page' in resource)) { +function bitmapStrike(resource: ThreeTextEngineResource): BitmapStrikeData { + if (resource.technique !== bitmap.id || !('strike' in resource)) { throw new Error('this Three plan target checkpoint realizes Bitmap draws only'); } - return resource.page as BitmapPageData; + return resource.strike as BitmapStrikeData; } function msdfData(resource: ThreeTextEngineResource): MsdfData { diff --git a/packages/text/src/three/engine-runtime.ts b/packages/text/src/three/engine-runtime.ts index 2f2ea1fb..a0248d3a 100644 --- a/packages/text/src/three/engine-runtime.ts +++ b/packages/text/src/three/engine-runtime.ts @@ -1,5 +1,5 @@ import { observeLoadedFontDispose, type LoadedFont } from '../loaded-font.js'; -import { bitmap, type BitmapData, type BitmapPageData } from '../raster/bitmap-technique.js'; +import { bitmap, type BitmapData, type BitmapStrikeData } 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'; @@ -40,7 +40,7 @@ interface RetainedResourceOwners { } export type ThreeTextEngineResource = - | Readonly<{ technique: typeof bitmap.id; page: BitmapPageData }> + | Readonly<{ technique: typeof bitmap.id; strike: BitmapStrikeData }> | Readonly<{ technique: typeof msdf.id; data: MsdfData }> | Readonly<{ technique: typeof slug.id; page: SlugPageData }> | Readonly<{ technique: string; resource: unknown; program: CompiledThreeRasterPlanProgram }>; @@ -210,7 +210,7 @@ export class ThreeTextEngineCoordinator { 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 }); + this.#retainResource(font, strike.pages[0]!.resource, { technique: bitmap.id, strike }); } return; } diff --git a/packages/text/src/three/font-loader.ts b/packages/text/src/three/font-loader.ts index 3fb6d043..ae400800 100644 --- a/packages/text/src/three/font-loader.ts +++ b/packages/text/src/three/font-loader.ts @@ -3,7 +3,14 @@ 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 TextRuntime } from '../text-runtime.js'; +import { + createTextRuntime, + type LoadedFontRequest, + type LoadedFontTechniques, + type LoadedFonts, + type LoadedFontsRequest, + type TextRuntime, +} from '../text-runtime.js'; import type { FontRegistry, RuntimeFontBake } from '../loader.js'; export interface ThreeFontLoaderOptions { @@ -69,6 +76,31 @@ export class FontLoader extends THREE.Loader, Loa return new Promise((resolve, reject) => this.load(request, resolve, onProgress, reject)); } + async loadFontsAsync( + request: LoadedFontsRequest & { readonly signal?: AbortSignal }, + ): Promise> { + this.#assertActive(); + const item = requestInputUrl(request); + this.manager.itemStart(item); + try { + const { signal, ...requested } = request; + const domain = this.#runtimeDomain(); + const runtime = await domain.runtime; + this.#assertActive(); + signal?.throwIfAborted(); + const normalized = normalizeRequests(requested, this.#options.runtimeBake); + const fonts = await runtime.loadFont(normalized, signal === undefined ? {} : { signal }); + this.#assertActive(); + for (const font of fonts) trackFont(domain, font); + return fonts; + } catch (error) { + this.manager.itemError(item); + throw error; + } finally { + this.manager.itemEnd(item); + } + } + dispose(): void { if (this.#disposed) return; this.#disposed = true; @@ -91,13 +123,7 @@ export class FontLoader extends THREE.Loader, Loa const normalized = normalizeRequest(requested as LoadedFontRequest, this.#options.runtimeBake); const font = await runtime.loadFont(normalized, signal === undefined ? {} : { signal }); this.#assertActive(); - if (!domain.fonts.has(font)) { - domain.fonts.add(font); - observeLoadedFontDispose(font, () => { - domain.fonts.delete(font); - maybeDisposeDomain(domain); - }); - } + trackFont(domain, font); return font; } @@ -140,10 +166,34 @@ function normalizeRequest( return request; } +function normalizeRequests( + request: LoadedFontsRequest, + runtimeBake: RuntimeFontBake | undefined, +): LoadedFontsRequest { + if ('source' in request.input && request.input.runtimeBake === undefined) { + if (runtimeBake === undefined) throw new TypeError('source font loading requires a runtime font baker'); + return { ...request, input: { ...request.input, runtimeBake } }; + } + return request; +} + function requestUrl(request: LoadedFontRequest): string { + return requestInputUrl(request); +} + +function requestInputUrl(request: { readonly input: LoadedFontRequest['input'] }): string { return String('baked' in request.input ? request.input.baked : request.input.source); } +function trackFont(domain: RuntimeDomain, font: LoadedFont): void { + if (domain.fonts.has(font)) return; + domain.fonts.add(font); + observeLoadedFontDispose(font, () => { + domain.fonts.delete(font); + maybeDisposeDomain(domain); + }); +} + function maybeDisposeDomain(domain: RuntimeDomain): void { if (domain.disposed || domain.loaderCount !== 0 || domain.fonts.size !== 0) return; domain.disposed = true; diff --git a/packages/text/src/three/text.ts b/packages/text/src/three/text.ts index f88e2734..47da1703 100644 --- a/packages/text/src/three/text.ts +++ b/packages/text/src/three/text.ts @@ -65,7 +65,7 @@ export type TextProperties = TextBasePrope Readonly<{ material?: ThreeTextMaterial }>; export type StandaloneTextProperties = TextProperties & - Readonly<{ capacity?: GlyphBufferCapacity }>; + Readonly<{ capacity?: GlyphBufferCapacity; pixelSnapping?: boolean }>; export type TextUpdate = | (Partial> & @@ -79,6 +79,8 @@ export interface TextGroupOptions { readonly compositing?: 'ordered' | 'independent'; readonly renderOrder?: number; readonly material?: ThreeTextMaterial; + /** Snap Bitmap vertices to physical pixels. Off by default because snapping quantizes animated transforms. */ + readonly pixelSnapping?: boolean; } export interface TextGlyphOriginSnapshot { @@ -117,6 +119,7 @@ export class Text extends THREE.Object3D { #desired: DesiredTextState; #leasedFonts: readonly LoadedFont[]; #standaloneCapacity: GlyphBufferCapacity; + readonly #pixelSnapping: boolean; #binding: ThreeTextBatchBinding | undefined; #textGroup: TextGroup | undefined; #desiredRevision = 0; @@ -136,11 +139,15 @@ export class Text extends THREE.Object3D { this.#leasedFonts = selectedFonts(normalized); acquireFonts(this.#leasedFonts, this.#runtime); this.#standaloneCapacity = normalizeCapacity(properties.capacity ?? { size: 256, policy: 'grow' }); + this.#pixelSnapping = normalizePixelSnapping(properties.pixelSnapping); } get textGroup(): TextGroup | undefined { return this.#textGroup; } + get pixelSnapping(): boolean { + return this.#pixelSnapping; + } get bound(): boolean { return this.#binding !== undefined; } @@ -374,8 +381,11 @@ export class Text extends THREE.Object3D { export class TextGroup extends THREE.Object3D { #capacity: GlyphBufferCapacity; readonly #compositing: 'ordered' | 'independent'; + readonly #pixelSnapping: boolean; #material: ThreeTextMaterial | undefined; #binding: ThreeTextBatchBinding | undefined; + readonly #transformTracker = new TextTransformTracker(); + readonly #texts: Text[] = []; #disposed = false; #error: unknown; onError: ((error: unknown) => void) | undefined; @@ -384,6 +394,7 @@ export class TextGroup extends THREE.Object3D { super(); this.#capacity = normalizeCapacity(options.capacity ?? { size: 4_096, policy: 'chunk' }); this.#compositing = normalizeCompositing(options.compositing); + this.#pixelSnapping = normalizePixelSnapping(options.pixelSnapping); this.#material = options.material; if (options.renderOrder !== undefined) this.renderOrder = options.renderOrder; } @@ -396,6 +407,9 @@ export class TextGroup extends THREE.Object3D { get textCount(): number { return this.#binding?.textCount ?? 0; } + get pixelSnapping(): boolean { + return this.#pixelSnapping; + } get disposed(): boolean { return this.#disposed; } @@ -438,14 +452,19 @@ export class TextGroup extends THREE.Object3D { } override updateMatrixWorld(force?: boolean): void { + super.updateMatrixWorld(force); if (!this.#disposed) { - const texts = collectTextDescendants(this); + const texts = collectTextDescendants(this, this.#texts); if (texts.length !== 0) { 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.#transformTracker.beginFrame(); + for (const text of texts) { + if (this.#transformTracker.pathChanged(text, this)) this.#binding.markTransformDirty(text); + } this.#binding.synchronize(); this.#error = undefined; } catch (error) { @@ -463,7 +482,6 @@ export class TextGroup extends THREE.Object3D { } } } - super.updateMatrixWorld(force); } bindText(text: Text): void { @@ -505,6 +523,8 @@ class ThreeTextBatchBinding { readonly #layoutInspections = new Map, ParagraphLayoutInspection>(); readonly #queryPlanView = new TextEngineRenderPlanView(); readonly #freeParagraphIds: number[] = []; + readonly #dirtyTransformIds = new Set(); + readonly #desiredTexts = new Set>(); #nextParagraphId = 1; #engineRevision = 0; #planRevision = 0; @@ -512,7 +532,8 @@ class ThreeTextBatchBinding { #lastPublication: TextEnginePublication | undefined; #requestCapacity: number; #resultCapacity: number; - #textCapacity: number; + #textCapacity = 0; + #capacity: GlyphBufferCapacity; #materialInvalidated = false; #disposed = false; @@ -522,17 +543,19 @@ class ThreeTextBatchBinding { 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.#capacity = capacity; 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 pixelSnapping() { + return owner.#pixelSnapping(); + }, get renderOrderBase() { return owner.#renderOrderBase(); }, @@ -585,13 +608,18 @@ class ThreeTextBatchBinding { if (layout !== undefined) this.#target.clearGlyphOriginOverrides(layout.glyphStableIds); } reconcile(texts: readonly Text[]): void { - const desired = new Set(texts); - for (const text of [...this.#paragraphs.keys()]) if (!desired.has(text)) this.removeText(text); + this.#desiredTexts.clear(); + for (const text of texts) this.#desiredTexts.add(text); + for (const text of this.#paragraphs.keys()) if (!this.#desiredTexts.has(text)) this.removeText(text); for (const text of texts) this.#ensureText(text, this.#group); } reconcileStandalone(text: Text): void { this.#ensureText(text, undefined); } + markTransformDirty(text: Text): void { + const paragraph = this.#paragraphs.get(text); + if (paragraph !== undefined) this.#dirtyTransformIds.add(paragraph.id); + } synchronize(semanticViewMask = 0): void { if (this.#disposed) return; this.#coordinator.assertFrameUpdateAllowed(); @@ -608,7 +636,8 @@ class ThreeTextBatchBinding { : []; }); if (changed.length === 0 && this.#removed.length === 0) { - this.#target.syncTransforms(); + this.#target.syncTransforms(this.#dirtyTransformIds, this.#group !== undefined); + this.#dirtyTransformIds.clear(); if (semanticViewMask !== 0 && !this.#hasSemanticViews(semanticViewMask)) { this.#retainSemanticViews(this.#querySemanticViews(semanticViewMask), semanticViewMask); } @@ -663,10 +692,17 @@ class ThreeTextBatchBinding { regions.push(geometry.region); } } - const totalTextLength = [...this.#paragraphs.keys()].reduce((total, text) => total + text.text.length, 0); + let totalTextLength = 0; + let maximumParagraphTextLength = 0; + for (const text of this.#paragraphs.keys()) { + totalTextLength += text.text.length; + maximumParagraphTextLength = Math.max(maximumParagraphTextLength, text.text.length); + } + this.#ensureCapacity(totalTextLength, maximumParagraphTextLength); const limits = engineLimits( Math.max(this.#paragraphs.size, paragraphMutations.length), totalTextLength, + maximumParagraphTextLength, Math.max(regions.length, this.#paragraphs.size), MAX_TEXT_ENGINE_OUTPUT_BYTES, Math.max(textMutations.length, styleMutations.length), @@ -728,6 +764,7 @@ class ThreeTextBatchBinding { committed = true; try { this.#target.apply(publication); + this.#dirtyTransformIds.clear(); this.#planRevision = publication.planRevision; this.#lastPublication = undefined; } catch (error) { @@ -745,9 +782,33 @@ class ThreeTextBatchBinding { } } setCapacity(value: GlyphBufferCapacity): void { + this.#capacity = 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); + } + #ensureCapacity(required: number, requiredParagraphText: number): void { + if (required > this.#capacity.size && this.#capacity.policy === 'fixed') { + throw new RangeError(`text requires ${required} glyph slots but fixed capacity is ${this.#capacity.size}`); + } + const target = + this.#capacity.policy === 'chunk' ? Math.ceil(required / this.#capacity.size) * this.#capacity.size : required; + const requestCapacity = Math.max(this.#requestCapacity, target * 32); + const resultCapacity = Math.max(this.#resultCapacity, target * 160); + const textCapacity = Math.max(this.#textCapacity, requiredParagraphText); + if ( + requestCapacity === this.#requestCapacity && + resultCapacity === this.#resultCapacity && + textCapacity === this.#textCapacity + ) { + return; + } + this.#requestCapacity = requestCapacity; + this.#resultCapacity = resultCapacity; + this.#textCapacity = textCapacity; + // Glyph capacity is aggregate batch storage, while Rust's text reservation sizes one paragraph scratch arena. + // Reserving the longest paragraph keeps sustained edits hot without multiplying the batch's total text by every + // scratch field. this.#session.reserve(this.#requestCapacity, this.#resultCapacity, this.#textCapacity); } invalidateMaterial(): void { @@ -766,6 +827,7 @@ class ThreeTextBatchBinding { if (paragraph === undefined) return; this.#paragraphs.delete(text); this.#textsByParagraph.delete(paragraph.id); + this.#dirtyTransformIds.delete(paragraph.id); this.#removed.push(paragraph); text.unbindFrom(this); } @@ -785,6 +847,8 @@ class ThreeTextBatchBinding { this.#layoutInspections.clear(); this.#removed.length = 0; this.#freeParagraphIds.length = 0; + this.#dirtyTransformIds.clear(); + this.#desiredTexts.clear(); } #ensureText(text: Text, group: TextGroup | undefined): void { validateBinding(this.#runtime, text); @@ -819,8 +883,18 @@ class ThreeTextBatchBinding { return this.#paragraphs.keys().next().value?.renderOrder ?? 0; } + #pixelSnapping(): boolean { + if (this.#group !== undefined) return this.#group.pixelSnapping; + return this.#paragraphs.keys().next().value?.pixelSnapping ?? false; + } + #querySemanticViews(semanticViewMask: number): TextEnginePublication { - const totalTextLength = [...this.#paragraphs.keys()].reduce((total, entry) => total + entry.text.length, 0); + let totalTextLength = 0; + let maximumParagraphTextLength = 0; + for (const entry of this.#paragraphs.keys()) { + totalTextLength += entry.text.length; + maximumParagraphTextLength = Math.max(maximumParagraphTextLength, entry.text.length); + } const publication = this.#session.update( compileTextEngineFrameUpdate({ sessionId: this.#session.handle, @@ -834,6 +908,7 @@ class ThreeTextBatchBinding { limits: engineLimits( this.#paragraphs.size, totalTextLength, + maximumParagraphTextLength, this.#paragraphs.size, MAX_TEXT_ENGINE_OUTPUT_BYTES, ), @@ -1050,6 +1125,7 @@ function axis(value: ParagraphContentBox['width'] | undefined): { function engineLimits( paragraphCount: number, textLength: number, + maximumParagraphTextLength: number, regionCount: number, maxOutputBytes: number, mutationRecordCount = 0, @@ -1057,7 +1133,9 @@ function engineLimits( return { maxParagraphs: Math.max(1, paragraphCount), maxClusters: Math.max(1, textLength * 2, mutationRecordCount), - maxLines: Math.max(1, textLength), + // Rust applies this limit while composing each paragraph. Using aggregate batch text here makes every paragraph + // reserve enough line scratch for the whole TextGroup, multiplying retained memory by paragraph count. + maxLines: Math.max(1, maximumParagraphTextLength), maxRegions: Math.max(1, regionCount), maxExclusions: 1, maxInlineObjects: 1, @@ -1264,6 +1342,11 @@ function normalizeCompositing(value: TextGroupOptions['compositing']): 'ordered' if (value === 'independent') return value; throw new TypeError('text group compositing mode is invalid'); } +function normalizePixelSnapping(value: boolean | undefined): boolean { + if (value === undefined || value === false) return false; + if (value === true) return true; + throw new TypeError('pixel snapping must be a boolean'); +} function nearestTextGroup(object: THREE.Object3D): TextGroup | undefined { let parent = object.parent; while (parent !== null) { @@ -1275,8 +1358,8 @@ function nearestTextGroup(object: THREE.Object3D): TextGroup | undefined { function eraseTextTechnique(text: Text): Text { return text as unknown as Text; } -function collectTextDescendants(group: TextGroup): Text[] { - const texts: Text[] = []; +function collectTextDescendants(group: TextGroup, texts: Text[]): Text[] { + texts.length = 0; for (const child of group.children) collect(child, texts); return texts; function collect(object: THREE.Object3D, result: Text[]): void { @@ -1285,6 +1368,61 @@ function collectTextDescendants(group: TextGroup): Text[] { for (const child of object.children) collect(child, result); } } + +interface ObservedTextTransform { + readonly matrix: Float64Array; + parent: THREE.Object3D | null; + visible: boolean; + frame: number; + changed: boolean; +} + +class TextTransformTracker { + readonly #observed = new WeakMap(); + #frame = 0; + + beginFrame(): void { + this.#frame += 1; + } + + pathChanged(text: Text, boundary: TextGroup): boolean { + let changed = false; + let object: THREE.Object3D | null = text; + while (object !== null && object !== boundary) { + if (this.#objectChanged(object)) changed = true; + object = object.parent; + } + return object !== boundary || changed; + } + + #objectChanged(object: THREE.Object3D): boolean { + const existing = this.#observed.get(object); + if (existing?.frame === this.#frame) return existing.changed; + const elements = object.matrix.elements; + let changed = existing === undefined || existing.parent !== object.parent || existing.visible !== object.visible; + if (existing === undefined) { + const matrix = new Float64Array(elements); + this.#observed.set(object, { + matrix, + parent: object.parent, + visible: object.visible, + frame: this.#frame, + changed: true, + }); + return true; + } + for (let index = 0; index < 16; index += 1) { + if (existing.matrix[index] === elements[index]) continue; + existing.matrix[index] = elements[index]!; + changed = true; + } + existing.parent = object.parent; + existing.visible = object.visible; + existing.frame = this.#frame; + existing.changed = changed; + return changed; + } +} function validateText(text: Text): void { validateBinding(text.runtime, text); } diff --git a/packages/text/tests/fixtures/inter-bitmap-v0.json b/packages/text/tests/fixtures/inter-bitmap-v0.json index ad3796f5..e474927a 100644 --- a/packages/text/tests/fixtures/inter-bitmap-v0.json +++ b/packages/text/tests/fixtures/inter-bitmap-v0.json @@ -116,8 +116,8 @@ { "role": "font", "id": "font-6a96d9c6f9e59fd6aeb51848413bd4dd8711730a5479a7d004979d80f3b3cd09", - "bytes": 927164, - "sha256": "55e0f03fcd9cec9312b03f58de112d299982e82db368d9a666db120ef8a4f471" + "bytes": 927152, + "sha256": "6ae4795a1398c3a31aaa10d941fa08e5a1545a3ea86e392bc3f641fd1c1f2d9c" } ], "report": { @@ -203,16 +203,16 @@ { "artifactId": "font-6a96d9c6f9e59fd6aeb51848413bd4dd8711730a5479a7d004979d80f3b3cd09", "role": "font", - "jsonBytes": 1895, - "paddingBytes": 1, - "totalBytes": 927164 + "jsonBytes": 1884, + "paddingBytes": 0, + "totalBytes": 927152 } ], "transport": [ { "artifactId": "font-6a96d9c6f9e59fd6aeb51848413bd4dd8711730a5479a7d004979d80f3b3cd09", "format": "raw", - "bytes": 927164 + "bytes": 927152 } ] } @@ -222,8 +222,8 @@ { "role": "font", "id": "font-6a96d9c6f9e59fd6aeb51848413bd4dd8711730a5479a7d004979d80f3b3cd09", - "bytes": 172556, - "sha256": "61f8a5008b3f4354561896916c0991c906ec6bc08e2a759b7aaeadefd25854c2" + "bytes": 172548, + "sha256": "0152d24cbba273bb78dfdab4e9b40d2f1072b634405d824d765de6d2997e3615" }, { "role": "raster", @@ -321,9 +321,9 @@ { "artifactId": "font-6a96d9c6f9e59fd6aeb51848413bd4dd8711730a5479a7d004979d80f3b3cd09", "role": "font", - "jsonBytes": 1472, - "paddingBytes": 0, - "totalBytes": 172556 + "jsonBytes": 1461, + "paddingBytes": 3, + "totalBytes": 172548 }, { "artifactId": "bitmap-6a96d9c6f9e59fd6aeb51848413bd4dd8711730a5479a7d004979d80f3b3cd09-f7937b09fb0ad97c17f62d30bf9e0479aae1f8c068ed7319dddde096e8d29a02.glb", @@ -337,7 +337,7 @@ { "artifactId": "font-6a96d9c6f9e59fd6aeb51848413bd4dd8711730a5479a7d004979d80f3b3cd09", "format": "raw", - "bytes": 172556 + "bytes": 172548 }, { "artifactId": "bitmap-6a96d9c6f9e59fd6aeb51848413bd4dd8711730a5479a7d004979d80f3b3cd09-f7937b09fb0ad97c17f62d30bf9e0479aae1f8c068ed7319dddde096e8d29a02.glb", @@ -357,8 +357,8 @@ { "role": "font", "id": "font-6a96d9c6f9e59fd6aeb51848413bd4dd8711730a5479a7d004979d80f3b3cd09", - "bytes": 172156, - "sha256": "af7bfb85f04a6a63c6462735a6e8ec6d739576adb354c07ca51e744814db2f7b" + "bytes": 172144, + "sha256": "edf896923f38c9e6080e176540699a7b96b7cd15606b0522447750e7595170b5" } ], "report": { @@ -427,16 +427,16 @@ { "artifactId": "font-6a96d9c6f9e59fd6aeb51848413bd4dd8711730a5479a7d004979d80f3b3cd09", "role": "font", - "jsonBytes": 1071, - "paddingBytes": 1, - "totalBytes": 172156 + "jsonBytes": 1060, + "paddingBytes": 0, + "totalBytes": 172144 } ], "transport": [ { "artifactId": "font-6a96d9c6f9e59fd6aeb51848413bd4dd8711730a5479a7d004979d80f3b3cd09", "format": "raw", - "bytes": 172156 + "bytes": 172144 } ] } diff --git a/packages/font-baker/tests/e2e/real-font.test.mjs b/packages/text/tests/font-baker/e2e/real-font.test.mjs similarity index 89% rename from packages/font-baker/tests/e2e/real-font.test.mjs rename to packages/text/tests/font-baker/e2e/real-font.test.mjs index 7b53bcb4..f1c8851c 100644 --- a/packages/font-baker/tests/e2e/real-font.test.mjs +++ b/packages/text/tests/font-baker/e2e/real-font.test.mjs @@ -8,11 +8,11 @@ import { promisify } from 'node:util'; import { fileURLToPath } from 'node:url'; import test from 'node:test'; -import { createFontBaker } from '../../dist/index.js'; -import { validateFontArtifact } from '../../dist/validator.js'; +import { createFontBaker } from '../../../dist/font-baker/index.js'; +import { validateFontArtifact } from '../../../dist/font-baker/validator.js'; -const fixtureDirectory = 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 fixtureDirectory = 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 executeFile = promisify(execFile); async function shapeReducedFont(t, shapingSfnt, fontFile, shapingDirectory) { @@ -24,7 +24,7 @@ async function shapeReducedFont(t, shapingSfnt, fontFile, shapingDirectory) { await executeFile('cargo', [ 'run', '--manifest-path', - fileURLToPath(new URL('../../rust/Cargo.toml', import.meta.url)), + fileURLToPath(new URL('../../../rust/font-baker/Cargo.toml', import.meta.url)), '--bin', 'generate-shaping-oracle', '--features', @@ -42,7 +42,7 @@ async function shapeReducedFont(t, shapingSfnt, fontFile, shapingDirectory) { test('the canonical Inter fixture bakes deterministically and retains HarfRust shaping', async (t) => { const [wasm, source, manifestSource, expectedOracleSource] = await Promise.all([ - readFile(new URL('../../dist/font_baker.wasm', import.meta.url)), + readFile(new URL('../../../dist/font_baker.wasm', import.meta.url)), readFile(new URL('Inter-Regular.ttf', fixtureDirectory)), readFile(new URL('manifest.json', fixtureDirectory), 'utf8'), readFile(new URL('harfrust.json', shapingDirectory), 'utf8'), @@ -105,10 +105,10 @@ test('the canonical Inter fixture bakes deterministically and retains HarfRust s }); test('the canonical Amiri fixture preserves exact complex shaping through the GLB', async (t) => { - const directory = new URL('../../../../apps/benchmarks/fixtures/fonts/amiri-1.002/', import.meta.url); - const casesDirectory = new URL('../../../../apps/benchmarks/fixtures/shaping/amiri-regular/', import.meta.url); + const directory = new URL('../../../../../apps/benchmarks/fixtures/fonts/amiri-1.002/', import.meta.url); + const casesDirectory = new URL('../../../../../apps/benchmarks/fixtures/shaping/amiri-regular/', import.meta.url); const [wasm, source, manifestSource, expectedOracleSource] = await Promise.all([ - readFile(new URL('../../dist/font_baker.wasm', import.meta.url)), + readFile(new URL('../../../dist/font_baker.wasm', import.meta.url)), readFile(new URL('Amiri-Regular.ttf', directory)), readFile(new URL('manifest.json', directory), 'utf8'), readFile(new URL('harfrust.json', casesDirectory), 'utf8'), @@ -155,10 +155,10 @@ test('the canonical Amiri fixture preserves exact complex shaping through the GL }); test('the authenticated Noto CJK fixture retains the closed shaping profile at the u16 limit', async (t) => { - const directory = new URL('../../../../apps/benchmarks/fixtures/fonts/noto-sans-cjk-2.004/', import.meta.url); - const casesDirectory = new URL('../../../../apps/benchmarks/fixtures/shaping/noto-sans-cjk/', import.meta.url); + const directory = new URL('../../../../../apps/benchmarks/fixtures/fonts/noto-sans-cjk-2.004/', import.meta.url); + const casesDirectory = new URL('../../../../../apps/benchmarks/fixtures/shaping/noto-sans-cjk/', import.meta.url); const [wasm, source, manifestSource, expectedOracleSource] = await Promise.all([ - readFile(new URL('../../dist/font_baker.wasm', import.meta.url)), + readFile(new URL('../../../dist/font_baker.wasm', import.meta.url)), readFile(new URL('NotoSansCJKjp-Regular.otf', directory)), readFile(new URL('manifest.json', directory), 'utf8'), readFile(new URL('harfrust.json', casesDirectory), 'utf8'), diff --git a/packages/font-baker/tests/fuzz/validator-fuzz-smoke.test.mjs b/packages/text/tests/font-baker/fuzz/validator-fuzz-smoke.test.mjs similarity index 81% rename from packages/font-baker/tests/fuzz/validator-fuzz-smoke.test.mjs rename to packages/text/tests/font-baker/fuzz/validator-fuzz-smoke.test.mjs index e7f965a7..f1aa06c4 100644 --- a/packages/font-baker/tests/fuzz/validator-fuzz-smoke.test.mjs +++ b/packages/text/tests/font-baker/fuzz/validator-fuzz-smoke.test.mjs @@ -2,14 +2,14 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; -import { createFontBaker } from '../../dist/index.js'; -import { FontArtifactValidationError, validateFontArtifact } from '../../dist/validator.js'; +import { createFontBaker } from '../../../dist/font-baker/index.js'; +import { FontArtifactValidationError, validateFontArtifact } from '../../../dist/font-baker/validator.js'; import { FONT_ARTIFACT_FUZZ_SEED, mutateFontArtifact } from '../support/font-artifact-mutations.mjs'; test('fixed-seed font artifact mutations fail safely and deterministically', async () => { const [source, wasm] = await Promise.all([ - readFile(new URL('../../../../apps/benchmarks/fixtures/fonts/inter-v4.1/Inter-Regular.ttf', import.meta.url)), - readFile(new URL('../../dist/font_baker.wasm', import.meta.url)), + readFile(new URL('../../../../../apps/benchmarks/fixtures/fonts/inter-v4.1/Inter-Regular.ttf', import.meta.url)), + readFile(new URL('../../../dist/font_baker.wasm', import.meta.url)), ]); const baker = await createFontBaker(wasm); const artifact = baker.bake({ diff --git a/packages/font-baker/tests/integration/capture-command.test.mjs b/packages/text/tests/font-baker/integration/capture-command.test.mjs similarity index 91% rename from packages/font-baker/tests/integration/capture-command.test.mjs rename to packages/text/tests/font-baker/integration/capture-command.test.mjs index 76c05fde..e5f9ec6c 100644 --- a/packages/font-baker/tests/integration/capture-command.test.mjs +++ b/packages/text/tests/font-baker/integration/capture-command.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { captureCommand } from '../../scripts/capture-command.mjs'; +import { captureCommand } from '../../../scripts/support/capture-command.mjs'; test('captured command output waits for the stdout stream to close', async () => { const expected = JSON.stringify({ abi: 42, payload: 'x'.repeat(65_536) }); diff --git a/packages/font-baker/tests/integration/extension-drafts.test.mjs b/packages/text/tests/font-baker/integration/extension-drafts.test.mjs similarity index 98% rename from packages/font-baker/tests/integration/extension-drafts.test.mjs rename to packages/text/tests/font-baker/integration/extension-drafts.test.mjs index c1dbfcd1..5842b328 100644 --- a/packages/font-baker/tests/integration/extension-drafts.test.mjs +++ b/packages/text/tests/font-baker/integration/extension-drafts.test.mjs @@ -5,7 +5,7 @@ import test, { before } from 'node:test'; import Ajv from 'ajv'; import draft04Schema from 'ajv/lib/refs/json-schema-draft-04.json' with { type: 'json' }; -const extensionRoot = new URL('../../../../docs/planning/extensions/', import.meta.url); +const extensionRoot = new URL('../../../../../docs/planning/extensions/', import.meta.url); let validateDistanceField; let validateSlug; diff --git a/packages/font-baker/tests/integration/fuzz-toolchain.test.mjs b/packages/text/tests/font-baker/integration/fuzz-toolchain.test.mjs similarity index 64% rename from packages/font-baker/tests/integration/fuzz-toolchain.test.mjs rename to packages/text/tests/font-baker/integration/fuzz-toolchain.test.mjs index 9bf47b12..8975bc8d 100644 --- a/packages/font-baker/tests/integration/fuzz-toolchain.test.mjs +++ b/packages/text/tests/font-baker/integration/fuzz-toolchain.test.mjs @@ -4,12 +4,12 @@ import test from 'node:test'; test('the fuzz-only nightly exception is exact, isolated, and mise-owned', async () => { const [productToolchain, fuzzToolchain, fuzzMise, fuzzManifest, fuzzLock, runner] = await Promise.all([ - readFile(new URL('../../../../rust-toolchain.toml', import.meta.url), 'utf8'), - readFile(new URL('../../fuzz/rust-toolchain.toml', import.meta.url), 'utf8'), - readFile(new URL('../../fuzz/mise.toml', import.meta.url), 'utf8'), - readFile(new URL('../../fuzz/Cargo.toml', import.meta.url), 'utf8'), - readFile(new URL('../../fuzz/Cargo.lock', import.meta.url), 'utf8'), - readFile(new URL('../../scripts/fuzz-rust.mjs', import.meta.url), 'utf8'), + readFile(new URL('../../../../../rust-toolchain.toml', import.meta.url), 'utf8'), + readFile(new URL('../../../rust/font-baker-fuzz/rust-toolchain.toml', import.meta.url), 'utf8'), + readFile(new URL('../../../rust/font-baker-fuzz/mise.toml', import.meta.url), 'utf8'), + readFile(new URL('../../../rust/font-baker-fuzz/Cargo.toml', import.meta.url), 'utf8'), + readFile(new URL('../../../rust/font-baker-fuzz/Cargo.lock', import.meta.url), 'utf8'), + readFile(new URL('../../../scripts/font-baker/fuzz-rust.mjs', import.meta.url), 'utf8'), ]); assert.match(productToolchain, /channel = "1\.97\.1"/); @@ -23,7 +23,7 @@ test('the fuzz-only nightly exception is exact, isolated, and mise-owned', async assert.match(fuzzLock, /name = "libfuzzer-sys"\nversion = "0\.4\.13"/); assert.match( fuzzManifest, - /pmndrs-text-font-baker = \{ path = "\.\.\/rust", default-features = false, features = \["std"\] \}/, + /pmndrs-text-font-baker = \{ path = "\.\.\/font-baker", default-features = false, features = \["std"\] \}/, ); assert.match(runner, /mise/); assert.match(runner, /cargo-fuzz 0\.13\.2/); diff --git a/packages/font-baker/tests/integration/reproducible-rust-env.test.mjs b/packages/text/tests/font-baker/integration/reproducible-rust-env.test.mjs similarity index 86% rename from packages/font-baker/tests/integration/reproducible-rust-env.test.mjs rename to packages/text/tests/font-baker/integration/reproducible-rust-env.test.mjs index 57244b79..9715872f 100644 --- a/packages/font-baker/tests/integration/reproducible-rust-env.test.mjs +++ b/packages/text/tests/font-baker/integration/reproducible-rust-env.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { reproducibleRustEnvironment } from '../../scripts/reproducible-rust-env.mjs'; +import { reproducibleRustEnvironment } from '../../../scripts/support/reproducible-rust-env.mjs'; test('canonical Rust builds replace host paths and ambient flags', () => { const environment = reproducibleRustEnvironment('/checkout/text', { diff --git a/packages/font-baker/tests/integration/validator.test.mjs b/packages/text/tests/font-baker/integration/validator.test.mjs similarity index 86% rename from packages/font-baker/tests/integration/validator.test.mjs rename to packages/text/tests/font-baker/integration/validator.test.mjs index 654369d4..af903d98 100644 --- a/packages/font-baker/tests/integration/validator.test.mjs +++ b/packages/text/tests/font-baker/integration/validator.test.mjs @@ -2,10 +2,10 @@ import assert from 'node:assert/strict'; import { readFile, readdir } from 'node:fs/promises'; import test, { before } from 'node:test'; -import { createFontBaker } from '../../dist/index.js'; -import { FONT_BAKER_VERSION, FONT_FORMAT_VERSION } from '../../dist/contract.js'; -import { FontArtifactValidationError, validateFontArtifact } from '../../dist/validator.js'; -import { fontBakerWasmUrl } from '../../dist/wasm-url.js'; +import { createFontBaker } from '../../../dist/font-baker/index.js'; +import { FONT_BAKER_VERSION, FONT_FORMAT_VERSION } from '../../../dist/font-baker/contract.js'; +import { FontArtifactValidationError, validateFontArtifact } from '../../../dist/font-baker/validator.js'; +import { fontBakerWasmUrl } from '../../../dist/font-baker/wasm-url.js'; const GLB_MAGIC = 0x46546c67; const JSON_CHUNK = 0x4e4f534a; @@ -15,8 +15,8 @@ let cjkProfileArtifact; before(async () => { const [source, wasm] = await Promise.all([ - readFile(new URL('../../../../apps/benchmarks/fixtures/fonts/inter-v4.1/Inter-Regular.ttf', import.meta.url)), - readFile(new URL('../../dist/font_baker.wasm', import.meta.url)), + readFile(new URL('../../../../../apps/benchmarks/fixtures/fonts/inter-v4.1/Inter-Regular.ttf', import.meta.url)), + readFile(new URL('../../../dist/font_baker.wasm', import.meta.url)), ]); const baker = await createFontBaker(wasm); artifact = baker.bake({ source, descriptor: { formatVersion: 0, fontFaceIndex: 0 } }).artifacts[0].bytes; @@ -107,41 +107,36 @@ test('keeps the packaged extension schema byte-identical to the canonical schema await Promise.all([ readFile( new URL( - '../../../../docs/planning/extensions/PMNDRS_font/schema/glTF.PMNDRS_font.schema.json', + '../../../../../docs/planning/extensions/PMNDRS_font/schema/glTF.PMNDRS_font.schema.json', import.meta.url, ), ), - readFile(new URL('../../src/schemas/extensions/glTF.PMNDRS_font.schema.json', import.meta.url)), - readFile(new URL('../../package.json', import.meta.url), 'utf8'), - readFile(new URL('../../dist/index.js', import.meta.url), 'utf8'), - readdir(new URL('../../src/schemas/gltf-2.0/', import.meta.url)), - readFile(new URL('../../src/schemas/KHRONOS-SPEC-LICENSE.txt', import.meta.url)), - readFile(new URL('../../dist/schemas/KHRONOS-SPEC-LICENSE.txt', import.meta.url)), + readFile(new URL('../../../src/font-baker/schemas/extensions/glTF.PMNDRS_font.schema.json', import.meta.url)), + readFile(new URL('../../../package.json', import.meta.url), 'utf8'), + readFile(new URL('../../../dist/font-baker/index.js', import.meta.url), 'utf8'), + readdir(new URL('../../../src/font-baker/schemas/gltf-2.0/', import.meta.url)), + readFile(new URL('../../../src/font-baker/schemas/KHRONOS-SPEC-LICENSE.txt', import.meta.url)), + readFile(new URL('../../../dist/font-baker/schemas/KHRONOS-SPEC-LICENSE.txt', import.meta.url)), ]); assert.deepEqual(packaged, canonical); assert.equal(schemaFiles.filter((name) => name.endsWith('.json')).length, 33); assert.deepEqual(distributedLicense, sourceLicense); const manifest = JSON.parse(manifestSource); - assert.deepEqual(manifest.exports['./validate'], { - types: './dist/validator.d.ts', - import: './dist/validator.js', - }); - assert.deepEqual(manifest.exports['./contract'], { - types: './dist/contract.d.ts', - import: './dist/contract.js', + assert.deepEqual(manifest.exports['./bake'], { + types: './dist/node/bake.d.ts', + import: './dist/node/bake.js', }); assert.equal(FONT_BAKER_VERSION, manifest.version); assert.equal(FONT_FORMAT_VERSION, 0); - assert.deepEqual(manifest.exports['./wasm-url'], { - types: './dist/wasm-url.d.ts', - import: './dist/wasm-url.js', - }); - assert.equal(fontBakerWasmUrl, new URL('../../dist/font_baker.wasm', import.meta.url).href); + assert.equal(fontBakerWasmUrl, new URL('../../../dist/font_baker.wasm', import.meta.url).href); assert.doesNotMatch(coreSource, /(?:ajv|gltf-validator|validator\.js)/); const property = JSON.parse( - await readFile(new URL('../../src/schemas/gltf-2.0/glTFProperty.schema.json', import.meta.url), 'utf8'), + await readFile( + new URL('../../../src/font-baker/schemas/gltf-2.0/glTFProperty.schema.json', import.meta.url), + 'utf8', + ), ); assert.equal(property.$schema, 'https://json-schema.org/draft/2020-12/schema'); assert.equal(property.$id, 'glTFProperty.schema.json'); @@ -157,11 +152,10 @@ test('rejects malformed GLB framing before schema or payload work', async () => ]; for (const [code, bytes] of cases) await rejectsWithCode(bytes, code); - const padded = artifact.slice(); - const jsonLength = readU32(padded, 12); - assert.equal(padded[20 + jsonLength - 1], 0x20); - padded[20 + jsonLength - 1] = 0; - await rejectsWithCode(padded, 'GLB_JSON'); + const invalidJson = artifact.slice(); + assert.equal(invalidJson[20], 0x7b); + invalidJson[20] = 0; + await rejectsWithCode(invalidJson, 'GLB_JSON'); }); test('covers every PMNDRS_font required field and raster-source union one field at a time', async () => { diff --git a/packages/font-baker/tests/integration/wasm-package.test.mjs b/packages/text/tests/font-baker/integration/wasm-package.test.mjs similarity index 76% rename from packages/font-baker/tests/integration/wasm-package.test.mjs rename to packages/text/tests/font-baker/integration/wasm-package.test.mjs index 11910992..d989a266 100644 --- a/packages/font-baker/tests/integration/wasm-package.test.mjs +++ b/packages/text/tests/font-baker/integration/wasm-package.test.mjs @@ -2,13 +2,25 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; -import { FontBakeError, createFontBaker, createFontBakerFromInstance, fontBakerAbi } from '../../dist/index.js'; +import { + FontBakeError, + createFontBaker, + createFontBakerFromInstance, + fontBakerAbi, +} from '../../../dist/font-baker/index.js'; const [wasm, rustReleaseWasm] = await Promise.all([ - readFile(new URL('../../dist/font_baker.wasm', import.meta.url)), - readFile(new URL('../../rust/target/wasm32-unknown-unknown/release/pmndrs_text_font_baker.wasm', import.meta.url)), + readFile(new URL('../../../dist/font_baker.wasm', import.meta.url)), + readFile( + new URL( + '../../../rust/font-baker/target/wasm32-unknown-unknown/release/pmndrs_text_font_baker.wasm', + import.meta.url, + ), + ), ]); -const publishedAbi = JSON.parse(await readFile(new URL('../../dist/font-baker-abi-v0.json', import.meta.url), 'utf8')); +const publishedAbi = JSON.parse( + await readFile(new URL('../../../dist/font-baker-abi-v0.json', import.meta.url), 'utf8'), +); test('the distributed module is the pinned size-optimized zero-import release module', async () => { assert(wasm.byteLength < rustReleaseWasm.byteLength); @@ -43,6 +55,38 @@ test('the TypeScript wrapper returns structured Rust errors', async () => { ); }); +test('prepares and inspects one reusable subset through the packaged Wasm API', async () => { + const source = await readFile( + new URL('../../../../../apps/benchmarks/fixtures/fonts/inter-v4.1/Inter-Regular.ttf', import.meta.url), + ); + const baker = await createFontBaker(wasm); + const prepared = baker.prepare({ + source, + selection: { + formatVersion: 0, + fontFaceIndex: 0, + unicodeRanges: [{ start: 0x20, end: 0x7e }], + }, + }); + + assert.equal(prepared.report.sourceBytes, source.byteLength); + assert.equal(prepared.report.preparedBytes, prepared.bytes.byteLength); + assert.ok(prepared.bytes.byteLength < source.byteLength); + const inspection = baker.inspect({ + source: prepared.bytes, + descriptor: { formatVersion: 0, fontFaceIndex: 0 }, + }); + assert.equal(inspection.glyphNameSource, 'none'); + assert.ok(inspection.glyphs.some(({ codePoint }) => codePoint === 0x41)); + assert.ok(!inspection.glyphs.some(({ codePoint }) => codePoint === 0xe9)); + assert.doesNotThrow(() => + baker.bake({ + source: prepared.bytes, + descriptor: { formatVersion: 0, fontFaceIndex: 0 }, + }), + ); +}); + test('the direct-memory shim releases earlier allocations when a later copy fails', () => { const released = []; let allocations = 0; @@ -158,6 +202,8 @@ function fakeFontBakerInstance({ allocate = () => 0, deallocate = () => undefine pmndrs_font_baker_alloc: allocate, pmndrs_font_baker_dealloc: deallocate, pmndrs_font_baker_bake: () => (response === undefined ? 0 : responsePointer), + pmndrs_font_baker_prepare: () => (response === undefined ? 0 : responsePointer), + pmndrs_font_baker_inspect: () => (response === undefined ? 0 : responsePointer), pmndrs_font_baker_result_len: () => response?.byteLength ?? 0, }, }; diff --git a/packages/font-baker/tests/support/font-artifact-mutations.mjs b/packages/text/tests/font-baker/support/font-artifact-mutations.mjs similarity index 100% rename from packages/font-baker/tests/support/font-artifact-mutations.mjs rename to packages/text/tests/font-baker/support/font-artifact-mutations.mjs diff --git a/packages/text/tests/fuzz/loader-fuzz-smoke.test.mjs b/packages/text/tests/fuzz/loader-fuzz-smoke.test.mjs index 3f39b044..356feb7b 100644 --- a/packages/text/tests/fuzz/loader-fuzz-smoke.test.mjs +++ b/packages/text/tests/fuzz/loader-fuzz-smoke.test.mjs @@ -3,14 +3,14 @@ import { readFile } from 'node:fs/promises'; import test from 'node:test'; import { FontLoadError, FontRegistry } from '../../dist/index.js'; -import { createFontBaker } from '@pmndrs/text-font-baker'; +import { createFontBaker } from '@pmndrs/text/bake'; import { ARTIFACT_FUZZ_SEED, mutateArtifact } from '../support/artifact-mutations.mjs'; test('fixed-seed loader artifact mutations fail safely, purely, and deterministically', async () => { const [source, wasm] = 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/font_baker.wasm', import.meta.url)), ]); const baker = await createFontBaker(wasm); const artifact = baker.bake({ diff --git a/packages/text/tests/integration/compose-bake.test.mjs b/packages/text/tests/integration/compose-bake.test.mjs index 969360f2..0d354e2b 100644 --- a/packages/text/tests/integration/compose-bake.test.mjs +++ b/packages/text/tests/integration/compose-bake.test.mjs @@ -2,8 +2,8 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test, { before } from 'node:test'; -import { createFontBaker } from '@pmndrs/text-font-baker'; -import { parseGlb, validateFontArtifact } from '@pmndrs/text-font-baker/validate'; +import { createFontBaker } from '@pmndrs/text/bake'; +import { parseGlb, validateFontArtifact } from '@pmndrs/text/bake'; import { bitmapBakerFromCore, createBitmapBaker } from '@pmndrs/text/bakers/bitmap'; import { validateBitmapArtifact } from '@pmndrs/text/bakers/bitmap/validate'; import { bitmapDescriptor, bitmapRasterKey } from '@pmndrs/text/raster/bitmap'; @@ -19,7 +19,7 @@ let golden; before(async () => { const [source, fontWasm, bitmapWasm, goldenBytes] = 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/font_baker.wasm', import.meta.url)), readFile(new URL('../../dist/bitmap_baker.wasm', import.meta.url)), readFile(new URL('../fixtures/inter-bitmap-v0.json', import.meta.url)), ]); diff --git a/packages/text/tests/integration/font-binding-wire.test.mjs b/packages/text/tests/integration/font-binding-wire.test.mjs index 99609df2..d08b91da 100644 --- a/packages/text/tests/integration/font-binding-wire.test.mjs +++ b/packages/text/tests/integration/font-binding-wire.test.mjs @@ -3,7 +3,7 @@ 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 { validateFontArtifact } from '@pmndrs/text/bake'; import { validateBitmapArtifact } from '../../dist/bakers/bitmap-validator.js'; import { validateMsdfArtifact } from '../../dist/bakers/msdf-validator.js'; diff --git a/packages/text/tests/integration/loader.test.mjs b/packages/text/tests/integration/loader.test.mjs index 72be5675..f1443185 100644 --- a/packages/text/tests/integration/loader.test.mjs +++ b/packages/text/tests/integration/loader.test.mjs @@ -8,7 +8,7 @@ import test, { after, before } from 'node:test'; import { FontLoader, FontLoadError, FontRegistry } from '@pmndrs/text'; import { bakeFont } from '@pmndrs/text/bake'; import { bitmapBaker } from '@pmndrs/text/bakers/bitmap'; -import { validateFontArtifact } from '@pmndrs/text-font-baker/validate'; +import { validateFontArtifact } from '@pmndrs/text/bake'; import { getRegisteredFontData } from '../../dist/internal/registered-font.js'; @@ -176,6 +176,37 @@ test('an explicit baked null request skips sibling discovery and uses runtime ba assert.deepEqual(calls, [sourceUrl]); }); +test('runtime persistence inherits source response cache policy', async () => { + const requests = []; + const now = Date.now(); + const cacheable = new FontLoader({ + fetch: async () => + new Response(sourceBytes, { + headers: { + 'cache-control': 'public, max-age=3600', + date: new Date(now).toUTCString(), + }, + }), + async runtimeBake(request) { + requests.push(request); + return embeddedBytes; + }, + }); + await cacheable.load({ source: 'https://assets.test/cacheable.ttf', baked: null }); + assert.ok(requests[0].cache.expiresAt > now); + assert.ok(requests[0].cache.expiresAt <= now + 3_600_000); + + const uncacheable = new FontLoader({ + fetch: async () => new Response(sourceBytes, { headers: { 'cache-control': 'no-store' } }), + async runtimeBake(request) { + requests.push(request); + return embeddedBytes; + }, + }); + await uncacheable.load({ source: 'https://assets.test/private.ttf', baked: null }); + assert.equal(requests[1].cache, undefined); +}); + test('missing and invalid probes fall back once with deduplicated diagnostics', async () => { const calls = []; const warnings = []; diff --git a/packages/text/tests/integration/node-bake.test.mjs b/packages/text/tests/integration/node-bake.test.mjs index e0e871f6..d606eb77 100644 --- a/packages/text/tests/integration/node-bake.test.mjs +++ b/packages/text/tests/integration/node-bake.test.mjs @@ -4,18 +4,22 @@ import { spawn } from 'node:child_process'; import { link, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { basename, dirname, join } from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import test from 'node:test'; import { bakeFont, bakeProject, NodeBakeError } from '@pmndrs/text/bake'; import { bitmapBaker } from '@pmndrs/text/bakers/bitmap'; import { validateBitmapArtifact } from '@pmndrs/text/bakers/bitmap/validate'; import { bitmapDescriptor, bitmapRasterKey } from '@pmndrs/text/raster/bitmap'; -import { validateFontArtifact } from '@pmndrs/text-font-baker/validate'; +import { validateFontArtifact } from '@pmndrs/text/bake'; import { runCli } from '../../dist/node/cli.js'; const fontUrl = new URL('../../../../apps/benchmarks/fixtures/fonts/inter-v4.1/Inter-Regular.ttf', import.meta.url); +const iconFontUrl = new URL( + '../../../../apps/benchmarks/fixtures/fonts/font-awesome-free-6.7.2/fa-solid-900.ttf', + import.meta.url, +); const goldenUrl = new URL('../fixtures/inter-bitmap-v0.json', import.meta.url); test('bakeFont writes exact combined embedded and external artifacts with complete reports', async (t) => { @@ -232,6 +236,7 @@ test('the installed CLI is a thin JSON-reporting layer over bakeProject', async const cli = new URL('../../dist/node/cli.js', import.meta.url); const result = await run(process.execPath, [ cli.pathname, + 'bake', '--project-root', root, '--output-root', @@ -245,6 +250,57 @@ test('the installed CLI is a thin JSON-reporting layer over bakeProject', async assert.deepEqual(report.diagnostics, []); }); +test('the CLI directly bakes and checks one known font from arguments', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'pmndrs-text-direct-cli-')); + t.after(() => rm(root, { recursive: true, force: true })); + const input = join(root, 'Inter-Regular.ttf'); + const output = join(root, 'Inter.font.glb'); + const source = await readFile(fontUrl); + await writeFile(input, source); + + const bake = captureIo(); + assert.equal( + await runCli(['bake', '--input', input, '--output', output, '--bitmap', '16', '--json'], bake.io), + 0, + bake.stderr(), + ); + const report = JSON.parse(bake.stdout()); + assert.equal(report.rasters.length, 1); + assert.equal((await validateFontArtifact(await readFile(output))).document.extensions.PMNDRS_font.rasters.length, 1); + + const check = captureIo(); + assert.equal( + await runCli(['bake', '--input', input, '--output', output, '--bitmap', '16', '--check'], check.io), + 0, + check.stderr(), + ); + + const subsetOutput = join(root, 'Inter-latin.font.glb'); + const subset = captureIo(); + assert.equal( + await runCli( + [ + 'bake', + '--input', + input, + '--output', + subsetOutput, + '--unicodes', + 'U+0061-007A,U+0020,U+0041-005A,U+0041', + '--json', + ], + subset.io, + ), + 0, + subset.stderr(), + ); + const subsetReport = JSON.parse(subset.stdout()); + assert.equal(subsetReport.preparation.sourceBytes, source.byteLength); + assert.ok(subsetReport.preparation.preparedBytes < subsetReport.preparation.sourceBytes); + assert.ok(subsetReport.preparation.glyphCount < (await validateFontArtifact(await readFile(output))).glyphCount); + assert.equal((await validateFontArtifact(await readFile(subsetOutput))).glyphCount, subsetReport.preparation.glyphCount); +}); + test('pre-cancellation and source/output overlap fail before filesystem mutation', async (t) => { const root = await mkdtemp(join(tmpdir(), 'pmndrs-text-node-errors-')); t.after(() => rm(root, { recursive: true, force: true })); @@ -345,14 +401,56 @@ test('rejects a lying plugin descriptor before calling its baker or publishing o test('CLI help and malformed arguments are deterministic and side-effect free', async () => { const help = captureIo(); assert.equal(await runCli(['--help'], help.io), 0); - assert.match(help.stdout(), /^Usage: pmndrs-text-bake/); + assert.match(help.stdout(), /^Usage: text /); + assert.match(help.stdout(), /text --help/); assert.equal(help.stderr(), ''); + const bakeHelp = captureIo(); + assert.equal(await runCli(['bake', '--help'], bakeHelp.io), 0); + assert.match(bakeHelp.stdout(), /^Usage:\n text bake/); + assert.match(bakeHelp.stdout(), /U\+0020-007E,U\+00A0-00FF,U\+4E00-9FFF/); + assert.match(bakeHelp.stdout(), /Selects code points, not raw glyph IDs/); + assert.doesNotMatch(bakeHelp.stdout(), /HarfBuzz|hb-subset/); + + const glyphHelp = captureIo(); + assert.equal(await runCli(['glyphs', '--help'], glyphHelp.io), 0); + assert.match(glyphHelp.stdout(), /^Usage: text glyphs /); + assert.match(glyphHelp.stdout(), /glyph names retained in its post or/); + + const version = captureIo(); + assert.equal(await runCli(['--version'], version.io), 0); + assert.match(version.stdout(), /^@pmndrs\/text \d+\.\d+\.\d+\n$/); + const malformed = captureIo(); - assert.equal(await runCli(['--output-root'], malformed.io), 2); + assert.equal(await runCli(['bake', '--output-root'], malformed.io), 2); assert.equal(malformed.stdout(), ''); assert.match(malformed.stderr(), /^--output-root requires a value/); - assert.match(malformed.stderr(), /Usage: pmndrs-text-bake/); + assert.match(malformed.stderr(), /Usage:\n text bake/); + + const unknown = captureIo(); + assert.equal(await runCli(['inspect'], unknown.io), 2); + assert.equal(unknown.stdout(), ''); + assert.match(unknown.stderr(), /^Unknown command: inspect/); + assert.match(unknown.stderr(), /Usage: text /); +}); + +test('the CLI surfaces font-provided icon names as JSON and reusable Unicode sets', async () => { + const json = captureIo(); + assert.equal(await runCli(['glyphs', fileURLToPath(iconFontUrl), '--name', 'globe', '--json'], json.io), 0); + assert.deepEqual(JSON.parse(json.stdout()).glyphs, [ + { unicode: 'U+F0AC', codePoint: 0xf0ac, glyphId: 537, name: 'globe' }, + { unicode: 'U+1F310', codePoint: 0x1f310, glyphId: 537, name: 'globe' }, + ]); + + const unicodeSet = captureIo(); + assert.equal( + await runCli( + ['glyphs', fileURLToPath(iconFontUrl), '--name', 'globe', '--name', 'earth-americas', '--unicode-set'], + unicodeSet.io, + ), + 0, + ); + assert.equal(unicodeSet.stdout(), 'U+F0AC,U+F57D,U+1F30E,U+1F310\n'); }); async function projectFixture(secondStrike = 16, options = {}) { diff --git a/packages/text/tests/integration/runtime-bake.test.mjs b/packages/text/tests/integration/runtime-bake.test.mjs index 6e9352df..99f90587 100644 --- a/packages/text/tests/integration/runtime-bake.test.mjs +++ b/packages/text/tests/integration/runtime-bake.test.mjs @@ -4,11 +4,18 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; -import { FontLoader } from '@pmndrs/text'; +import { createTextRuntime, FontLoader } from '@pmndrs/text'; import { bakeFont } from '@pmndrs/text/bake'; import { bakeFontInWorker } from '@pmndrs/text/runtime-bake'; -import { createFontBaker } from '@pmndrs/text-font-baker'; -import { fontBakerWasmUrl } from '@pmndrs/text-font-baker/wasm-url'; +import { createFontBaker } from '@pmndrs/text/bake'; +import { fontBakerWasmUrl } from '@pmndrs/text/bake'; +import { bitmap } from '@pmndrs/text/raster/bitmap'; +import { msdf } from '@pmndrs/text/raster/msdf'; +import { slug } from '@pmndrs/text/raster/slug'; +import bitmapBaker from '../../dist/bakers/bitmap.js'; +import msdfBaker from '../../dist/bakers/msdf.js'; +import slugBaker from '../../dist/bakers/slug.js'; +import { resolveRasterBakePlan } from '../../dist/internal/raster-bake-plan.js'; const fixtureDirectory = new URL('../../../../apps/benchmarks/fixtures/fonts/inter-v4.1/', import.meta.url); const fixturePromise = Promise.all([ @@ -239,6 +246,108 @@ test('the Worker entry runs the portable baker and transfers the exact canonical assert.deepEqual(transfer, [value.artifacts[0].bytes]); }); +test('the Worker prepares once and matches the Node canonical GLB for every built-in raster', async (t) => { + const { source } = await fixturePromise; + const outputRoot = await mkdtemp(join(tmpdir(), 'pmndrs-text-runtime-subset-parity-')); + const output = join(outputRoot, 'Inter-ascii.font.glb'); + t.after(() => rm(outputRoot, { recursive: true, force: true })); + const unicodeRanges = [{ start: 0x20, end: 0x7e }]; + const plans = await Promise.all( + [ + { baker: bitmapBaker, packaging: embeddedPackaging(), options: { strikes: [16] } }, + { baker: msdfBaker, packaging: embeddedPackaging(), options: undefined }, + { baker: slugBaker, packaging: embeddedPackaging(), options: undefined }, + ].map(resolveRasterBakePlan), + ); + await bakeFont({ + input: new URL('Inter-Regular.ttf', fixtureDirectory), + output, + font: { fontFaceIndex: 0 }, + unicodeRanges, + rasters: plans, + }); + const expected = await readFile(output); + + const originals = { + addEventListener: globalThis.addEventListener, + fetch: globalThis.fetch, + postMessage: globalThis.postMessage, + }; + let receive; + const result = Promise.withResolvers(); + globalThis.addEventListener = (type, listener) => { + if (type === 'message') receive = listener; + }; + globalThis.fetch = async (input) => new Response(await readFile(new URL(String(input)))); + globalThis.postMessage = (value, transfer) => { + if (value.type === 'bake-font-result-v0') result.resolve({ value, transfer }); + }; + t.after(() => { + restoreGlobal('addEventListener', originals.addEventListener); + restoreGlobal('fetch', originals.fetch); + restoreGlobal('postMessage', originals.postMessage); + }); + + await import(`../../dist/runtime-bake-worker.js?test=subset-raster-parity-${Date.now()}`); + receive({ + data: { + type: 'bake-font-v0', + id: 23, + source: source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength), + font: { formatVersion: 0, fontFaceIndex: 0 }, + unicodeRanges, + rasters: plans.map(({ baker, descriptor, rasterKey }) => ({ + kind: baker.kind, + extension: baker.extension, + version: baker.version, + descriptor, + rasterKey, + })), + }, + }); + const { value, transfer } = await result.promise; + assert.equal(value.ok, true); + assert.equal(value.artifacts.length, 1); + assert.deepEqual(Buffer.from(value.artifacts[0].bytes), expected); + assert.deepEqual(transfer, [value.artifacts[0].bytes]); +}); + +test('one TextRuntime source load sends its normalized ranges and complete raster tuple once', async (t) => { + const { source } = await fixturePromise; + const baked = await readFile( + new URL('../../../../apps/r3f-hello-world/assets/inter-latin.font.glb', import.meta.url), + ); + const requests = []; + const runtime = await createTextRuntime({ + wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), + }); + t.after(() => runtime.dispose()); + const runtimeBake = async (request) => { + requests.push(request); + return baked; + }; + const [bitmapFont, msdfFont, slugFont] = await runtime.loadFont({ + input: { + source: `data:font/ttf;base64,${Buffer.from(source).toString('base64')}`, + runtimeBake, + unicodeRanges: [ + { start: 0x41, end: 0x5a }, + { start: 0x20, end: 0x7e }, + ], + }, + rasters: [{ technique: bitmap, options: { strikes: [32] } }, { technique: msdf }, { technique: slug }], + }); + + assert.equal(requests.length, 1); + assert.deepEqual(requests[0].unicodeRanges, [{ start: 0x20, end: 0x7e }]); + assert.deepEqual( + requests[0].rasters.map(({ kind }) => kind), + ['bitmap', 'msdf', 'slug'], + ); + assert.equal(bitmapFont.font, msdfFont.font); + assert.equal(msdfFont.font, slugFont.font); +}); + test('the Worker retries a failed Wasm fetch and retains the recovered core', async (t) => { const { source } = await fixturePromise; const originals = { @@ -299,3 +408,7 @@ function restoreGlobal(key, value) { if (value === undefined) delete globalThis[key]; else globalThis[key] = value; } + +function embeddedPackaging() { + return { artifact: 'embedded', pages: 'embedded' }; +} diff --git a/packages/text/tests/integration/runtime-font-cache.test.mjs b/packages/text/tests/integration/runtime-font-cache.test.mjs new file mode 100644 index 00000000..056f74de --- /dev/null +++ b/packages/text/tests/integration/runtime-font-cache.test.mjs @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import test from 'node:test'; + +import { createCache } from '../../dist/internal/runtime-font-cache.js'; + +test('runtime GLB cache keys include source, normalized ranges, and exact raster plans', async () => { + const storage = new MemoryCacheStorage(); + const cache = createCache(storage, 'https://assets.test', () => 1_000); + const source = new Uint8Array([1, 2, 3]); + const request = runtimeRequest(); + const key = await cache.key(source, request); + + assert.equal(await cache.key(source, runtimeRequest()), key); + assert.notEqual(await cache.key(new Uint8Array([1, 2, 4]), request), key); + assert.notEqual(await cache.key(source, { ...request, unicodeRanges: [{ start: 0x20, end: 0x7f }] }), key); + assert.notEqual(await cache.key(source, { ...request, rasters: [] }), key); +}); + +test('runtime GLB cache returns exact bytes and honors the source response expiration', async () => { + const storage = new MemoryCacheStorage(); + let now = 1_000; + const cache = createCache(storage, 'https://assets.test', () => now); + const artifact = { + id: 'font-fixture', + bytes: new Uint8Array([4, 5, 6]), + sha256: sha256(new Uint8Array([4, 5, 6])), + }; + await cache.put('fixture', artifact, 2_000); + assert.deepEqual(await cache.match('fixture'), artifact); + + now = 2_000; + assert.equal(await cache.match('fixture'), undefined); + + await cache.put('already-expired', artifact, now); + assert.equal(await cache.match('already-expired'), undefined); + assert.equal(storage.cache.responses.size, 0); +}); + +test('runtime GLB cache failure remains a transparent miss', async () => { + const cache = createCache(new ThrowingCacheStorage(), 'https://assets.test', () => 1_000); + assert.equal(await cache.match('fixture'), undefined); + await assert.doesNotReject( + cache.put('fixture', { id: 'font-fixture', bytes: new Uint8Array([1]), sha256: 'b'.repeat(64) }, 2_000), + ); +}); + +function runtimeRequest() { + return { + type: 'bake-font-v0', + id: 1, + source: new ArrayBuffer(0), + font: { formatVersion: 0, fontFaceIndex: 0 }, + unicodeRanges: [{ start: 0x20, end: 0x7e }], + rasters: [ + { + kind: 'bitmap', + extension: 'PMNDRS_font_bitmap', + version: 0, + rasterKey: 'c'.repeat(64), + descriptor: { generatorVersion: '0.0.0', strikes: [16] }, + }, + ], + }; +} + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex'); +} + +class MemoryCacheStorage { + cache = new MemoryCache(); + + async open() { + return this.cache; + } +} + +class ThrowingCacheStorage { + async open() { + throw new DOMException('quota', 'QuotaExceededError'); + } +} + +class MemoryCache { + responses = new Map(); + + async match(request) { + return this.responses.get(request.url)?.clone(); + } + + async put(request, response) { + this.responses.set(request.url, response.clone()); + } + + async delete(request) { + return this.responses.delete(request.url); + } + + async keys() { + return [...this.responses.keys()].map((url) => new Request(url)); + } +} diff --git a/packages/text/tests/integration/shaper-registration.test.mjs b/packages/text/tests/integration/shaper-registration.test.mjs index 93306a8f..c731e50f 100644 --- a/packages/text/tests/integration/shaper-registration.test.mjs +++ b/packages/text/tests/integration/shaper-registration.test.mjs @@ -4,8 +4,8 @@ import test from 'node:test'; 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 { createFontBaker } from '@pmndrs/text/bake'; +import { validateFontArtifact } from '@pmndrs/text/bake'; import { fontBindingBytes, renderPolicyBytes, renderPolicyBytesFromPrograms } from '../support/engine-abi.mjs'; const fixtureDirectory = new URL('../../../../apps/benchmarks/fixtures/fonts/inter-v4.1/', import.meta.url); @@ -14,7 +14,7 @@ const shaperAbiUrl = new URL('../../dist/text-shaper-abi-v0.json', import.meta.u async function fixture() { const [source, bakerWasm, shaperWasm] = await Promise.all([ readFile(new URL('Inter-Regular.ttf', fixtureDirectory)), - readFile(new URL('../../../font-baker/dist/font_baker.wasm', import.meta.url)), + readFile(new URL('../../dist/font_baker.wasm', import.meta.url)), readFile(shaperWasmUrl), ]); const baker = await createFontBaker(bakerWasm); @@ -283,6 +283,74 @@ test('text_update advances missing clusters through an ordered font stack', asyn ); }); +test('text_update appends a reordered Devanagari grapheme after a conjunct', async () => { + const [artifact, shaperWasm, abi] = await Promise.all([ + 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 validated = await validateFontArtifact(artifact); + 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 }, 202, validated); + registerSimpleBinding({ abi, fn, memory }, 1002, 202, validated, 72, 1); + + const stack = copyToWasm(memory, fn.allocate, Uint8Array.of(0xea, 3, 0, 0)); + assert.equal(fn.registerFontStack(17, stack.pointer, 1), abi.status.ok); + fn.deallocate(stack.pointer, stack.length); + 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, 16 * 1024, 256 * 1024, 64), abi.status.ok); + + const prefix = 'कर्म क्षेत्र में प्रगति निरंतर चलती है। प्र'; + const appended = 'त्ये'; + assert.equal(prefix.length, 43); + assert.equal(appended.length, 4); + const initial = engineStyleUpdateBytes(abi, { + sessionId: 29, + policyHandle: 23, + fontStackHandle: 17, + text: utf16Units(prefix), + textEnd: prefix.length, + maxClusters: 86, + geometry: true, + }); + let requestPointer = fn.requestPointer(29); + new Uint8Array(memory.buffer, requestPointer, initial.byteLength).set(initial); + let resultPointer = fn.textUpdate(29, requestPointer, initial.byteLength); + let result = new DataView(memory.buffer, resultPointer, abi.layouts.engineResult.size); + assert.equal(result.getUint32(abi.layouts.engineResult.status, true), abi.status.ok); + + const update = engineStyleUpdateBytes(abi, { + sessionId: 29, + policyHandle: 23, + fontStackHandle: 17, + expectedEngineRevision: 1, + consumedPlanRevision: 1, + acknowledgedPublicationGeneration: 1, + text: utf16Units(appended), + textStart: prefix.length, + textEnd: prefix.length + appended.length, + maxClusters: 94, + }); + requestPointer = fn.requestPointer(29); + new Uint8Array(memory.buffer, requestPointer, update.byteLength).set(update); + resultPointer = fn.textUpdate(29, requestPointer, update.byteLength); + result = new DataView(memory.buffer, resultPointer, abi.layouts.engineResult.size); + assert.equal(result.getUint32(abi.layouts.engineResult.status, true), abi.status.ok); +}); + test('shaper ownership stays scoped to its FontRegistry', async () => { const { artifact, shaperWasm } = await fixture(); const firstRegistry = new FontRegistry(); @@ -367,7 +435,10 @@ function engineStyleUpdateBytes( consumedPlanRevision = 0, acknowledgedPublicationGeneration = 0, text = [], + textStart = 0, textEnd = text.length, + deleteCount = 0, + maxClusters = 2, removeRoot = false, geometry = false, }, @@ -408,7 +479,7 @@ function engineStyleUpdateBytes( 'maxInlineObjects', 'maxSlotsPerBand', ]) { - view.setUint32(request[field], field === 'maxClusters' ? 2 : 1, true); + view.setUint32(request[field], field === 'maxClusters' ? maxClusters : 1, true); } view.setUint32(request.maxOutputBytes, 64 * 1024, true); view.setUint32(request.paragraphMutationsOffset, paragraphRecordOffset, true); @@ -431,6 +502,8 @@ function engineStyleUpdateBytes( 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.textStart, textStart, true); + view.setUint32(textRecordOffset + textRecord.deleteCount, deleteCount, 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); @@ -486,6 +559,12 @@ function engineStyleUpdateBytes( return bytes; } +function utf16Units(value) { + const units = new Uint16Array(value.length); + for (let index = 0; index < value.length; index += 1) units[index] = value.charCodeAt(index); + return units; +} + function align(value, alignment) { return Math.ceil(value / alignment) * alignment; } diff --git a/packages/text/tests/integration/three-engine-runtime.test.mjs b/packages/text/tests/integration/three-engine-runtime.test.mjs index 56b54838..80a4612e 100644 --- a/packages/text/tests/integration/three-engine-runtime.test.mjs +++ b/packages/text/tests/integration/three-engine-runtime.test.mjs @@ -3,7 +3,7 @@ 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 { validateFontArtifact } from '@pmndrs/text/bake'; import { read as readKtx2 } from 'ktx-parse'; import * as THREE from 'three/webgpu'; diff --git a/packages/text/tests/integration/three-shader.test.mjs b/packages/text/tests/integration/three-shader.test.mjs index f0f131f4..a87ec565 100644 --- a/packages/text/tests/integration/three-shader.test.mjs +++ b/packages/text/tests/integration/three-shader.test.mjs @@ -67,6 +67,36 @@ test('a custom Three material composes over the Bitmap shader in the Rust comman runtime.dispose(); }); +test('Bitmap pixel snapping is an explicit opt-in graph specialization', 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 clipPositions = []; + const material = defineTextMaterial((context) => { + clipPositions.push(context.shader.clipPosition); + return context.createDefaultMaterial(); + }); + const scene = new THREE.Scene(); + const unsnapped = new Text({ font, material, text: 'A' }); + const snapped = new Text({ font, material, pixelSnapping: true, text: 'B' }); + scene.add(unsnapped, snapped); + scene.updateMatrixWorld(); + + assert.equal(clipPositions[0], TSL.modelViewProjection); + assert.notEqual(clipPositions[1], TSL.modelViewProjection); + + unsnapped.dispose(); + snapped.dispose(); + font.dispose(); + runtime.dispose(); +}); + 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 73e01142..c7536322 100644 --- a/packages/text/tests/integration/three-v1.test.mjs +++ b/packages/text/tests/integration/three-v1.test.mjs @@ -5,6 +5,7 @@ import { gunzipSync } from 'node:zlib'; import { createFontStack, createTextRuntime, FontRegistry } from '@pmndrs/text'; import { bitmap } from '@pmndrs/text/three/bitmap'; +import { msdf } from '@pmndrs/text/three/msdf'; import { slug } from '@pmndrs/text/three/slug'; import { defineTextMaterial, Text, TextGroup } from '@pmndrs/text/three'; import * as THREE from 'three/webgpu'; @@ -22,6 +23,23 @@ const iconSlugFontUrl = new URL( '../../../../apps/benchmarks/fixtures/rendering/font-awesome-free-6.7.2-slug.font.glb.gz', import.meta.url, ); +const multiTechniqueFontUrl = new URL('../../../../apps/r3f-hello-world/assets/inter-latin.font.glb', import.meta.url); + +test('one runtime request registers one font and returns typed resources for every declared technique', async () => { + const runtime = await createTextRuntime({ + wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), + }); + const [bitmapFont, msdfFont, slugFont] = await runtime.loadFont({ + input: { baked: dataUrl(await readFile(multiTechniqueFontUrl)) }, + rasters: [{ technique: bitmap, options: { strikes: [32] } }, { technique: msdf }, { technique: slug }], + }); + assert.equal(bitmapFont.font, msdfFont.font); + assert.equal(msdfFont.font, slugFont.font); + assert.equal(bitmapFont.technique, bitmap); + assert.equal(msdfFont.technique, msdf); + assert.equal(slugFont.technique, slug); + runtime.dispose(); +}); test('Three Text and TextGroup late-bind, synchronize, reparent, and dispose through the scene graph', async () => { const registry = new FontRegistry(); @@ -460,11 +478,39 @@ test('TextGroup realizes two public Text objects as one indexed Rust draw', asyn assert.deepEqual(pboUploadOrigins.subarray(canonicalOrigins.length), new Float32Array(4)); const version = transforms.version; + let forcedTextWorldUpdates = 0; + const updateRightWorldMatrix = right.updateWorldMatrix.bind(right); + right.updateWorldMatrix = (...arguments_) => { + forcedTextWorldUpdates += 1; + return updateRightWorldMatrix(...arguments_); + }; + group.position.x = 11; + scene.updateMatrixWorld(); + assert.equal(transforms.version, version, 'moving the shared root must not upload unchanged relative transforms'); + assert.equal(forcedTextWorldUpdates, 0, 'moving the shared root must not force each Text world matrix a second time'); + 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(forcedTextWorldUpdates, 0, 'the normal Three traversal supplies current matrices to transform patches'); + + const nestedParent = new THREE.Group(); + group.add(nestedParent); + nestedParent.add(right); + nestedParent.position.x = 3; + scene.updateMatrixWorld(); + assert.equal(transforms.array[2 * 16 + 12], 10, 'nested parent motion patches only the affected transform path'); + nestedParent.visible = false; + scene.updateMatrixWorld(); + assert.deepEqual( + Array.from(transforms.array.subarray(2 * 16, 3 * 16)), + Array(16).fill(0), + 'nested parent visibility suppresses instances whose draw proxy lives at the shared root', + ); + nestedParent.visible = true; + scene.updateMatrixWorld(); assert.equal( right.snapshotGlyphOrigins().displayedX[0], rightOrigins.shapedX[0] + 4, @@ -592,6 +638,11 @@ test('Bitmap strike changes fully initialize a replacement indexed batch', async scene.add(group); scene.updateMatrixWorld(); assert.equal(group.error, undefined); + const initialDraw = group.children.find((child) => child.isMesh); + assert.ok(initialDraw); + const initialStart = initialDraw.userData.pmndrsTextRunStart; + const initialOrigins = initialDraw.geometry.getAttribute('_pmndrsText_1').array; + const initialAdvance = initialOrigins[(initialStart + 1) * 2] - initialOrigins[initialStart * 2]; label.style = { ...label.style, fontSize: 16 }; scene.updateMatrixWorld(); @@ -601,6 +652,12 @@ test('Bitmap strike changes fully initialize a replacement indexed batch', async 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]); + const scaledOrigins = draw.geometry.getAttribute('_pmndrsText_1').array; + const scaledAdvance = scaledOrigins[(start + 1) * 2] - scaledOrigins[start * 2]; + assert.ok( + Math.abs(scaledAdvance - initialAdvance * 2) < 1e-5, + 'a metric-only font-size mutation must rebuild advances without reshaping', + ); label.contentBox = { ...label.contentBox, width: { mode: 'exact', size: 40 } }; scene.updateMatrixWorld(); @@ -612,6 +669,38 @@ test('Bitmap strike changes fully initialize a replacement indexed batch', async runtime.dispose(); }); +test('multi-page Bitmap strikes remain one ordered texture-array draw', async () => { + const runtime = await createTextRuntime({ + 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] } }, + }); + assert.ok(font.data.strikes[1].pages.length > 1, 'the regression fixture must contain a multi-page 32 ppem strike'); + const scene = new THREE.Scene(); + const group = new TextGroup(); + const label = new Text({ + font, + rasterPixelRatio: 2, + text: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789 !?.,;:'.repeat(24), + style: { fontSize: 16 }, + contentBox: { width: { mode: 'exact', size: 480 }, wrap: 'word' }, + }); + group.add(label); + scene.add(group); + scene.updateMatrixWorld(); + assert.equal(group.error, undefined); + const draws = group.children.filter((child) => child.isMesh); + assert.equal(draws.length, 1, 'atlas page changes must select texture-array layers without fragmenting draws'); + assert.ok(draws[0].geometry.getAttribute('_pmndrsText_6'), 'the Bitmap plan must publish a page-layer 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({ @@ -691,6 +780,42 @@ test('TextGroup atomically replaces child paragraphs without multiplying retaine runtime.dispose(); }); +test('TextGroup grows aggregate glyph storage without reserving one aggregate-sized paragraph', 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: 'chunk' } }); + const labels = Array.from({ length: 684 }, (_, index) => new Text({ font, text: `icon-${String(index)}` })); + group.add(...labels); + scene.add(group); + scene.updateMatrixWorld(); + + assert.equal(group.error, undefined); + assert.equal(group.textCount, labels.length); + assert.equal(group.children.filter((child) => child.isMesh).length, 1); + + for (let cycle = 0; cycle < 200; cycle += 1) { + for (let offset = 0; offset < 48; offset += 1) { + const index = (cycle * 23 + offset) % labels.length; + labels[index].text = `recycled-${String(cycle)}-${String(index)}`; + } + scene.updateMatrixWorld(); + assert.equal(group.error, undefined, `recycling cycle ${String(cycle)} must remain publishable`); + } + + group.dispose(); + for (const label of labels) label.dispose(); + font.dispose(); + runtime.dispose(); +}); + function dataUrl(bytes) { return `data:model/gltf-binary;base64,${bytes.toString('base64')}`; } diff --git a/packages/text/tests/package/esm-only.test.mjs b/packages/text/tests/package/esm-only.test.mjs index f253ec0e..f40052de 100644 --- a/packages/text/tests/package/esm-only.test.mjs +++ b/packages/text/tests/package/esm-only.test.mjs @@ -12,7 +12,7 @@ test('the published contract is ESM-only', async () => { assert.equal(manifest.type, 'module'); assert.equal(manifest.main, undefined); assert.equal(manifest.module, undefined); - assert.deepEqual(manifest.bin, { 'pmndrs-text-bake': './dist/node/cli.js' }); + assert.deepEqual(manifest.bin, { text: './bin/text.js' }); assert.equal(manifest.exports['./internal/raster-baker-profile'], undefined); assert.deepEqual(manifest.pmndrs, { text: { bitmap: './bakers/bitmap', msdf: './bakers/msdf', slug: './bakers/slug' }, @@ -29,6 +29,8 @@ test('the published contract is ESM-only', async () => { './mtsdf-abi.json', './slug-baker.wasm', './slug-abi.json', + './font-baker.wasm', + './font-baker-abi.json', './text-shaper.wasm', './shaper-abi.json', ].includes(subpath), @@ -64,7 +66,7 @@ test('the public loader graph exposes registration without eager baker or Node h assert.match(runtimeHost, /workerUrl:\s*new URL\(["']\.\/runtime-bake-worker\.js["']/); assert.match(serialWorkerHost, /new Worker\(this\.#protocol\.workerUrl/); assert.match(serialWorkerHost, /type:\s*["']module["']/); - assert.match(runtimeWorker, /from ["']@pmndrs\/text-font-baker\/wasm-url["']/); + assert.match(runtimeWorker, /from ["']\.\/font-baker\/wasm-url\.js["']/); assert.doesNotMatch( `${runtimeHost}\n${runtimeWorker}`, /(?:node:|font-baker\/validate|compose-bake|compiler-adapter|discovery|gltf-validator|ktx-parse|ajv)/, @@ -73,8 +75,5 @@ test('the public loader graph exposes registration without eager baker or Node h const source = await readFile(new URL(`../../dist/internal/${helper}`, import.meta.url), 'utf8'); assert.doesNotMatch(source, /(?:^|\n)\s*(?:import|export\s+\{.*\}\s+from)\s/m); } - await assert.rejects( - readFile(new URL('../../dist/font_baker.wasm', import.meta.url)), - (error) => error?.code === 'ENOENT', - ); + assert.ok((await readFile(new URL('../../dist/font_baker.wasm', import.meta.url))).byteLength > 0); }); diff --git a/packages/text/tests/package/packed-package.test.mjs b/packages/text/tests/package/packed-package.test.mjs index fdce6d78..60a52f00 100644 --- a/packages/text/tests/package/packed-package.test.mjs +++ b/packages/text/tests/package/packed-package.test.mjs @@ -36,6 +36,7 @@ test('the packed package exposes every ESM subpath and no CommonJS entry', async assert.equal(packedFiles.includes('dist/slug-baker-abi-v0.json'), true); assert.deepEqual([...new Set(packedFiles.map((path) => path.split('/')[0]))].sort(), [ 'LICENSE', + 'bin', 'dist', 'package.json', ]); @@ -75,14 +76,14 @@ test('the packed package exposes every ESM subpath and no CommonJS entry', async assert.match(serialWorkerHost, /new Worker\(this\.#protocol\.workerUrl/); assert.match(serialWorkerHost, /type:\s*["']module["']/); - const cli = join(installedDirectory, 'dist/node/cli.js'); + const cli = join(installedDirectory, 'bin/text.js'); assert.notEqual((await stat(cli)).mode & 0o111, 0, 'the packed CLI must be executable'); const cliHelp = spawnSync(process.execPath, [cli, '--help'], { cwd: join(temporaryDirectory, 'consumer'), encoding: 'utf8', }); assert.equal(cliHelp.status, 0, cliHelp.stderr); - assert.match(cliHelp.stdout, /pmndrs-text-bake/); + assert.match(cliHelp.stdout, /^Usage: text /); const commonJs = spawnSync(process.execPath, ['-e', "require('@pmndrs/text')"], { cwd: dirname(installedDirectory), diff --git a/packages/text/tests/package/r3f-webgpu.test.mjs b/packages/text/tests/package/r3f-webgpu.test.mjs index 7ea770e8..1abff11b 100644 --- a/packages/text/tests/package/r3f-webgpu.test.mjs +++ b/packages/text/tests/package/r3f-webgpu.test.mjs @@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises'; import test from 'node:test'; const packageManifest = new URL('../../package.json', import.meta.url); -const reactSource = new URL('../../src/r3f.ts', import.meta.url); +const reactSource = new URL('../../src/react.ts', import.meta.url); test('pins the R3F v10 WebGPU entry without browser-global import side effects', async () => { assert.equal(globalThis.localStorage, undefined); diff --git a/packages/text/tests/types/r3f-v1-api.test.ts b/packages/text/tests/types/r3f-v1-api.test.ts index 1e8c04e1..f29944f8 100644 --- a/packages/text/tests/types/r3f-v1-api.test.ts +++ b/packages/text/tests/types/r3f-v1-api.test.ts @@ -1,18 +1,22 @@ import { createElement } from 'react'; -import type { LoadedFont } from '../../src/index.js'; -import { Text, TextGroup, useFont } from '../../src/r3f.js'; +import type { FontStack, LoadedFont } from '../../src/index.js'; +import { Text, TextGroup, useFont } from '../../src/react.js'; import type { ThreeTextMaterial } from '../../src/three.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 bitmapFont: LoadedFont; declare const mtsdfFont: LoadedFont; +declare const slugFont: LoadedFont; +declare const selectedStack: FontStack | FontStack | FontStack; declare const material: ThreeTextMaterial; const inline = createElement(Text, { paint: { color: '#ff00ff' } }, 'span'); -const label = createElement(Text, { font: bitmapFont, material }, 'Typed ', inline); -const labels = createElement(TextGroup, { compositing: 'independent', material }, label); +const label = createElement(Text, { font: bitmapFont, material, pixelSnapping: true }, 'Typed ', inline); +const labels = createElement(TextGroup, { compositing: 'independent', material, pixelSnapping: true }, label); +const selected = createElement(Text, { font: selectedStack }, 'Selected at runtime'); function FontConsumer(): null { const loaded: LoadedFont = useFont({ @@ -20,11 +24,23 @@ function FontConsumer(): null { raster: { technique: bitmap, options: { strikes: [16] } }, }); void loaded; + const [loadedBitmap, loadedMsdf, loadedSlug] = useFont({ + input: { baked: '/fonts/Inter.font.glb' }, + rasters: [{ technique: bitmap, options: { strikes: [16] } }, { technique: msdf }, { technique: slug }], + }); + loadedBitmap satisfies LoadedFont; + loadedMsdf satisfies LoadedFont; + loadedSlug satisfies LoadedFont; return null; } // @ts-expect-error The selected font technique must match the Text technique. createElement(Text, { font: mtsdfFont }, 'wrong technique'); +// @ts-expect-error An outer Text font must be a loaded font selection. +createElement(Text, { font: 42 }, 'invalid font'); + void labels; +void selected; +void slugFont; void FontConsumer; diff --git a/packages/text/tests/types/text-runtime-api.test.ts b/packages/text/tests/types/text-runtime-api.test.ts index b907a00a..3fc3537f 100644 --- a/packages/text/tests/types/text-runtime-api.test.ts +++ b/packages/text/tests/types/text-runtime-api.test.ts @@ -14,12 +14,35 @@ async function loadTargetV1Fonts(): Promise { }); await created.loadFont({ input: { baked: '/fonts/Inter.font.glb' }, raster: { technique: msdf } }); await created.loadFont({ input: { baked: '/fonts/Inter.font.glb' }, raster: { technique: slug } }); + const [bitmapFont, msdfFont, slugFont] = await created.loadFont({ + input: { baked: '/fonts/Inter.font.glb' }, + rasters: [{ technique: bitmap, options: { strikes: [16, 32] } }, { technique: msdf }, { technique: slug }], + }); + bitmapFont satisfies import('../../src/index.js').LoadedFont; + msdfFont satisfies import('../../src/index.js').LoadedFont; + slugFont satisfies import('../../src/index.js').LoadedFont; + await created.loadFont({ + input: { + source: '/fonts/Inter.ttf', + runtimeBake: async () => new Uint8Array(), + unicodeRanges: [{ start: 0x20, end: 0x7e }], + }, + rasters: [{ technique: bitmap, options: { strikes: [16] } }, { technique: slug }], + }); created.loadFont({ input: { baked: '/fonts/Inter.font.glb' }, // @ts-expect-error Bitmap technique options are required. raster: { technique: bitmap }, }); + created.loadFont({ + input: { baked: '/fonts/Inter.font.glb' }, + rasters: [ + // @ts-expect-error Bitmap options remain required inside a multi-technique request. + { technique: bitmap }, + { technique: msdf }, + ], + }); } void loadTargetV1Fonts; diff --git a/packages/text/tests/types/three-v1-api.test.ts b/packages/text/tests/types/three-v1-api.test.ts index b5880137..4802f942 100644 --- a/packages/text/tests/types/three-v1-api.test.ts +++ b/packages/text/tests/types/three-v1-api.test.ts @@ -7,8 +7,8 @@ declare const bitmapFont: LoadedFont; 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({ compositing: 'independent' }); +const label = new Text({ font: bitmapFont, pixelSnapping: true, text: txt`Typed ${emphasis`span`}` }); +const labels = new TextGroup({ compositing: 'independent', pixelSnapping: true }); const compositing: 'ordered' | 'independent' = labels.compositing; labels.add(label); label.text = 'Updated'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73e20194..e6169a7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,9 +34,6 @@ importers: '@pmndrs/text': specifier: workspace:* version: link:../../packages/text - '@pmndrs/text-font-baker': - specifier: workspace:* - version: link:../../packages/font-baker '@pmndrs/text-glyph-example-raster': specifier: workspace:* version: link:../../packages/glyph-example-raster @@ -193,22 +190,6 @@ importers: 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: - specifier: 6.15.0 - version: 6.15.0 - gltf-validator: - specifier: 2.0.0-dev.3.10 - version: 2.0.0-dev.3.10 - devDependencies: - binaryen: - specifier: 129.0.0 - version: 129.0.0 - typescript: - specifier: 7.0.2 - version: 7.0.2 - packages/glyph-example-raster: dependencies: '@pmndrs/text': @@ -239,9 +220,12 @@ importers: '@cto.af/linebreak': specifier: 4.0.3 version: 4.0.3 - '@pmndrs/text-font-baker': - specifier: workspace:* - version: link:../font-baker + ajv: + specifier: 6.15.0 + version: 6.15.0 + gltf-validator: + specifier: 2.0.0-dev.3.10 + version: 2.0.0-dev.3.10 ktx-parse: specifier: 1.1.0 version: 1.1.0