diff --git a/CHANGELOG.md b/CHANGELOG.md index 47a94d8f0..cd88cc299 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Add a `hidden` option to form annotation methods, for a field that should start hidden (e.g. one an interactive action reveals later) instead of the usual default of visible and printable - 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 ### [v0.20.2] - 2026-08-29 diff --git a/docs/forms.md b/docs/forms.md index db6f4d952..ab024eb9f 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -388,6 +388,23 @@ 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 +With a custom font, a viewer building the appearance this way may show the +field in a substitute font: the font PDFKit embeds for page content is +subsetted and addressed by glyph id, which gives the viewer no way to resolve +the field's text itself. Passing `embedFonts` to `initForm` embeds a +complete, character-addressable copy of each font used in a field, which +viewers can resolve any text against: + +```js +doc.font('fonts/MyFont.ttf'); +doc.initForm({ embedFonts: true }); +``` + +That copy holds the whole font rather than the glyphs used so far, so it adds +roughly the size of the font file to the document, per font. It is off by +default for that reason, and only affects custom fonts — the standard 14 +fonts are not embedded at all. + ### 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..995820a81 100644 --- a/lib/font/embedded.js +++ b/lib/font/embedded.js @@ -1,9 +1,52 @@ 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; +} + +/** + * Builds a subset of `font` holding every glyph, numbered exactly as in `font` + * itself. + * + * This goes through the ordinary subset encoder rather than using the font's + * own program buffer untouched, because the source 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. + * + * Including every glyph in ascending order makes the subset's own renumbering + * (fontkit's `Subset#includeGlyph`) assign each glyph the id it already had, + * so glyph ids taken from `font` address the resulting program directly. + */ +function completeSubsetOf(font) { + const subset = font.createSubset(); + for (let gid = 0; gid < font.numGlyphs; gid++) { + subset.includeGlyph(gid); + } + return subset; +} + class EmbeddedFont extends PDFFont { constructor(document, font, id) { super(); @@ -120,15 +163,63 @@ class EmbeddedFont extends PDFFont { return width * scale; } - embed() { - const isCFF = this.subset.cff != null; - const fontFile = this.document.ref(); + /** + * Returns the PDFReference of a complete, text-addressable embedding of this + * font, embedding it the first time it's requested. + * + * The font `ref()` returns is a Type0 composite font under `/Encoding + * /Identity-H`, subsetted down to the glyphs pdfkit has drawn so far and + * addressed directly by glyph id, so it carries neither a character + * encoding nor the glyphs a caller has not used yet. A consumer that has to + * resolve arbitrary text against the font on its own -- rather than being + * handed glyph ids, as pdfkit's own content streams are -- needs both. This + * embedding is complete and addressed by character code instead. + * + * It costs a full copy of the font program, so call it only when that is + * actually needed. + */ + completeRef() { + return this.completeDictionary != null + ? this.completeDictionary + : (this.completeDictionary = this.document.ref()); + } + finalize() { + if (this.embedded) { + return; + } + if (this.dictionary != null) { + this.embed(); + } + if (this.completeDictionary != null) { + this.embedComplete(); + } + this.embedded = true; + } + + /** + * Embeds a font program and its descriptor, and returns them together with + * the `/BaseFont` name they must be referenced under. + * + * `complete` says whether the program holds every glyph. Such a program is + * not a subset in the sense of spec 9.6.4, so its name must not carry the + * subset tag and it needs no `/CIDSet`. + */ + embedProgram(subset, complete) { + const isCFF = subset.cff != null; + const fontProgram = subset.encode(); + + const fontFile = this.document.ref(); if (isCFF) { fontFile.data.Subtype = 'CIDFontType0C'; + } else if (complete) { + // Required for FontFile2 (spec 9.9, Table 127): the length in bytes of + // the uncompressed TrueType program. Without it, Acrobat reports the + // font as one it "could not be extracted" when it loads the program + // rather than just the dictionary. + fontFile.data.Length1 = fontProgram.length; } - - fontFile.end(this.subset.encode()); + fontFile.end(fontProgram); const familyClass = ((this.font['OS/2'] != null @@ -149,11 +240,16 @@ class EmbeddedFont extends PDFFont { flags |= 1 << 6; } - // generate a tag (6 uppercase letters. 17 is the char code offset from '0' to 'A'. 73 will map to 'Z') + // A subset is named with a six-uppercase-letter tag meaning "an arbitrary + // subset of the font named after the +" (spec 9.6.4); 17 is the char code + // offset from '0' to 'A', and 73 maps to 'Z'. A complete program is no + // such subset, and tagging it anyway would give two different programs + // the same name, which Acrobat rejects. + const postscriptName = this.font.postscriptName?.replaceAll(' ', '_'); const tag = [1, 2, 3, 4, 5, 6] .map((i) => String.fromCharCode((this.id.charCodeAt(i) || 73) + 17)) .join(''); - const name = tag + '+' + this.font.postscriptName?.replaceAll(' ', '_'); + const name = complete ? postscriptName : `${tag}+${postscriptName}`; const { bbox } = this.font; const descriptor = this.document.ref({ @@ -180,7 +276,9 @@ class EmbeddedFont extends PDFFont { descriptor.data.FontFile2 = fontFile; } - if (this.document.subset && this.document.subset === 1) { + // /CIDSet lists the CIDs a subset actually contains, which PDF/A-1 + // requires of a subsetted font and only of one. + if (!complete && this.document.subset && this.document.subset === 1) { const maxCID = this.widths.length - 1; const cidSetBuffer = new Uint8Array(Math.ceil((maxCID + 1) / 8)); for (let cid = 0; cid <= maxCID; cid++) { @@ -197,6 +295,12 @@ class EmbeddedFont extends PDFFont { descriptor.end(); + return { descriptor, name, isCFF }; + } + + embed() { + const { descriptor, name, isCFF } = this.embedProgram(this.subset, false); + const descendantFontData = { Type: 'Font', Subtype: 'CIDFontType0', @@ -231,6 +335,122 @@ class EmbeddedFont extends PDFFont { return this.dictionary.end(); } + /** + * Embeds the font `completeRef()` hands out: a composite font holding every + * glyph, addressed through a custom WinAnsiEncoding-to-glyph CMap instead of + * the usual `/Identity-H`. See `completeRef()` for why it exists separately + * from `embed()`, and `winAnsiToGidCmap()` for why it is a composite font + * with a custom `/Encoding` rather than a simple font with `/Encoding + * /WinAnsiEncoding`. + */ + embedComplete() { + const { descriptor, name, isCFF } = this.embedProgram( + completeSubsetOf(this.font), + true, + ); + + // One width per glyph id, matching the font program above, which holds + // every glyph rather than 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.completeDictionary.data = { + Type: 'Font', + Subtype: 'Type0', + BaseFont: name, + Encoding: this.winAnsiToGidCmap(), + DescendantFonts: [descendantFont], + }; + + return this.completeDictionary.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 `embedComplete()`), 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 whoever writes the content stream already + * knows which glyph id corresponds to each character, which is exactly what + * a consumer starting from plain text does not know -- and fontkit's subset + * encoder never retains the font's own cmap or glyph-name tables it 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 such a consumer a + * character encoding to resolve text against 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 8178c4a86..5fb55570c 100644 --- a/lib/mixins/acroform.js +++ b/lib/mixins/acroform.js @@ -206,20 +206,39 @@ function mapFormat(options, pdfObject) { } } +// The font reference to use in the /DR and /DA resources of a form or a +// field. +// +// `NeedAppearances` asks the reader to regenerate a field's appearance from +// its plain-text value, which it can only do with a font it can resolve that +// text against on its own. The font pdfkit embeds for its own content streams +// is subsetted and addressed by glyph id, so a reader given only that falls +// back to a substitute font for the field (see #1096). An embedded font can +// provide a complete, text-addressable embedding instead, at the cost of a +// second copy of the font program in the file -- so this is only used when +// `initForm` was asked for it. Standard fonts need nothing of the sort, and +// have no `completeRef`. +function formFontRef(doc, font) { + return doc._acroform.embedFonts && typeof font.completeRef === 'function' + ? font.completeRef() + : font.ref(); +} + export default { /** * Must call if adding AcroForms to a document. Must also call font() before * this method to set the default font. */ - initForm() { + initForm(options = {}) { if (!this._font) { throw new Error('Must set a font before calling initForm method'); } this._acroform = { fonts: {}, defaultFont: this._font.name, + embedFonts: options.embedFonts === true, }; - this._acroform.fonts[this._font.id] = this._font.ref(); + this._acroform.fonts[this._font.id] = formFontRef(this, this._font); let data = { Fields: [], @@ -229,7 +248,7 @@ export default { Font: {}, }, }; - data.DR.Font[this._font.id] = this._font.ref(); + data.DR.Font[this._font.id] = formFontRef(this, this._font); const AcroForm = this.ref(data); this._root.data.AcroForm = AcroForm; return this; @@ -389,7 +408,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] = formFontRef(this, _font); } // add current font to field's resource dict (RD) if not the default acroform font @@ -399,7 +418,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] = formFontRef(this, _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 81c47e520..c2c60ed18 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'); @@ -532,4 +545,171 @@ describe('acroform', () => { } } }); + + test('without embedFonts the form reuses the content-stream font', () => { + const docData = logData(doc); + + doc.font('tests/fonts/Roboto-Regular.ttf'); + 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'), + ); + const drFontRef = docData[acroFormIdx].match( + /\/DR\s*<<\s*\/Font\s*<<\s*\/\S+\s+(\d+)\s+0\s+R/, + ); + expect(drFontRef).not.toBeNull(); + + // The same object the page content references, and no second copy of the + // font program: the default stays exactly as it was before the option. + const pageFontRefIdx = docData.findIndex( + (item) => + typeof item === 'string' && + item.includes('/ProcSet') && + item.includes('/Font'), + ); + const pageFontRef = docData[pageFontRefIdx].match(/\/F\d+ (\d+) 0 R/); + expect(drFontRef[1]).toBe(pageFontRef[1]); + expect(objectBody(docData, drFontRef[1])).toContain( + '/Encoding /Identity-H', + ); + }); + + // 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('embedFonts gives the form a font readers resolve field text against', () => { + const docData = logData(doc); + + doc.font('tests/fonts/Roboto-Regular.ttf'); + doc.initForm({ embedFonts: true }); + 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('embedFonts resolves field text for a CFF-flavored font too', () => { + const docData = logData(doc); + + doc.font('tests/fonts/Montserrat-Bold.otf'); + doc.initForm({ embedFonts: true }); + 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+/); + }); });