Fix doc.list() throwing on a numbered or lettered list with align center or right - #1800
Open
MaxFreedomPollard wants to merge 1 commit into
Open
MaxFreedomPollard wants to merge 1 commit into
MaxFreedomPollard wants to merge 1 commit into
Conversation
…ter or right The item label is drawn on its own at the item indent, but list() passed it the same options object the LineWrapper owns. With align center or right, _fragment took the paragraph alignment and offset the label by lineWidth and textWidth, which the wrapper only writes once it emits a line, so the first label was positioned from undefined and PDFObject.number threw. With align justify that undefined lineWidth leaves the word spacing as NaN. NaN is falsy where the spacing would be encoded, so a plain justified list still gets through, but the rendered width it feeds is NaN as well: underline and strike throw on the very first label, and link and goTo collapse to a zero width rectangle. From the second label on the spacing is a real number taken from the previous line. A one word label has nothing to stretch, so the number does not move, but the bytes change and that spacing is still in the rendered width. The shared object also carried the previous item's textWidth into every later label, with no alignment option involved at all, so the decoration and the annotation rectangle of label two onwards were sized from the previous item's text. list() now builds its own options for the label, with align forced to left and the wrapper's line measurements dropped, which covers all of it.
blikblum
requested changes
Sep 16, 2026
Comment on lines
+287
to
+291
| const labelOptions = { ...options, align: 'left' }; | ||
| delete labelOptions.lineWidth; | ||
| delete labelOptions.textWidth; | ||
| delete labelOptions.wordCount; | ||
|
|
Member
There was a problem hiding this comment.
Suggested change
| const labelOptions = { ...options, align: 'left' }; | |
| delete labelOptions.lineWidth; | |
| delete labelOptions.textWidth; | |
| delete labelOptions.wordCount; | |
| const { | |
| lineWidth, | |
| textWidth, | |
| wordCount, | |
| ...labelOptions | |
| } = options; | |
| labelOptions.align = 'left'; |
if lint complains suppress the warning in the line
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 kind of change does this PR introduce?
Bug fix.
doc.list(['alpha', 'beta'], { listType: 'numbered', align: 'center' })throwsError: unsupported number: NaNinstead of producing a PDF. The same happens foralign: 'right'and forlistType: 'lettered'.align: 'justify'on its own does not throw, but withunderlineorstrikeit throws on the first label, and withlinkorgoToit gives that label a zero width rectangle,/Rect [72 708.9 72 720]. A justified list also writes a bogus word spacing into every label after the first, taken from the previous line: a one word label has nothing to stretch, so the printed number does not move, but the bytes change and that spacing feeds the width that underline, strike, link and goTo use. Bullet lists and plaindoc.text()with the same alignment are fine. Separately, and with no alignment option involved at all, the underline, strike, link and goTo of every label after the first are sized from the previous item's text:doc.list(['alpha', 'beta'], { listType: 'numbered', link: 'http://example.com' })gives the label2.a rectangle of/Rect [72 695.028 101.351999 706.128]when that label is 10.008 points wide. All of it comes from the same line. No open issue covers any of it.The bug
lib/mixins/text.js, in the
firstLinehandler thatlist()installs on its LineWrapper:optionshere is the object the LineWrapper was constructed with and writes into. lib/line_wrapper.js setsoptions.lineWidth,options.textWidthandoptions.wordCountinsideemitLine(), andfirstLineis emitted before the firstemitLine(), so on the first item all three are still undefined when the label is drawn._fragmentthen applies the paragraph alignment to that label:x becomes NaN and
PDFObject.numberthrows. In normal useoptions.widthis truthy here without the caller setting anything:_initOptionsdefaults it tothis.page.width - this.x - this.page.margins.rightwheneverlineBreak !== false. It is falsy only if the caller passeslineBreak: false, or the list starts at the right margin, where the same function clamps that default to 0.justifyreaches the second label only by accident. It derives a word spacing fromoptions.lineWidth, soMath.max(0, NaN)leaveswordSpacingas NaN, and NaN is falsy at the point where the spacing would be encoded, so the glyphs are still written.renderedWidthis not so lucky: it addswordSpacing * (measuredWordCount - 1)to the measured width, andNaN * 0is NaN. That NaN reacheslineToforunderlineandstrikeand throws on the very first label. ForlinkandgoToit goes through_convertRect, whereNaN < 0andNaN > 0are both false, so the annotation collapses to a zero width rectangle with no error at all.From the second label on,
lineWidthholds the width of the previous line and the spacing is a real number. Master writes the second label of a justified numbered list as[<322e> -36666] TJwhere the fix writes[<322e> 0] TJ. The-36666is a trailing adjustment after the only glyph run, so the number still prints at x=72 either way; what is wrong is the spacing in the stream, and once again therenderedWidthit feeds.The last part of the defect needs no alignment. Once the first item has been wrapped, the shared object holds that item's
textWidth, and it is still there when the next item's label is drawn._fragmentcomputesrenderedWidthfromoptions.textWidth ?? this.widthOfString(...), so the stale value wins over measuring the label. That width is what drives the link and goTo rectangles and the underline and strike lines.Reproduction of the throw:
On master:
Error: unsupported number: NaN, thrown at lib/object.js:170, from_fragmentat lib/mixins/text.js:627, from thefirstLinehandler at lib/mixins/text.js:355.Reproduction of the width bug, on the default path:
Master underlines the label
2.from x=72 to x=101.351999, which is 72 plus the width ofalpha, instead of to x=82.008. Swapunderlineforlinkand the same list writes/Rect [72 695.028 101.351999 706.128]for that label, where the correct rectangle is/Rect [72 695.028 82.008 706.128]. The first label is right in both cases, because there is no earlier item to inherit from.The fix
list()builds one options object for the label, once, and passes that to the label fragment:Forcing
align: 'left'stops the label taking the paragraph alignment. Dropping the three line measurements keeps any wrapper's writes out of the label options, so the existing??fallback in_fragmentmeasures the label itself. Both parts are needed: the alignment alone still leaves a stale width behind, and the measurements alone still centre the number on top of the body text.The three deletes also close the one case a plain copy does not reach. A list placed directly after a
continued: truetext run inherits that run'stextWidththrough_initOptions, which copies every key ofthis._textOptionsthat the new options leave undefined, and the earlier wrapper had already written its measurements into that object. Withalign: 'left'and nothing else,doc.text(long, { continued: true })followed by an underlined numbered list underlines both labels to the width of the continued run, and master underlines the first to that width and the second to the width of item one. With the deletes both labels underline from 72 to 82.008, which is the label.What does not change: bullet lists, and numbered and lettered lists that use the default alignment and none of
underline,strike,linkorgoTo. I compared the full output of a nested numbered list, a lettered list, a bullet list and a bullet list withalign: 'center'before and after, and it is byte identical. Labels of numbered and lettered lists that do use one of those four options, oralign: 'justify', do change from the second label onwards, and they change because master was wrong. No visual test passesaligntolist(), so the tests/visual snapshots are unaffected.One alternative worth ruling out, since it is the shape of the earlier fix in #1784: this could be patched inside
_fragmentinstead, by falling back to a measuredlineWidththe way that PR did fortextWidth. That would leave the alignment applied to the label, which centers the number inside the item's line width and so prints it on top of the centered body text, and pushes it to the right margin foralign: 'right'. The label is not one of the wrapped lines, so it should not take the paragraph alignment at all.Testing
Seven cases added to tests/unit/list.spec.js.
A
test.eachover numbered and lettered crossed with center and right asserts the label stays at the item indent (x=72) while the body is aligned (x=300.324 centered, x=510.648 right). All four fail on master withunsupported number: NaN.One test asserts that the default alignment still puts the label at 72 and the body at 90 for both items. It passes before and after, and exists to guard the path that must not move.
One test covers the justify path,
align: 'justify'withunderline: true. On master it throwsunsupported number: NaNout oflineToon the first label. With the fix both labels are underlined from 72 to 82.008, so the second label's width is pinned too.One test asserts the link annotation of the second label. On master the diff is:
npx vitest run tests/unit/list.spec.jsgives 6 failed and 3 passed with lib/mixins/text.js from master, and 9 passed on the branch.npx vitest run tests/unitgives 42 files and 461 tests passed.npm run prettierreports that all matched files use Prettier code style andnpm run lintexits clean. I could not run tests/visual locally, so CI will be the first run of that suite.Checklist: