Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions apps/benchmarks/src/benchmark/package-size-budgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
40 changes: 20 additions & 20 deletions apps/benchmarks/src/generated/package-sizes.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
86 changes: 50 additions & 36 deletions apps/benchmarks/src/workloads/editorial/scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComparisonWorkloadConfiguration, 'amount' | 'animationSpeed' | 'fontSize' | 'layoutWidthRatio'> & {
Expand All @@ -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(
Expand Down
11 changes: 11 additions & 0 deletions docs/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/packages/benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/packages/glyph.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions packages/glyph/src/text-properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
83 changes: 62 additions & 21 deletions packages/glyph/src/three/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -1106,7 +1140,7 @@ function compileEngineGeometry(
maxLines,
regionStart,
resumeCluster: 0,
regionCount: 1,
regionCount: columns.count,
resumeRegion: 0,
widthMode: width.mode,
heightMode: height.mode,
Expand All @@ -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,
};
}),
};
}

Expand Down Expand Up @@ -1319,6 +1357,9 @@ function normalizeDesired<Technique extends AnyRasterTechnique>(
properties: TextProperties<Technique>,
): DesiredTextState<Technique> {
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<Technique>);
return Object.freeze({
font: properties.font,
Expand Down
Loading
Loading