From eb4a9b8a71879658fd08669f6c35a59268c3562a Mon Sep 17 00:00:00 2001 From: Logan Besecker <3487677+lbesecker195@users.noreply.github.com> Date: Mon, 14 Sep 2026 01:12:42 -0700 Subject: [PATCH] Fix decomposed accents with standard fonts Standard fonts encode each character as its WinAnsi code in hex. A combining mark such as U+0308 has no WinAnsi code, so its character code went into the hex string as three digits, and every character after it was read from the wrong byte. Text in NFD, e.g. "u" followed by a combining diaeresis, came out as unrelated characters. Compose the text (NFC) before encoding and measuring it, so those sequences use the precomposed letters that WinAnsi has. Fixes #1661 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + lib/font/standard.js | 8 ++++++-- tests/unit/text.spec.js | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd88cc29..4f631379 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Fix annotations placed under `doc.rotate()` marking the wrong area, because `_convertRect` derived each corner's y from the already transformed x and mapped only two of the four corners, so the rectangle a viewer makes interactive did not follow the rotated content. Fixes #1153 - Add `onClick`, `onMouseDown`, `onMouseEnter`, `onMouseExit`, `onFocus` and `onBlur` options to form annotation methods, for the JavaScript a field runs on each of those events. Each accepts a string or a plain function, whose source text is written into the action - Add an `embedFonts` option to `initForm`, embedding a complete, character-addressable copy of each custom font used in a form field. Viewers that regenerate a field's appearance from its value, such as Adobe Acrobat/Reader, need one to resolve field text; without it they fall back to a substitute font. It adds roughly the size of the font file per font, so it is off by default. Fixes #1096 +- Fix standard fonts printing the wrong characters for text with decomposed accents (NFD), such as `u` followed by a combining diaeresis, because the combining mark has no WinAnsi code and its longer hex code shifted every character after it. Text is composed (NFC) before it is encoded and measured. Fixes #1661 ### [v0.20.2] - 2026-08-29 diff --git a/lib/font/standard.js b/lib/font/standard.js index 57dd8b65..a297bb5f 100644 --- a/lib/font/standard.js +++ b/lib/font/standard.js @@ -30,8 +30,12 @@ class StandardFont extends PDFFont { } encode(text) { + // Standard fonts use WinAnsiEncoding, which has precomposed letters like + // "ü" but no combining marks. Compose first, so that "u" followed by + // U+0308 is encoded as "ü" instead of a code the encoding cannot hold. + text = `${text}`.normalize('NFC'); const encoded = this.font.encodeText(text); - const glyphs = this.font.glyphsForString(`${text}`); + const glyphs = this.font.glyphsForString(text); const advances = this.font.advancesForGlyphs(glyphs); const positions = []; for (let i = 0; i < glyphs.length; i++) { @@ -49,7 +53,7 @@ class StandardFont extends PDFFont { } widthOfString(string, size) { - const glyphs = this.font.glyphsForString(`${string}`); + const glyphs = this.font.glyphsForString(`${string}`.normalize('NFC')); const advances = this.font.advancesForGlyphs(glyphs); let width = 0; diff --git a/tests/unit/text.spec.js b/tests/unit/text.spec.js index 6609ab9b..a714f83b 100644 --- a/tests/unit/text.spec.js +++ b/tests/unit/text.spec.js @@ -220,6 +220,42 @@ Q }); }); + describe('text with decomposed characters (NFD)', () => { + const textStream = (text) => { + const doc = new PDFDocument({ + info: { CreationDate: new Date(Date.UTC(2018, 1, 1)) }, + compress: false, + }); + const docData = logData(doc); + doc.text(text, 100, 80); + doc.end(); + return docData + .filter((item) => Buffer.isBuffer(item)) + .map((item) => item.toString('binary')) + .find((item) => item.includes(' TJ')); + }; + + test('renders the same as the composed text with a standard font', () => { + const composed = 'Text f\u00fcr \u00c9t\u00e9 na\u00efve'; + const decomposed = composed.normalize('NFD'); + expect(decomposed).not.toBe(composed); + + expect(textStream(decomposed)).toBe(textStream(composed)); + // "ü", "É", "é" and "ï" as single WinAnsi bytes (fc, c9, e9, ef) + expect(textStream(decomposed)).toContain( + '[<54> 120 <65> 30 <78742066fc7220c974e9206e61ef76> 25 <65> 0] TJ', + ); + }); + + test('measures the same width as the composed text with a standard font', () => { + // Helvetica "i" is narrower than "\u00ef", unlike most accented letters + const composed = 'na\u00efve'; + expect(document.widthOfString(composed.normalize('NFD'))).toBe( + document.widthOfString(composed), + ); + }); + }); + describe('text with structure parent links', () => { beforeEach(() => { document = new PDFDocument({