Repository navigation
Binary-search line breaks in splitOutLines (cubic -> n log n) - #169
Open
davebytemfg wants to merge 1 commit into
Open
davebytemfg wants to merge 1 commit into
davebytemfg wants to merge 1 commit into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What?
splitOutLinesinsrc/api/text/layout.tsfinds the longest prefix of a line that fitsmaxWidth. 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.
layoutMultilineTextwith the standard Helvetica font, 8 pt, in a 220 pt wide box:widthOfTextAtSizecalls before → afterEach 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 > 0whereinput[i]is whitespace (the same positionslastIndexOfWhitespacereturns, 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.lastIndexOfWhitespaceis 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?
tests/api/text/layout.spec.ts:jest.spyOn, so it is deterministic rather than timing-based.yarn test(906 passed, 1 skipped),yarn lint(no errors or warnings in typecheck` all pass.@cantoo/pdf-lib@2.11.1in 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.Checklist
🤖 Generated with Claude Code