Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/quick-words-measure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@stll/folio-core": patch
---

Reduce paragraph layout text measurements by reusing visible word widths for ordinary trailing whitespace.
73 changes: 71 additions & 2 deletions packages/core/src/layout-engine/measure/measureParagraph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,14 @@ import {
} from "./__tests__/fakeTextMeasure";
import { hashParagraphBlock } from "./cache";
import { resetLineBreakProvider, setLineBreakProvider } from "./lineBreakProvider";
import { buildFontString } from "./measureHelpers";
import { buildFontString, buildRunFontStyle } from "./measureHelpers";
import { clampFloatingWrapMargins, getRunCharWidths, measureParagraph } from "./measureParagraph";
import { getFontMetrics, measureTextWidth } from "./measureProvider";
import {
getFontMetrics,
getMeasureProvider,
measureTextWidth,
setMeasureProvider,
} from "./measureProvider";

const PT_TO_PX = 96 / 72;

Expand Down Expand Up @@ -550,6 +555,70 @@ describe("measureParagraph cross-run line breaking", () => {
charIndex: number,
): boolean => lines.some((line) => line.fromRun === runIndex && line.fromChar === charIndex);

test("reuses visible-word measurements for ordinary trailing spaces", () => {
withFakeTextMeasure(
(getMeasureCount) => {
const text = "alpha beta gamma delta epsilon zeta eta theta";
measureParagraph(paragraph([{ kind: "text", text }]), 1000);

// Eight visible words, one shared space measurement, and one font-metrics probe.
expect(getMeasureCount()).toBe(10);
},
{ charWidth: fixedCharWidth(5) },
);
});

test("includes scaled letter spacing at the visible-word boundary", () => {
withFakeTextMeasure(
() => {
const run = {
kind: "text" as const,
text: "ab cd",
letterSpacing: 2,
horizontalScale: 150,
};
const fontStyle = buildRunFontStyle(run, "Calibri", 11);
const expectedWidth =
measureTextWidth("ab ", fontStyle) + measureTextWidth("cd", fontStyle);
const measured = measureParagraph(paragraph([run]), 1000);

expect(measured.lines[0]?.width).toBe(expectedWidth);
},
{ charWidth: fixedCharWidth(5) },
);
});

test("keeps exact full-string measurement for kerning and literal tabs", () => {
withFakeTextMeasure(
() => {
const baseProvider = getMeasureProvider();
setMeasureProvider({
...baseProvider,
measureTextWidth: (text, measuredStyle) => {
const measuredWidth = baseProvider.measureTextWidth(text, measuredStyle);
if (measuredStyle.kerning && text === "AV ") {
return measuredWidth - 2;
}
if (text === "a\t") {
return 40;
}
return measuredWidth;
},
});

const kerned = measureParagraph(
paragraph([{ kind: "text", text: "AV X", kerningMinPt: 1 }]),
1000,
);
const tabbed = measureParagraph(paragraph([{ kind: "text", text: "a\tb" }]), 1000);

expect(kerned.lines[0]?.width).toBe(18);
expect(tabbed.lines[0]?.width).toBe(45);
},
{ charWidth: fixedCharWidth(5) },
);
});

test("keeps an adjacent footnote marker glued to the preceding word", () => {
withFakeTextMeasure(() => {
const runs: Run[] = [
Expand Down
36 changes: 33 additions & 3 deletions packages/core/src/layout-engine/measure/measureParagraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,35 @@ function trimTrailingSpacesAndTabs(text: string): string {
return text.slice(0, end);
}

function measureWordWithTrailingWhitespace(
word: string,
style: FontStyle,
): { measuredWord: string; wordWidth: number; fullWordWidth: number } {
const measuredWord = trimTrailingSpacesAndTabs(word);
const wordWidth = measureTextWidth(measuredWord, style);
const trailingWhitespace = word.slice(measuredWord.length);
if (trailingWhitespace.length === 0) {
return { measuredWord, wordWidth, fullWordWidth: wordWidth };
}

// Tabs have contextual advances, while kerning can change the advance at
// the split boundary. Keep the exact full-string measurement for both.
if (style.kerning || trailingWhitespace.includes("\t")) {
return { measuredWord, wordWidth, fullWordWidth: measureTextWidth(word, style) };
}

const trailingWhitespaceWidth = measureTextWidth(trailingWhitespace, style);
const boundarySpacing =
measuredWord.length > 0
? (style.letterSpacing ?? 0) * ((style.horizontalScale ?? 100) / 100)
: 0;
return {
measuredWord,
wordWidth,
fullWordWidth: wordWidth + trailingWhitespaceWidth + boundarySpacing,
};
}

function trailingCodePoint(text: string): string | undefined {
if (text.length === 0) {
return undefined;
Expand Down Expand Up @@ -1835,9 +1864,10 @@ export function measureParagraph(
// Keep the full width while accumulating in case another word follows
// on this line, then remove any whitespace still trailing at finalize.
const word = text.slice(charIndex, nextBreak);
const measuredWord = trimTrailingSpacesAndTabs(word);
const wordWidth = measureTextWidth(measuredWord, style);
const fullWordWidth = measureTextWidth(word, style);
const { measuredWord, wordWidth, fullWordWidth } = measureWordWithTrailingWhitespace(
word,
style,
);
const hangingPunctuationWidth = trailingHangingPunctuationWidth(
measuredWord,
style,
Expand Down
Loading