diff --git a/CHANGELOG.md b/CHANGELOG.md index f0a210ae8..a63835a22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ### Unreleased +- Fix AcroForm text fields with a custom embedded font rendering in the wrong font in readers (e.g. Adobe Acrobat/Reader) that regenerate a field's appearance from its value, by giving the AcroForm `/DR` and `/DA` resources their own complete, simply-encoded font instead of reusing the subsetted `Identity-H` font pdfkit's content streams address by glyph ID. Fixes #1096 + ### [v0.20.2] - 2026-08-29 - Fix bundlers and file tracers packing the ESM copies of the standard font metrics instead of the CommonJS ones the Node build actually loads, which left `Cannot find module` errors for every standard font at runtime, by resolving the internal `#standard-fonts/*` mapping to a single file under all conditions diff --git a/docs/forms.md b/docs/forms.md index 544eafb69..65500449f 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -346,6 +346,16 @@ Some form documents may not need to generate appearances. This may be the case for text Form Annotations that initially have no value. This is not true for push button widget annotations. Please test +Because `NeedAppearances` asks the viewer to build a field's appearance from +its plain-text value at any time, the viewer needs to resolve that text to +glyphs on its own using the font in the AcroForm's `DR`/`DA` resources. When a +custom font is used, PDFKit embeds a dedicated, non-subsetted copy of that +font for this purpose (distinct from the subsetted, glyph-ID-addressed copy +used in page content), so a viewer can look up any character itself instead of +falling back to a substitute font. + + + ### Document JavaScript Many PDF Viewers, aside from Adobe Acrobat Reader, do not implement document diff --git a/lib/font/afm.js b/lib/font/afm.js index 53e72c66d..f3a9f4829 100644 --- a/lib/font/afm.js +++ b/lib/font/afm.js @@ -1,4 +1,8 @@ -const WIN_ANSI_MAP = { +// Maps Unicode code points to their WinAnsiEncoding code, for the block of +// codes (0x80-0x9F) where WinAnsiEncoding diverges from Latin-1/Unicode. +// Exported so other font embedders can build the inverse (code -> code point) +// mapping needed to describe a font with a standard /Encoding entry. +export const WIN_ANSI_MAP = { 402: 131, 8211: 150, 8212: 151, diff --git a/lib/font/embedded.js b/lib/font/embedded.js index 269218939..a9c5c09fc 100644 --- a/lib/font/embedded.js +++ b/lib/font/embedded.js @@ -1,9 +1,30 @@ import PDFFont from '../font'; +import { WIN_ANSI_MAP } from './afm'; const toHex = function (num) { return `0000${num.toString(16)}`.slice(-4); }; +// Inverse of WIN_ANSI_MAP (code point -> WinAnsiEncoding code), for the one +// block (0x80-0x9F) where the two diverge; every other code between +// FIRST_WIN_ANSI_CHAR and LAST_WIN_ANSI_CHAR maps 1:1 to the same code point. +const WIN_ANSI_CODE_TO_UNICODE = Object.fromEntries( + Object.entries(WIN_ANSI_MAP).map(([codePoint, code]) => [ + code, + Number(codePoint), + ]), +); +const UNDEFINED_WIN_ANSI_CODES = new Set([129, 141, 143, 144, 157]); // unused slots in that block +const FIRST_WIN_ANSI_CHAR = 32; +const LAST_WIN_ANSI_CHAR = 255; + +function unicodeForWinAnsiCode(code) { + if (UNDEFINED_WIN_ANSI_CODES.has(code)) { + return null; + } + return WIN_ANSI_CODE_TO_UNICODE[code] ?? code; +} + class EmbeddedFont extends PDFFont { constructor(document, font, id) { super(); @@ -120,6 +141,43 @@ class EmbeddedFont extends PDFFont { return width * scale; } + /** + * Returns the PDFReference to use for this font in an AcroForm's /DR and + * /DA resources, embedding a dedicated font for that purpose the first + * time it's requested. + * + * This can't reuse `ref()`: that font is a Type0 composite font under + * `/Encoding /Identity-H`, subsetted down to only the glyphs used in + * content streams pdfkit writes itself, which address glyphs directly by + * glyph ID and therefore never need a `cmap` table (pdfkit's subsetter + * omits it). But pdfkit's `initForm()` always sets `NeedAppearances`, + * which asks the reader to regenerate a field's appearance from its + * plain-text value at any time -- and Identity-H gives it no character + * encoding to resolve that text against. Without a way to map field text + * to glyphs, readers such as Adobe Acrobat/Reader silently fall back to a + * substitute font (see foliojs/pdfkit#1096). The font `embedForAcroForm()` + * builds is a complete, non-subsetted, standard-encoding font instead, so + * a reader can resolve arbitrary field text against it on its own. + */ + acroFormRef() { + return this.acroFormDictionary != null + ? this.acroFormDictionary + : (this.acroFormDictionary = this.document.ref()); + } + + finalize() { + if (this.embedded) { + return; + } + if (this.dictionary != null) { + this.embed(); + } + if (this.acroFormDictionary != null) { + this.embedForAcroForm(); + } + this.embedded = true; + } + embed() { const isCFF = this.subset.cff != null; const fontFile = this.document.ref(); @@ -231,6 +289,205 @@ class EmbeddedFont extends PDFFont { return this.dictionary.end(); } + /** + * Embeds a second, standalone font for AcroForm use: a composite font + * holding every glyph, addressed through a custom WinAnsiEncoding-to-glyph + * CMap instead of the usual `/Identity-H`. See `acroFormRef()` for why + * this exists separately from `embed()`, and `winAnsiToGidCmap()` for why + * it's a composite font with a custom `/Encoding` rather than a simple + * font with `/Encoding /WinAnsiEncoding`. + */ + embedForAcroForm() { + const isCFF = this.subset.cff != null; + + // Build a subset that includes every glyph rather than embedding the + // font's own program buffer untouched: a reader must be able to resolve + // *any* character a user later types into the field, not just the ones + // already drawn elsewhere in the document, so it can't be a subset in + // pdfkit's usual sense. This still goes through the ordinary subset + // encoder (not the font's raw bytes) because the source font may be a + // WOFF/WOFF2 file, whose raw bytes are a compressed container rather + // than a valid standalone TrueType/CFF program; fontkit's subset encoder + // already normalizes any source format into one. + // + // Because every glyph is included in ascending order, the subset's own + // renumbering (fontkit's `Subset#includeGlyph`) assigns each glyph the + // same id it already had in `this.font`, so the ids used below to build + // the encoding and widths still apply directly to this font program. + const fullSubset = this.font.createSubset(); + for (let gid = 0; gid < this.font.numGlyphs; gid++) { + fullSubset.includeGlyph(gid); + } + const fontProgram = fullSubset.encode(); + + const fontFile = this.document.ref(); + if (isCFF) { + fontFile.data.Subtype = 'CIDFontType0C'; + } else { + // Required for FontFile2 (spec 9.9, Table 127): the length in bytes of + // the uncompressed TrueType program. Without it, Acrobat's forms + // engine reports the font as one it "could not be extracted" when it + // loads it to regenerate this field's appearance. + fontFile.data.Length1 = fontProgram.length; + } + fontFile.end(fontProgram); + + const familyClass = + ((this.font['OS/2'] != null + ? this.font['OS/2'].sFamilyClass + : undefined) || 0) >> 8; + let flags = 0; + if (this.font.post.isFixedPitch) { + flags |= 1 << 0; + } + if (1 <= familyClass && familyClass <= 7) { + flags |= 1 << 1; + } + flags |= 1 << 2; // assume the font uses non-latin characters, as embed() does + if (this.font.head.macStyle.italic) { + flags |= 1 << 6; + } + + // This font program isn't a subset in pdfkit's usual sense (it holds + // every glyph, not just the ones used so far), so, unlike `embed()`, its + // name must not use the subset-tag convention (a six-uppercase-letter + // prefix meaning "an arbitrary subset of the font named after the +", + // spec 9.6.4). Reusing that tag previously gave both fonts the same + // `/BaseFont` name despite different font programs, which is what caused + // Acrobat to report the font this method builds as one it "could not be + // extracted". + const name = this.font.postscriptName?.replaceAll(' ', '_'); + + const { bbox } = this.font; + const descriptor = this.document.ref({ + Type: 'FontDescriptor', + FontName: name, + Flags: flags, + FontBBox: [ + bbox.minX * this.scale, + bbox.minY * this.scale, + bbox.maxX * this.scale, + bbox.maxY * this.scale, + ], + ItalicAngle: this.font.italicAngle, + Ascent: this.ascender, + Descent: this.descender, + CapHeight: (this.font.capHeight || this.font.ascent) * this.scale, + XHeight: (this.font.xHeight || 0) * this.scale, + StemV: 0, + }); + + if (isCFF) { + descriptor.data.FontFile3 = fontFile; + } else { + descriptor.data.FontFile2 = fontFile; + } + + descriptor.end(); + + // One width per glyph id, matching the font program above (which holds + // every glyph, not only the WinAnsiEncoding-representable ones). + const widths = []; + for (let gid = 0; gid < this.font.numGlyphs; gid++) { + widths.push(this.font.getGlyph(gid).advanceWidth * this.scale); + } + + const descendantFontData = { + Type: 'Font', + Subtype: isCFF ? 'CIDFontType0' : 'CIDFontType2', + BaseFont: name, + CIDSystemInfo: { + Registry: new String('Adobe'), + Ordering: new String('Identity'), + Supplement: 0, + }, + FontDescriptor: descriptor, + W: [0, widths], + }; + if (!isCFF) { + descendantFontData.CIDToGIDMap = 'Identity'; + } + + const descendantFont = this.document.ref(descendantFontData); + descendantFont.end(); + + this.acroFormDictionary.data = { + Type: 'Font', + Subtype: 'Type0', + BaseFont: name, + Encoding: this.winAnsiToGidCmap(), + DescendantFonts: [descendantFont], + }; + + return this.acroFormDictionary.end(); + } + + /** + * Builds an embedded CMap mapping each single-byte WinAnsiEncoding code to + * the id of the glyph it represents (which are the same numbers as CIDs + * here, see `embedForAcroForm()`), for use as that method's Type0 font's + * `/Encoding` in place of a standard name such as `/Identity-H`. + * + * `Identity-H` only works when the content stream author already knows + * which glyph id corresponds to each character, which is exactly the + * capability a reader regenerating a field's appearance from its + * plain-text value doesn't have -- and fontkit's subset encoder never + * retains the font's own cmap or glyph-name tables that a reader could + * otherwise have used, no matter how many glyphs a subset includes + * (composite fonts, which is all pdfkit ever produces elsewhere, never + * need them, so the encoder doesn't build them). This gives a reader a + * character encoding to resolve field text against on its own anyway, + * without depending on either. + */ + winAnsiToGidCmap() { + const cmap = this.document.ref(); + cmap.data.Type = 'CMap'; + + const entries = []; + for (let code = FIRST_WIN_ANSI_CHAR; code <= LAST_WIN_ANSI_CHAR; code++) { + const codePoint = unicodeForWinAnsiCode(code); + if (codePoint == null || !this.font.hasGlyphForCodePoint(codePoint)) { + continue; + } + const gid = this.font.glyphForCodePoint(codePoint).id; + entries.push(`<${code.toString(16).padStart(2, '0')}> ${gid}`); + } + + const chunkSize = 100; + const chunks = Math.ceil(entries.length / chunkSize); + const ranges = []; + for (let i = 0; i < chunks; i++) { + const start = i * chunkSize; + const end = Math.min((i + 1) * chunkSize, entries.length); + ranges.push( + `${end - start} begincidchar\n${entries.slice(start, end).join('\n')}\nendcidchar`, + ); + } + + cmap.end(`\ +/CIDInit /ProcSet findresource begin +12 dict begin +begincmap +/CIDSystemInfo << + /Registry (Adobe) + /Ordering (Identity) + /Supplement 0 +>> def +/CMapName /Adobe-Identity-WinAnsi def +/CMapType 1 def +1 begincodespacerange +<20> +endcodespacerange +${ranges.join('\n')} +endcmap +CMapName currentdict /CMap defineresource pop +end +end\ +`); + + return cmap; + } + // Maps the glyph ids encoded in the PDF back to unicode strings // Because of ligature substitutions and the like, there may be one or more // unicode characters represented by each glyph. diff --git a/lib/mixins/acroform.js b/lib/mixins/acroform.js index d283b1c4b..2f1a5a3f4 100644 --- a/lib/mixins/acroform.js +++ b/lib/mixins/acroform.js @@ -160,6 +160,15 @@ function mapFormat(options, pdfObject) { } } +// AcroForm's /DR and /DA resources need a font a reader can resolve +// arbitrary field text against on its own (see EmbeddedFont#acroFormRef for +// why); StandardFont has no such distinction, so it falls back to ref(). +function acroFormFontRef(font) { + return typeof font.acroFormRef === 'function' + ? font.acroFormRef() + : font.ref(); +} + export default { /** * Must call if adding AcroForms to a document. Must also call font() before @@ -173,7 +182,7 @@ export default { fonts: {}, defaultFont: this._font.name, }; - this._acroform.fonts[this._font.id] = this._font.ref(); + this._acroform.fonts[this._font.id] = acroFormFontRef(this._font); let data = { Fields: [], @@ -183,7 +192,7 @@ export default { Font: {}, }, }; - data.DR.Font[this._font.id] = this._font.ref(); + data.DR.Font[this._font.id] = acroFormFontRef(this._font); const AcroForm = this.ref(data); this._root.data.AcroForm = AcroForm; return this; @@ -341,7 +350,7 @@ export default { const { _acroform, _font } = this; // add current font to document-level AcroForm dict if necessary if (_acroform.fonts[_font.id] == null) { - _acroform.fonts[_font.id] = _font.ref(); + _acroform.fonts[_font.id] = acroFormFontRef(_font); } // add current font to field's resource dict (RD) if not the default acroform font @@ -351,7 +360,7 @@ export default { // Get the fontSize option. If not set use auto sizing const fontSize = options.fontSize || 0; - pdfObject.DR.Font[_font.id] = _font.ref(); + pdfObject.DR.Font[_font.id] = acroFormFontRef(_font); pdfObject.DA = new String(`/${_font.id} ${fontSize} Tf 0 g`); } }, diff --git a/tests/fonts/Montserrat-Bold.otf b/tests/fonts/Montserrat-Bold.otf new file mode 100644 index 000000000..cdfb83df2 Binary files /dev/null and b/tests/fonts/Montserrat-Bold.otf differ diff --git a/tests/unit/acroform.spec.js b/tests/unit/acroform.spec.js index ac01fe6a1..5a491ebe4 100644 --- a/tests/unit/acroform.spec.js +++ b/tests/unit/acroform.spec.js @@ -1,8 +1,21 @@ +import zlib from 'zlib'; import PDFDocument from '../../lib/document'; import PDFSecurity from '../../lib/security'; import { logData, joinTokens } from './helpers'; import PDFFontFactory from '../../lib/font_factory'; +// Returns the body (as a single binary string, stream bytes included) of the +// `n 0 obj ... endobj` entry logged by `logData`. +function objectBody(docData, n) { + const start = docData.indexOf(`${n} 0 obj`); + if (start === -1) return null; + const end = docData.indexOf('endobj', start); + return docData + .slice(start + 1, end) + .map((item) => (item instanceof Buffer ? item.toString('binary') : item)) + .join('\n'); +} + // manual mock for PDFSecurity to ensure stored id will be the same accross different systems PDFSecurity.generateFileID = () => { return Buffer.from('mocked-pdf-id'); @@ -63,7 +76,7 @@ describe('acroform', () => { test('init standard fonts', () => { const expected = [ - '12 0 obj', + '13 0 obj', joinTokens( '<<', '/FT', @@ -75,7 +88,7 @@ describe('acroform', () => { '/Font', '<<', '/F3', - '10 0 R', + '12 0 R', '>>', '>>', '/DA', @@ -417,4 +430,139 @@ describe('acroform', () => { } } }); + + // Regression test for https://github.com/foliojs/pdfkit/issues/1096: + // a custom embedded font applied to a form field rendered with the wrong + // font in readers (e.g. Adobe Acrobat/Reader) that regenerate the field's + // appearance from its value, even though the same font renders correctly + // for ordinary page text. + test('AcroForm uses a font a reader can resolve field text against on its own', () => { + const docData = logData(doc); + + doc.font('tests/fonts/Roboto-Regular.ttf'); + doc.initForm(); + doc.formText('field1', 10, 10, 200, 20, { value: 'Hello' }); + // Also draw with the same font in the page content, so the test proves + // the two usages embed independently rather than sharing one font object. + doc.text('Hello', 10, 100); + doc.end(); + + // Locate the AcroForm dict, and the font object its /DR references. + const acroFormIdx = docData.findIndex( + (item) => typeof item === 'string' && item.includes('/NeedAppearances'), + ); + expect(acroFormIdx).toBeGreaterThan(-1); + const drFontRef = docData[acroFormIdx].match( + /\/DR\s*<<\s*\/Font\s*<<\s*\/\S+\s+(\d+)\s+0\s+R/, + ); + expect(drFontRef).not.toBeNull(); + const acroFormFontBody = objectBody(docData, drFontRef[1]); + + // The AcroForm font is a composite font, like the one pdfkit uses in + // content streams, but addressed through a custom CMap instead of + // `/Identity-H`: Identity-H has no character encoding a reader could + // resolve on its own, since it only works when the content stream + // author (pdfkit itself) already knows which glyph id corresponds to + // each character. + expect(acroFormFontBody).toContain('/Subtype /Type0'); + expect(acroFormFontBody).not.toContain('/Encoding /Identity-H'); + + // The font actually used to draw page text is a different object, + // untouched: still the subsetted Type0/Identity-H composite font. + const pageFontRefIdx = docData.findIndex( + (item) => + typeof item === 'string' && + item.includes('/ProcSet') && + item.includes('/Font'), + ); + expect(pageFontRefIdx).toBeGreaterThan(-1); + const pageFontRef = docData[pageFontRefIdx].match(/\/F\d+ (\d+) 0 R/); + expect(pageFontRef[1]).not.toBe(drFontRef[1]); + const contentFontBody = objectBody(docData, pageFontRef[1]); + expect(contentFontBody).toContain('/Subtype /Type0'); + expect(contentFontBody).toContain('/Encoding /Identity-H'); + + // The whole point: the AcroForm font's /Encoding must be a custom CMap a + // reader can use to resolve arbitrary WinAnsiEncoding field text to a + // glyph on its own -- built from `this.font`'s own character coverage, + // not from whatever `this.subset` (the font used for the page text + // above) happens to already include. + const encodingRef = acroFormFontBody.match(/\/Encoding (\d+) 0 R/); + expect(encodingRef).not.toBeNull(); + const cmapObjectBody = objectBody(docData, encodingRef[1]); + expect(cmapObjectBody).toContain('/Type /CMap'); + const cmapStreamMatch = cmapObjectBody.match( + /stream\r?\n([\s\S]*?)\r?\nendstream/, + ); + const cmapBody = zlib + .inflateSync(Buffer.from(cmapStreamMatch[1], 'binary')) + .toString('latin1'); + expect(cmapBody).toContain('begincidchar'); + // 'H' (0x48) is in "Hello", drawn as page content above, but WinAnsi code + // 0x21 ('!') never appears anywhere in this test -- the CMap must cover + // it anyway, since it isn't built from the glyphs used so far. + expect(cmapBody).toMatch(/<48> \d+/); + expect(cmapBody).toMatch(/<21> \d+/); + }); + + // Same regression as above, but for a CFF-flavored font (OpenType/CFF + // rather than TrueType). fontkit's CFF subsetter always emits CID-keyed, + // nameless output, and a naive "subset then embed" approach still leaves + // the AcroForm font unreadable by a viewer -- the composite font with a + // custom WinAnsi CMap must work for this font format too, embedded as + // `/FontFile3 /Subtype /CIDFontType0C` rather than `/FontFile2`. + test('AcroForm resolves field text for a CFF-flavored font too', () => { + const docData = logData(doc); + + doc.font('tests/fonts/Montserrat-Bold.otf'); + doc.initForm(); + doc.formText('field1', 10, 10, 200, 20, { value: 'Hello' }); + doc.text('Hello', 10, 100); + doc.end(); + + const acroFormIdx = docData.findIndex( + (item) => typeof item === 'string' && item.includes('/NeedAppearances'), + ); + expect(acroFormIdx).toBeGreaterThan(-1); + const drFontRef = docData[acroFormIdx].match( + /\/DR\s*<<\s*\/Font\s*<<\s*\/\S+\s+(\d+)\s+0\s+R/, + ); + expect(drFontRef).not.toBeNull(); + const acroFormFontBody = objectBody(docData, drFontRef[1]); + + expect(acroFormFontBody).toContain('/Subtype /Type0'); + expect(acroFormFontBody).not.toContain('/Encoding /Identity-H'); + + // Descendant font must be CIDFontType0/CIDFontType0C, not the + // TrueType-only CIDFontType2/FontFile2 path. + const descendantRef = acroFormFontBody.match( + /\/DescendantFonts\s*\[\s*(\d+)\s+0\s+R/, + ); + expect(descendantRef).not.toBeNull(); + const descendantBody = objectBody(docData, descendantRef[1]); + expect(descendantBody).toContain('/Subtype /CIDFontType0'); + expect(descendantBody).not.toContain('/CIDToGIDMap'); + + const descriptorRef = descendantBody.match(/\/FontDescriptor (\d+) 0 R/); + expect(descriptorRef).not.toBeNull(); + const descriptorBody = objectBody(docData, descriptorRef[1]); + const fontFileRef = descriptorBody.match(/\/FontFile3 (\d+) 0 R/); + expect(fontFileRef).not.toBeNull(); + const fontFileBody = objectBody(docData, fontFileRef[1]); + expect(fontFileBody).toContain('/Subtype /CIDFontType0C'); + + const encodingRef = acroFormFontBody.match(/\/Encoding (\d+) 0 R/); + expect(encodingRef).not.toBeNull(); + const cmapObjectBody = objectBody(docData, encodingRef[1]); + expect(cmapObjectBody).toContain('/Type /CMap'); + const cmapStreamMatch = cmapObjectBody.match( + /stream\r?\n([\s\S]*?)\r?\nendstream/, + ); + const cmapBody = zlib + .inflateSync(Buffer.from(cmapStreamMatch[1], 'binary')) + .toString('latin1'); + expect(cmapBody).toContain('begincidchar'); + expect(cmapBody).toMatch(/<48> \d+/); + expect(cmapBody).toMatch(/<21> \d+/); + }); });