From 09c3a5c1f7239f15a1c7b634e317842bb735ad81 Mon Sep 17 00:00:00 2001 From: KBS Date: Tue, 29 Sep 2026 12:48:57 +0900 Subject: [PATCH 1/3] Include wordSpacing in boundsOfString with lineBreak: false The single-line branch of boundsOfString used widthOfString, which does not include word spacing, while _fragment adds it to each word and to the underline width. Add wordSpacing for each word gap, as the wrapped branch does since #1806. --- CHANGELOG.md | 1 + lib/mixins/text.js | 5 ++++- tests/unit/text.spec.js | 21 +++++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index daca229d..4527a6a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Unreleased +- Fix `boundsOfString()` with `lineBreak: false` leaving out `wordSpacing`, so its width now matches the rendered text and its underline - Fix the underline, strike, link and goTo of wrapped text, and `boundsOfString()` with a `width`, counting `characterSpacing` and `wordSpacing` twice, so they no longer run past the text and match the same text drawn with `lineBreak: false` - Fix the Node build throwing once a bundler inlines it into an application bundle, `Cannot find module '#standard-fonts/Helvetica'` on the first document from an ESM bundle and `Invalid URL` while importing a CommonJS one, because the standard font metrics and the PDF/A ICC profile were resolved relative to pdfkit's own package directory at import time. Both are now resolved on first use, a standard font that cannot be loaded names itself and the fix in its error, and `registerStdFonts` is exported from the Node build as it already was from the browser build, so a bundle can register the fonts it uses from `pdfkit/standard-fonts/`. The Node build now requires the standard fonts by a path relative to the built file, which replaces the `#standard-fonts/*` import mapping and lets an ESM bundle load them from a copy of `js/standard-fonts` next to it. Fixes #1801 - Fix `doc.list()` drawing the label of a `numbered` or `lettered` item with the line wrapper's own options object, which threw `unsupported number: NaN` for `align` `center` and `right`, applied a word spacing derived from the previous line to the label for `align` `justify`, and sized the underline, strike, link and goTo of every label after the first from the previous item's text diff --git a/lib/mixins/text.js b/lib/mixins/text.js index 7c42b5e3..f01f277b 100644 --- a/lib/mixins/text.js +++ b/lib/mixins/text.js @@ -184,7 +184,10 @@ export default { } else { // render paragraphs as single lines for (let line of string.split('\n')) { - const lineWidth = this.widthOfString(line, options); + const wordCount = line.trim() ? line.trim().split(/\s+/).length : 0; + const lineWidth = + this.widthOfString(line, options) + + (options.wordSpacing ?? 0) * Math.max(0, wordCount - 1); this.y += lineHeight; contentWidth = Math.max(contentWidth, lineWidth); } diff --git a/tests/unit/text.spec.js b/tests/unit/text.spec.js index d883bc5a..bb7a572f 100644 --- a/tests/unit/text.spec.js +++ b/tests/unit/text.spec.js @@ -242,6 +242,27 @@ Q new RegExp(`50 [\\d.]+ m\n${end.replace('.', '\\.')}\\d* [\\d.]+ l`), ]); }); + + test('bounds of text with lineBreak false and wordSpacing', () => { + const docData = logData(document); + const options = { lineBreak: false, underline: true, wordSpacing: 5 }; + const bounds = document.boundsOfString( + 'Hello big world', + 50, + 50, + options, + ); + + document.text('Hello big world', 50, 50, options); + document.end(); + + // as wide as the underline, which includes the word spacing + expect(bounds.width.toFixed(2)).toBe('88.74'); + expect(docData).toContainChunk([ + 'stream', + /50 [\d.]+ m\n138\.74\d* [\d.]+ l/, + ]); + }); }); describe('text with structure parent links', () => { From 94951765d79e8d9917927af00ccbfb7fa246ab75 Mon Sep 17 00:00:00 2001 From: youdie006 Date: Wed, 30 Sep 2026 10:38:32 +0900 Subject: [PATCH 2/3] Count word gaps only when wordSpacing is set, with one trim Address review: avoid the second trim call and the split array. --- lib/mixins/text.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/mixins/text.js b/lib/mixins/text.js index f01f277b..81f9a7d5 100644 --- a/lib/mixins/text.js +++ b/lib/mixins/text.js @@ -184,10 +184,12 @@ export default { } else { // render paragraphs as single lines for (let line of string.split('\n')) { - const wordCount = line.trim() ? line.trim().split(/\s+/).length : 0; - const lineWidth = - this.widthOfString(line, options) + - (options.wordSpacing ?? 0) * Math.max(0, wordCount - 1); + let lineWidth = this.widthOfString(line, options); + if (options.wordSpacing) { + // one wordSpacing per gap between words + const gaps = line.trim().match(/\s+/g); + lineWidth += options.wordSpacing * (gaps ? gaps.length : 0); + } this.y += lineHeight; contentWidth = Math.max(contentWidth, lineWidth); } From 753f2f3726da5e7952dc66a2c17d932402ff9335 Mon Sep 17 00:00:00 2001 From: youdie006 Date: Wed, 30 Sep 2026 12:14:31 +0900 Subject: [PATCH 3/3] Count word gaps without building an array Whitespace runs are collapsed when wordSpacing is set, so each gap is one character and a global regex test loop counts them. --- lib/mixins/text.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/mixins/text.js b/lib/mixins/text.js index 81f9a7d5..78c872d7 100644 --- a/lib/mixins/text.js +++ b/lib/mixins/text.js @@ -186,9 +186,10 @@ export default { for (let line of string.split('\n')) { let lineWidth = this.widthOfString(line, options); if (options.wordSpacing) { - // one wordSpacing per gap between words - const gaps = line.trim().match(/\s+/g); - lineWidth += options.wordSpacing * (gaps ? gaps.length : 0); + // whitespace runs were collapsed above, so each gap is one character + const trimmed = line.trim(); + const space = /\s/g; + while (space.test(trimmed)) lineWidth += options.wordSpacing; } this.y += lineHeight; contentWidth = Math.max(contentWidth, lineWidth);