Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions docs/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion lib/font/afm.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
257 changes: 257 additions & 0 deletions lib/font/embedded.js
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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> <ff>
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.
Expand Down
17 changes: 13 additions & 4 deletions lib/mixins/acroform.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: [],
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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`);
}
},
Expand Down
Binary file added tests/fonts/Montserrat-Bold.otf
Binary file not shown.
Loading