diff --git a/apps/benchmarks/src/benchmark/package-size-budgets.ts b/apps/benchmarks/src/benchmark/package-size-budgets.ts index 2f961f98..27ff05fe 100644 --- a/apps/benchmarks/src/benchmark/package-size-budgets.ts +++ b/apps/benchmarks/src/benchmark/package-size-budgets.ts @@ -47,8 +47,9 @@ export const packageSizeBudgets = { // rendering feature (D-248): decorating-box cascade state, per-cluster run aggregation, resource-free plan rows, // and paint-order gather passes. The 11.14 typography tier re-priced the ceilings once for all of its layers: // constraint decode/validation, paragraph spacing, first-line indent, bounded justification, and the graduated - // D-245 kernels total roughly +1.3 KB raw, with the margins sized to cover the Linux CI toolchain's slightly - // larger gzip output alongside the recorded macOS host. + // D-245 kernels total roughly +1.3 KB raw. The margins cover cross-host build variance: the Linux toolchain + // emits equal-length but byte-different wasm (different sha256) whose compressed sizes run a few hundred bytes + // above the recorded macOS host's. 'text-shaper-wasm': { rawBytes: 1_112_000, minifiedBytes: 1_112_000, @@ -60,11 +61,13 @@ export const packageSizeBudgets = { // of schema-derived executor lookups, program buffer derivation, and the // glyph-origin schema map replacing literal id ranges. Real code, not comments; // the compressed ceilings hold with tight headroom by design. + // Column flow (contentBox columns over ordered regions) added ~+1.7 KB raw of + // geometry derivation and validation in the Three adapter. 'three-runtime-js': { - rawBytes: 368_000, - minifiedBytes: 240_500, - gzipBytes: 62_300, - brotliBytes: 52_500, + rawBytes: 370_000, + minifiedBytes: 241_500, + gzipBytes: 62_500, + brotliBytes: 52_800, }, 'font-inter-bitmap-16-32': { rawBytes: 3_200_000, diff --git a/apps/benchmarks/src/generated/package-sizes.json b/apps/benchmarks/src/generated/package-sizes.json index 36e55e50..a9e1878a 100644 --- a/apps/benchmarks/src/generated/package-sizes.json +++ b/apps/benchmarks/src/generated/package-sizes.json @@ -54,11 +54,11 @@ "label": "Three.js adapter JS", "status": "measured", "format": "javascript", - "sha256": "88f14f0cd623536f38a756445720d9e6dcb77cf11742b846134f8d2c3649ca8d", - "rawBytes": 367017, - "minifiedBytes": 240030, - "gzipBytes": 61834, - "brotliBytes": 52200 + "sha256": "eceda386dec0b4693cef644e0eedafaf8d5c6108e742255ebce8ffe542865d9f", + "rawBytes": 368735, + "minifiedBytes": 241081, + "gzipBytes": 62162, + "brotliBytes": 52529 }, { "id": "font-inter-bitmap-16-32", @@ -164,33 +164,33 @@ "label": "Bitmap runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "b435a4421fc79794b62ec512d85089a4fb700a61e9ce4ed2cf8ff8fc7ae75f66", - "rawBytes": 356269, - "minifiedBytes": 232707, - "gzipBytes": 60710, - "brotliBytes": 50694 + "sha256": "36a3ffd852b48d1152f9744c08543996272ea3093f7918d4df865cc04c3125ff", + "rawBytes": 357987, + "minifiedBytes": 233758, + "gzipBytes": 61040, + "brotliBytes": 50973 }, { "id": "mtsdf-runtime-js", "label": "MSDF runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "0e0593aef91751ca3a0b9aa31257f60c82613ea139404eff16fa26f3e85ec515", - "rawBytes": 356265, - "minifiedBytes": 232688, - "gzipBytes": 60760, - "brotliBytes": 50667 + "sha256": "40acd1d3fea163c682b0e86ce0b10f8de54781821f1039f32da1adf3e310f23d", + "rawBytes": 357983, + "minifiedBytes": 233739, + "gzipBytes": 61092, + "brotliBytes": 50969 }, { "id": "slug-runtime-js", "label": "Slug runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "e507d2fc115b2f12f9d1763b05b14f67f254ffac2d1d4af69a606fcb11e33cff", - "rawBytes": 356267, - "minifiedBytes": 232782, - "gzipBytes": 60618, - "brotliBytes": 50751 + "sha256": "6b5d7e3631de794bb69e2b6bccd498fcbe7c0ad7b6e2521a30164e72381516ad", + "rawBytes": 357985, + "minifiedBytes": 233833, + "gzipBytes": 60950, + "brotliBytes": 51003 }, { "id": "bitmap-baker-wasm", diff --git a/apps/benchmarks/src/workloads/editorial/scene.ts b/apps/benchmarks/src/workloads/editorial/scene.ts index e3b9575c..d3f907e8 100644 --- a/apps/benchmarks/src/workloads/editorial/scene.ts +++ b/apps/benchmarks/src/workloads/editorial/scene.ts @@ -61,6 +61,11 @@ export function editorialColumnWidth( return Math.max(220, baseWidth * (0.82 + Math.sin(phase) * 0.16)); } +/** The columned body keeps a fixed page height; reflow refills it as the measure breathes. */ +export function editorialBodyHeight(fontSize: number): number { + return Math.ceil(fontSize * LIVE_TEXT_LINE_HEIGHT * 11); +} + export function createEditorialEntries( context: WorkloadTextFactoryContext & Pick & { @@ -69,43 +74,52 @@ export function createEditorialEntries( }, ): readonly ComparisonWorkloadEntry[] { const width = editorialColumnWidth(context, context.viewportWidth, context.animationElapsedMs); - // The amount control repeats the editorial cycle: 50 keeps one full column, - // and larger volumes stack additional justified pages onto the same measure. - const paragraphCount = Math.max(EDITORIAL_TEXT.length, Math.round((context.amount / 50) * EDITORIAL_TEXT.length)); - const sourceTexts = Array.from( - { length: paragraphCount }, - (_, index) => EDITORIAL_TEXT[index % EDITORIAL_TEXT.length]!, - ); - return sourceTexts.map((sourceText, index) => { - const text = new Text({ - font: context.font, - rasterPixelRatio: context.dpr, - text: sourceText, - style: { - fontSize: context.fontSize, - lineHeight: LIVE_TEXT_LINE_HEIGHT, - wordSpacing: index % EDITORIAL_TEXT.length === 1 ? context.fontSize * 0.05 : 0, - }, - paint: { color: paintColor(LIVE_TEXT_COLOR) }, - contentBox: { - width: exactWidth(width), - wrap: 'word', - align: 'justify', - firstLineIndent: index % EDITORIAL_TEXT.length === 0 && index === 0 ? 0 : context.fontSize * 1.5, - spaceBefore: index === 0 ? 0 : context.fontSize * 0.6, - spaceAfter: context.fontSize * 0.4, - justify: EDITORIAL_JUSTIFY, - lastLine: index === sourceTexts.length - 1 ? 'justify' : 'auto', - }, - }); - return { - node: text, - role: index === 0 ? 'primary' : 'secondary', - sourceText, - text, - lastWidth: width, - }; + // A real editorial page: one single-measure justified lede, then the body + // flowing through two ordered justified columns under it. The amount control + // scales how much body text refills the fixed page height. + const repeats = Math.max(1, Math.round(context.amount / 25)); + const bodyText = Array.from({ length: repeats }, (_, cycle) => + EDITORIAL_TEXT.slice(cycle === 0 ? 1 : 0).join(' '), + ).join(' '); + const lede = new Text({ + font: context.font, + rasterPixelRatio: context.dpr, + text: EDITORIAL_TEXT[0], + style: { fontSize: context.fontSize, lineHeight: LIVE_TEXT_LINE_HEIGHT }, + paint: { color: paintColor(LIVE_TEXT_COLOR) }, + contentBox: { + width: exactWidth(width), + wrap: 'word', + align: 'justify', + spaceAfter: context.fontSize * 0.8, + justify: EDITORIAL_JUSTIFY, + }, + }); + const body = new Text({ + font: context.font, + rasterPixelRatio: context.dpr, + text: bodyText, + style: { + fontSize: context.fontSize, + lineHeight: LIVE_TEXT_LINE_HEIGHT, + wordSpacing: context.fontSize * 0.05, + }, + paint: { color: paintColor(LIVE_TEXT_COLOR) }, + contentBox: { + width: exactWidth(width), + height: { mode: 'exact', size: editorialBodyHeight(context.fontSize) }, + columns: { count: 2, gap: context.fontSize }, + wrap: 'word', + align: 'justify', + firstLineIndent: context.fontSize * 1.5, + justify: EDITORIAL_JUSTIFY, + overflow: 'clip', + }, }); + return [ + { node: lede, role: 'primary', sourceText: EDITORIAL_TEXT[0], text: lede, lastWidth: width }, + { node: body, role: 'secondary', sourceText: bodyText, text: body, lastWidth: width }, + ]; } export function animateEditorialEntries( diff --git a/docs/log.md b/docs/log.md index cb68ba2b..d415ce90 100644 --- a/docs/log.md +++ b/docs/log.md @@ -13,6 +13,17 @@ ## 2026-08-12 +- **Column flow (11.18 slice)** — `ParagraphContentBox` gains `columns { count, gap }`, mapping the public `Text` + onto the engine's already-proven ordered multi-region flow: side-by-side region rectangles inside the exact + content-box width, filled in order without balancing. The column height is the flow signal, so columns require + an exact width and a bounded height, both rejected loudly at construction; column zero keeps the paragraph's + region id, so single-column request bytes are unchanged. The editorial workload becomes a real page — one + single-measure justified lede over a body flowing through two justified columns with indent and bounded + elasticity, batching to three draws (lede plus one clipped draw per column) over 869 glyphs at 120 FPS. The + drop-cap exclusion surface stays with 11.18 proper. One misstep recorded: the first workload rewrite was a + silent no-op — a source replace anchored on pre-formatter text matched nothing and reported success; the stale + 768-glyph telemetry exposed it, and the redo asserts its anchors. + - **Editorial workload (11.14, layer 5)** — The typography tier's product proof: three justified paragraphs on one animated measure — first-line indents after the opening paragraph, paragraph space before and after, per-span word spacing, word-space ratios bounded to [0.75, 1.35] with a 0.4-unit letter-gap budget, and a justified last diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index 1c6f1b59..14d69498 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/glyph-benchmarks' documentation_type: reference -source_digest: 'sha256:e43e7329cbf1dfa14cf7d049a9b558d4f588d650a47ca5da17c159f83efcdb56' +source_digest: 'sha256:00aa46b3f1dea9587df3a45417bd881260f05c41895121b46b17c6aa71ecc284' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest diff --git a/docs/packages/glyph.md b/docs/packages/glyph.md index 0a9744a3..acb4820e 100644 --- a/docs/packages/glyph.md +++ b/docs/packages/glyph.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/glyph workspace_package: '@pmndrs/glyph' documentation_type: reference -source_digest: 'sha256:31d96bf7e6de7586b3fd2f7ece097c90ac759d3c992b4ff329660a403d0fcc1d' +source_digest: 'sha256:c5b0141b2c814098bb31a9fa20f01bb013ce931951de96bb4fa59cddbee0c0c3' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest diff --git a/packages/glyph/src/text-properties.ts b/packages/glyph/src/text-properties.ts index 2c9c26d2..edfff657 100644 --- a/packages/glyph/src/text-properties.ts +++ b/packages/glyph/src/text-properties.ts @@ -41,6 +41,13 @@ export interface ParagraphContentBox { }; /** Whether the final and hard-broken lines also justify. Defaults to 'auto'. */ readonly lastLine?: 'auto' | 'justify'; + /** + * Flow the paragraph through side-by-side ordered columns inside the exact + * content-box width. Text fills each column in order without balancing, so + * the final column may run short. Requires an exact `width`; `gap` is the + * inline space between adjacent columns in layout units. + */ + readonly columns?: { readonly count: number; readonly gap?: number }; } export interface ParagraphStyle { diff --git a/packages/glyph/src/three/text.ts b/packages/glyph/src/three/text.ts index 5b2f49d4..edab7952 100644 --- a/packages/glyph/src/three/text.ts +++ b/packages/glyph/src/three/text.ts @@ -691,7 +691,7 @@ class ThreeTextBatchBinding { if (semanticChanges & GEOMETRY_CHANGE) { const geometry = compileEngineGeometry(paragraph, properties.contentBox, regions.length, content.length); constraints.push(geometry.constraint); - regions.push(geometry.region); + regions.push(...geometry.regions); } } let totalTextLength = 0; @@ -1081,18 +1081,52 @@ function engineDecoration( }; } +/** + * Column region ids stride the high byte so every paragraph's extra columns + * stay unique in the frame's region table while column zero keeps the + * paragraph id itself — the single-column request bytes are unchanged. + */ +const COLUMN_REGION_ID_STRIDE = 0x01_00_00_00; + +function normalizedColumns(contentBox: ParagraphContentBox | undefined): { count: number; gap: number } { + const columns = contentBox?.columns; + if (columns === undefined) return { count: 1, gap: 0 }; + const gap = columns.gap ?? 0; + if (!Number.isSafeInteger(columns.count) || columns.count < 1 || columns.count > 16) { + throw new RangeError('contentBox columns count must be an integer between 1 and 16'); + } + if (!Number.isFinite(gap) || gap < 0) { + throw new RangeError('contentBox columns gap must be a nonnegative finite number'); + } + if (columns.count > 1 && contentBox?.width?.mode !== 'exact') { + throw new TypeError('contentBox columns require an exact width to derive the column measure'); + } + // Ordered columns fill without balancing, so the column height is the only + // signal that advances flow into the next region: unbounded height would + // keep every line in the first column forever. + if (columns.count > 1 && contentBox?.height === undefined) { + throw new TypeError('contentBox columns require a bounded height to fill columns in order'); + } + return { count: columns.count, gap }; +} + function compileEngineGeometry( paragraph: RetainedEngineParagraph, contentBox: ParagraphContentBox | undefined, regionStart: number, textLength: number, -): { readonly constraint: TextEngineConstraint; readonly region: TextEngineRegion } { +): { readonly constraint: TextEngineConstraint; readonly regions: readonly TextEngineRegion[] } { const width = axis(contentBox?.width); const height = axis(contentBox?.height); + const columns = normalizedColumns(contentBox); const inlineEnd = width.mode === 'unconstrained' ? 0x01_00_00_00 : width.size; const blockEnd = height.mode === 'unconstrained' ? 0x01_00_00_00 : height.size; const maxLines = contentBox?.maxLines ?? Math.max(1, textLength); const geometryRevision = paragraph.geometryRevision + 1; + const columnWidth = (inlineEnd - columns.gap * (columns.count - 1)) / columns.count; + if (columns.count > 1 && columnWidth <= 0) { + throw new RangeError('contentBox columns and gap leave no positive column measure'); + } return { constraint: { paragraphId: paragraph.id, @@ -1106,7 +1140,7 @@ function compileEngineGeometry( maxLines, regionStart, resumeCluster: 0, - regionCount: 1, + regionCount: columns.count, resumeRegion: 0, widthMode: width.mode, heightMode: height.mode, @@ -1120,24 +1154,28 @@ function compileEngineGeometry( ...(contentBox?.justify === undefined ? {} : { justify: contentBox.justify }), ...(contentBox?.lastLine === undefined ? {} : { lastLine: contentBox.lastLine }), }, - region: { - id: paragraph.id, - geometryRevision, - transformIndex: paragraph.id, - shape: 'rectangle', - exclusionStart: 0, - exclusionCount: 0, - writingMode: 'horizontal-tb', - textOrientation: 'mixed', - inlineStart: 0, - blockStart: 0, - inlineEnd, - blockEnd, - clipInlineStart: 0, - clipBlockStart: 0, - clipInlineEnd: inlineEnd, - clipBlockEnd: blockEnd, - }, + regions: Array.from({ length: columns.count }, (_, column) => { + const inlineStart = column * (columnWidth + columns.gap); + const columnInlineEnd = column === columns.count - 1 ? inlineEnd : inlineStart + columnWidth; + return { + id: paragraph.id + COLUMN_REGION_ID_STRIDE * column, + geometryRevision, + transformIndex: paragraph.id, + shape: 'rectangle' as const, + exclusionStart: 0, + exclusionCount: 0, + writingMode: 'horizontal-tb' as const, + textOrientation: 'mixed' as const, + inlineStart, + blockStart: 0, + inlineEnd: columnInlineEnd, + blockEnd, + clipInlineStart: inlineStart, + clipBlockStart: 0, + clipInlineEnd: columnInlineEnd, + clipBlockEnd: blockEnd, + }; + }), }; } @@ -1319,6 +1357,9 @@ function normalizeDesired( properties: TextProperties, ): DesiredTextState { if (properties === undefined) throw new TypeError('Text properties are required'); + // Column geometry is validated here so an impossible combination fails at + // construction or set() instead of surfacing later as a bind-time error. + normalizedColumns(properties.contentBox); const formatted = typeof properties.text === 'string' ? undefined : (properties.text as FormattedText); return Object.freeze({ font: properties.font, diff --git a/packages/glyph/tests/integration/three-v1.test.mjs b/packages/glyph/tests/integration/three-v1.test.mjs index a0cb3780..7bd5cc2b 100644 --- a/packages/glyph/tests/integration/three-v1.test.mjs +++ b/packages/glyph/tests/integration/three-v1.test.mjs @@ -240,6 +240,65 @@ test('Three Text and TextGroup late-bind, synchronize, reparent, and dispose thr paragraph.dispose(); } + // Column flow: one paragraph fills side-by-side ordered regions without + // balancing. The column height is the flow signal, so the reference layout is + // one column at the exact column measure; halving its height (plus a line of + // slack) must push the tail of the text into the second column. + const columnText = 'the quick brown fox jumps over the lazy dog and keeps running until the column turns'; + const columnMeasureWidth = (420 - 20) / 2; + const reference = new Text({ + font, + text: columnText, + contentBox: { width: { mode: 'exact', size: columnMeasureWidth } }, + }); + scene.add(reference); + scene.updateMatrixWorld(); + const referenceMeasure = reference.measureLayout(); + assert.ok(referenceMeasure.lineCount >= 4, 'the fixture text must wrap well past two lines at the column measure'); + const columnHeight = Math.ceil(referenceMeasure.contentHeight * 0.6); + const twoColumns = new Text({ + font, + text: columnText, + contentBox: { + width: { mode: 'exact', size: 420 }, + height: { mode: 'exact', size: columnHeight }, + columns: { count: 2, gap: 20 }, + }, + }); + scene.add(twoColumns); + scene.updateMatrixWorld(); + const doubleMeasure = twoColumns.measureLayout(); + assert.equal(doubleMeasure.overflowed, false, 'two columns at 60% height must hold the whole text'); + assert.ok( + doubleMeasure.contentHeight <= columnHeight, + 'the columned block extent must stay inside the column height', + ); + const columnStarts = twoColumns.inspectLayout().x; + const secondColumnStart = columnMeasureWidth + 20; + assert.ok( + Array.from(columnStarts).some((x) => x >= secondColumnStart), + 'glyphs must flow into the second column', + ); + assert.throws( + () => new Text({ font, text: columnText, contentBox: { columns: { count: 2 } } }), + /columns/, + 'columns without an exact width must be rejected', + ); + assert.throws( + () => + new Text({ + font, + text: columnText, + contentBox: { width: { mode: 'exact', size: 420 }, columns: { count: 2 } }, + }), + /columns/, + 'columns without a bounded height must be rejected', + ); + for (const paragraph of [reference, twoColumns]) { + paragraph.removeFromParent(); + paragraph.dispose(); + } + label.removeFromParent(); label.dispose(); font.dispose();