Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 17 additions & 0 deletions docs/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
236 changes: 228 additions & 8 deletions lib/font/embedded.js
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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
Expand All @@ -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({
Expand All @@ -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++) {
Expand All @@ -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',
Expand Down Expand Up @@ -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> <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
29 changes: 24 additions & 5 deletions lib/mixins/acroform.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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`);
}
},
Expand Down
Binary file added tests/fonts/Montserrat-Bold.otf
Binary file not shown.
Loading
Loading