From 3d34dd15cce90c912ad7256ba95c9c3f812fabc7 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 23 Jul 2026 22:13:12 -0300 Subject: [PATCH 1/2] feat(core): segment-fit line-breaking seam for plain-text measurement Add a swappable SegmentFitEngine seam to the layout-engine measurement path, gated behind a new `segmentFitLineBreaking` measurement feature flag. A registered engine fits plain-text runs from prepared segment widths instead of the word-walk's per-call word re-measurement and `findMaxFittingLength` slice-probe binary search. With the flag off or no engine installed (the default), the block is skipped and the legacy walk runs byte-identically. - segmentFit.ts: swappable engine registry + walk driver + style allowlist; core imports no concrete engine. - featureFlags.ts: add segmentFitLineBreaking flag (default off) + accessor, preserving the existing workerFontMetrics flag. - measureParagraph.ts: conservative call-site admission gate; declines automatic-hyphenation and protected-cross-run-glue runs. - cache.ts: clearAllCaches drops engine-prepared state on font-env change. - segmentFit.test.ts: engine-consulted parity + glue-run guard. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/segment-fit-seam.md | 13 + .../layout-bridge-engine-measuring.api.md | 1 + .../core/src/layout-engine/measure/cache.ts | 4 + .../src/layout-engine/measure/featureFlags.ts | 15 ++ .../layout-engine/measure/measureParagraph.ts | 68 ++++- .../layout-engine/measure/segmentFit.test.ts | 254 ++++++++++++++++++ .../src/layout-engine/measure/segmentFit.ts | 204 ++++++++++++++ 7 files changed, 555 insertions(+), 4 deletions(-) create mode 100644 .changeset/segment-fit-seam.md create mode 100644 packages/core/src/layout-engine/measure/segmentFit.test.ts create mode 100644 packages/core/src/layout-engine/measure/segmentFit.ts diff --git a/.changeset/segment-fit-seam.md b/.changeset/segment-fit-seam.md new file mode 100644 index 00000000..5ab74af1 --- /dev/null +++ b/.changeset/segment-fit-seam.md @@ -0,0 +1,13 @@ +--- +"@stll/folio-core": minor +--- + +feat(core): segment-fit line-breaking seam for plain-text measurement + +Add a swappable `SegmentFitEngine` seam to the layout-engine measurement path. +A registered engine fits plain-text runs from prepared segment widths instead of +the word-walk's per-call word re-measurement and `findMaxFittingLength` +slice-probe binary search. Gated behind the new `segmentFitLineBreaking` +measurement feature flag; with the flag off or no engine installed, the legacy +walk runs byte-identically. `@stll/premirror-bridge` provides a +`@chenglou/pretext`-backed engine with frozen parity tests. diff --git a/api-reports/core/layout-bridge-engine-measuring.api.md b/api-reports/core/layout-bridge-engine-measuring.api.md index 148d9362..23b8f37f 100644 --- a/api-reports/core/layout-bridge-engine-measuring.api.md +++ b/api-reports/core/layout-bridge-engine-measuring.api.md @@ -80,6 +80,7 @@ export type FloatingLineSegmentZone = { // @public export type FolioMeasurementFeatureFlags = { workerFontMetrics?: boolean; + segmentFitLineBreaking?: boolean; }; // @public diff --git a/packages/core/src/layout-engine/measure/cache.ts b/packages/core/src/layout-engine/measure/cache.ts index 83fe8bf4..bd3d5120 100644 --- a/packages/core/src/layout-engine/measure/cache.ts +++ b/packages/core/src/layout-engine/measure/cache.ts @@ -9,6 +9,7 @@ import type { ParagraphBlock, ParagraphMeasure } from "../types"; import { lineBreakPolicyCacheParts } from "./effectiveLineBreakPolicy"; import { getLineBreakProviderGeneration } from "./lineBreakProvider"; import { clearFontResolvedCache } from "./measureHelpers"; +import { clearSegmentFitEngineCaches } from "./segmentFit"; // ============================================================================= // TEXT WIDTH CACHE @@ -451,6 +452,9 @@ export function clearAllCaches(): void { clearFontMetricsCache(); clearParagraphMeasureCache(); clearFontResolvedCache(); + // Engine-prepared segment widths are derived from the same canvas metrics + // as the caches above; a font-environment change must drop them too. + clearSegmentFitEngineCaches(); } /** diff --git a/packages/core/src/layout-engine/measure/featureFlags.ts b/packages/core/src/layout-engine/measure/featureFlags.ts index 2fe8fa2c..40096048 100644 --- a/packages/core/src/layout-engine/measure/featureFlags.ts +++ b/packages/core/src/layout-engine/measure/featureFlags.ts @@ -24,6 +24,13 @@ export type FolioMeasurementFeatureFlags = { * runs on the main thread exactly as before. */ workerFontMetrics?: boolean; + /** + * Fit plain text runs from prepared segment widths (premirror port) via a + * registered SegmentFitEngine instead of the word-walk's slice-probe + * binary search. Requires an engine (see `setSegmentFitEngine`); with the + * flag on but no engine registered, behaviour is unchanged. + */ + segmentFitLineBreaking?: boolean; }; declare global { @@ -40,6 +47,14 @@ export function isWorkerFontMetricsEnabled(): boolean { return globalThis.__folioFeatureFlags?.workerFontMetrics === true; } +/** + * Read the segment-fit line-breaking flag. Same strict-true semantics as the + * worker flag: any value other than `true` leaves the legacy word walk active. + */ +export function isSegmentFitLineBreakingEnabled(): boolean { + return globalThis.__folioFeatureFlags?.segmentFitLineBreaking === true; +} + /** * Test-only helper to set the flag bag without polluting host state. * Production callers should set `globalThis.__folioFeatureFlags` directly. diff --git a/packages/core/src/layout-engine/measure/measureParagraph.ts b/packages/core/src/layout-engine/measure/measureParagraph.ts index 331c8552..907c5841 100644 --- a/packages/core/src/layout-engine/measure/measureParagraph.ts +++ b/packages/core/src/layout-engine/measure/measureParagraph.ts @@ -38,8 +38,9 @@ import { type FloatingLineSegmentZone, } from "./floatingZones"; import { getListMarkerInlineWidth } from "./listMarkerWidth"; -import { buildRunFontStyle, ptToPx, twipsToPx } from "./measureHelpers"; +import { buildFontString, buildRunFontStyle, ptToPx, twipsToPx } from "./measureHelpers"; import { getFontMetrics, measureRun, measureTextWidth } from "./measureProvider"; +import { isSegmentFitActive, runSegmentFitWalk, styleSupportsSegmentFit } from "./segmentFit"; import type { FontMetrics, FontStyle } from "./measureTypes"; import { findGraphemeBreaks, @@ -1810,13 +1811,72 @@ export function measureParagraph( continue; } + // Segment-fit strategy (premirror port): when an engine is installed and + // the flag is on, fit plain text runs from prepared segment widths + // instead of measuring word slices per call. Admission is deliberately + // conservative — only runs whose line breaks the seam reproduces exactly + // are handed to it; everything the legacy walk does beyond a plain word + // fit stays legacy: justified shrink tolerance, automatic hyphenation's + // mid-word breaks, cross-run glue (#991, both plain and protected), and + // styles outside the engine's font-string model. Pieces the engine + // refuses (e.g. an overlong token on an empty line) fall through to the + // legacy walk at `segmentConsumedUpTo`. With the flag off or no engine + // installed (production default), the whole block is skipped and the + // legacy walk runs byte-identically. + let segmentConsumedUpTo = 0; + if ( + isSegmentFitActive() && + styleSupportsSegmentFit(style) && + !isJustifiedParagraph && + effectiveLineBreakPolicy.automaticHyphenation.type === "disabled" && + (trailingGlueWidths[runIndex] ?? 0) === 0 && + (protectedCrossRunGlueWidths[runIndex] ?? 0) === 0 + ) { + let lastConsumed = 0; + /* eslint-disable no-loop-func -- SAFETY: the host callbacks are + consumed synchronously inside runSegmentFitWalk before this loop + iteration advances; they never escape the call. */ + segmentConsumedUpTo = runSegmentFitWalk(text, buildFontString(style), { + spaceLeft: () => currentLine.availableWidth - currentLine.width + WIDTH_TOLERANCE, + lineHasContent: () => currentLine.width > 0, + commit: (width, endChar) => { + const piece = text.slice(lastConsumed, endChar); + const trimmedPiece = trimTrailingSpacesAndTabs(piece); + currentLine.width += width; + currentLine.trailingWhitespaceWidth = + piece === trimmedPiece + ? 0 + : Math.max(0, width - measureTextWidth(trimmedPiece, style)); + currentLine.regularSpaceWidth += compressibleSpaceWidth(piece, style); + currentLine.toRun = runIndex; + currentLine.toChar = endChar; + lastConsumed = endChar; + }, + wrap: (fromChar) => { + startNewLine(runIndex, fromChar); + updateMaxFont(lineHeightStyle); + }, + }); + /* eslint-enable no-loop-func */ + if (segmentConsumedUpTo >= text.length) { + continue; + } + } + // Find word break points for wrapping const wordBreaks = findWordBreaks(text, breakPolicy); - // Process text word by word - let charIndex = crossRunResume?.runIndex === runIndex ? crossRunResume.charIndex : 0; - if (crossRunResume?.runIndex === runIndex) { + // Process text word by word. When the segment-fit engine consumed the + // head of this run, the legacy walk resumes at that offset; otherwise it + // honours a pending cross-run hyphenation resume. + let charIndex: number; + if (segmentConsumedUpTo > 0) { + charIndex = segmentConsumedUpTo; + } else if (crossRunResume?.runIndex === runIndex) { + charIndex = crossRunResume.charIndex; crossRunResume = undefined; + } else { + charIndex = 0; } let wordBreakIndex = 0; let activeHyphenationWord: diff --git a/packages/core/src/layout-engine/measure/segmentFit.test.ts b/packages/core/src/layout-engine/measure/segmentFit.test.ts new file mode 100644 index 00000000..5bbf9caf --- /dev/null +++ b/packages/core/src/layout-engine/measure/segmentFit.test.ts @@ -0,0 +1,254 @@ +import { afterEach, describe, expect, test } from "bun:test"; + +import type { ParagraphBlock, TextRun } from "../types"; +import { fixedCharWidth, withFakeTextMeasure } from "./__tests__/fakeTextMeasure"; +import { clearAllCaches } from "./cache"; +import { setFolioMeasurementFlags } from "./featureFlags"; +import { measureParagraph } from "./measureParagraph"; +import { + isSegmentFitActive, + resetSegmentFitEngine, + runSegmentFitWalk, + setSegmentFitEngine, + styleSupportsSegmentFit, + type SegmentFitEngine, + type SegmentFitLine, +} from "./segmentFit"; + +const fakeMeasure = { charWidth: fixedCharWidth(5) }; +const CHAR_W = 5; + +function textRun(text: string, extra?: Partial): TextRun { + return { kind: "text", text, fontFamily: "Stub", fontSize: 12, ...extra }; +} + +function para(runs: TextRun[], attrs?: ParagraphBlock["attrs"]): ParagraphBlock { + return { kind: "paragraph", id: "p1", runs, ...(attrs ? { attrs } : {}) }; +} + +/** Space-splitting engine sharing the fixed 5px/char math. Records calls. */ +function makeFakeEngine() { + const calls: { prepares: string[] } = { prepares: [] }; + const engine: SegmentFitEngine = { + prepare(text: string) { + calls.prepares.push(text); + return { text }; + }, + fitLine(prepared, cursor, maxWidth): SegmentFitLine | null { + const { text } = prepared as { text: string }; + const start = (cursor as number | null) ?? 0; + if (start >= text.length) { + return null; + } + // Greedy words; a line's trailing break-space hangs (fit width + // excludes it), mirroring folio's legacy trim + pretext behaviour. + let end = start; + for (;;) { + let next = text.indexOf(" ", end); + next = next === -1 ? text.length : next + 1; + const fitWidth = (next - start) * CHAR_W - (text[next - 1] === " " ? CHAR_W : 0); + if (fitWidth > maxWidth) { + break; + } + end = next; + if (end >= text.length) { + break; + } + } + if (end === start) { + return null; // overlong word: refuse, core decides + } + return { endChar: end, width: (end - start) * CHAR_W, cursor: end }; + }, + }; + return { engine, calls }; +} + +afterEach(() => { + resetSegmentFitEngine(); + setFolioMeasurementFlags(undefined); +}); + +describe("segmentFit registry + gates", () => { + test("inactive without the feature flag even when an engine is installed", () => { + setSegmentFitEngine(makeFakeEngine().engine); + expect(isSegmentFitActive()).toBe(false); + setFolioMeasurementFlags({ segmentFitLineBreaking: true }); + expect(isSegmentFitActive()).toBe(true); + }); + + test("style allowlist declines letterSpacing, eastAsia fonts, transforms, scaling", () => { + expect(styleSupportsSegmentFit({ fontFamily: "X", fontSize: 12 })).toBe(true); + expect(styleSupportsSegmentFit({ fontVariant: "small-caps" })).toBe(true); + expect(styleSupportsSegmentFit({ letterSpacing: 0.5 })).toBe(false); + expect(styleSupportsSegmentFit({ eastAsiaFontFamily: "SimSun" })).toBe(false); + expect(styleSupportsSegmentFit({ textTransform: "uppercase" })).toBe(false); + expect(styleSupportsSegmentFit({ horizontalScale: 150 })).toBe(false); + expect(styleSupportsSegmentFit({ horizontalScale: 100 })).toBe(true); + }); + + test("runSegmentFitWalk consults supportsText before prepare", () => { + const { engine, calls } = makeFakeEngine(); + setSegmentFitEngine({ ...engine, supportsText: () => false }); + const consumed = runSegmentFitWalk("hello", "16px X", { + spaceLeft: () => 100, + lineHasContent: () => false, + commit: () => {}, + wrap: () => {}, + }); + expect(consumed).toBe(0); + expect(calls.prepares.length).toBe(0); + }); +}); + +describe("measureParagraph segment-fit wiring", () => { + test("flag off: registered engine is never consulted", () => { + withFakeTextMeasure(() => { + const { engine, calls } = makeFakeEngine(); + setSegmentFitEngine(engine); + measureParagraph(para([textRun("hello world wraps here")]), 50); + expect(calls.prepares.length).toBe(0); + }, fakeMeasure); + }); + + test("flag on: engine path matches legacy line breaks and heights for spaced text", () => { + withFakeTextMeasure(() => { + const block = para([textRun("aaaa bbbb cccc dddd")]); + const legacy = measureParagraph(block, 50); + + setFolioMeasurementFlags({ segmentFitLineBreaking: true }); + const { engine, calls } = makeFakeEngine(); + setSegmentFitEngine(engine); + const seg = measureParagraph(block, 50); + + expect(calls.prepares).toEqual(["aaaa bbbb cccc dddd"]); + expect(seg.lines.map((l) => [l.fromChar, l.toChar])).toEqual( + legacy.lines.map((l) => [l.fromChar, l.toChar]), + ); + expect(seg.lines.map((l) => l.width)).toEqual(legacy.lines.map((l) => l.width)); + expect(seg.totalHeight).toBeCloseTo(legacy.totalHeight, 4); + }, fakeMeasure); + }); + + test("justified paragraphs bypass the engine (shrink-tolerance stays legacy)", () => { + withFakeTextMeasure(() => { + setFolioMeasurementFlags({ segmentFitLineBreaking: true }); + const { engine, calls } = makeFakeEngine(); + setSegmentFitEngine(engine); + measureParagraph(para([textRun("justify me across lines")], { alignment: "justify" }), 50); + expect(calls.prepares.length).toBe(0); + }, fakeMeasure); + }); + + test("automatic-hyphenation paragraphs bypass the engine (mid-word breaks stay legacy)", () => { + withFakeTextMeasure(() => { + // The seam fits by whole segments; automatic hyphenation inserts breaks + // INSIDE a word, which a space-fit engine cannot reproduce. Such runs + // must stay on the legacy walk or line breaks would diverge. + const mk = () => + para([textRun("hyphenationworthy longwords everywhere")], { + automaticHyphenation: { enabled: true }, + }); + const legacy = measureParagraph(mk(), 40); + + setFolioMeasurementFlags({ segmentFitLineBreaking: true }); + const { engine, calls } = makeFakeEngine(); + setSegmentFitEngine(engine); + const seg = measureParagraph(mk(), 40); + + expect(calls.prepares.length).toBe(0); + expect(seg.lines.map((l) => [l.fromChar, l.toChar])).toEqual( + legacy.lines.map((l) => [l.fromChar, l.toChar]), + ); + }, fakeMeasure); + }); + + test("letterSpacing runs bypass the engine but measure identically to legacy", () => { + withFakeTextMeasure(() => { + const mk = () => para([textRun("spaced out text", { letterSpacing: 1 })]); + const legacy = measureParagraph(mk(), 40); + + setFolioMeasurementFlags({ segmentFitLineBreaking: true }); + const { engine, calls } = makeFakeEngine(); + setSegmentFitEngine(engine); + const seg = measureParagraph(mk(), 40); + + expect(calls.prepares.length).toBe(0); + expect(seg.lines.map((l) => [l.fromChar, l.toChar])).toEqual( + legacy.lines.map((l) => [l.fromChar, l.toChar]), + ); + }, fakeMeasure); + }); + + test("run-tail glue candidates (#991) bypass the engine for the glued run", () => { + withFakeTextMeasure(() => { + setFolioMeasurementFlags({ segmentFitLineBreaking: true }); + const { engine, calls } = makeFakeEngine(); + setSegmentFitEngine(engine); + // Run 1 ends without a break char and run 2 starts glued: the wrap + // decision for run 1's tail needs cross-run lookahead the engine + // cannot see, so run 1 must stay legacy. Run 2 has no glue tail and + // may use the engine. + const block = para([textRun("word glued"), textRun("Tail more words here")]); + measureParagraph(block, 50); + expect(calls.prepares).toEqual(["Tail more words here"]); + }, fakeMeasure); + }); + + test("engine refusing an overlong token falls back to legacy hard-breaking identically", () => { + withFakeTextMeasure(() => { + const token = "x".repeat(30); + const legacy = measureParagraph(para([textRun(token)]), 50); + + setFolioMeasurementFlags({ segmentFitLineBreaking: true }); + setSegmentFitEngine(makeFakeEngine().engine); + const seg = measureParagraph(para([textRun(token)]), 50); + + expect(seg.lines.map((l) => [l.fromChar, l.toChar])).toEqual( + legacy.lines.map((l) => [l.fromChar, l.toChar]), + ); + }, fakeMeasure); + }); +}); + +describe("eastAsia dual-font bypass (folio PR #2 review)", () => { + test("a run with eastAsiaFontFamily measures legacy-identically and never reaches the engine", () => { + withFakeTextMeasure(() => { + const mk = () => para([textRun("中文 text mixed 內容", { eastAsiaFontFamily: "SimSun" })]); + const legacy = measureParagraph(mk(), 50); + + setFolioMeasurementFlags({ segmentFitLineBreaking: true }); + const { engine, calls } = makeFakeEngine(); + setSegmentFitEngine(engine); + const seg = measureParagraph(mk(), 50); + + expect(calls.prepares.length).toBe(0); + expect(seg.lines.map((l) => [l.fromChar, l.toChar])).toEqual( + legacy.lines.map((l) => [l.fromChar, l.toChar]), + ); + }, fakeMeasure); + }); + + test("clearAllCaches drops the installed engine prepared state", () => { + let cleared = 0; + const { engine } = makeFakeEngine(); + setSegmentFitEngine({ + ...engine, + clearCaches: () => { + cleared += 1; + }, + }); + clearAllCaches(); + expect(cleared).toBe(1); + }); + + test("empty text never reaches prepare", () => { + withFakeTextMeasure(() => { + setFolioMeasurementFlags({ segmentFitLineBreaking: true }); + const { engine, calls } = makeFakeEngine(); + setSegmentFitEngine(engine); + measureParagraph(para([textRun("")]), 50); + expect(calls.prepares.length).toBe(0); + }, fakeMeasure); + }); +}); diff --git a/packages/core/src/layout-engine/measure/segmentFit.ts b/packages/core/src/layout-engine/measure/segmentFit.ts new file mode 100644 index 00000000..c52003c8 --- /dev/null +++ b/packages/core/src/layout-engine/measure/segmentFit.ts @@ -0,0 +1,204 @@ +/** + * Segment-based line fitting seam (premirror port, adapted to folio's + * measurement idioms). + * + * Lineage / credit (moral duty, not just license): the segment-fit design — + * prepare a text once, then fit lines by arithmetic — is `@chenglou/pretext`'s. + * The premirror line this seam serves downstream (`@stll/premirror-bridge`, + * `@premirror/*`) descends from `samwillis/premirror` (MIT © 2026 Sam Willis); + * those ported packages carry his copyright header and LICENSE. This file is + * folio-core's own seam — the pluggable extension point — so no concrete engine + * (and therefore no pretext / premirror code) is imported here. + * + * The legacy text walk in `measureParagraph` fits words one canvas + * measurement at a time and hard-breaks overlong words by binary-searching + * `measureTextWidth(text.slice(0, mid))` probes (`findMaxFittingLength`) — + * the probe slices are unique strings, so they pollute the width cache and + * defeat it (the `workerFontMetrics` prewarm flag exists to soften exactly + * this). A segment-fit engine replaces that inner loop for plain text runs + * with a prepare-once model: segments are measured once per (text, font) + * and lines are then fitted by pure arithmetic (pretext's design). + * + * Folio idioms, deliberately mirrored from `measureProvider`: + * - the engine is a swappable registry (`setSegmentFitEngine` / + * `resetSegmentFitEngine`); core never imports a concrete engine — + * `@stll/premirror-bridge` installs a `@chenglou/pretext`-backed one; + * - activation is a feature flag in the `globalThis.__folioFeatureFlags` + * bag (`segmentFitLineBreaking`), default OFF — see featureFlags.ts. + * + * NOTE (scope): the flag bag and this registry are process-global, like + * every other folio measurement flag — acceptable while experimental; a + * per-editor strategy needs options threading before any default flips. + * + * Scope guard: this seam replaces ONLY the per-run word walk for plain + * text runs. Tabs, fields, equations, images, floating zones, justified + * shrink tolerance, cross-run glue (#991), automatic hyphenation, and CJK + * dual-font runs stay on the legacy walk (see `styleSupportsSegmentFit` and + * the call-site admission gate in `measureParagraph`). + */ + +import { isSegmentFitLineBreakingEnabled } from "./featureFlags"; +import type { FontStyle } from "./measureTypes"; + +/** One fitted line piece returned by the engine. */ +export type SegmentFitLine = { + /** Exclusive end offset in the run's text (UTF-16 code units). */ + endChar: number; + /** + * Measured advance width of the fitted piece in px. MAY include a hung + * trailing space (pretext does); consumers must not assume either way — + * the commit path clamps derived trailing-whitespace widths to >= 0. + */ + width: number; + /** Opaque continuation cursor; pass back to `fitLine` for the next piece. */ + cursor: unknown; +}; + +/** A prepared (segmented + measured) text handle. Opaque to core. */ +export type SegmentFitPrepared = unknown; + +/** + * A pluggable line-fitting engine consumed by `measureParagraph` when the + * `segmentFitLineBreaking` flag is on. Implementations prepare a text once + * (segmenting + measuring) and then fit line pieces by arithmetic. + */ +export type SegmentFitEngine = { + /** + * Whether the engine can fit `text` with offsets that stay aligned to the + * ORIGINAL string. Engines that normalize input before segmenting (e.g. + * pretext rewrites \r\n, \r, \f) must decline such texts here — otherwise + * returned end offsets would drift against the run text the painter and + * click mapping slice. Declined runs measure through the legacy walk. + */ + supportsText?: (text: string) => boolean; + /** + * Prepare `text` for fitting under the given CSS font string (the exact + * string `buildFontString(style)` produces, so both strategies measure + * with identical canvas font state). Implementations should cache. + */ + prepare: (text: string, cssFont: string) => SegmentFitPrepared; + /** + * Fit the next line piece starting at `cursor` (null = start of text) + * into `maxWidth` px. Returns null when nothing fits (caller decides + * whether to wrap or hand the remainder to the legacy walk). `endChar` + * must advance strictly beyond the cursor position when a piece is + * returned. + */ + fitLine: ( + prepared: SegmentFitPrepared, + cursor: unknown | null, + maxWidth: number, + ) => SegmentFitLine | null; + /** + * Drop any prepared/measured state. Invoked via the measuring pipeline's + * `clearAllCaches()` so engine caches never outlive a font-environment + * change (web fonts finishing to load) that invalidates the canvas + * metrics the prepared widths were derived from. + */ + clearCaches?: () => void; +}; + +let activeEngine: SegmentFitEngine | null = null; + +export const setSegmentFitEngine = (engine: SegmentFitEngine): void => { + activeEngine = engine; +}; + +export const resetSegmentFitEngine = (): void => { + activeEngine = null; +}; + +export const getSegmentFitEngine = (): SegmentFitEngine | null => activeEngine; + +/** + * Invalidate the installed engine's prepared state. Wired into the + * measuring pipeline's `clearAllCaches()`. + */ +export const clearSegmentFitEngineCaches = (): void => { + activeEngine?.clearCaches?.(); +}; + +/** The seam is live only when BOTH the flag is on and an engine is installed. */ +export function isSegmentFitActive(): boolean { + return activeEngine !== null && isSegmentFitLineBreakingEnabled(); +} + +/** + * Whether a text run's style can be fitted by the engine without semantic + * loss. Explicit allowlist over FontStyle members (opt-in gates rot + * silently, so admit only styles proven engine-safe): + * - fontFamily, fontSize, bold, italic, fontVariant: carried by + * `buildFontString`, engine-safe; + * - letterSpacing: applied arithmetically outside the font string — decline; + * - eastAsiaFontFamily: dual-font per-script measurement — decline; + * - textTransform: legacy transforms text before measuring — decline; + * - horizontalScale: post-measure width multiplier — decline. + */ +export function styleSupportsSegmentFit(style: FontStyle): boolean { + return ( + !style.letterSpacing && + !style.eastAsiaFontFamily && + !style.textTransform && + (style.horizontalScale === undefined || style.horizontalScale === 100) + ); +} + +/** Callbacks the walk uses to talk to `measureParagraph`'s line state. */ +export type SegmentFitWalkHost = { + /** Current line's remaining budget (includes the caller's tolerance). */ + spaceLeft: () => number; + lineHasContent: () => boolean; + /** Commit a fitted piece to the current line (width + exclusive end char). */ + commit: (width: number, endChar: number) => void; + /** Start a new line at `fromChar` and re-apply the run's font metrics. */ + wrap: (fromChar: number) => void; +}; + +/** + * Drive the installed engine over one text run. Returns how many UTF-16 + * code units were consumed; a return < text.length means the engine + * declined or refused a piece on an empty line (e.g. an overlong token) + * and the caller's legacy walk must take over from that offset. + */ +export function runSegmentFitWalk(text: string, cssFont: string, host: SegmentFitWalkHost): number { + const engine = activeEngine; + if (!engine) { + return 0; + } + if (text.length === 0) { + return 0; + } + if (engine.supportsText && !engine.supportsText(text)) { + return 0; + } + const prepared = engine.prepare(text, cssFont); + let cursor: unknown | null = null; + let consumed = 0; + while (consumed < text.length) { + // Engines force-fit at least one grapheme per line (CSS behavior); + // never offer a used-up line, wrap it instead. + if (host.lineHasContent() && host.spaceLeft() <= 0) { + host.wrap(consumed); + continue; + } + const piece = engine.fitLine(prepared, cursor, host.spaceLeft()); + if (piece && piece.endChar > consumed) { + host.commit(piece.width, piece.endChar); + cursor = piece.cursor; + consumed = piece.endChar; + if (consumed < text.length) { + host.wrap(consumed); + } + continue; + } + if (host.lineHasContent()) { + // Nothing fits beside existing content: wrap and retry. + host.wrap(consumed); + continue; + } + // Engine refused on an empty line (overlong piece): hand the remainder + // of this run to the caller's legacy hard-breaking. + break; + } + return consumed; +} From 47002c5fc79a1e884069f81a658faab2c1e1252f Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 23 Jul 2026 22:13:12 -0300 Subject: [PATCH 2/2] feat(premirror-bridge): @chenglou/pretext-backed SegmentFitEngine + benchmarks New private (unpublished) folio plugin providing a pretext-backed engine for the folio-core segment-fit seam, with a frozen parity suite and benchmark harnesses. - pretextEngine.ts: prepare-once segmentation + measurement, pure-arithmetic line fitting; declines offset-unsafe (CR/FF) text. - pretextParity.test.ts: exact line-break/width parity vs the legacy walk on spaced/CJK/overlong/trailing-space text; frozen canvas-call profile. - bench/: deterministic per-paragraph micro-benchmark + real-browser corpus harness, with RESULTS.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- bun.lock | 15 + packages/premirror-bridge/.gitignore | 2 + packages/premirror-bridge/NOTICE | 21 ++ packages/premirror-bridge/README.md | 47 +++ packages/premirror-bridge/bench/.gitignore | 1 + packages/premirror-bridge/bench/RESULTS.md | 97 +++++ .../bench/measure-browser.mjs | 241 +++++++++++++ .../premirror-bridge/bench/measure-engine.mjs | 330 ++++++++++++++++++ packages/premirror-bridge/package.json | 22 ++ packages/premirror-bridge/src/index.ts | 19 + .../premirror-bridge/src/pretextEngine.ts | 167 +++++++++ .../src/pretextParity.test.ts | 181 ++++++++++ packages/premirror-bridge/tsconfig.json | 20 ++ 13 files changed, 1163 insertions(+) create mode 100644 packages/premirror-bridge/.gitignore create mode 100644 packages/premirror-bridge/NOTICE create mode 100644 packages/premirror-bridge/README.md create mode 100644 packages/premirror-bridge/bench/.gitignore create mode 100644 packages/premirror-bridge/bench/RESULTS.md create mode 100644 packages/premirror-bridge/bench/measure-browser.mjs create mode 100644 packages/premirror-bridge/bench/measure-engine.mjs create mode 100644 packages/premirror-bridge/package.json create mode 100644 packages/premirror-bridge/src/index.ts create mode 100644 packages/premirror-bridge/src/pretextEngine.ts create mode 100644 packages/premirror-bridge/src/pretextParity.test.ts create mode 100644 packages/premirror-bridge/tsconfig.json diff --git a/bun.lock b/bun.lock index cbb3c2b4..c75f77ac 100644 --- a/bun.lock +++ b/bun.lock @@ -149,6 +149,17 @@ "vue-tsc": "^3.3.7", }, }, + "packages/premirror-bridge": { + "name": "@stll/premirror-bridge", + "version": "0.1.0", + "dependencies": { + "@chenglou/pretext": "0.0.3", + "@stll/folio-core": "workspace:*", + }, + "devDependencies": { + "@types/bun": "1.3.14", + }, + }, "packages/react": { "name": "@stll/folio-react", "version": "0.12.2", @@ -320,6 +331,8 @@ "@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "human-id": "^4.1.1", "prettier": "^2.7.1" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="], + "@chenglou/pretext": ["@chenglou/pretext@0.0.3", "", {}, "sha512-RQmqMqUAPRCyv4R3LlRi/ao6KbNWYclqLA+V1HS7sWgyUUbjn3JmmlfXZSY/BjM4rbmIaMSyIVisYocYGYftiQ=="], + "@clack/core": ["@clack/core@1.4.3", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ=="], "@clack/prompts": ["@clack/prompts@1.7.0", "", { "dependencies": { "@clack/core": "1.4.3", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A=="], @@ -814,6 +827,8 @@ "@stll/playground-vue": ["@stll/playground-vue@workspace:packages/playground-vue"], + "@stll/premirror-bridge": ["@stll/premirror-bridge@workspace:packages/premirror-bridge"], + "@stll/template-conditions": ["@stll/template-conditions@0.1.0", "", { "dependencies": { "@stll/conditions": "^0.1.0", "better-result": "2.9.2" } }, "sha512-GkgzEDiSqpzG3GexlOfVXHgYiQSME8osQIZ645F9ooQbvDcsTVX5YVpfvmPwe3EOhRol6WjEyO2t/39+hNvQsg=="], "@tailwindcss/cli": ["@tailwindcss/cli@4.3.3", "", { "dependencies": { "@parcel/watcher": "2.5.1", "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "enhanced-resolve": "^5.24.1", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.3.3" }, "bin": { "tailwindcss": "./dist/index.mjs" } }, "sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw=="], diff --git a/packages/premirror-bridge/.gitignore b/packages/premirror-bridge/.gitignore new file mode 100644 index 00000000..ff2c5856 --- /dev/null +++ b/packages/premirror-bridge/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +*.tsbuildinfo diff --git a/packages/premirror-bridge/NOTICE b/packages/premirror-bridge/NOTICE new file mode 100644 index 00000000..cab5cee2 --- /dev/null +++ b/packages/premirror-bridge/NOTICE @@ -0,0 +1,21 @@ +@stll/premirror-bridge +======================= + +This package is folio-side glue between folio-core's measurement seam and the +pretext/premirror text-layout stack. It contains no verbatim third-party source, +but it exists to serve, and descends in design from, the following works. Credit +is given here as a moral duty, not merely to satisfy a license file. + +- @chenglou/pretext (MIT) — the segment-fit layout design and arithmetic this + bridge's engine wraps (prepare-once segmentation + measurement, then pure-math + line fitting). Copyright (c) chenglou. + https://www.npmjs.com/package/@chenglou/pretext + +- samwillis/premirror (MIT © 2026 Sam Willis) — the premirror line this bridge + belongs to. Improvements worth upstreaming are offered back as pull requests. + https://github.com/samwillis/premirror + +This package itself is licensed Apache-2.0, consistent with the STLL/folio +project (see package.json). Apache-2.0 is compatible with building on the +MIT-licensed works credited above; their MIT terms continue to govern those +upstream sources. diff --git a/packages/premirror-bridge/README.md b/packages/premirror-bridge/README.md new file mode 100644 index 00000000..ee1003fe --- /dev/null +++ b/packages/premirror-bridge/README.md @@ -0,0 +1,47 @@ +# @stll/premirror-bridge + +A **separately-versioned folio plugin**: a +[`@chenglou/pretext`](https://www.npmjs.com/package/@chenglou/pretext)-backed +`SegmentFitEngine` for folio-core's measurement seam, with a frozen parity +suite against folio's legacy word-walk. + +It depends only on `@stll/folio-core` (the seam, shipped in +`layout-engine/measure/segmentFit.ts`) plus its own third-party dep, +`@chenglou/pretext` — the only package in this workspace allowed to import it +(the one-pretext-surface invariant). It is vendored as a folio workspace member +(`packages/premirror-bridge`), resolved via `workspace:*` instead of the former +machine-local `bun link`. + +## Usage + +```ts +import { pretextSegmentFitEngine } from "@stll/premirror-bridge"; +import { setSegmentFitEngine } from "@stll/folio-core/layout-engine/measure/segmentFit"; + +setSegmentFitEngine(pretextSegmentFitEngine); +globalThis.__folioFeatureFlags = { segmentFitLineBreaking: true }; +``` + +With the flag off or the engine not installed, folio measures exactly as before +(the seam is dormant). Turning it on routes plain-text line fitting through +pretext's prepare-once/fit-by-arithmetic model. + +## Dependency pin (workspace vs. release) + +Inside this monorepo the package resolves `@stll/folio-core` via +`workspace:*`. If it is ever published standalone, replace the workspace range +with an exact version pin of the folio-core release that carries the +segment-fit seam. That is the only ship-time change; the source does not +move. + +## Scope + +This package is E-2: the pretext engine + parity harness. The composer golden +fixtures that pull `@premirror/*` belong to E-3 (the `@premirror/*` rebase) and +are intentionally not included here — keeping E-2's dependency surface to +`@chenglou/pretext` + `@stll/folio-core` only. + +## Credit + +See [NOTICE](./NOTICE). The engine wraps `@chenglou/pretext` (MIT); the bridge +belongs to the premirror line, `samwillis/premirror` (MIT © 2026 Sam Willis). diff --git a/packages/premirror-bridge/bench/.gitignore b/packages/premirror-bridge/bench/.gitignore new file mode 100644 index 00000000..fd2b823a --- /dev/null +++ b/packages/premirror-bridge/bench/.gitignore @@ -0,0 +1 @@ +measure-engine-*.json diff --git a/packages/premirror-bridge/bench/RESULTS.md b/packages/premirror-bridge/bench/RESULTS.md new file mode 100644 index 00000000..3f235f2b --- /dev/null +++ b/packages/premirror-bridge/bench/RESULTS.md @@ -0,0 +1,97 @@ +# Segment-fit measurement: benchmark results + +The segment-fit seam routes plain-text line breaking through a prepare-once +segmentation + pure-arithmetic line fitter (`@chenglou/pretext`) instead of the +legacy word-walk's per-call word re-measurement and `findMaxFittingLength` +slice-probe binary search. The metric that matters is the number of canvas +`measureText` calls: real-canvas `measureText` (font shaping) is the expensive +operation, and avoiding it is the point of the seam. + +Three harnesses, from most deterministic to most realistic. + +## 1. Deterministic parity (frozen in `src/pretextParity.test.ts`) + +Both engines measured through a fixed 5px/char fake canvas (linear, kerning-free +— widths agree by construction, so any divergence is algorithmic). Frozen, +probe-verified: + +| case | legacy calls | segment-fit calls | +| --- | ---: | ---: | +| first-pass 100-word paragraph (w120) | 199 | 127 | +| overlong 400-char token (w50) | 82 | 3 | +| repeat measure (both paths) | 0 | 0 | + +Line-break and width parity is **exact** on spaced text, trailing-space edges, +overlong tokens, and space-less CJK. The engine declines offset-unsafe text +(CR/FF) and measures legacy-identically there. + +## 2. Cold per-paragraph micro-benchmark (`bench/measure-engine.mjs`) + +Real `measureParagraph` hot path, deterministic fake canvas, cache cleared before +each measure (true first-paint), warmup dropped, 200 reps. Cold canvas +`measureText` calls per paragraph, legacy vs segment-fit: + +| archetype | width | legacy | segment-fit | saved | +| --- | ---: | ---: | ---: | ---: | +| prose 100w | 120 | 201 | 38 | 81% | +| prose 100w | 600 | 201 | 8 | 96% | +| prose 400w | 120 | 801 | 163 | 80% | +| overlong 400c | 120 | 55 | 1 | 98% | +| CJK 120c | any | 23 | 1 | 96% | +| mixed 16-run paragraph | any | ~196 | ~186 | ~5% | + +Warm (steady-state) re-measurement: **0 extra calls on both paths** — folio's +width cache already covers repeats, so the honest win is first-pass and +changed-text measurement, not steady state. Multi-run paragraphs barely benefit +(the cross-run word walk is unchanged). + +Run: `bun bench/measure-engine.mjs` + +## 3. Real-browser corpus (`bench/measure-browser.mjs`) + +The synthetic single-paragraph reductions above do **not** transfer uniformly to +real documents. This harness loads real `.docx` into the actual editor in real +headless Chromium with real fonts (Carlito/Arimo), a fresh browser context per +(document, engine) so the measure caches start cold, and patches the **real** +`CanvasRenderingContext2D.measureText` to count calls. It compares the pretext +path (default) against the legacy walk (`?segmentfit=off`). + +A corpus of 500 real `.docx` (2 KB – 884 KB, median ~12 KB), 492 paired cleanly: + +| metric | result | +| --- | ---: | +| aggregate canvas `measureText` calls | 92,129 → 84,517 (**8.3% fewer**) | +| documents with fewer calls | 166 / 492 (34%) | +| documents unchanged | 280 (57%) | +| documents +1 call (noise) | 46 | +| per-document reduction, median (all docs) | 0% | +| per-document reduction, median (among the 166 it engages) | ~87% (up to 97%) | + +Engagement is concentrated in small, simple, plain-text documents: + +| size | docs | engaged | calls saved | +| --- | ---: | ---: | ---: | +| < 5 KB | 104 | 98% | 82.6% | +| 5–15 KB | 261 | 10% | 4.7% | +| 15–50 KB | 83 | 23% | 1.2% | +| > 50 KB | 44 | 41% | 7.4% | + +**Layout parity (correctness):** 490 / 492 documents paginate to an identical +page count with the seam on vs off. **2 large documents shifted by one page.** +This is the seam's documented break-rule divergence from the legacy walk +(trailing whitespace hangs past the line edge; CJK/Thai breaks between +characters) — both OOXML/CSS-defensible on the segment-fit side, but not +byte-identical to the legacy walk on those two documents. + +Run (needs a served playground on `:4200` and a TSV of `sizepath` rows): +`FIXTURES=corpus.tsv bun bench/measure-browser.mjs` + +## Reading + +Where the seam engages it is a large win (80–98% fewer measurement calls on the +paragraphs the legacy walk re-measures word-by-word or slice-probes), it is a +no-op at steady state, and its cost when it does not help is +1 call. The +aggregate real-document win is modest (~8%) because most real paragraphs are +short or otherwise ineligible, but the win is real, concentrated, and never +negative beyond noise. The two one-page pagination shifts are the one behavior +change to weigh against enabling it by default. diff --git a/packages/premirror-bridge/bench/measure-browser.mjs b/packages/premirror-bridge/bench/measure-browser.mjs new file mode 100644 index 00000000..efc694f7 --- /dev/null +++ b/packages/premirror-bridge/bench/measure-browser.mjs @@ -0,0 +1,241 @@ +// REAL measure benchmark: loads real .docx fixtures into the actual folio +// editor in real Chromium with real fonts, and measures the REAL +// CanvasRenderingContext2D.measureText cost (call count + cumulative wall-time +// spent inside font measurement) during a COLD full-document layout, A/B-ing +// the pretext SegmentFitEngine (flag ON, "/") against the legacy word-walk +// (flag OFF, "/?segmentfit=off" == what upstream folio ships). +// +// No fake canvas. No synthetic paragraphs. Each (fixture, engine) runs in a +// FRESH browser context => the folio measure caches start cold (module state is +// per-realm), which is exactly how production opens a document. +// +// The docx is delivered via the editor's own file-open path (#file-input -> +// file.arrayBuffer() -> parse -> layout), so nothing is stubbed. measureText is +// patched via addInitScript before any page script runs; counters are reset +// after the initial (tiny default-doc) mount, so the numbers reflect only the +// uploaded document's layout. +// +// Usage: +// FIXTURES=/tmp/nb_500_clean.tsv OUT=... bun bench-measure-browser.mjs +// BASE=http://localhost:4200 LIMIT=1 bun bench-measure-browser.mjs # smoke +import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; + +import { chromium } from "@playwright/test"; + +const BASE = process.env.BASE ?? "http://localhost:4200"; +const FIXTURES = process.env.FIXTURES ?? "/tmp/nb_500_clean.tsv"; +const LIMIT = process.env.LIMIT ? Number(process.env.LIMIT) : Infinity; +const OUT = process.env.OUT ?? "./measure-browser.jsonl"; +const SUMMARY = process.env.SUMMARY ?? OUT.replace(/\.jsonl$/, ".summary.json"); +const SETTLE_POLL_MS = 100; +const SETTLE_STABLE = 3; // consecutive equal page counts => layout settled +const LOAD_TIMEOUT_MS = Number(process.env.LOAD_TIMEOUT_MS ?? 60000); + +const PATCH = () => { + const proto = CanvasRenderingContext2D.prototype; + const orig = proto.measureText; + window.__mtN = 0; + window.__mtMs = 0; + proto.measureText = function patched(text) { + const a = performance.now(); + const r = orig.call(this, text); + window.__mtMs += performance.now() - a; + window.__mtN += 1; + return r; + }; + window.__mtReset = () => { + window.__mtN = 0; + window.__mtMs = 0; + }; +}; + +const ENGINES = [ + ["pretext", "/"], + ["legacy", "/?segmentfit=off"], +]; + +const loadFixture = async (browser, name, filePath, engineName, urlPath) => { + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + await context.addInitScript(PATCH); + const page = await context.newPage(); + const row = { name, filePath, engine: engineName }; + const errors = []; + page.on("pageerror", (e) => errors.push(String(e).slice(0, 160))); + try { + await page.goto(`${BASE}${urlPath}`, { waitUntil: "load", timeout: LOAD_TIMEOUT_MS }); + // editor mounted + test hook installed + initial (default doc) layout done + await page.waitForFunction(() => typeof globalThis.__folioPlayground?.getEditorRef === "function", null, { + timeout: LOAD_TIMEOUT_MS, + }); + await page.waitForSelector('[data-testid="folio-editor"]', { timeout: LOAD_TIMEOUT_MS }).catch(() => {}); + // confirm the flag state actually matches what we intend + row.flagOn = await page.evaluate(() => globalThis.__folioFeatureFlags?.segmentFitLineBreaking === true); + + // reset the measureText counters AFTER the default-doc mount so we capture + // only the uploaded document's cold layout. + await page.evaluate(() => globalThis.__mtReset?.()); + const t0 = Date.now(); + await page.setInputFiles("#file-input", filePath); + + // wait for layout to settle: getTotalPages() > 0 and stable for N polls, + // or a page-level parse error surfaces. + let stable = 0; + let last = -1; + let pages = 0; + const deadline = Date.now() + LOAD_TIMEOUT_MS; + while (Date.now() < deadline) { + pages = await page.evaluate(() => globalThis.__folioPlayground?.getEditorRef?.()?.getTotalPages?.() ?? 0); + if (pages > 0 && pages === last) { + stable += 1; + if (stable >= SETTLE_STABLE) break; + } else { + stable = 0; + } + last = pages; + await page.waitForTimeout(SETTLE_POLL_MS); + } + row.wallMs = Date.now() - t0; + row.pages = pages; + const mt = await page.evaluate(() => ({ n: globalThis.__mtN ?? 0, ms: globalThis.__mtMs ?? 0 })); + row.measureTextCalls = mt.n; + row.measureTextMs = Math.round(mt.ms * 100) / 100; + row.outcome = pages > 0 ? "ok" : "no-pages"; + } catch (error) { + row.outcome = "error"; + row.error = String(error).split("\n")[0].slice(0, 200); + } finally { + if (errors.length) row.pageErrors = errors.slice(0, 2); + await context.close().catch(() => {}); + } + return row; +}; + +// ---- stats ---------------------------------------------------------------- +const quantile = (s, q) => { + if (!s.length) return null; + const p = (s.length - 1) * q; + const lo = Math.floor(p); + const hi = Math.ceil(p); + return lo === hi ? s[lo] : s[lo] + (s[hi] - s[lo]) * (p - lo); +}; +const stats = (vals) => { + const n = vals.filter((v) => Number.isFinite(v)).sort((a, b) => a - b); + if (!n.length) return null; + const sum = n.reduce((a, b) => a + b, 0); + return { + n: n.length, + sum: Math.round(sum), + mean: Math.round((sum / n.length) * 100) / 100, + p50: Math.round(quantile(n, 0.5) * 100) / 100, + p95: Math.round(quantile(n, 0.95) * 100) / 100, + p99: Math.round(quantile(n, 0.99) * 100) / 100, + max: Math.round(n.at(-1) * 100) / 100, + }; +}; + +// ---- main ----------------------------------------------------------------- +const fixtures = readFileSync(FIXTURES, "utf8") + .trim() + .split("\n") + .map((line, i) => { + const [sz, path] = line.split("\t"); + return { name: `nb_${String(i).padStart(4, "0")}`, size: Number(sz), path }; + }) + .slice(0, LIMIT); + +writeFileSync(OUT, ""); +const browser = await chromium.launch({ headless: true }); +const rows = []; +let done = 0; + +// Concurrency pool. The headline metric (measureText CALL COUNT) is +// deterministic per (doc, engine) and unaffected by CPU contention, so running +// CONC docs in parallel is safe for it; measureTextMs is wall-time and under +// load is only indicative (we treat call count as the real signal, and the +// paired call counts are what the conclusion rests on). +const CONC = Number(process.env.CONC ?? 5); +const oneDoc = async (fx) => { + const perEngine = {}; + for (const [engineName, urlPath] of ENGINES) { + const row = await loadFixture(browser, fx.name, fx.path, engineName, urlPath); + row.size = fx.size; + rows.push(row); + appendFileSync(OUT, `${JSON.stringify(row)}\n`); + perEngine[engineName] = row; + } + done += 1; + const p = perEngine.pretext; + const l = perEngine.legacy; + console.error( + `[${done}/${fixtures.length}] ${fx.name} (${fx.size}B) pages=${l?.pages ?? "?"} ` + + `calls L=${l?.measureTextCalls ?? "?"} P=${p?.measureTextCalls ?? "?"}`, + ); +}; + +const queue = [...fixtures]; +const worker = async () => { + while (queue.length) { + const fx = queue.shift(); + if (fx) await oneDoc(fx); + } +}; +await Promise.all(Array.from({ length: CONC }, () => worker())); +await browser.close(); + +// ---- aggregate ------------------------------------------------------------ +const byEngine = {}; +for (const eng of ["legacy", "pretext"]) { + const er = rows.filter((r) => r.engine === eng && r.outcome === "ok"); + byEngine[eng] = { + docs: er.length, + measureTextCalls: stats(er.map((r) => r.measureTextCalls)), + measureTextMs: stats(er.map((r) => r.measureTextMs)), + pages: stats(er.map((r) => r.pages)), + }; +} +// paired per-doc deltas (docs where both engines produced pages) +const paired = []; +const byName = new Map(); +for (const r of rows) byName.set(`${r.name}:${r.engine}`, r); +for (const fx of fixtures) { + const l = byName.get(`${fx.name}:legacy`); + const p = byName.get(`${fx.name}:pretext`); + if (l?.outcome === "ok" && p?.outcome === "ok" && l.measureTextCalls > 0) { + paired.push({ + name: fx.name, + size: fx.size, + callsL: l.measureTextCalls, + callsP: p.measureTextCalls, + callRedPct: 1 - p.measureTextCalls / l.measureTextCalls, + mtMsL: l.measureTextMs, + mtMsP: p.measureTextMs, + pagesMatch: l.pages === p.pages, + }); + } +} +const summary = { + harness: "measure-browser", + base: BASE, + fixtures: fixtures.length, + paired: paired.length, + pageMismatches: paired.filter((x) => !x.pagesMatch).length, + byEngine, + totals: { + legacyCalls: byEngine.legacy.measureTextCalls?.sum, + pretextCalls: byEngine.pretext.measureTextCalls?.sum, + callReductionPct: + byEngine.legacy.measureTextCalls && byEngine.pretext.measureTextCalls + ? Math.round((1 - byEngine.pretext.measureTextCalls.sum / byEngine.legacy.measureTextCalls.sum) * 1000) / 10 + : null, + legacyMtMs: byEngine.legacy.measureTextMs?.sum, + pretextMtMs: byEngine.pretext.measureTextMs?.sum, + mtMsReductionPct: + byEngine.legacy.measureTextMs && byEngine.pretext.measureTextMs + ? Math.round((1 - byEngine.pretext.measureTextMs.sum / byEngine.legacy.measureTextMs.sum) * 1000) / 10 + : null, + }, + perDocCallReduction: stats(paired.map((x) => x.callRedPct * 100)), +}; +writeFileSync(SUMMARY, `${JSON.stringify(summary, null, 2)}\n`); +console.log(JSON.stringify(summary, null, 2)); +console.error(`\nrows: ${OUT}\nsummary: ${SUMMARY}`); diff --git a/packages/premirror-bridge/bench/measure-engine.mjs b/packages/premirror-bridge/bench/measure-engine.mjs new file mode 100644 index 00000000..210d4dff --- /dev/null +++ b/packages/premirror-bridge/bench/measure-engine.mjs @@ -0,0 +1,330 @@ +// Comprehensive micro-benchmark for folio's paragraph MEASURE hot path +// (`measureParagraph`), A/B-ing the pretext SegmentFitEngine (flag ON) against +// the legacy word-walk (flag OFF == what upstream folio ships). +// +// WHY this harness and not the browser ones: +// - bench-redline3-stats.mjs measures the whole redline3 app end-to-end. +// - tests/perf/segmentfit-baseline.mjs measures WARM full-document relayout, +// where its own note says pretext is expected to be at parity (folio's +// width cache already covers repeat measurement). +// The pretext win is COLD-cache / first-pass / changed-text measurement, and +// the honest, machine-independent proxy for real font-measurement cost is the +// number of canvas `measureText` calls (real canvas measureText is the +// expensive op; pretext's whole purpose is to not call it). This harness +// drives the real hot path headlessly through the deterministic fake canvas +// (which counts calls AND whose per-call cost scales with string length, so +// wall-time is a faithful relative signal too). +// +// Metrics per (archetype x width x engine): +// - coldCalls : mean canvas measureText calls for a FRESH paragraph +// (unique text every rep == "you just edited this line") +// - coldMs : wall-time distribution (p50/p95/p99/mean/sd) of one cold +// measure, warmup dropped +// - warmCalls : calls when re-measuring the SAME text (cache steady state) +// - opsPerSec : 1000 / mean(coldMs) +// Plus a document-scale macro: measure a K-paragraph document cold, total ms +// and total canvas calls (the "open / full relayout" cost that users feel). +// +// Modes: +// MODE=full (default) : run legacy AND pretext (needs @stll/premirror-bridge) +// MODE=legacy : run legacy only (works on an upstream checkout that +// has no segment-fit engine at all) +// +// Usage: +// bun bench-measure-engine.mjs +// MODE=legacy LABEL=upstream bun bench-measure-engine.mjs +// M=400 WARMUP=25 WIDTHS=50,120,300,600 DOC_PARAS=500 bun bench-measure-engine.mjs +import { writeFileSync } from "node:fs"; + +// ---- config --------------------------------------------------------------- +const MODE = process.env.MODE ?? "full"; +const LABEL = process.env.LABEL ?? (MODE === "legacy" ? "legacy-only" : "ours"); +const M = Number(process.env.M ?? 200); // cold measure reps per cell +const WARMUP = Number(process.env.WARMUP ?? 15); +const WARM_REPS = Number(process.env.WARM_REPS ?? 200); +const WIDTHS = (process.env.WIDTHS ?? "50,120,300,600").split(",").map(Number); +const DOC_PARAS = Number(process.env.DOC_PARAS ?? 500); +const DOC_REPS = Number(process.env.DOC_REPS ?? 5); +const DOC_WIDTH = Number(process.env.DOC_WIDTH ?? 600); +const OUT = process.env.OUT ?? `./measure-engine-${LABEL}.json`; + +// ---- dynamic imports (legacy mode never touches segment-fit / bridge) ----- +const measureMod = await import("@stll/folio-core/layout-engine/measure/measureParagraph"); +const fakeMod = await import( + "@stll/folio-core/layout-engine/measure/__tests__/fakeTextMeasure" +); +const flagsMod = await import("@stll/folio-core/layout-engine/measure/featureFlags"); +const cacheMod = await import("@stll/folio-core/layout-engine/measure/cache"); +const { measureParagraph } = measureMod; +const { clearAllCaches } = cacheMod; // reset the per-word/per-slice width cache => true cold +const { withFakeTextMeasure, uppercaseAwareCharWidth } = fakeMod; +const { setFolioMeasurementFlags } = flagsMod; + +let segmentMod = null; +let pretextEngine = null; +let clearPreparedCache = () => {}; +if (MODE !== "legacy") { + segmentMod = await import("@stll/folio-core/layout-engine/measure/segmentFit"); + const bridge = await import("@stll/premirror-bridge"); + pretextEngine = bridge.pretextSegmentFitEngine; + clearPreparedCache = bridge.clearPreparedCache; +} + +// ---- engine toggles ------------------------------------------------------- +const useLegacy = () => { + setFolioMeasurementFlags(undefined); + segmentMod?.resetSegmentFitEngine(); + clearPreparedCache(); +}; +const usePretext = () => { + setFolioMeasurementFlags({ segmentFitLineBreaking: true }); + segmentMod.setSegmentFitEngine(pretextEngine); + clearPreparedCache(); +}; + +const ENGINES = + MODE === "legacy" ? [["legacy", useLegacy]] : [ + ["legacy", useLegacy], + ["pretext", usePretext], + ]; + +// ---- paragraph corpus (seed => unique-but-structurally-stable text) ------- +const WORD_BANK = + "the quick brown fox jumps over a lazy dog while parties hereto agree that any dispute arising under this agreement shall be resolved amicably".split( + " ", + ); + +// Novel prose: each position gets a realistic-length but DISTINCT token +// (index-suffixed), modelling the high novel-token density of real legal prose +// (party names, numbers, defined terms) rather than a 24-word loop the width +// cache would saturate. Coldness across reps comes from clearAllCaches(), so +// no per-rep seed is needed here. +const proseRuns = (wordCount) => { + const words = []; + for (let i = 0; i < wordCount; i += 1) words.push(`${WORD_BANK[i % WORD_BANK.length]}${i}`); + return [{ kind: "text", text: words.join(" "), fontFamily: "Stub", fontSize: 12 }]; +}; + +const CJK = "甲乙丙丁戊己庚辛壬癸子丑寅卯辰巳午未申酉戌亥"; +const ARCHETYPES = { + "prose-12w": () => proseRuns(12), + "prose-100w": () => proseRuns(100), + "prose-400w": () => proseRuns(400), + "overlong-400c": () => [ + { kind: "text", text: "x".repeat(400), fontFamily: "Stub", fontSize: 12 }, + ], + "cjk-120c": () => [ + { kind: "text", text: CJK.repeat(6).slice(0, 120), fontFamily: "Stub", fontSize: 12 }, + ], + // 96 novel words split across 16 alternating font runs => cross-run word walking + "mixed-16runs": () => { + const runs = []; + for (let i = 0; i < 16; i += 1) { + const w = []; + for (let j = 0; j < 6; j += 1) { + const k = i * 6 + j; + w.push(`${WORD_BANK[k % WORD_BANK.length]}${k}`); + } + runs.push({ + kind: "text", + text: `${w.join(" ")} `, + fontFamily: i % 2 === 0 ? "Stub" : "Stub2", + fontSize: 12, + }); + } + return runs; + }, +}; + +const para = (runs) => ({ kind: "paragraph", id: "p", runs }); + +// ---- stats ---------------------------------------------------------------- +const quantile = (sorted, q) => { + if (sorted.length === 0) return null; + const pos = (sorted.length - 1) * q; + const lo = Math.floor(pos); + const hi = Math.ceil(pos); + return lo === hi ? sorted[lo] : sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo); +}; +const round = (v, d = 4) => (v == null ? null : Math.round(v * 10 ** d) / 10 ** d); +const stats = (values) => { + const nums = values.filter((v) => Number.isFinite(v)); + if (nums.length === 0) return null; + const mean = nums.reduce((a, b) => a + b, 0) / nums.length; + const sd = + nums.length > 1 + ? Math.sqrt(nums.reduce((a, b) => a + (b - mean) ** 2, 0) / (nums.length - 1)) + : 0; + const s = [...nums].sort((a, b) => a - b); + return { + n: nums.length, + mean: round(mean), + sd: round(sd), + p50: round(quantile(s, 0.5)), + p95: round(quantile(s, 0.95)), + p99: round(quantile(s, 0.99)), + min: round(s.at(0)), + max: round(s.at(-1)), + }; +}; + +// Reset ALL measurement state so the next measure is genuinely cold, for BOTH +// engines (folio's per-word/slice width+font caches AND pretext's prepared +// cache). No-op for the pretext cache in legacy mode. +const coldReset = () => { + clearAllCaches(); + clearPreparedCache(); +}; + +// ---- one cell: cold + warm for a given archetype/width -------------------- +const runCell = (getCount, makeRuns, width) => { + const block = para(makeRuns()); // identical text; coldness comes from coldReset() + + // Warmup (JIT) — dropped. + for (let i = 0; i < WARMUP; i += 1) { + coldReset(); + measureParagraph(block, width); + } + + // Cold: cache cleared before each rep => true first-paint cost. Time only the + // measure; count canvas calls per rep. + const coldMs = []; + let coldCallsTotal = 0; + for (let i = 0; i < M; i += 1) { + coldReset(); + const c0 = getCount(); + const t0 = performance.now(); + measureParagraph(block, width); + coldMs.push(performance.now() - t0); + coldCallsTotal += getCount() - c0; + } + + // Warm: same text re-measured with caches intact (cache steady state). + coldReset(); + measureParagraph(block, width); // prime + const w0 = getCount(); + const warmMs = []; + for (let i = 0; i < WARM_REPS; i += 1) { + const t0 = performance.now(); + measureParagraph(block, width); + warmMs.push(performance.now() - t0); + } + const warmCallsTotal = getCount() - w0; + + const cold = stats(coldMs); + return { + width, + coldCallsPerMeasure: round(coldCallsTotal / M, 2), + coldMs: cold, + opsPerSec: cold && cold.mean > 0 ? Math.round(1000 / cold.mean) : null, + warmCallsPerMeasure: round(warmCallsTotal / WARM_REPS, 2), + warmMs: stats(warmMs), + }; +}; + +// ---- document-scale macro: cold "open document" of NOVEL content ---------- +// Each paragraph gets genuinely novel tokens (uid-suffixed), modelling a +// document with no repeated content — the realistic high-water mark for a cold +// full-document measure pass. Cache is cleared once per rep (document open), +// then builds naturally across paragraphs within the pass. +const docParaKind = (uid) => { + if (uid % 25 === 0) return "overlong"; + if (uid % 5 === 0) return 400; + return 100; +}; +const docParaRuns = (uid) => { + const kind = docParaKind(uid); + if (kind === "overlong") { + return [ + { kind: "text", text: `${"x".repeat(380)}${uid}`, fontFamily: "Stub", fontSize: 12 }, + ]; + } + const words = []; + for (let i = 0; i < kind; i += 1) words.push(`${WORD_BANK[i % WORD_BANK.length]}${uid}_${i}`); + return [{ kind: "text", text: words.join(" "), fontFamily: "Stub", fontSize: 12 }]; +}; + +const runDoc = (getCount) => { + // warmup pass (JIT), then measured reps with a fresh novel doc each time. + for (let i = 0; i < DOC_PARAS; i += 1) { + coldReset(); + measureParagraph(para(docParaRuns(9_000_000 + i)), DOC_WIDTH); + } + + const ms = []; + let callsTotal = 0; + for (let r = 0; r < DOC_REPS; r += 1) { + const rBlocks = []; + for (let i = 0; i < DOC_PARAS; i += 1) { + rBlocks.push(para(docParaRuns(r * DOC_PARAS + i))); + } + coldReset(); // cold document open; cache builds across the pass + const c0 = getCount(); + const t0 = performance.now(); + for (const b of rBlocks) measureParagraph(b, DOC_WIDTH); + ms.push(performance.now() - t0); + callsTotal += getCount() - c0; + } + return { + paragraphs: DOC_PARAS, + width: DOC_WIDTH, + reps: DOC_REPS, + totalMs: stats(ms), + canvasCallsPerDoc: Math.round(callsTotal / DOC_REPS), + }; +}; + +// ---- main ----------------------------------------------------------------- +const result = { + harness: "measure-engine", + label: LABEL, + mode: MODE, + config: { M, WARMUP, WARM_REPS, WIDTHS, DOC_PARAS, DOC_REPS, DOC_WIDTH }, + engines: {}, +}; + +withFakeTextMeasure((getCount) => { + for (const [engineName, setEngine] of ENGINES) { + const cells = {}; + for (const [archName, makeRuns] of Object.entries(ARCHETYPES)) { + cells[archName] = {}; + for (const width of WIDTHS) { + setEngine(); // reset caches + engine before each cell + cells[archName][`w${width}`] = runCell(getCount, makeRuns, width); + } + } + setEngine(); + const doc = runDoc(getCount); + result.engines[engineName] = { cells, doc }; + console.error(`[${LABEL}] ${engineName}: doc ${doc.paragraphs}p -> ${doc.totalMs.p50}ms p50, ${doc.canvasCallsPerDoc} canvas calls`); + } +}, { charWidth: uppercaseAwareCharWidth }); + +writeFileSync(OUT, `${JSON.stringify(result, null, 2)}\n`); +console.error(`written: ${OUT}`); + +// ---- console summary table ------------------------------------------------ +const pct = (from, to) => (from > 0 ? `${Math.round((1 - to / from) * 100)}%` : "—"); +if (result.engines.legacy && result.engines.pretext) { + console.log(`\n=== ${LABEL}: pretext vs legacy — COLD canvas measureText calls per paragraph ===`); + console.log("archetype".padEnd(16), "width".padStart(6), "legacy".padStart(9), "pretext".padStart(9), "saved".padStart(7)); + for (const arch of Object.keys(ARCHETYPES)) { + for (const width of WIDTHS) { + const L = result.engines.legacy.cells[arch][`w${width}`]; + const P = result.engines.pretext.cells[arch][`w${width}`]; + console.log( + arch.padEnd(16), + String(width).padStart(6), + String(L.coldCallsPerMeasure).padStart(9), + String(P.coldCallsPerMeasure).padStart(9), + pct(L.coldCallsPerMeasure, P.coldCallsPerMeasure).padStart(7), + ); + } + } + const dl = result.engines.legacy.doc; + const dp = result.engines.pretext.doc; + console.log(`\n=== document-scale (${dl.paragraphs} paras @ ${dl.width}px, cold) ===`); + console.log(`legacy : ${dl.totalMs.p50}ms p50 (${dl.totalMs.mean}±${dl.totalMs.sd}), ${dl.canvasCallsPerDoc} canvas calls`); + console.log(`pretext: ${dp.totalMs.p50}ms p50 (${dp.totalMs.mean}±${dp.totalMs.sd}), ${dp.canvasCallsPerDoc} canvas calls`); + console.log(`saved : ${pct(dl.totalMs.p50, dp.totalMs.p50)} time, ${pct(dl.canvasCallsPerDoc, dp.canvasCallsPerDoc)} canvas calls`); +} diff --git a/packages/premirror-bridge/package.json b/packages/premirror-bridge/package.json new file mode 100644 index 00000000..c15a5000 --- /dev/null +++ b/packages/premirror-bridge/package.json @@ -0,0 +1,22 @@ +{ + "name": "@stll/premirror-bridge", + "version": "0.1.0", + "private": true, + "description": "Separately-versioned folio plugin: a @chenglou/pretext-backed SegmentFitEngine for folio-core's measurement seam, with frozen parity tests. Consumers pin this package; it depends only on published @stll/folio-core plus its own third-party deps.", + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "bun test src", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@chenglou/pretext": "0.0.3", + "@stll/folio-core": "workspace:*" + }, + "devDependencies": { + "@types/bun": "1.3.14" + } +} diff --git a/packages/premirror-bridge/src/index.ts b/packages/premirror-bridge/src/index.ts new file mode 100644 index 00000000..5c09f6fc --- /dev/null +++ b/packages/premirror-bridge/src/index.ts @@ -0,0 +1,19 @@ +/** + * @stll/premirror-bridge — folio-side bridge to the pretext/premirror stack. + * + * Exposes the pretext-backed SegmentFitEngine for folio's measurement seam + * (see `@stll/folio-core` layout-engine/measure/segmentFit.ts). Install at a + * composition root: + * + * import { pretextSegmentFitEngine } from "@stll/premirror-bridge"; + * import { setSegmentFitEngine } from "@stll/folio-core/layout-engine/measure/segmentFit"; + * + * setSegmentFitEngine(pretextSegmentFitEngine); + * globalThis.__folioFeatureFlags = { segmentFitLineBreaking: true }; + * + * Credit (moral duty): the engine wraps @chenglou/pretext (MIT); this bridge + * belongs to the premirror line (samwillis/premirror, MIT © Sam Willis). See + * NOTICE. + */ + +export { pretextSegmentFitEngine, clearPreparedCache, preparedCacheSize } from "./pretextEngine"; diff --git a/packages/premirror-bridge/src/pretextEngine.ts b/packages/premirror-bridge/src/pretextEngine.ts new file mode 100644 index 00000000..fef4705d --- /dev/null +++ b/packages/premirror-bridge/src/pretextEngine.ts @@ -0,0 +1,167 @@ +/** + * @chenglou/pretext-backed SegmentFitEngine for folio's measuring pipeline + * (premirror port). + * + * prepare() segments + measures text ONCE per (font, text) via pretext + * (Intl.Segmenter word/grapheme segmentation, per-segment canvas widths, + * grapheme prefix tables for overflow-wrap). fitLine() is then pure + * arithmetic — no canvas calls, no string slicing. This replaces the legacy + * walk's per-call word re-measurement and `findMaxFittingLength`'s + * slice-probe binary search. + * + * Divergences from the legacy walk (both Word/CSS-correct on pretext's + * side, characterized in pretextParity.test.ts): + * - trailing whitespace hangs past the line edge instead of forcing a break; + * - CJK/Thai text breaks between characters (legacy only breaks at + * space/hyphen/tab); + * - overlong tokens char-break from pre-measured grapheme prefix widths. + * + * Credit (moral duty, not just license): the segment-fit design and this + * engine's arithmetic are @chenglou/pretext's (MIT); this file is folio-side + * glue that adapts pretext to folio-core's `SegmentFitEngine` seam. See + * NOTICE for the premirror (samwillis/premirror, MIT © Sam Willis) lineage + * this bridge belongs to. + */ + +import { layoutNextLine, prepareWithSegments } from "@chenglou/pretext"; +import type { + SegmentFitEngine, + SegmentFitLine, +} from "@stll/folio-core/layout-engine/measure/segmentFit"; + +type LayoutCursor = { segmentIndex: number; graphemeIndex: number }; + +type PreparedHandle = { + prepared: ReturnType; + segments: string[]; + /** Cumulative UTF-16 offset of each segment start. */ + segCharStart: number[]; + /** Lazily-built grapheme length tables per segment (for mid-segment breaks). */ + graphemeLengths: (number[] | null)[]; +}; + +const PREPARED_CACHE_MAX = 2000; +const preparedCache = new Map(); + +/** + * U+0000 (NUL) cannot appear in a CSS font string, so it is an unambiguous + * separator for the composite (font, text) cache key: without it, identical + * concatenations collide (e.g. font "12px A" + text "B hello" vs font + * "12px A B" + text "hello"). + */ +const KEY_SEP = String.fromCharCode(0); + +let graphemeSegmenter: Intl.Segmenter | null = null; +function getGraphemeSegmenter(): Intl.Segmenter { + if (!graphemeSegmenter) { + graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + } + return graphemeSegmenter; +} + +function buildHandle(text: string, cssFont: string): PreparedHandle { + const prepared = prepareWithSegments(text, cssFont, { whiteSpace: "pre-wrap" }); + const segments = (prepared as unknown as { segments: string[] }).segments; + const segCharStart: number[] = []; + let offset = 0; + for (const segment of segments) { + segCharStart.push(offset); + offset += segment.length; + } + return { + prepared, + segments, + segCharStart, + graphemeLengths: Array.from({ length: segments.length }, () => null), + }; +} + +function graphemeLengthsFor(handle: PreparedHandle, segmentIndex: number): number[] { + let lengths = handle.graphemeLengths[segmentIndex]; + if (!lengths) { + lengths = []; + const segText = handle.segments[segmentIndex] ?? ""; + for (const g of getGraphemeSegmenter().segment(segText)) { + lengths.push(g.segment.length); + } + handle.graphemeLengths[segmentIndex] = lengths; + } + return lengths; +} + +function cursorToChar(handle: PreparedHandle, cursor: LayoutCursor): number { + if (cursor.segmentIndex < 0 || !Number.isFinite(cursor.segmentIndex)) return 0; + if (cursor.segmentIndex >= handle.segments.length) { + const last = handle.segments.length - 1; + if (last < 0) return 0; + return handle.segCharStart[last]! + handle.segments[last]!.length; + } + let chars = handle.segCharStart[cursor.segmentIndex]!; + if (cursor.graphemeIndex > 0) { + const lengths = graphemeLengthsFor(handle, cursor.segmentIndex); + const upto = Math.min(cursor.graphemeIndex, lengths.length); + for (let i = 0; i < upto; i++) chars += lengths[i]!; + } + return chars; +} + +/** Exposed for tests: number of prepared handles currently cached. */ +export function preparedCacheSize(): number { + return preparedCache.size; +} + +/** Exposed for tests and host teardown. */ +export function clearPreparedCache(): void { + preparedCache.clear(); +} + +/** + * Pretext's pre-wrap analysis normalizes CRLF, CR, and FF to LF BEFORE + * segmenting, so cursor offsets are into the normalized string. Decline any + * text containing CR or FF so end offsets never drift against the original + * run (parsed DOCX runs never contain them — breaks are w:br elements — but + * the seam is public API and must be safe for arbitrary hosts). + */ +const OFFSET_UNSAFE = /[\r\f]/; + +export const pretextSegmentFitEngine: SegmentFitEngine = { + supportsText(text: string): boolean { + return !OFFSET_UNSAFE.test(text); + }, + + clearCaches(): void { + clearPreparedCache(); + }, + + prepare(text: string, cssFont: string): unknown { + const key = `${cssFont}${KEY_SEP}${text}`; + const hit = preparedCache.get(key); + if (hit) { + // LRU refresh + preparedCache.delete(key); + preparedCache.set(key, hit); + return hit; + } + const handle = buildHandle(text, cssFont); + preparedCache.set(key, handle); + if (preparedCache.size > PREPARED_CACHE_MAX) { + const oldest = preparedCache.keys().next().value; + if (oldest !== undefined) preparedCache.delete(oldest); + } + return handle; + }, + + fitLine(prepared: unknown, cursor: unknown | null, maxWidth: number): SegmentFitLine | null { + const handle = prepared as PreparedHandle; + const start: LayoutCursor = (cursor as LayoutCursor | null) ?? { + segmentIndex: 0, + graphemeIndex: 0, + }; + const line = layoutNextLine(handle.prepared, start, maxWidth); + if (!line) return null; + const startChar = cursorToChar(handle, line.start); + const endChar = cursorToChar(handle, line.end); + if (!Number.isFinite(endChar) || endChar <= startChar) return null; + return { endChar, width: line.width, cursor: line.end }; + }, +}; diff --git a/packages/premirror-bridge/src/pretextParity.test.ts b/packages/premirror-bridge/src/pretextParity.test.ts new file mode 100644 index 00000000..8b540536 --- /dev/null +++ b/packages/premirror-bridge/src/pretextParity.test.ts @@ -0,0 +1,181 @@ +/** + * Parity suite: real @chenglou/pretext (via pretextSegmentFitEngine) vs + * folio's legacy word-walk, both measuring through the deterministic fake + * canvas (`withFakeTextMeasure`, fixed 5px/char — linear and kerning-free, + * so widths agree by construction and any divergence is ALGORITHMIC). + * + * Characterized outcomes (probe-verified, frozen): + * - spaced text, edge trailing-space widths, overlong tokens, CJK: EXACT + * line-break AND width parity. (Folio's legacy already hangs trailing + * spaces, so the edge-width divergence seen against a non-hanging legacy + * walk does not exist here.) + * - canvas call counts (fixed metrics, 100-word paragraph, width 120): + * first measure 199 -> 127; repeat measures are 0 on BOTH paths (folio's + * width cache already covers steady state — the honest win is first-pass + * and changed-text measurement, NOT steady-state). + * - overlong 400-char token at width 50: 82 -> 3 canvas calls. Killing the + * `findMaxFittingLength` slice probes (unique slice keys that pollute the + * width cache; the `workerFontMetrics` prewarm exists to soften exactly + * this) is folio's headline gain from the seam. + */ +import { afterEach, describe, expect, test } from "bun:test"; + +import { + fixedCharWidth, + withFakeTextMeasure, +} from "@stll/folio-core/layout-engine/measure/__tests__/fakeTextMeasure"; +import { setFolioMeasurementFlags } from "@stll/folio-core/layout-engine/measure/featureFlags"; +import { measureParagraph } from "@stll/folio-core/layout-engine/measure/measureParagraph"; +import { + resetSegmentFitEngine, + setSegmentFitEngine, +} from "@stll/folio-core/layout-engine/measure/segmentFit"; +import type { ParagraphBlock } from "@stll/folio-core/layout-engine"; + +import { clearPreparedCache, pretextSegmentFitEngine } from "./pretextEngine"; + +const fakeMeasure = { charWidth: fixedCharWidth(5) }; + +function para(text: string): ParagraphBlock { + return { + kind: "paragraph", + id: "p", + runs: [{ kind: "text", text, fontFamily: "Stub", fontSize: 12 }], + }; +} + +function breakOffsets(m: ReturnType): Array<[number, number]> { + return m.lines.map((l) => [l.fromChar, l.toChar]); +} + +function measureLegacy(block: ParagraphBlock, width: number) { + setFolioMeasurementFlags(undefined); + resetSegmentFitEngine(); + return measureParagraph(block, width); +} + +function measureSegment(block: ParagraphBlock, width: number) { + setFolioMeasurementFlags({ segmentFitLineBreaking: true }); + setSegmentFitEngine(pretextSegmentFitEngine); + return measureParagraph(block, width); +} + +afterEach(() => { + setFolioMeasurementFlags(undefined); + resetSegmentFitEngine(); + clearPreparedCache(); +}); + +describe("pretext vs folio legacy line breaks (deterministic metrics)", () => { + test("spaced text: exact break and width parity", () => { + withFakeTextMeasure(() => { + const legacy = measureLegacy(para("aaaa bbbb cccc dddd"), 50); + const segment = measureSegment(para("aaaa bbbb cccc dddd"), 50); + expect(breakOffsets(segment)).toEqual(breakOffsets(legacy)); + expect(breakOffsets(segment)).toEqual([ + [0, 10], + [10, 19], + ]); + expect(segment.lines.map((l) => l.width)).toEqual(legacy.lines.map((l) => l.width)); + expect(segment.totalHeight).toBeCloseTo(legacy.totalHeight, 4); + }, fakeMeasure); + }); + + test("edge trailing-space width: parity (folio legacy already hangs trailing spaces)", () => { + withFakeTextMeasure(() => { + const legacy = measureLegacy(para("aaaa bbbb cccc dddd"), 45); + const segment = measureSegment(para("aaaa bbbb cccc dddd"), 45); + expect(breakOffsets(segment)).toEqual(breakOffsets(legacy)); + expect(breakOffsets(segment)).toEqual([ + [0, 10], + [10, 19], + ]); + expect(segment.lines.map((l) => l.width)).toEqual(legacy.lines.map((l) => l.width)); + }, fakeMeasure); + }); + + test("overlong token: exact parity with legacy hard-breaking", () => { + withFakeTextMeasure(() => { + const token = "x".repeat(45); + const legacy = measureLegacy(para(token), 50); + const segment = measureSegment(para(token), 50); + expect(breakOffsets(segment)).toEqual(breakOffsets(legacy)); + expect(segment.lines.length).toBe(5); + }, fakeMeasure); + }); + + test("space-less CJK: exact parity (both sides break per ideograph)", () => { + withFakeTextMeasure(() => { + const text = "甲乙丙丁戊己庚辛壬癸".repeat(3); + const legacy = measureLegacy(para(text), 50); + const segment = measureSegment(para(text), 50); + expect(breakOffsets(segment)).toEqual(breakOffsets(legacy)); + expect(segment.lines.length).toBe(3); + }, fakeMeasure); + }); + + test("offset-safety guard: CR and FF texts are declined and measure legacy-identically", () => { + withFakeTextMeasure(() => { + const text = "first part\r\nsecond part with more words"; + expect(pretextSegmentFitEngine.supportsText!(text)).toBe(false); + const legacy = measureLegacy(para(text), 50); + const segment = measureSegment(para(text), 50); + expect(breakOffsets(segment)).toEqual(breakOffsets(legacy)); + }, fakeMeasure); + }); + + test("prepared-cache key: identical concatenations get distinct entries", () => { + clearPreparedCache(); + const a = pretextSegmentFitEngine.prepare("B hello", "12px A"); + const b = pretextSegmentFitEngine.prepare("hello", "12px A B"); + expect(a).not.toBe(b); + }); +}); + +describe("canvas call profile (probe-frozen)", () => { + const text100 = Array.from({ length: 100 }, (_, i) => `w${i}`).join(" "); + + test("first-pass measurement drops (199 -> 127); repeats stay 0 on both paths", () => { + withFakeTextMeasure((getCount) => { + const l0 = getCount(); + measureLegacy(para(text100), 120); + const legacyFirst = getCount() - l0; + const l1 = getCount(); + measureLegacy(para(text100), 120); + const legacyRepeat = getCount() - l1; + + clearPreparedCache(); + setFolioMeasurementFlags({ segmentFitLineBreaking: true }); + setSegmentFitEngine(pretextSegmentFitEngine); + const s0 = getCount(); + measureParagraph(para(text100), 120); + const segmentFirst = getCount() - s0; + const s1 = getCount(); + measureParagraph(para(text100), 120); + const segmentRepeat = getCount() - s1; + + expect(legacyRepeat).toBe(0); // folio's width cache already covers repeats + expect(segmentRepeat).toBe(0); + expect(segmentFirst).toBeLessThan(legacyFirst * 0.75); + }, fakeMeasure); + }); + + test("overlong-token slice probes collapse (82 -> 3)", () => { + withFakeTextMeasure((getCount) => { + const token = "y".repeat(400); + const l0 = getCount(); + measureLegacy(para(token), 50); + const legacyProbes = getCount() - l0; + + clearPreparedCache(); + setFolioMeasurementFlags({ segmentFitLineBreaking: true }); + setSegmentFitEngine(pretextSegmentFitEngine); + const s0 = getCount(); + measureParagraph(para(`${token}z`), 50); // distinct text: no width-cache reuse + const segmentProbes = getCount() - s0; + + expect(legacyProbes).toBeGreaterThan(50); + expect(segmentProbes).toBeLessThan(10); + }, fakeMeasure); + }); +}); diff --git a/packages/premirror-bridge/tsconfig.json b/packages/premirror-bridge/tsconfig.json new file mode 100644 index 00000000..237e01e9 --- /dev/null +++ b/packages/premirror-bridge/tsconfig.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "Preserve", + "moduleResolution": "bundler", + "moduleDetection": "force", + "types": ["bun"], + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "include": ["src"], + "exclude": ["node_modules"] +}