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({