Skip to content

Binary-search line breaks in splitOutLines (cubic -> n log n) - #169

Open
davebytemfg wants to merge 1 commit into
cantoo-scribe:masterfrom
davebytemfg:fast-split-out-lines
Open

davebytemfg wants to merge 1 commit into
cantoo-scribe:masterfrom
davebytemfg:fast-split-out-lines

Conversation

@davebytemfg

Copy link
Copy Markdown

What?

splitOutLines in src/api/text/layout.ts finds the longest prefix of a line that fits maxWidth. It tries the whole input first, then each earlier whitespace break in turn, and for every candidate it re-measures (widthOfTextAtSize) and re-encodes (encodeText) the full prefix.

This PR finds the same break by binary search over the same candidate break positions, and encodes only the line it returns. The result is identical; the number of measurements per line drops from O(words) to O(log words).

Why?

For a paragraph of n characters, the current code does O(n) measurements per line, each over an O(n) prefix, across O(n) lines, so the cost is roughly cubic. It's invisible on short text and severe on a long paragraph in a multiline form field. layoutMultilineText with the standard Helvetica font, 8 pt, in a 220 pt wide box:

Paragraph Before After widthOfTextAtSize calls before → after
424 chars 9 ms <1 ms 251 → 45
849 chars 43 ms <1 ms 1,096 → 101
1,699 chars 316 ms 2 ms 4,571 → 229
3,399 chars 2,511 ms 7 ms 18,661 → 508

Each doubling multiplies the time by about 8. Embedded (fontkit) fonts measure more slowly than the standard 14, which makes it much worse. In our app, filling a form field with a 3,400-character backstory (setText + updateAppearances) took over 30 seconds, and the export was killed. A 1,700-character one took 17 seconds.

How?

The candidate breaks are exactly the ones the current loop visits: the full input length, then every index i > 0 where input[i] is whitespace (the same positions lastIndexOfWhitespace returns, walking backwards). The current loop returns the first candidate, from the longest down, whose prefix width is < maxWidth. A prefix's width doesn't decrease as it gets longer, so that is the longest candidate that fits, and binary search finds it.

When nothing fits, it returns the whole input, unchanged from today. The returned { line, encoded, width, remainder } is the same, so callers are untouched. lastIndexOfWhitespace is no longer used and is removed.

Alternatives considered: measuring words incrementally and summing their widths would be faster still. But it would change results slightly where kerning crosses a word boundary, and this change aims to be behavior-identical.

Testing?

  • Two unit tests in tests/api/text/layout.spec.ts:
    • Unchanged output: a fixed paragraph wraps to exactly the lines it wraps to today. This test also passes against the previous implementation.
    • Bounded work: a 3,399-character paragraph is laid out with fewer than now). Against the previous implementation it fails with 18,661. It countscalls with jest.spyOn, so it is deterministic rather than timing-based.
  • Equivalence: run side by side with the original function on 4,000 randomized and short words, double spaces), random widths 10–260 pt and sizes 4–14 pt, in Helvetica and an embedded TTF (Lato). Every line of every wrap was identical.
  • yarn test (906 passed, 1 skipped), yarn lint (no errors or warnings in typecheck` all pass.
  • We carry this as a patch to @cantoo/pdf-lib@2.11.1 in an app that fills form-fillable character sheets. Our full test suite passes with it.

New Dependencies?

No.

Screenshots

N/A: the output is unchanged.

Suggested Reading?

Yes.

Anything Else?

  • computeFontSize (auto-size) walks words once per candidate size, which is linear per size and was not the problem here, so it is left alone.
  • I didn't run the integration apps (Node, Deno, browser, React Native). This one internal function, with no API, platform or output differences.
  • No public API changed, so there are no doc comments to update.

Checklist

  • I read CONTRIBUTING.md.
  • I read MAINTAINERSHIP.md#pull-requests.
  • I added/updated unit tests for my changes.
  • I added/updated integration tests for my changes.
  • I ran the integration tests.
  • I tested my changes in Node, Deno, and the browser.
  • I viewed documents produced with my changes in Adobe Acrobat, Foxit Reader, Firefox, and Chrome.
  • I added/updated doc comments for any new/modified public APIs.
  • My changes work for both new and existing PDF files.
  • I ran the linter on my changes.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dgrelaud added a commit to carboneio/pdf-lib that referenced this pull request Oct 16, 2026
splitOutLines tried every whitespace break from the end of the input
and re-measured and re-encoded the whole prefix at each one, so laying
out a long paragraph cost roughly cubic time in its length. It now
finds the same break by binary search over the same candidate
positions, and encodes only the line it returns.

This only affects the appearance of multiline text fields and option
lists. Line breaks are unchanged. Filling a multiline field with
1,700 characters goes from 334 ms to 4 ms (npm run bench).

Ported from cantoo-scribe#169 by @davebytemfg:
cantoo-scribe#169
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant