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
13 changes: 13 additions & 0 deletions .changeset/segment-fit-seam.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions api-reports/core/layout-bridge-engine-measuring.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export type FloatingLineSegmentZone = {
// @public
export type FolioMeasurementFeatureFlags = {
workerFontMetrics?: boolean;
segmentFitLineBreaking?: boolean;
};

// @public
Expand Down
15 changes: 15 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions packages/core/src/layout-engine/measure/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
}

/**
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/layout-engine/measure/featureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.
Expand Down
68 changes: 64 additions & 4 deletions packages/core/src/layout-engine/measure/measureParagraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading