PDF.js Source Walkthrough: fonts
October 27, 2026 · StreamPDF Team

Introduction
The previous articles covered the object model, XRef and Catalog — the infrastructure of the content layer. Once we reach content-stream evaluation, no Tj, TJ, ' or " operator can work without one thing: a font. The content stream only writes character codes; what actually decides "which shape this code draws, how wide it is, and which Unicode it maps to in the text layer" is the font dictionary and the font program behind it.
src/core/fonts.js (3801 lines) is the endpoint of font handling on the worker side. Its upstream is the encoding tables provided by encodings.js, the CMaps parsed by cmap.js, the glyph-name to Unicode map in glyphlist.js, the standard-font substitution tables in standard_fonts.js, and the base metrics in metrics.js; its downstream is glyph-outline compilation in font_renderer.js, text drawing in canvas.js, and FontFaceObject in the main-thread font_loader.js.
In one sentence: it translates the PDF font dictionary plus optional font program bytes into one unified Font object, and gives the evaluator glyphs, widths, Unicode and embeddable OpenType data.
Version note: This article follows exactly the version of
fonts.jsin the current repository. This version has only two classes,FontandErrorFont, and noTrueTypeFont,Type1Font,Type3FontorCFFFontsubclasses:Type1Fontcomes fromtype1_font.js,CFFFontcomes fromcff_font.js, the TrueType repair logic lives inFont.checkAndRepair, and Type3 returns directly from the constructor.FontFlags,ToUnicodeMapandIdentityToUnicodeMapare imported and are not defined in this file; the glyph lookup entry point is_charToGlyph, notcharToGlyph; the space width is the private getter_spaceWidth, notgetSpaceWidth; base metrics go throughgetFontBasicMetricsinmetrics.js, and this file has nogetBaseFontMetrics(that one lives on the evaluator inevaluator.js); there is also nogetType3Glyphand noFontRenderTypeconstant. Do not apply APIs from other versions you remember when reading the source.
Module Overview
fonts.js splits into nine blocks. The first seven revolve around "turning a font program into renderable data", and the last two handle metrics and the last-resort path:
| Block | Key classes or methods | Responsibility |
|---|---|---|
| Constants and utilities | PRIVATE_USE_AREAS, PDF_GLYPH_SPACE_UNITS, EXPORT_DATA_PROPERTIES, int16, signedInt16 | Private-use ranges, glyph-space conversion, export field tables, small binary helpers |
| Format detection | isTrueTypeFile, isTrueTypeCollectionFile, isOpenTypeFile, isType1File, isCFFFile, getFontFileType | Read the file header to determine the real type instead of trusting the dictionary |
| Mapping construction | adjustMapping, getRanges, createCmapTable, buildToFontChar, getSymbolGlyphIdEncoding | Move character codes into the private use area, rebuild cmap, OS/2, post, name |
| Glyph object | Glyph | The complete result of one character-code translation, shared by evaluator and text layer |
| Font body | Font constructor | Read properties, dispatch by type, fall back on failure |
| TrueType repair | checkAndRepair | Validate and rebuild tables, sanitize glyphs, fix metrics |
| Type1/CFF wrapping | convert | Wrap CFF data in an OTF shell and generate the necessary tables |
| Glyph queries | _charToGlyph, charsToGlyphs, _spaceWidth, encodeString, glyphCacheValues | Query, cache, reverse encoding |
| Export and last resort | exportData, fallbackToSystemFont, ErrorFont | Compress the result into binary, use a system font when the font is missing |
Its place in the whole chain can be drawn like this:
// Font dictionary /FontFile2 /FontFile3 + character codes in the content stream
// → PartialEvaluator.extractDataStructures / extractWidths (property preparation)
// → new Font(name, file, properties, options) (this article)
// → getFontFileType → checkAndRepair / convert
// → toUnicode / toFontChar / widths / fontMatrix
// → Font._charToGlyph / charsToGlyphs (character code → Glyph)
// → Font.renderer (FontRendererFactory) (glyph outlines)
// → exportData → TranslatedFont.send → FontFaceObject (main-thread loading)
// → text drawing in canvas.js and text selection in text_layerFont does not parse content streams, and it does not paint glyphs onto the canvas. Its boundary is clear: given a font dictionary and a font program, produce a stable query interface of "character code in, glyph and width out", and prepare font data the browser can load. The sections below follow source order, but group related helper functions under the same topic instead of translating line by line.
Font Type Dispatch and the Construction Flow
Constructor Inputs and Outputs
The Font constructor signature is short, but its arguments are the carefully prepared results of the evaluator:
constructor(name, file, properties, evaluatorOptions) {
this.name = name;
this.psName = null;
this.mimetype = null;
this.disableFontFace = evaluatorOptions.disableFontFace;
this.fontExtraProperties = evaluatorOptions.fontExtraProperties;
this.loadedName = properties.loadedName;
this.isType3Font = properties.isType3Font;
this.missingFile = false;
this.cssFontInfo = properties.cssFontInfo;name is the base font name from the PDF, possibly with a subset prefix such as ABCDEF+; file is the stream taken from /FontFile, /FontFile2 or /FontFile3, and may be empty; properties is the property bag assembled by PartialEvaluator.extractDataStructures and extractWidths; evaluatorOptions cares about only two switches: whether font embedding is disabled and whether extra properties are exported.
loadedName is not the PDF name but a globally unique name generated by the evaluator, shaped like docId_fontId, which later becomes the key for the CSS font-family and for commonobj messages. missingFile starts as false and is set to true as soon as the system-font fallback is used, so the layers above can tell whether this font was pieced together.
The output of the constructor is not a plain data object but a set of methods: _charToGlyph, charsToGlyphs, getCharPositions, encodeString, exportData, plus a lazily built renderer getter. Every field is intermediate state for those methods.
Deriving serif, monospace and fallbackName from Flags
The /Flags entry in the font descriptor is often untrustworthy, so the source performs a derivation of "trust Flags first, then trust the name":
let isSerifFont = !!(properties.flags & FontFlags.Serif);
// Fallback to checking the font name, in order to improve text-selection,
// since the /Flags-entry is often wrong (fixes issue13845.pdf).
if (!isSerifFont && !properties.isSimulatedFlags) {
const stdFontMap = getStdFontMap(),
nonStdFontMap = getNonStdFontMap(),
serifFonts = getSerifFonts();
for (const namePart of name.split('+')) {
let fontName = normalizeFontName(namePart);
fontName = stdFontMap[fontName] || nonStdFontMap[fontName] || fontName;
fontName = fontName.split('-', 1)[0];
if (serifFonts[fontName]) {
isSerifFont = true;
break;
}
}
}
this.isSerifFont = isSerifFont;
this.isSymbolicFont = !!(properties.flags & FontFlags.Symbolic);
this.isMonospace = !!(properties.flags & FontFlags.FixedPitch);FontFlags is a bit mask: Serif is 2, Symbolic is 4, FixedPitch is 1. Bit arithmetic can only answer "is this flag present", not "is this flag true". So when the Serif bit is not set and the flags are not simulated, the source splits the name and normalizes each part: strip the subset prefix, replace aliases through the standard font maps, then strip the weight suffix, and finally look it up in getSerifFonts. That way names such as ArialNarrow and TimesNewRomanPS-BoldMT still land in the correct serif group even when the Flags are wrong.
fallbackName is the last card for CSS, with the priority "invalid font name, then monospace, then serif, then sans-serif":
const matches = name.match(/^InvalidPDFjsFont_(.*)_\d+$/);
this.isInvalidPDFjsFont = !!matches;
if (this.isInvalidPDFjsFont) {
this.fallbackName = matches[1];
} else if (this.isMonospace) {
this.fallbackName = 'monospace';
} else if (this.isSerifFont) {
this.fallbackName = 'serif';
} else {
this.fallbackName = 'sans-serif';
}
if (this.systemFontInfo?.guessFallback) {
// Once the fallback name is guessed, we don't want to guess it again.
this.systemFontInfo.guessFallback = false;
this.systemFontInfo.css += `,${this.fallbackName}`;
}The InvalidPDFjsFont_ prefix is a placeholder the evaluator creates when the font name is completely invalid. The part captured by the regex is itself part of the original font name, so it can be used directly as a fallback. systemFontInfo.css is the CSS font-family string produced by system-font substitution, and guessFallback guarantees that "append one fallback" happens only once, so a font with the same name is not extended repeatedly across pages.
Design note:
Fonttreats/Flagsas "read it, but do not worship it". A bit mask is cheap metadata and is used for the default decision first; as soon as it is missing or contradicts the name, the name and the mapping tables cross-check it. This design keeps font grouping stable on damaged PDFs, because a font name is harder to tamper with arbitrarily than Flags — and it also determines system-font substitution and text-layer display.
Unified conversion of metric fields
The middle of the constructor converts the PDF "thousandth of a font size" unit into the "one em" unit used throughout the rest of the file:
this.differences = properties.differences;
this.widths = properties.widths;
this.defaultWidth = properties.defaultWidth;
this.composite = properties.composite;
this.cMap = properties.cMap;
this.capHeight = properties.capHeight / PDF_GLYPH_SPACE_UNITS;
this.ascent = properties.ascent / PDF_GLYPH_SPACE_UNITS;
this.descent = properties.descent / PDF_GLYPH_SPACE_UNITS;
this.lineHeight = this.ascent - this.descent;
this.fontMatrix = properties.fontMatrix;
this.bbox = properties.bbox;
this.defaultEncoding = properties.defaultEncoding;
this.toUnicode = properties.toUnicode;
this.toFontChar = [];PDF_GLYPH_SPACE_UNITS is 1000. The PDF /Ascent, /Descent and /CapHeight entries are written in "thousandths of text space units"; dividing by 1000 turns them into "multiples of the font size", so the layers above can multiply by the font size to get a height in device space. lineHeight is the plain ascent - descent; if a real font program is parsed later, it is overwritten using the hhea table.
fontMatrix is kept as is, because the glyph space of a Type3 font is not thousandths but is defined by /FontMatrix itself. bbox is also kept unchanged: the Type3 d1 and the later createOS2Table both depend on it.
One detail is easy to miss: toFontChar is initialized to an empty array, not null. It is a sparse array indexed by character code, where the index is the character code in the PDF content stream and the value is the font char code to hand to the browser font. The Type3 branch fills it immediately; the other branches fill it during repair or conversion.
The early return for Type3
Type3 fonts have no font program; their glyphs are content streams:
if (properties.type === 'Type3') {
for (let charCode = 0; charCode < 256; charCode++) {
this.toFontChar[charCode] = this.differences[charCode] || properties.defaultEncoding[charCode];
}
return;
}These dozen lines are the earliest fork in the whole constructor. The "font program" of Type3 is a set of content streams in /CharProcs, which the evaluator later compiles into an operatorList through TranslatedFont.loadType3Data, so Font only needs to build the character-code to glyph-name mapping here.
Note that the mapped value is a glyph name, not a glyph id:
if (this.isType3Font) {
// Font char code in this case is actually a glyph name.
operatorListId = fontCharCode;
}That snippet is inside _charToGlyph. For Type3, fontCharCode comes from toFontChar and may be a name such as "square"; operatorListId is therefore that name as well, and the canvas layer uses it to look up the matching operatorList in charProcOperatorList. For ordinary fonts operatorListId is undefined, and the canvas layer uses the browser font or renderer.getPath.
Two paths: with and without a font program
The second half of the constructor has only two main paths: no file means fall back; a file means handle it according to its real type.
if (!file || file.isEmpty) {
if (file) {
// Some bad PDF generators will include empty font files,
// attempting to recover by assuming that no file exists.
warn('Font file is empty in "' + name + '" (' + this.loadedName + ')');
}
this.fallbackToSystemFont(properties);
return;
}
// Parse the font file to determine the correct type/subtype, rather than
// relying on the (often incorrect) data in the font dictionary; (see e.g.
// issue6782.pdf, issue7598.pdf, and issue9949.pdf).
[type, subtype] = getFontFileType(file, properties);file.isEmpty is the shortcut check for "stream length is zero". An empty font file is not an error: it only warns and then treats the font as not embedded, which matters because many generators write a zero-length /FontFile2, and throwing here would turn the whole page into an error font.
getFontFileType ignores /Subtype and reads the file header instead:
function getFontFileType(file, { type, subtype, composite }) {
let fileType, fileSubtype;
if (isTrueTypeFile(file) || isTrueTypeCollectionFile(file)) {
fileType = composite ? 'CIDFontType2' : 'TrueType';
} else if (isOpenTypeFile(file)) {
fileType = composite ? 'CIDFontType2' : 'OpenType';
} else if (isType1File(file)) {
if (composite) {
fileType = 'CIDFontType0';
} else {
fileType = type === 'MMType1' ? 'MMType1' : 'Type1';
}
} else if (isCFFFile(file)) {
if (composite) {
fileType = 'CIDFontType0';
fileSubtype = 'CIDFontType0C';
} else {
fileType = type === 'MMType1' ? 'MMType1' : 'Type1';
fileSubtype = 'Type1C';
}
} else {
warn('getFontFileType: Unable to detect correct font file Type/Subtype.');
fileType = type;
fileSubtype = subtype;
}
return [fileType, fileSubtype];
}The order is TrueType, TrueType Collection, OpenType, Type1, CFF. CFF comes last because its header is not a fixed magic number but a version plus header length, which is easier to misjudge than the other formats. composite decides "simple font" versus "CID font": the same CFF data is called Type1C in a simple font and CIDFontType0C in a CID font.
isTrueTypeFile checks whether the first four bytes are \x00\x01\x00\x00 or "true", isTrueTypeCollectionFile checks "ttcf", isOpenTypeFile checks "OTTO", isType1File checks %! or the PFB marker \x80\x01, and isCFFFile checks the version range in the first byte and whether the fourth byte (offset size) is between 1 and 4. All of these only peekBytes and never advance the stream position.
Overriding the dictionary declaration with the real header pays off directly: it is very common for the dictionary to say Type1 while the file is actually CFF, and the reverse also happens, a file declared CIDFontType2 that is really Type1. If dispatch trusted the declaration, Type1Font would fail outright on CFF data and the only option left would be the system-font fallback, with a large loss of glyph fidelity.
Three processing paths
Once the real type is known, the constructor splits into three paths with one switch:
let data;
try {
switch (type) {
case 'MMType1':
info('MMType1 font (' + name + '), falling back to Type1.');
/* falls through */
case 'Type1':
case 'CIDFontType0':
this.mimetype = 'font/opentype';
const cff =
subtype === 'Type1C' || subtype === 'CIDFontType0C'
? new CFFFont(file, properties)
: new Type1Font(name, file, properties);
adjustWidths(properties);
// Wrap the CFF data inside an OTF font file
data = this.convert(name, cff, properties);
break;
case 'OpenType':
case 'TrueType':
case 'CIDFontType2':
this.mimetype = 'font/opentype';
// Repair the TrueType file. It is can be damaged in the point of
// view of the sanitizer
data = this.checkAndRepair(name, file, properties);
adjustWidths(properties);
if (this.isOpenType) {
type = 'OpenType';
}
break;
default:
throw new FormatError(`Font ${type} is not supported`);
}
} catch (e) {
warn(e);
this.fallbackToSystemFont(properties);
return;
}All three paths share one goal: "produce data in the end, a piece of OpenType bytes the browser can consume". They differ in the source of the data and the way it is processed:
| Real type | Parser | How it is produced | Final mimetype |
|---|---|---|---|
TrueType / OpenType / CIDFontType2 | Font.checkAndRepair | Repair tables in place and reassemble the OTF | font/opentype |
Type1 / CIDFontType0 (Type1 program) | Type1Font | Font.convert wraps it in an OTF shell | font/opentype |
Type1C / CIDFontType0C | CFFFont | Font.convert wraps it in an OTF shell | font/opentype |
MMType1 | degraded to Type1 | same as above | font/opentype |
Type3 | no font program | the constructor returns early, char procs compiled later | not set |
MMType1 is Multiple Master Type1; PDF.js does not implement variable axes and simply reads it as ordinary Type1. adjustWidths is called on both main paths, and it converts /Widths from font-matrix space back to thousandths of a unit:
function adjustWidths(properties) {
if (!properties.fontMatrix) {
return;
}
if (properties.fontMatrix[0] === FONT_IDENTITY_MATRIX[0]) {
return;
}
// adjusting width to fontMatrix scale
const scale = 0.001 / properties.fontMatrix[0];
const glyphsWidths = properties.widths;
for (const glyph in glyphsWidths) {
glyphsWidths[glyph] *= scale;
}
properties.defaultWidth *= scale;
}The default font matrix has [0] equal to 0.001, so ordinary fonts skip this entirely. Only non-standard matrices (common for Type3, and sometimes written by Type1) need the scaling: widths are defined in glyph space, while the later _charToGlyph expects thousandths of a unit relative to the em.
Exceptions, fallback and property write-back
The whole switch sits inside a try/catch. Any parse exception only triggers warn and the system-font fallback; it is never rethrown:
amendFallbackToUnicode(properties);
this.data = data;
// Transfer some properties again that could change during font conversion
this.type = type;
this.subtype = subtype;
this.fontMatrix = properties.fontMatrix;
this.widths = properties.widths;
this.defaultWidth = properties.defaultWidth;
this.toUnicode = properties.toUnicode;
this.seacMap = properties.seacMap;This block comes after the try and at the end of the constructor. It reads several fields of properties a second time, because repair or conversion modifies properties in place: adjustWidths changes the widths, adjustMapping writes a new toFontChar, convert writes seacMap, and checkAndRepair may change fontMatrix. Without the write-back, the fields on Font would still hold the values from the start of construction.
amendFallbackToUnicode must be called after every adjustment to toUnicode. Its comment is explicit: it may only be called at the end of font parsing, otherwise it would overwrite patches such as adjustType1ToUnicode. The function itself only merges the entries of fallbackToUnicode that toUnicode has not already covered:
function amendFallbackToUnicode(properties) {
if (!properties.fallbackToUnicode) {
return;
}
if (properties.toUnicode instanceof IdentityToUnicodeMap) {
return;
}
const toUnicode = [];
for (const charCode in properties.fallbackToUnicode) {
if (properties.toUnicode.has(charCode)) {
continue; // The font dictionary has a `ToUnicode` entry.
}
toUnicode[charCode] = properties.fallbackToUnicode[charCode];
}
if (toUnicode.length > 0) {
properties.toUnicode.amend(toUnicode);
}
}fallbackToUnicode is the fallback mapping the evaluator derives from the encodings built into the font program; it has lower priority than the dictionary /ToUnicode, so it only fills in codes the dictionary never provided. IdentityToUnicodeMap is rejected outright, because its amend is unreachable.
Design note: The constructor treats "failure" as a normal branch instead of an exception exit. When a font is broken, the worst outcome is imprecise glyphs and a possibly misaligned text layer, but the page can still be drawn, selected and copied. This trade-off shapes every later sub-flow: each validation point tries to repair first, degrades if repair fails, and throws only when "even degradation is meaningless".
The Glyph Object and the Two-Level Cache
The nine fields of Glyph
Every time the evaluator meets a text operator, it eventually receives a set of Glyph objects. They are the "result value" of the whole font chain:
class Glyph {
constructor(
originalCharCode,
fontChar,
unicode,
accent,
width,
vmetric,
operatorListId,
isSpace,
isInFont,
) {
this.originalCharCode = originalCharCode;
this.fontChar = fontChar;
this.unicode = unicode;
this.accent = accent;
this.width = width;
this.vmetric = vmetric;
this.operatorListId = operatorListId;
this.isSpace = isSpace;
this.isInFont = isInFont;
}
/**
* This property, which is only used by `PartialEvaluator.getTextContent`,
* is purposely made non-serializable.
* @type {Object}
*/
get category() {
return shadow(
this,
'category',
getCharUnicodeCategory(this.unicode),
/* nonSerializable = */ true,
);
}
}The nine fields correspond to nine ways of consuming the glyph:
| Field | Meaning | Main consumers |
|---|---|---|
originalCharCode | The original character code from the content stream | Debugging, text-layer positioning |
fontChar | The string actually used for drawing (usually one code point) | fillText in canvas.js |
unicode | The Unicode character shown by the text layer | text_layer, copy/paste, search |
accent | Accent part and offset of a seac composite glyph | Path rendering in canvas.js |
width | Character advance width relative to the em | Text positioning, text-layer width |
vmetric | Vertical metrics for vertical writing fonts | Vertical text positioning |
operatorListId | Key of a Type3 glyph in charProcOperatorList | Canvas layer looking up the glyph program |
isSpace | Whether this character is treated as a space in the content stream | Space merging in the text layer |
isInFont | Whether the character code really exists in the font mapping | Text selection and missing-glyph detection |
category is a lazy getter that uses shadow to freeze the result into a non-serializable data property. It only serves getTextContent, because Glyph itself is transferred across threads through commonobj messages, and the Unicode category object is not a structured-clone-friendly type.
The complete flow of _charToGlyph
The real translation entry point is _charToGlyph. The first thing it does is not a table lookup but a cache lookup:
_charToGlyph(charcode, isSpace = false) {
let glyph = this.#glyphCache.get(charcode);
// All `Glyph`-properties, except `isSpace` in multi-byte strings,
// depend indirectly on the `charcode`.
if (glyph?.isSpace === isSpace) {
return glyph;
}
let fontCharCode, width, operatorListId;The cache key is only charcode, but isSpace also affects the result, so a hit is followed by an isSpace comparison. The comment explains why: in multi-byte strings the same character code can appear in both "treated as a space" and "not treated as a space" contexts, all other fields of Glyph are uniquely determined by the character code, and only isSpace changes with the context.
Next comes the width-code resolution:
let widthCode = charcode;
if (this.cMap?.contains(charcode)) {
widthCode = this.cMap.lookup(charcode);
if (typeof widthCode === 'string') {
widthCode = convertCidString(charcode, widthCode);
}
}
width = this.widths[widthCode];
if (typeof width !== 'number') {
width = this.defaultWidth;
}
const vmetric = this.vmetrics?.[widthCode] || this.defaultVMetrics;The width table of a composite font is not indexed by character code but by CID. cMap.lookup turns the character code into a CID; for some CMaps the result is a multi-byte string and convertCidString folds it into a 16-bit integer. When the width table has no entry, defaultWidth is used instead of 0, because 0 would make all text on the line overlap.
convertCidString only accepts strings of length 1 or 2:
function convertCidString(charCode, cid, shouldThrow = false) {
switch (cid.length) {
case 1:
return cid.charCodeAt(0);
case 2:
return (cid.charCodeAt(0) << 8) | cid.charCodeAt(1);
}
const msg = `Unsupported CID string (charCode ${charCode}): "${cid}".`;
if (shouldThrow) {
throw new FormatError(msg);
}
warn(msg);
return cid;
}A length of 2 is combined into a big-endian 16-bit integer; a length above 2 has no legal interpretation, and shouldThrow decides between throwing and returning the original string. The CID mapping in checkAndRepair passes shouldThrow = true, because a CID larger than 16 bits certainly means corrupted font data; _charToGlyph keeps the default false, preferring to return the original string over failing the whole page of text.
Then come Unicode and toFontChar:
let unicode = this.toUnicode.get(charcode) || charcode;
if (typeof unicode === 'number') {
unicode = String.fromCharCode(unicode);
}
let isInFont = this.toFontChar[charcode] !== undefined;
// First try the toFontChar map, if it's not there then try falling
// back to the char code.
fontCharCode = this.toFontChar[charcode] || charcode;toUnicode.get may return a string or a number (IdentityToUnicodeMap returns String.fromCharCode, while ToUnicodeMap returns the array element directly, which may be a number or a string). Normalizing to a string means the text layer never has to check the type.
isInFont only checks whether toFontChar has this index. It is the approximation of "does this character code have a corresponding glyph": toFontChar is filled by adjustMapping and buildToFontChar, and an unfilled index means "the font has no such glyph". The text layer uses it to distinguish "the character really exists" from "the code merely exists in the encoding".
fontCharCode prefers toFontChar and falls back to the character code. The former is "the code point mapped into the browser font", the latter is "assume the character code is itself the code point". For standard fonts and simple fonts these are often the same; for composite fonts and fonts moved into the private use area they are entirely different.
Handling .notdef in missing fonts
When a font is not embedded and the system-font fallback is used, .notdef must not actually be drawn:
if (this.missingFile) {
const glyphName = this.differences[charcode] || this.defaultEncoding[charcode];
if ((glyphName === '.notdef' || glyphName === '') && this.type === 'Type1') {
// .notdef glyphs should be invisible in non-embedded Type1 fonts, so
// replace them with spaces.
fontCharCode = 0x20;
if (glyphName === '') {
// Ensure that other relevant glyph properties are also updated
// (fixes issue18059.pdf).
width ||= this._spaceWidth;
unicode = String.fromCharCode(fontCharCode);
}
}
fontCharCode = mapSpecialUnicodeValues(fontCharCode);
}Only Type1 is handled here. The reason is that after standard-font substitution, a Type1 font missing a glyph falls back to .notdef, and .notdef in a system font usually shows up as a hollow box or a strange symbol. Replacing it with a space at least does not break the layout visually. The width has to become _spaceWidth as well, otherwise the result is an invisible but wide hole.
mapSpecialUnicodeValues maps a set of special code points commonly found in PDF fonts back to the correct Unicode. Some symbol fonts, for example, use the 0xF0 range as private code points, and only after mapping can the browser font find the correct glyph.
seac composite glyphs
Type1 has a mechanism that builds one character out of two glyphs, called seac (Standard Encoding Accented Character):
let accent = null;
if (this.seacMap?.[charcode]) {
isInFont = true;
const seac = this.seacMap[charcode];
fontCharCode = seac.baseFontCharCode;
accent = {
fontChar: String.fromCodePoint(seac.accentFontCharCode),
offset: seac.accentOffset,
};
}seacMap is built during convert: each key is an original character code, and the value holds "the font char code of the base glyph", "the font char code of the accent glyph" and "the accent offset". _charToGlyph swaps fontCharCode for the base glyph and puts the accent information in accent separately, because the accent is not drawn in sequence — it is layered on top after the base glyph, at the given offset.
isInFont is forced to true here: even if toFontChar has no entry for this index, seac proves the font really can draw this character.
Generating fontChar
The last step turns the numeric code point into a drawable string:
let fontChar = "";
if (typeof fontCharCode === "number") {
if (fontCharCode <= 0x10ffff) {
fontChar = String.fromCodePoint(fontCharCode);
} else {
warn(`charToGlyph - invalid fontCharCode: ${fontCharCode}`);
}
}
if (this.missingFile && this.vertical && fontChar.length === 1) {
const vertical = getVerticalPresentationForm()[fontChar.charCodeAt(0)];
if (vertical) {
fontChar = unicode = String.fromCharCode(vertical);
}
}
glyph = new Glyph(
charcode,
fontChar,
unicode,
accent,
width,
vmetric,
operatorListId,
isSpace,
isInFont
);
this.#glyphCache.set(charcode, glyph);
return glyph;
}String.fromCodePoint rather than String.fromCharCode is used because code points outside the BMP may be needed beyond the private use area. A code point above 0x10FFFF is not legal Unicode, so the code only warns and keeps an empty string — an empty string on the canvas means "drawn, but with zero width", which is safer than throwing.
Vertical text is only handled in the missing-font case: getVerticalPresentationForm converts horizontal characters into their vertical presentation form (for example, turning horizontal parentheses into vertical ones). Embedded fonts already contain vertical glyphs, so this substitution is unnecessary there.
Multi-byte splitting in charsToGlyphs
Above single-code translation there is a "whole string translation" layer. It is the direct entry point for the content-stream Tj operator:
charsToGlyphs(chars) {
// If we translated this string before, just grab it from the cache.
let glyphs = this.#charsCache.get(chars);
if (glyphs) {
return glyphs;
}
glyphs = [];
if (this.cMap) {
// Composite fonts have multi-byte strings, convert the string from
// single-byte to multi-byte.
const c = Object.create(null),
ii = chars.length;
let i = 0;
while (i < ii) {
this.cMap.readCharCode(chars, i, c);
const { charcode, length } = c;
i += length;
// Space is char with code 0x20 and length 1 in multiple-byte codes.
const glyph = this._charToGlyph(
charcode,
length === 1 && chars.charCodeAt(i - 1) === 0x20
);
glyphs.push(glyph);
}
} else {
for (let i = 0, ii = chars.length; i < ii; ++i) {
const charcode = chars.charCodeAt(i);
const glyph = this._charToGlyph(charcode, charcode === 0x20);
glyphs.push(glyph);
}
}
// Enter the translated string into the cache.
this.#charsCache.set(chars, glyphs);
return glyphs;
}Simple fonts take charCodeAt per character; composite fonts must go through cMap.readCharCode to know "how many bytes this code occupies". readCharCode writes its result into the reused object c, avoiding a new allocation on every iteration, a typical hot-path optimization.
The space check gains one extra condition on the multi-byte path: length === 1. The reason is that in a multi-byte CMap the byte 0x20 may be only part of a two-byte code; only "one byte long and exactly 0x20" is a real space. Simple fonts do not need the condition, because every character already occupies one byte.
Division of work between the two caches
Font holds two Map instances internally:
class Font {
#charsCache = new Map();
#glyphCache = new Map();Their keys and values are completely different:
| Dimension | #glyphCache | #charsCache |
|---|---|---|
| Key | A single character code (number) | A whole string (chars) |
| Value | A Glyph instance | An array of Glyph |
| Hit scenario | The same character appears in several places | The same text is drawn repeatedly |
| Invalidation | None, valid for the lifetime of the font | None, valid for the lifetime of the font |
| Typical callers | _charToGlyph itself, per-character text-layer queries | Whole-string translation for Tj, TJ |
The two caches are in a containment relationship: a string-cache hit never touches the character cache; a string-cache miss still reuses the character cache while translating glyph by glyph. So "the same text repeated" skips splitting and table lookups, and "the same character scattered around" skips table lookups and object allocation.
The caches have no active invalidation. Font objects are keyed by reference in Catalog.fontCache and are not rebuilt when pages change; when the document is cleaned up, the whole font cache is emptied and the font objects are released. Font therefore needs no LRU and no capacity limit — its lifetime naturally matches the document.
What glyphCacheValues is for
glyphCacheValues exposes a value iterator, not the whole Map:
get glyphCacheValues() {
return this.#glyphCache.values();
}It has exactly one caller: TranslatedFont.fallback. When the main thread reports "font loading failed", the worker must resend every glyph path it has already parsed, because the main thread may have received only the font metadata and not the glyph paths that are sent on demand:
PartialEvaluator.buildFontPaths(
this.font,
/* glyphs = */ this.font.glyphCacheValues,
handler,
evaluatorOptions,
);Exposing only the iterator rather than the Map is an encapsulation choice: callers can iterate but cannot write or delete, which keeps external code from breaking cache consistency.
getCharPositions and byte-level positioning
The text layer needs to know "which slice of the original string each glyph corresponds to", and getCharPositions exists for that:
getCharPositions(chars) {
// This function doesn't use a cache because
// it's called only when saving or printing.
const positions = [];
if (this.cMap) {
const c = Object.create(null);
let i = 0;
while (i < chars.length) {
this.cMap.readCharCode(chars, i, c);
const length = c.length;
positions.push([i, i + length]);
i += length;
}
} else {
for (let i = 0, ii = chars.length; i < ii; ++i) {
positions.push([i, i + 1]);
}
}
return positions;
}The return value is an array of "start index, end index" pairs. Simple fonts give every character one position, composite fonts split by the actual byte length from the CMap. It does not reuse charsToGlyphs, because the two care about different things: one needs byte ranges, the other needs glyph objects. The comment states explicitly that it is not cached, since it is only called when saving or printing, far less often than rendering.
Design note:
Glyphcompresses "what to draw with, what to display, how wide, and where it comes from" into one object, and#glyphCachethen keeps that object resident by character code. The canvas layer, text layer and printing path therefore receive the same result and there is no drift such as "the canvas uses code point A while the text layer uses Unicode B". The cost is thatGlyphmust stay immutable; the caches have no invalidation, so any field overwritten from outside would contaminate every later use.
ToUnicode and toFontChar
Division of work between the two maps
The font layer has two mappings in opposite directions:
toUnicode: character code to Unicode, used by the text layer;toFontChar: character code to the code point inside the font, used for drawing.
The two mappings happen to coincide in most simple fonts, but they separate completely in composite fonts, symbol fonts and after a move into the private use area. _charToGlyph looks up both tables precisely so that "the shape drawn" and "the text copied" are each correct.
ToUnicodeMap and IdentityToUnicodeMap
These two classes are not in fonts.js, but their types are checked repeatedly inside the font constructor, so their semantics have to be clear first:
class ToUnicodeMap {
constructor(cmap = []) {
// The elements of this._map can be integers or strings, depending on how
// `cmap` was created.
this._map = cmap;
}
get length() {
return this._map.length;
}
forEach(callback) {
for (const charCode in this._map) {
callback(charCode, this._map[charCode].codePointAt(0));
}
}
has(i) {
return this._map[i] !== undefined;
}
get(i) {
return this._map[i];
}ToUnicodeMap is a thin wrapper around a sparse array. forEach visits only the indices that actually exist, and the callback's second argument is a code point integer; get returns the raw element, which may be a string or a number. That "uncertain type" is exactly why _charToGlyph has to check typeof unicode === "number".
IdentityToUnicodeMap stores no array at all and represents the identity mapping with just a first and a last value:
class IdentityToUnicodeMap {
constructor(firstChar, lastChar) {
this.firstChar = firstChar;
this.lastChar = lastChar;
}
get length() {
return this.lastChar + 1 - this.firstChar;
}
forEach(callback) {
for (let i = this.firstChar, ii = this.lastChar; i <= ii; i++) {
callback(i, i);
}
}
has(i) {
return this.firstChar <= i && i <= this.lastChar;
}An identity mapping means "the character code is itself the Unicode". This is most common in CID fonts: the character code is a CID, under an Identity CMap the CID is the character code, and the glyphs in the font program are ordered the same way. Using a range instead of an array avoids allocating a huge sparse array for tens of thousands of CIDs.
IdentityToUnicodeMap.amend is unreachable. That explains why amendFallbackToUnicode, adjustTrueTypeToUnicode and adjustType1ToUnicode all begin by checking instanceof IdentityToUnicodeMap: it is not that "patching is unnecessary" but that "patching would crash outright".
Building toFontChar: buildToFontChar
Standard font fallback, symbol fonts and Dingbats all go through buildToFontChar:
function buildToFontChar(encoding, glyphsUnicodeMap, differences) {
const toFontChar = [];
let unicode;
for (let i = 0, ii = encoding.length; i < ii; i++) {
unicode = getUnicodeForGlyph(encoding[i], glyphsUnicodeMap);
if (unicode !== -1) {
toFontChar[i] = unicode;
}
}
for (const charCode in differences) {
unicode = getUnicodeForGlyph(differences[charCode], glyphsUnicodeMap);
if (unicode !== -1) {
toFontChar[+charCode] = unicode;
}
}
return toFontChar;
}The order of the two passes matters: fill by the encoding table first, then overwrite with /Differences. The meaning of /Differences is "deviate from the base encoding at a certain character code", so it must be written later. getUnicodeForGlyph returns -1 when a glyph name has no Unicode, and in that case nothing is written rather than 0 — writing 0 would make toFontChar look like "this glyph exists" and would distort the isInFont check.
adjustMapping: moving character codes into the private use area
The most common transformation applied to embedded fonts is adjustMapping. It moves the whole "character code to glyph id" mapping into the Unicode private use area:
function adjustMapping(charCodeToGlyphId, hasGlyph, newGlyphZeroId, toUnicode) {
const newMap = Object.create(null);
const toUnicodeExtraMap = new Map();
const toFontChar = [];
const usedGlyphIds = new Set();
let privateUseAreaIndex = 0;
const privateUseOffetStart = PRIVATE_USE_AREAS[privateUseAreaIndex][0];
let nextAvailableFontCharCode = privateUseOffetStart;
let privateUseOffetEnd = PRIVATE_USE_AREAS[privateUseAreaIndex][1];
const isInPrivateArea = code =>
(PRIVATE_USE_AREAS[0][0] <= code && code <= PRIVATE_USE_AREAS[0][1]) ||
(PRIVATE_USE_AREAS[1][0] <= code && code <= PRIVATE_USE_AREAS[1][1]);
let LIGATURE_TO_UNICODE = null;There are two private use ranges: 0xE000 to 0xF8FF in the BMP, and 0x100000 to 0x10FFFD in plane 16. The purpose of the move is stated in the documentation comment of adjustMapping: it avoids the problem that "glyphs in some Unicode areas are not drawn, or are transformed by the shaper".
While walking the original mapping, empty glyphs are skipped first:
for (const originalCharCode in charCodeToGlyphId) {
let glyphId = charCodeToGlyphId[originalCharCode];
// For missing glyphs don't create the mappings so the glyph isn't
// drawn.
if (!hasGlyph(glyphId)) {
continue;
}
if (nextAvailableFontCharCode > privateUseOffetEnd) {
privateUseAreaIndex++;
if (privateUseAreaIndex >= PRIVATE_USE_AREAS.length) {
warn("Ran out of space in font private use area.");
break;
}
nextAvailableFontCharCode = PRIVATE_USE_AREAS[privateUseAreaIndex][0];
privateUseOffetEnd = PRIVATE_USE_AREAS[privateUseAreaIndex][1];
}
const fontCharCode = nextAvailableFontCharCode++;
if (glyphId === 0) {
glyphId = newGlyphZeroId;
}hasGlyph is a callback passed in from outside: the TrueType path passes hasGlyph(glyphId) (checking missingGlyphs), and the convert path passes font.hasGlyphId.bind(font). Skipping empty glyphs is meant to "not create a mapping, so the glyph is not drawn" — if an empty glyph were given a private code point, the browser would try to draw it and produce a blank but occupied position.
glyphId === 0 is replaced with newGlyphZeroId, because .notdef is copied to the end in the rebuilt font. This patches "glyph 0 is often not displayed in browsers": when a font program puts a valid glyph at position 0, the source copies it and appends it, then redirects every mapping that pointed at 0 to the new position.
Next come Unicode fixes and deduplication:
let unicode = toUnicode.get(originalCharCode);
if (typeof unicode === "string") {
if (unicode.length === 1) {
unicode = unicode.codePointAt(0);
} else {
if (!LIGATURE_TO_UNICODE) {
LIGATURE_TO_UNICODE = new Map();
// The code range [0xfb00, 0xfb4f] contains some ligature characters
// but not all.
for (let i = 0xfb00; i <= 0xfb4f; i++) {
const normalized = String.fromCharCode(i).normalize("NFKD");
if (normalized.length > 1) {
LIGATURE_TO_UNICODE.set(normalized, i);
}
}
}
unicode = LIGATURE_TO_UNICODE.get(unicode) || unicode.codePointAt(0);
}
}
if (unicode && !isInPrivateArea(unicode) && !usedGlyphIds.has(glyphId)) {
toUnicodeExtraMap.set(unicode, glyphId);
usedGlyphIds.add(glyphId);
}
newMap[fontCharCode] = glyphId;
toFontChar[originalCharCode] = fontCharCode;
}The bug reference 1778484 quoted in the comment explains why toUnicodeExtraMap exists: once character codes are moved into the private use area, fonts generated when printing to PDF carry the wrong characters. The fix is to write the original Unicode into cmap as well, so the printing backend can map glyph ids back by Unicode.
toUnicodeExtraMap only records entries "not already in the private area, and whose glyph id has not been used". usedGlyphIds prevents one glyph from being registered under several Unicodes, because one glyph mapping to multiple code points in cmap makes some backends behave inconsistently.
The function returns four values:
return {
toFontChar,
charCodeToGlyphId: newMap,
toUnicodeExtraMap,
nextAvailableFontCharCode,
};nextAvailableFontCharCode is later used by convert to "temporarily create" new code points for the base and accent glyphs of seac.
The cost of moving into the private use area
Moving character codes into the private use area is not lossless. It has three consequences:
toFontCharis completely separated from the character code, soisInFontcan only be judged fromtoFontCharand never guessed from the character code;toUnicodemust keep the original code points, otherwise the text layer receives private-use characters;- the printing and copying path needs the extra
toUnicodeExtraMapto map private-use code points back to real Unicode.
The benefit is greater: browser font stacks treat some Unicode areas specially (combining marks, RTL controls, parts of the CJK compatibility blocks), so using the original code point directly may result in nothing being drawn, misalignment, or reordering by the shaper. Moving into the private use area tells the browser "these are ordinary glyphs, do no language-specific processing on them".
adjustTrueTypeToUnicode and adjustType1ToUnicode
Both fallback paths also try to "infer ToUnicode from the embedded encoding". The TrueType version is called at the end of checkAndRepair:
// Try to infer if the fallback encoding should really be `WinAnsiEncoding`.
if (properties.defaultEncoding === WinAnsiEncoding) {
return;
}
for (const r of nameRecords) {
if (!isWinNameRecord(r)) {
return; // Not Windows, hence `WinAnsiEncoding` wouldn't make sense.
}
}
const encoding = WinAnsiEncoding;
const toUnicode = [],
glyphsUnicodeMap = getGlyphsUnicode();
for (const charCode in encoding) {
const glyphName = encoding[charCode];
if (glyphName === "") {
continue;
}
const unicode = glyphsUnicodeMap[glyphName];
if (unicode === undefined) {
continue;
}
toUnicode[charCode] = String.fromCharCode(unicode);
}
if (toUnicode.length > 0) {
properties.toUnicode.amend(toUnicode);
}
}There are five gates before the call: do not patch internal fonts, do not patch when /ToUnicode is present, do not patch when /Encoding is present, do not patch identity mappings, do not patch non-symbolic fonts. Only when "the font is symbolic, has no encoding information, and all name records are Windows records" is WinAnsiEncoding inferred. This is a conservative inference: symbol fonts usually have no meaningful character semantics, and only Windows platform name records suggest that it is actually a text font disguised as a symbol font.
The Type1 version is more direct and uses the encoding embedded in the font program:
function adjustType1ToUnicode(properties, builtInEncoding) {
if (properties.isInternalFont) {
return;
}
if (properties.hasIncludedToUnicodeMap) {
return; // The font dictionary has a `ToUnicode` entry.
}
if (builtInEncoding === properties.defaultEncoding) {
return; // No point in trying to adjust `toUnicode` if the encodings match.
}
if (properties.toUnicode instanceof IdentityToUnicodeMap) {
return;
}
const toUnicode = [],
glyphsUnicodeMap = getGlyphsUnicode();
for (const charCode in builtInEncoding) {
if (properties.hasEncoding) {
if (
properties.baseEncodingName ||
properties.differences[charCode] !== undefined
) {
continue; // The font dictionary has an `Encoding`/`Differences` entry.
}
}When the dictionary already provides /BaseEncoding or a /Differences entry for a code, that code follows the dictionary and is no longer overwritten by the embedded encoding. This is the same priority rule as "whole table first, differences second" in buildToFontChar: explicit dictionary information always beats implicit information from the program.
Design note: Every patch to
toUnicodefollows "fill gaps only, never overwrite explicit values".amendFallbackToUnicodecheckshas,adjustType1ToUnicodechecksdifferences, andadjustTrueTypeToUnicodecheckshasIncludedToUnicodeMapfirst. The reason is that/ToUnicodeis the PDF author's (or generator's) explicit declaration of "what this byte should be read as", while encoding-table inference is only a guess. A guess may fill a blank but must not overturn a declaration.
The TrueType Repair Pipeline
checkAndRepair is the longest method in this file, running from line 1423 to line 3282, close to 1900 lines. It is not a "parser" but a repair pipeline: it reshapes a font that may come from any generator and may violate the OpenType specification into a version both browsers and OTS accept.
Copy first, never modify the original stream
The first actual action of the method is a copy:
// The following steps modify the original font data, making copy
font = new Stream(new Uint8Array(font.getBytes()));The comment is blunt: later steps modify the data in place, so a copy is required first. font.getBytes() reads the whole stream into one Uint8Array, which is then wrapped in a new Stream. This way, even if repair fails and the font falls back, the original stream stays intact and the information in properties is not polluted.
Before the copy, the method defines a large set of closure functions: readTables, readTableEntry, readOpenTypeHeader, readTrueTypeCollectionHeader, readTrueTypeCollectionData, readCmapTable, sanitizeMetrics, sanitizeGlyph, sanitizeHead, sanitizeGlyphLocations, readPostScriptTable, readNameTable, sanitizeTTProgram, checkInvalidFunctions, foldTTTable, sanitizeTTPrograms. They are placed inside the method so they can share the font stream object and properties, without exposing TrueType-only tools at module level.
Reading and filtering tables
readTableEntry reads one 16-byte table directory entry, with one detail: it seeks back to the start of the file by offset to re-read the table data:
function readTableEntry(file) {
const tag = file.getString(4);
const checksum = file.getInt32() >>> 0;
const offset = file.getInt32() >>> 0;
const length = file.getInt32() >>> 0;
// Read the table associated data
const previousPosition = file.pos;
file.pos = file.start || 0;
file.skip(offset);
const data = file.getBytes(length);
file.pos = previousPosition;
if (tag === "head") {
// clearing checksum adjustment
data[8] = data[9] = data[10] = data[11] = 0;
data[17] |= 0x20; // Set font optimized for cleartype flag.
}
// The "CFF " table may be replaced completely, hence its data shouldn't
// need to be read and/or modified piecewise through a `DataView`.
const view =
tag === "CFF "
? null
: new DataView(data.buffer, data.byteOffset, data.byteLength);As soon as the head table is read, the checksum adjustment is cleared and the ClearType optimization bit is set. The checksum is "the checksum compensation value for the whole file", which inevitably changes after repair, so it is zeroed first. The CFF table gets no DataView, because it is later replaced wholesale and modifying it through a byte view would be pointless.
readTables keeps only the whitelisted tables:
function readTables(file, numTables) {
const tables = Object.create(null);
tables['OS/2'] = null;
tables.cmap = null;
tables.head = null;
tables.hhea = null;
tables.hmtx = null;
tables.maxp = null;
tables.name = null;
tables.post = null;
for (let i = 0; i < numTables; i++) {
const table = readTableEntry(file);
if (!VALID_TABLES.includes(table.tag)) {
continue; // skipping table if it's not a required or optional table
}
if (table.length === 0) {
continue; // skipping empty tables
}
tables[table.tag] = table;
}
return tables;
}The whitelist is OS/2, cmap, head, hhea, hmtx, maxp, name, post, loca, glyf, fpgm, prep, cvt and CFF . These are the tables required "for rendering and metrics" or "for TrueType instructions". Everything else (DSIG, GDEF, GPOS, GSUB, kern, hdmx and so on) is dropped, because PDF.js only needs to "display fixed content" and has no use for layout features or digital signatures. Dropping unknown tables also sidesteps a large number of parser compatibility problems.
Object.create(null) works like the CmdCache mentioned earlier: table tags come from file content and may be strings such as __proto__; a prototype-less object avoids false hits.
Early branching for TrueType Collection and OpenType
A TrueType Collection is "several fonts packed in one file". The source reads the TTC header first, then picks the font by name:
let header, tables;
if (isTrueTypeCollectionFile(font)) {
const ttcData = readTrueTypeCollectionData(font, this.name);
header = ttcData.header;
tables = ttcData.tables;
} else {
header = readOpenTypeHeader(font);
tables = readTables(font, header.numTables);
}
const isTrueType = !tables['CFF '];readTrueTypeCollectionData walks every font in offsetTable, reads its name table, and compares the name with this.name after stripping whitespace. If that fails it falls back to matching the name pieces split on +. Only if that also fails does it throw FormatError. Once a match is found, all later reads are based on this font's table directory rather than the whole TTC file.
Next comes the special CFF branch:
// OpenType font (skip composite fonts with non-default glyph mapping).
if (
(header.version === 'OTTO' &&
(!properties.composite || (properties.fontFileN === 'FontFile3' && parsedCff?.isCIDFont))) ||
!tables.head ||
!tables.hhea ||
!tables.maxp ||
!tables.post
) {
// No major tables: throwing everything at `CFFFont`.
return this.convert(name, new CFFFont(new Stream(tables['CFF '].data), properties), properties);
}This means: if the file is OTTO (an OpenType shell) and "is not a composite font, or is a composite font but its CFF is a CID font", go straight to convert; if any of head, hhea, maxp, post is missing, also go to convert. convert re-wraps the CFF data in an OTF layer, so TrueType outlines and instructions do not need repair.
The this.isOpenType flag decides whether the later checkAndRepair processes glyf. A pure CFF OpenType drops glyf, loca, fpgm, prep and cvt , keeping only glyph metrics and the CFF data.
Required tables and maxp version correction
Once on the main TrueType path, the source performs a few hard checks:
if (!tables.loca) {
throw new FormatError('Required "loca" table is not found');
}
if (!tables.glyf) {
warn('Required "glyf" table is not found -- trying to recover.');
// Note: We use `sanitizeGlyphLocations` to add dummy glyf data below.
tables.glyf = {
tag: 'glyf',
data: new Uint8Array(0),
};
}A missing loca throws immediately, because without it no glyph can be located; a missing glyf only warns and substitutes an empty table, and sanitizeGlyphLocations later builds a "single point" placeholder glyph so the font is at least legal. The difference embodies the principle "fail only when there is no alternative".
The maxp version number is also frequently wrong:
font.pos = (font.start || 0) + tables.maxp.offset;
let version = font.getInt32();
const numGlyphs = numGlyphsFromCFF ?? font.getUint16();
if (version === 0x00005000 && tables.maxp.length !== 6) {
tables.maxp.data = tables.maxp.data.subarray(0, 6);
tables.maxp.length = 6;
}
if (version !== 0x00010000 && version !== 0x00005000) {
if (tables.maxp.length === 6) {
version = 0x0005000;
} else if (tables.maxp.length >= 32) {
version = 0x00010000;
} else {
throw new FormatError(`"maxp" table has a wrong version number`);
}
writeUint32(tables.maxp.data, 0, version);
}OpenType maxp has two legal versions: 0x00005000 (6 bytes only, used by CFF fonts) and 0x00010000 (32 bytes, used by TrueType). The source infers the version from the table length instead of trusting the first 4 bytes. A length of 6 writes 0.5, a length of at least 32 writes 1.0, and any other length throws. This is typical "structure proves itself": the table length is a physical fact, while the version number is a field that can be written incorrectly.
Repairing glyph locations
The loca table stores each glyph's offset in glyf, and its length must be exactly (numGlyphs + 1) times the bytes per entry:
if (tables.loca) {
const locaLength = isGlyphLocationsLong ? (numGlyphs + 1) * 4 : (numGlyphs + 1) * 2;
if (tables.loca.length !== locaLength) {
warn("Incorrect 'loca' table length -- attempting to fix it.");
// The length of the loca table is wrong (see #13425), so we check if we
// have enough space to fix it.
const sortedTables = Object.values(tables)
.filter(Boolean)
.sort((a, b) => a.offset - b.offset);
const locaIndex = sortedTables.indexOf(tables.loca);
const nextTable = sortedTables[locaIndex + 1] || null;
if (nextTable && tables.loca.offset + locaLength < nextTable.offset) {
const previousPos = font.pos;
font.pos = font.start || 0;
font.skip(tables.loca.offset);
tables.loca.data = font.getBytes(locaLength);
tables.loca.length = locaLength;
font.pos = previousPos;
}
}
}The repair technique is "re-read with the correct length": as long as the loca start plus the correct length does not run past the start of the next table, the bytes really exist in the file and only the directory entry recorded the wrong length, so a fresh read is enough. If there is not enough room, the table is left as is, and sanitizeGlyphLocations later does its best with the available length.
isGlyphLocationsLong comes from the indexToLocFormat field of the head table. sanitizeHead infers it from the loca length when it falls outside the range 0 to 1:
const numGlyphsPlusOne = numGlyphs + 1;
if (locaLength === numGlyphsPlusOne << 1) {
// 0x0000 indicates the loca table consists of short offsets
data[50] = 0;
data[51] = 0;
} else if (locaLength === numGlyphsPlusOne << 2) {
// 0x0001 indicates the loca table consists of long offsets
data[50] = 0;
data[51] = 1;
} else {
throw new FormatError('Could not fix indexToLocFormat: ' + indexToLocFormat);
}Short offsets take 2 bytes per entry, long offsets take 4. << 1 and << 2 express "multiply by 2" and "multiply by 4", matching the length formula one to one. It throws only when neither matches, because at that point glyph boundaries cannot be determined and any guess would draw misplaced shapes.
sanitizeGlyph and glyph outline cleanup
sanitizeGlyph is the gatekeeper for a single glyph. It checks the offset range first, then fixes the bounding box:
const glyf = source.subarray(sourceStart, sourceEnd);
// Sanitize the glyph bounding box.
const xMin = signedInt16(glyf[2], glyf[3]);
const yMin = signedInt16(glyf[4], glyf[5]);
const xMax = signedInt16(glyf[6], glyf[7]);
const yMax = signedInt16(glyf[8], glyf[9]);
if (xMin > xMax) {
writeSignedInt16(glyf, 2, xMax);
writeSignedInt16(glyf, 6, xMin);
}
if (yMin > yMax) {
writeSignedInt16(glyf, 4, yMax);
writeSignedInt16(glyf, 8, yMin);
}
const contoursCount = signedInt16(glyf[0], glyf[1]);
if (contoursCount < 0) {
if (contoursCount < -1) {
// OTS doesn't like contour count to be less than -1.
// The glyph data offsets are very likely wrong and
// having something lower than -1, very likely, implies
// to have some garbage data.
return glyphProfile;
}
// complex glyph, writing as is
dest.set(glyf, destStart);
glyphProfile.length = glyf.length;
return glyphProfile;
}A contour count below 0 means this is a composite glyph (built from other glyphs), so it is kept as is. A value below -1 is not a legal composite glyph and means the offsets are completely wrong; a profile of length 0 is returned, which effectively drops the glyph.
For simple glyphs the cleanup is more detailed:
// validating flags
let coordinatesLength = 0;
for (i = 0; i < flagsCount; i++) {
const flag = glyf[j++];
if (flag & 0xc0) {
// reserved flags must be zero, cleaning up
glyf[j - 1] = flag & 0x3f;
}
let xLength = 2;
if (flag & 2) {
xLength = 1;
} else if (flag & 16) {
xLength = 0;
}
let yLength = 2;
if (flag & 4) {
yLength = 1;
} else if (flag & 32) {
yLength = 0;
}
const xyLength = xLength + yLength;
coordinatesLength += xyLength;
if (flag & 8) {
const repeat = glyf[j++];
if (repeat === 0) {
// The repeat count should be non-zero when the repeat flag is set.
glyf[j - 1] ^= 8;
}
i += repeat;
coordinatesLength += repeat * xyLength;
}
}
// glyph without coordinates will be rejected
if (coordinatesLength === 0) {
return glyphProfile;
}The top two bits of the flags are reserved and the specification requires them to be zero, so they are cleared here; xLength and yLength decide whether each coordinate takes 0, 1 or 2 bytes according to the flags; the repeat flag means "the following points reuse the same flag", but the repeat count must not be 0, and when it is 0 the repeat flag itself is cleared. The purpose of these fixes is to make the total length of the coordinate data computable, and from that to judge whether the data is complete.
When hintsValid is false, the TrueType instructions inside the glyph are deleted entirely:
if (!hintsValid && instructionsLength > 0) {
dest.set(glyf.subarray(0, instructionsStart), destStart);
dest.set([0, 0], destStart + instructionsStart);
dest.set(glyf.subarray(instructionsEnd, glyphDataLength), destStart + instructionsStart + 2);
glyphDataLength -= instructionsLength;
if (glyf.length - glyphDataLength > 3) {
glyphDataLength = (glyphDataLength + 3) & ~3;
}
glyphProfile.length = glyphDataLength;
return glyphProfile;
}The instruction length field is written as 0, the instruction bytes are skipped, and the coordinate data that follows moves forward. This is the degradation for "do not trust hinting": no grid fitting, but the outline is still drawn. & ~3 is four-byte alignment, which OpenType requires for glyph data.
The three reorderings in sanitizeGlyphLocations
sanitizeGlyphLocations repairs "the loca table itself". The specification requires offsets to be non-decreasing and forbids 0 as a marker of absence; many real fonts violate both, so the source performs three sorts:
const locaEntries = [];
// There are numGlyphs + 1 loca table entries.
for (i = 0, j = 0; i < numGlyphs + 1; i++, j += itemSize) {
let offset = itemDecode(locaData, j);
if (offset > oldGlyfDataLength) {
offset = oldGlyfDataLength;
}
locaEntries.push({
index: i,
offset,
endOffset: 0,
});
}
locaEntries.sort((a, b) => a.offset - b.offset);
// Now the offsets are sorted, calculate the end offset of each glyph.
// The last loca entry's endOffset is not calculated since it's the end
// of the data and will be stored on the previous entry's endOffset.
for (i = 0; i < numGlyphs; i++) {
locaEntries[i].endOffset = locaEntries[i + 1].offset;
}
// Re-sort so glyphs aren't out of order.
locaEntries.sort((a, b) => a.index - b.index);The first sort is by offset, making "the previous offset is less than the next" hold; after sorting, the difference between adjacent entries is that glyph's byte length. The second sort restores the original index order, keeping the correspondence between glyph id and glyph data unchanged.
The third pass targets "several empty glyphs at the start":
// Calculate the endOffset of the "first" glyph correctly when there are
// *multiple* empty ones at the start of the data (fixes issue14618.pdf).
for (i = 0; i < numGlyphs; i++) {
const { offset, endOffset } = locaEntries[i];
if (offset !== 0 || endOffset !== 0) {
break;
}
const nextOffset = locaEntries[i + 1].offset;
if (nextOffset === 0) {
continue;
}
locaEntries[i].endOffset = nextOffset;
break;
}When both glyph 0 and glyph 1 have offset 0, the length computed from the adjacent difference for glyph 0 is also 0, but the actual data may start at some later non-zero offset. This loop gives a length to the last empty glyph before "the first non-zero offset", so that the whole glyph data block is not treated as empty.
Cycle detection for composite glyphs
Before sanitizing glyph by glyph, sanitizeGlyphLocations calls pruneCompositeGlyphCycles:
const droppedGlyphs = pruneCompositeGlyphCycles(oldGlyfData, locaEntries, numGlyphs);A composite glyph may reference other glyphs, and those references can form cycles. A cycle would recurse forever at render time, so it must be found and dropped in advance. This function lives in glyf.js and returns "a set of dropped glyph ids"; the later loop returns an empty profile for any id in that set instead of trying to parse it.
This is exactly how the work is split between glyf.js and fonts.js: fonts.js owns "table structure and metrics", and glyf.js owns "the graph structure of glyph outlines".
Final glyph write
After every glyph is sanitized, the source rewrites loca and glyf:
const missingGlyphs = Object.create(null);
let writeOffset = 0;
itemEncode(locaData, 0, writeOffset);
for (i = 0, j = itemSize; i < numGlyphs; i++, j += itemSize) {
const glyphProfile = droppedGlyphs.has(i)
? { length: 0, sizeOfInstructions: 0 }
: sanitizeGlyph(
oldGlyfData,
locaEntries[i].offset,
locaEntries[i].endOffset,
newGlyfData,
writeOffset,
hintsValid,
);
const newLength = glyphProfile.length;
if (newLength === 0) {
missingGlyphs[i] = true;
}
if (glyphProfile.sizeOfInstructions > maxSizeOfInstructions) {
maxSizeOfInstructions = glyphProfile.sizeOfInstructions;
}
writeOffset += newLength;
itemEncode(locaData, j, writeOffset);
}missingGlyphs is the data source for the later hasGlyph: glyphs whose length was zeroed are marked missing, and adjustMapping skips them when allocating mappings. maxSizeOfInstructions is collected on the side and written back to maxp, because this field is wrong in many fonts as well.
If every glyph was sanitized down to empty, the source builds a minimal legal glyph:
if (writeOffset === 0) {
// glyf table cannot be empty -- redoing the glyf and loca tables
// to have single glyph with one point
const simpleGlyph = new Uint8Array([0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 49, 0]);
for (i = 0, j = itemSize; i < numGlyphsOut; i++, j += itemSize) {
itemEncode(locaData, j, simpleGlyph.length);
}
glyf.data = simpleGlyph;
}The glyf table cannot be empty, otherwise OTS rejects the entire font. A "one contour, one point" glyph at least makes the table structure legal. The font ultimately draws nothing, but the page does not become an error font because of it.
Duplicating glyph 0
Browsers generally do not display glyph 0, while many fonts put a valid glyph there. The general countermeasure in the source is to duplicate it and append the copy:
} else if (dupFirstEntry) {
// Browsers will not display a glyph at position 0. Typically glyph 0
// is notdef, but a number of fonts put a valid glyph there so it must
// be duplicated and appended.
const firstEntryLength = itemDecode(locaData, itemSize);
if (newGlyfData.length > firstEntryLength + writeOffset) {
glyf.data = newGlyfData.subarray(0, firstEntryLength + writeOffset);
} else {
glyf.data = new Uint8Array(firstEntryLength + writeOffset);
glyf.data.set(newGlyfData.subarray(0, writeOffset));
}
glyf.data.set(newGlyfData.subarray(0, firstEntryLength), writeOffset);
itemEncode(
loca.data,
locaData.length - itemSize,
writeOffset + firstEntryLength
);
}After duplication, numGlyphsOut becomes numGlyphs + 1 and glyphZeroId becomes numGlyphsOut - 1. Every mapping in adjustMapping that pointed at glyph 0 is redirected to the new position. If the total glyph count has already reached 0xFFFF there is no room to duplicate, so dupFirstEntry is set to false and glyphZeroId falls back to 0; the source comment says this "is better than failing the whole font".
sanitizeMetrics and hmtx padding
hmtx must provide "advance width plus left side bearing" for every glyph. Some fonts write full metrics only for the first few glyphs and only widths for the rest:
const numOfSidebearings = numGlyphs - numOfMetrics;
const numMissing = numOfSidebearings - ((metrics.length - numOfMetrics * 4) >> 1);
if (numMissing > 0) {
// For each missing glyph, we set both the width and lsb to 0 (zero).
// Since we need to add two properties for each glyph, this explains
// the use of |numMissing * 2| when initializing the typed array.
const entries = new Uint8Array(metrics.length + numMissing * 2);
entries.set(metrics.data);
if (dupFirstEntry) {
// Set the sidebearing value of the duplicated glyph.
entries[metrics.length] = metrics.data[2];
entries[metrics.length + 1] = metrics.data[3];
}
metrics.data = entries;
}numOfMetrics is the "number of full metrics" declared in hhea. The remaining glyphs only need 2 bytes for the left side bearing, so the missing byte count is padded with zeros as numMissing * 2. When glyph 0 is duplicated, the new glyph reuses the original glyph 0 side-bearing bytes, so metrics before and after duplication stay consistent.
The same function also fixes out-of-range caretOffset and numOfMetrics:
if (numOfMetrics > numGlyphs) {
info(
`The numOfMetrics (${numOfMetrics}) should not be ` +
`greater than the numGlyphs (${numGlyphs}).`,
);
// Reduce numOfMetrics if it is greater than numGlyphs
numOfMetrics = numGlyphs;
header.data[34] = (numOfMetrics & 0xff00) >> 8;
header.data[35] = numOfMetrics & 0x00ff;
}When numOfMetrics exceeds the total glyph count, the extra metrics mean nothing, so it is truncated to the total and written back to hhea.
Static analysis of TrueType instructions
sanitizeTTProgram is a simplified TrueType interpreter whose purpose is not to execute instructions but to walk the control flow and count function definitions and stack changes:
function sanitizeTTPrograms(fpgm, prep, cvt, maxFunctionDefs) {
const ttContext = {
functionsDefined: [],
functionsUsed: [],
functionsStackDeltas: [],
tooComplexToFollowFunctions: false,
hintsValid: true,
};
if (fpgm) {
sanitizeTTProgram(fpgm, ttContext);
}
if (prep) {
sanitizeTTProgram(prep, ttContext);
}
if (fpgm) {
checkInvalidFunctions(ttContext, maxFunctionDefs);
}
if (cvt && cvt.length & 1) {
const cvtData = new Uint8Array(cvt.length + 1);
cvtData.set(cvt.data);
cvt.data = cvtData;
}
return ttContext.hintsValid;
}fpgm defines functions, prep runs preparation code once, and cvt is the control value table. The cvt length must be even; when it is odd, one byte is added. If the walk finds "a call to an undefined function", "too many function definitions" or "the stack depth goes negative", hintsValid is set to false and the instructions of every later glyph are deleted.
sanitizeTTProgram maintains a stack model but does not execute it precisely:
// Adjusting stack not exactly, but just enough to get function id
if (!inFDEF && !inELSE) {
let stackDelta = 0;
if (op <= 0x8e) {
stackDelta = TTOpsStackDeltas[op];
} else if (op >= 0xc0 && op <= 0xdf) {
stackDelta = -1;
} else if (op >= 0xe0) {
stackDelta = -2;
}
if (op >= 0x71 && op <= 0x75) {
n = stack.pop();
if (!isNaN(n)) {
stackDelta = -n * 2;
}
}
while (stackDelta < 0 && stack.length > 0) {
stack.pop();
stackDelta++;
}
while (stackDelta > 0) {
stack.push(NaN); // pushing any number into stack
stackDelta--;
}
}TTOpsStackDeltas is a table of "the net effect of each instruction on stack depth". The actual values do not matter; the source only needs to know "whether the top of the stack is a valid function number when CALL is executed". So missing values are filled with NaN, and any NaN is treated as invalid. This design lets the static analysis finish in constant space without simulating the actual values inside the font.
The real source of metrics
After cleaning up the table structure, the source overwrites the PDF dictionary metrics with values from head and hhea:
// Extract some more font properties from the OpenType head and
// hhea tables; yMin and descent value are always negative.
const metricsOverride = {
unitsPerEm: int16(tables.head.data[18], tables.head.data[19]),
yMax: signedInt16(tables.head.data[42], tables.head.data[43]),
yMin: signedInt16(tables.head.data[38], tables.head.data[39]),
ascent: signedInt16(tables.hhea.data[4], tables.hhea.data[5]),
descent: signedInt16(tables.hhea.data[6], tables.hhea.data[7]),
lineGap: signedInt16(tables.hhea.data[8], tables.hhea.data[9]),
};
// PDF FontDescriptor metrics lie -- using data from actual font.
this.ascent = metricsOverride.ascent / metricsOverride.unitsPerEm;
this.descent = metricsOverride.descent / metricsOverride.unitsPerEm;
this.lineGap = metricsOverride.lineGap / metricsOverride.unitsPerEm;The comment "PDF FontDescriptor metrics lie" is the bluntest sentence in this code. /Ascent and /Descent are declarations written by the author in the dictionary, while hhea holds measured values from the font program itself. Rendering uses the latter so that text selection and layout match the real glyphs.
Dividing by unitsPerEm rather than 1000 is because unitsPerEm in head can be 1024, 2048 or 1000. This step converts "design units" into "relative to the em", the same goal as properties.ascent / 1000 above, but more precise.
lineHeight is updated as well:
if (this.cssFontInfo?.lineHeight) {
this.lineHeight = this.cssFontInfo.metrics.lineHeight;
this.lineGap = this.cssFontInfo.metrics.lineGap;
} else {
this.lineHeight = this.ascent - this.descent + this.lineGap;
}When CSS font information is present, line height and line gap are fully determined by cssFontInfo.metrics, and the font's own hhea only provides ascent and descent. The normal path adds lineGap into the line height, which is the typographic formula OpenType recommends.
Composite fonts: CMap and CIDToGIDMap
The mapping from character code to glyph id takes two completely different routes. Composite fonts use the CMap directly:
if (properties.composite) {
const cidToGidMap = properties.cidToGidMap || [];
const isCidToGidMapEmpty = cidToGidMap.length === 0;
properties.cMap.forEach(function (charCode, cid) {
if (typeof cid === 'string') {
cid = convertCidString(charCode, cid /* shouldThrow = true */);
}
if (cid > 0xffff) {
throw new FormatError('Max size of CID is 65,535');
}
let glyphId = -1;
if (isCidToGidMapEmpty) {
glyphId = cid;
} else if (cidToGidMap[cid] !== undefined) {
glyphId = cidToGidMap[cid];
}
if (glyphId >= 0 && glyphId < numGlyphs && hasGlyph(glyphId)) {
charCodeToGlyphId[charCode] = glyphId;
}
});
}Without a /CIDToGIDMap, the CID is the glyph id; with a mapping table, it is looked up. If the lookup fails, the value is out of range, or the glyph is missing, no mapping is created. This keeps toFontChar in _charToGlyph free of entries that "point at a glyph that does not exist".
Simple fonts: priority of cmap subtables
The mapping for simple fonts has to reconcile two sources: the font's embedded cmap and the PDF dictionary Encoding. readCmapTable first picks subtables according to the specification:
if (
platformId === 0 &&
(encodingId === /* Unicode Default */ 0 ||
encodingId === /* Unicode 1.1 */ 1 ||
encodingId === /* Unicode BMP */ 3)
) {
useTable = true;
// Continue the loop since there still may be a higher priority
// table.
} else if (platformId === 1 && encodingId === 0) {
useTable = true;
// Continue the loop since there still may be a higher priority
// table.
} else if (
platformId === 3 &&
encodingId === 1 &&
(hasEncoding || !potentialTable)
) {
useTable = true;
if (!isSymbolicFont) {
canBreak = true;
}
} else if (isSymbolicFont && platformId === 3 && encodingId === 0) {
useTable = true;The priority is: non-symbolic fonts prefer (3, 1), then (1, 0) and platform 0; symbolic fonts prefer (3, 0). The source comment explains that it exploits the fact that "subtables are sorted by platformId", so it can keep scanning when a higher-priority table may still come, and break early once the best is certain.
Subtable formats 0, 2, 4, 6 and 12 are supported:
| Format | Structure | Typical use |
|---|---|---|
| 0 | 256-byte direct index | Early Mac fonts |
| 2 | High/low byte segments plus subheaders | CJK double-byte fonts |
| 4 | Segment mapping plus idDelta | Most common Windows BMP |
| 6 | Direct mapping of a contiguous range | Small subset fonts |
| 12 | Grouped mapping, supports supplementary planes | Large character sets, emoji |
Reading format 4 contains an offset conversion that is easy to misread: offsetIndex comes from (rangeOffset >> 1) - (segCount - segIndex), then the maximum of that and offsetIndex + segment.end - segment.start + 1 is used as the number of entries to read from glyphIdArray.
rangeOffset is "the byte distance from the current idRangeOffset field to glyphIdArray"; shifting right by one gives "how many 16-bit entries". Subtracting "the number of segments from the current one to the end of the array" gives the starting index of this segment inside glyphIdArray. The formula comes from the specification; the source just follows it.
The three mapping rules for simple fonts
Once a cmap subtable is chosen, the source handles three cases according to section 9.6.6.4 of the specification. The most common is "an Encoding exists, the font is not symbolic, and the subtable is (3, 1) or (1, 0)":
for (const mapping of cmapMappings) {
if (mapping.charCode !== unicodeOrCharCode) {
continue;
}
charCodeToGlyphId[charCode] = mapping.glyphId;
break;
}It first looks up the glyph name of the character code across /Differences, /BaseEncoding and StandardEncoding, then fixes non-standard names with recoverGlyphName, and finally converts the glyph name into a Unicode or MacRoman index to find the matching cmap entry. The key to this order is that cmap keys are Unicode while PDF keys are character codes, and the two must be bridged through the glyph name.
Platform 0 subtables are used directly with Unicode semantics:
} else if (cmapPlatformId === 0) {
// Default Unicode semantics, use the charcodes as is.
for (const mapping of cmapMappings) {
charCodeToGlyphId[mapping.charCode] = mapping.glyphId;
}
// Always prefer the BaseEncoding/Differences arrays, when they exist
// (fixes issue13433.pdf).
forcePostTable = true;
} else if (cmapPlatformId === 3 && cmapEncodingId === 0) {
// When a (3, 0) cmap table is present, it is used instead but the
// spec has special rules for char codes in the range of 0xF000 to
// 0xF0FF and it says the (3, 0) table should map the values from
// the (1, 0) table by prepending 0xF0 to the char codes. To reverse
// this, the upper bits of the char code are cleared, but only for the
// special range since some PDFs have char codes outside of this range
// (e.g. 0x2013) which when masked would overwrite other values in the
// cmap.
for (const mapping of cmapMappings) {
let charCode = mapping.charCode;
if (charCode >= 0xf000 && charCode <= 0xf0ff) {
charCode &= 0xff;
}
charCodeToGlyphId[charCode] = mapping.glyphId;
}
} else {The 0xF000 to 0xF0FF range in (3, 0) is the product of "prefixing single-byte codes with 0xF0", so the reverse mapping has to clear the high bits. Restricting this to that range is necessary: some PDFs use Unicode such as 0x2013 directly as a character code, and an unconditional & 0xFF would fold them into the low byte incorrectly.
The last step fills gaps using the post table:
// Last, try to map any missing charcodes using the post table.
if (properties.glyphNames && (baseEncoding.length || this.differences.length)) {
for (let i = 0; i < 256; ++i) {
if (!forcePostTable && charCodeToGlyphId[i] !== undefined) {
continue;
}
const glyphName = this.differences[i] || baseEncoding[i];
if (!glyphName) {
continue;
}
const glyphId = properties.glyphNames.indexOf(glyphName);
if (glyphId > 0 && hasGlyph(glyphId)) {
charCodeToGlyphId[i] = glyphId;
}
}
}glyphNames in the post table is "an array of glyph names ordered by glyph id", so the result of indexOf is the glyph id. When forcePostTable is true, even existing mappings are overwritten, because the platform 0 cmap has already provided Unicode semantics, while the PDF dictionary Encoding is the author's final declaration of the character codes.
Private use area rebuild and OS/2
Once the mapping is built, the source calls adjustMapping and rebuilds cmap and OS/2:
// When `cssFontInfo` is set, the font is used to render text in the HTML
// view (e.g. with Xfa) so nothing must be moved in the private use area.
if (!properties.cssFontInfo) {
// Converting glyphs and ids into font's cmap table
const newMapping = adjustMapping(charCodeToGlyphId, hasGlyph, glyphZeroId, this.toUnicode);
this.toFontChar = newMapping.toFontChar;
tables.cmap = {
tag: 'cmap',
data: createCmapTable(newMapping.charCodeToGlyphId, newMapping.toUnicodeExtraMap, numGlyphsOut),
};
if (!tables['OS/2'] || !validateOS2Table(tables['OS/2'], font)) {
tables['OS/2'] = {
tag: 'OS/2',
data: createOS2Table(properties, newMapping.charCodeToGlyphId, metricsOverride),
};
}
}When cssFontInfo is present the move is skipped, because such fonts must render with real Unicode in the HTML view, and moving them into the private use area would make the browser unable to find the corresponding characters. This is the exception introduced by XFA support.
validateOS2Table only checks a few fields that "would make the font completely unusable":
function validateOS2Table(os2, file) {
file.pos = (file.start || 0) + os2.offset;
const version = file.getUint16();
const minLength = [78, 86, 96, 96, 96, 100][version];
if (minLength === undefined || os2.length < minLength) {
return false;
}
// TODO verify all OS/2 tables fields, but currently we validate only those
// that give us issues
file.skip(60); // skipping type, misc sizes, panose, unicode ranges
const selection = file.getUint16();
if (version < 4 && selection & 0x0300) {
return false;
}
const firstChar = file.getUint16();
const lastChar = file.getUint16();
if (firstChar > lastChar) {
return false;
}
file.skip(6); // skipping sTypoAscender/Descender/LineGap
const usWinAscent = file.getUint16();
if (usWinAscent === 0) {
// makes font unreadable by windows
return false;
}
// OS/2 appears to be valid, resetting some fields
os2.data[8] = os2.data[9] = 0; // IE rejects fonts if fsType != 0
return true;
}The checks cover the minimum length for the version, style bits that older versions disallow, the order of the first and last character, and a non-zero usWinAscent. Finally fsType is zeroed, because IE refuses to load fonts based on the embedding permission bits. If validation passes the original table is kept; if it fails the whole table is rebuilt.
createOS2Table and createCmapTable are pure byte assembly, writing big-endian integers through DataBuilder. They are not expanded here; it is enough to know that the rebuilt tables match the mapping exactly, whereas the original tables may have drifted from the repaired mapping.
The name table and ToUnicode inference
// Re-creating 'name' table
if (!tables.name) {
tables.name = {
tag: 'name',
data: createNameTable(this.name),
};
} else {
// ... using existing 'name' table as prototype
const [namePrototype, nameRecords] = readNameTable(tables.name);
tables.name.data = createNameTable(name, namePrototype);
this.psName = namePrototype[0][6] || null;
if (!properties.composite) {
// For TrueType fonts that do not include `ToUnicode` or `Encoding`
// data, attempt to use the name-table to improve text selection.
adjustTrueTypeToUnicode(properties, this.isSymbolicFont, nameRecords);
}
}createNameTable has to generate both Mac and Windows strings: one byte per character for Mac, two for Windows. namePrototype is the string data from the original table, kept as a template for information such as copyright and family name. psName takes name ID 6 (the PostScript name), later used for CSS or debugging.
Finally the kept tables are handed to OpenTypeFileBuilder for reassembly:
const builder = new OpenTypeFileBuilder(header.version);
for (const tableTag in tables) {
builder.addTable(tableTag, tables[tableTag].data);
}
return builder.toArray();header.version is the original file's version marker (\x00\x01\x00\x00 or OTTO), kept unchanged so the font type is not altered.
Design note: Every step of
checkAndRepairanswers the same question — "this field is broken; can I still draw characters?". A broken table directory is re-read with the real length, a broken loca is sorted and rebuilt, a broken glyph is zeroed and marked missing, broken instructions are deleted whole, and even an entirely empty glyf still gets a placeholder glyph. Only two kinds of place throw: when glyph boundaries cannot be determined (both length formulas fail forindexToLocFormat), and when a required table is missing and cannot be rebuilt (loca,maxp,head,hhea). This is the extreme version of the "repair first" philosophy.
OTF Wrapping for Type1 and CFF
Why they cannot be handed over directly
Neither Type1 nor CFF is a format a browser can load directly. Browsers want an sfnt container (usually with an otf or ttf extension) holding the head, hhea, hmtx, maxp, cmap, name, post and OS/2 tables. Type1 is PostScript source with an encrypted section, and CFF is a compact container of glyph programs; neither is accepted by FontFace on its own.
The job of convert is to "build a shell": put the original glyph program into the CFF table and hand-assemble the other tables from font properties. It does not parse outlines, because the browser parses CFF itself.
glyphZeroId: the two formats treat glyph 0 differently
// Type 1 fonts have a notdef inserted at the beginning, so glyph 0
// becomes glyph 1. In a CFF font glyph 0 is appended to the end of the
// char strings.
const glyphZeroId = font instanceof CFFFont ? font.numGlyphs - 1 : 1;
const mapping = font.getGlyphMapping(properties);
let newMapping = null;
let newCharCodeToGlyphId = mapping;
let toUnicodeExtraMap = null;When parsing, Type1's Type1Font inserts a .notdef first, so all original glyphs shift by one and the "real glyph" behind glyph 0 is 1. CFF is the opposite: CFFFont copies glyph 0 to the end of the charstring array, so the real glyph 0 is the last one.
Both values are passed into adjustMapping as newGlyphZeroId, redirecting every character code that pointed at glyph 0 to "the copy that can actually be drawn".
adjustMapping and code point allocation for seac
if (!properties.cssFontInfo) {
newMapping = adjustMapping(mapping, font.hasGlyphId.bind(font), glyphZeroId, this.toUnicode);
this.toFontChar = newMapping.toFontChar;
newCharCodeToGlyphId = newMapping.charCodeToGlyphId;
toUnicodeExtraMap = newMapping.toUnicodeExtraMap;
}
const numGlyphs = font.numGlyphs;On the Type1/CFF path hasGlyph is font.hasGlyphId. The Type1 implementation looks like this:
hasGlyphId(id) {
if (id < 0 || id >= this.numGlyphs) {
return false;
}
if (id === 0) {
// notdef is always defined.
return true;
}
const glyph = this.charstrings[id - 1];
return glyph.charstring.length > 0;
}CFFFont forwards the call straight to the underlying CFF parser: return this.cff.hasGlyphId(id);. Both have the same meaning — "the charstring for this glyph id exists and is non-empty" — only the data source changes from glyf to charstrings.
Handling seac needs a "temporary code point" allocator, createCharCode: it first looks in charCodeToGlyphId for a character code already pointing at the target glyph id, and if none exists it takes a new one from newMapping.nextAvailableFontCharCode and writes it back into the mapping. charCode | 0 is necessary: the keys produced by for in are strings, and | 0 converts them back to 32-bit integers.
The core loop of seac:
const seacs = font.seacs;
if (newMapping && SEAC_ANALYSIS_ENABLED && seacs?.length) {
const matrix = properties.fontMatrix || FONT_IDENTITY_MATRIX;
const charset = font.getCharset();
const seacMap = Object.create(null);
for (let glyphId in seacs) {
glyphId |= 0;
const seac = seacs[glyphId];
const baseGlyphName = StandardEncoding[seac[2]];
const accentGlyphName = StandardEncoding[seac[3]];
const baseGlyphId = charset.indexOf(baseGlyphName);
const accentGlyphId = charset.indexOf(accentGlyphName);
if (baseGlyphId < 0 || accentGlyphId < 0) {
continue;
}
const accentOffset = {
x: seac[0] * matrix[0] + seac[1] * matrix[2] + matrix[4],
y: seac[0] * matrix[1] + seac[1] * matrix[3] + matrix[5],
};The four values in seacs come from the Type1 seac instruction: [asb, adx, bchar, achar], namely the accent's horizontal displacement, the base glyph encoding and the accent glyph encoding. Both glyph names are looked up through StandardEncoding, then resolved to glyph ids in the charset.
The offset calculation is a full affine transform: x takes matrix entries 0, 2 and 4, y takes entries 1, 3 and 5. That way the accent still lands in the right place even when the font matrix carries rotation or skew.
A seac mapping is only built when "this glyph originally had a character code":
const charCodes = getCharCodes(mapping, glyphId);
if (!charCodes) {
// There's no point in mapping it if the char code was never mapped
// to begin with.
continue;
}Without a character code, the content stream would never use this composite glyph, and building a mapping for it would only waste code points.
The fake .notdef in hmtx
(function fontTableHmtx() {
const charstrings = font.charstrings;
const cffWidths = font.cff?.widths ?? null;
const hmtx = new DataBuilder({ exactLength: numGlyphs * 4 });
// Fake .notdef (width=0 and lsb=0) first, skip redundant assignment.
hmtx.skip(4);
for (let i = 1, ii = numGlyphs; i < ii; i++) {
let width = 0;
if (charstrings) {
width = charstrings[i - 1].width || 0;
} else if (cffWidths) {
width = Math.ceil(cffWidths[i] || 0);
}
hmtx.setInt16(width);
hmtx.skip(2); // Use lsb=0, skip redundant assignment.
}
return hmtx.data;
})();The first 4 bytes stay 0 and represent .notdef. The loop starts at 1, and charstrings[i - 1] is used because the Type1 charstring array has one fewer entry than the glyph ids (.notdef is inserted). Widths are rounded up with Math.ceil, because hmtx only accepts integers while CFF widths may be fractional design units.
Fixed table headers and metric sources
The head, hhea and maxp tables are all written by hand through DataBuilder:
const builder = new OpenTypeFileBuilder('\x4F\x54\x54\x4F');
// PostScript Font Program
builder.addTable('CFF ', font.data);
// OS/2 and Windows Specific metrics
builder.addTable('OS/2', createOS2Table(properties, newCharCodeToGlyphId));
// Character to glyphs mapping
builder.addTable('cmap', createCmapTable(newCharCodeToGlyphId, toUnicodeExtraMap, numGlyphs));The version marker "\x4F\x54\x54\x4F" is ASCII OTTO, meaning "this is an OpenType font with CFF outlines". The order places CFF first, then OS/2 and cmap, with the remaining tables appended afterwards.
There is one easily misread detail in hhea:
hhea.setSafeInt16(properties.capHeight); // caretSlopeRise
hhea.setSafeInt16(Math.tan(properties.italicAngle) * properties.xHeight); // caretSlopeRunThis version of the repository writes capHeight into the caretSlopeRise slot and tan(italicAngle) * xHeight into caretSlopeRun. In the specification those two fields describe the caret slope and have nothing to do with capHeight; this looks more like a historical field misalignment than a deliberate design. The comments still say caretSlopeRise, so the authors know the field names, but the assigned values come from font metrics.
The last field of hhea is written as the total glyph count:
hhea.setInt16(numGlyphs); // Number of HMetricsThat amounts to declaring "every glyph has full metrics", consistent with the hmtx above writing 4 bytes for every glyph.
ToUnicode correction after wrapping
if (properties.builtInEncoding) {
// For Type1 fonts that do not include either `ToUnicode` or `Encoding`
// data, attempt to use the `builtInEncoding` to improve text selection.
adjustType1ToUnicode(properties, properties.builtInEncoding);
}This step must happen at the start of convert, before adjustMapping. The reason is direct: adjustMapping reads this.toUnicode to decide the contents of toUnicodeExtraMap, so if toUnicode is not yet complete, the added Unicode would not make it into the new cmap.
Similarly, properties.fixedPitch = false is forced at the beginning:
// TODO: Check the charstring widths to determine this.
properties.fixedPitch = false;The monospace property of Type1/CFF is not parsed reliably, so the source chooses "treat everything as non-monospace" and lets the layers above rely on the width table. The TODO shows the authors know this is imprecise.
The boundary between CFFFont and Type1Font
Neither class is in fonts.js, but the constructor's selection logic is:
const cff =
subtype === 'Type1C' || subtype === 'CIDFontType0C'
? new CFFFont(file, properties)
: new Type1Font(name, file, properties);Type1Font decrypts the eexec section, parses the PostScript dictionary, extracts charstrings and converts Type1 outlines into CFF. CFFFont reads the CFF INDEX structures, charset, encoding and private dictionary directly. Their common output is "charstrings plus seacs, charset and widths", and convert depends only on those shared interfaces, not on the implementation.
CFFFont has one extra capability: duplicateFirstGlyph. When checkAndRepair meets "an OTTO shell that is really CFF", it calls it first to copy glyph 0 to the end, then recompiles with CFFCompiler:
if (parsedCff) {
try {
parsedCff.duplicateFirstGlyph();
tables['CFF '].data = new CFFCompiler(parsedCff).compile();
numGlyphsFromCFF = parsedCff.charStringCount;
} catch {
warn('Failed to compile font ' + properties.loadedName);
}
}This is also the only place where "repair changes the total glyph count": numGlyphs therefore prefers numGlyphsFromCFF over the old value in maxp. A compile failure only warns and keeps the original CFF data, which is then handled with the old table structure.
Design note: The core assumption of the wrapping route is "browsers parse CFF correctly but do not understand Type1 or bare CFF containers".
converttherefore only does container conversion and table completion, and never rewrites outlines. This makes Type1/CFF rendering fidelity depend entirely on the browser's own CFF parser, and avoids implementing a full outline interpreter inside the worker.
Type3 and Character Procedures
The early return in the constructor
Type3 gets only a dozen lines in the constructor:
if (properties.type === 'Type3') {
for (let charCode = 0; charCode < 256; charCode++) {
this.toFontChar[charCode] = this.differences[charCode] || properties.defaultEncoding[charCode];
}
return;
}The values of toFontChar are glyph names, not code points. This is the most fundamental difference between Type3 and the other fonts: the glyphs of other fonts are defined by a font program, while Type3 glyphs are defined by a content stream of the same name inside /CharProcs.
The loop stops at 256 because Type3 only supports single-byte encodings. /Differences takes priority over /Encoding, consistent with the rule for simple fonts.
Where operatorListId comes from
_charToGlyph has a Type3-specific branch:
if (this.isType3Font) {
// Font char code in this case is actually a glyph name.
operatorListId = fontCharCode;
}fontCharCode comes from toFontChar, which for Type3 is the glyph name. Once the canvas layer has operatorListId, it looks up the corresponding operatorList in charProcOperatorList and executes it instead of calling fillText.
This assignment happens before fontChar is generated, so for Type3 fontCharCode is still a string and is not processed by String.fromCodePoint. When generating fontChar, the source checks typeof fontCharCode === "number"; strings are skipped and fontChar stays an empty string. Type3 drawing relies entirely on operatorListId, so an empty fontChar is correct.
Compiling character procedures
charProcOperatorList is not built in fonts.js but in TranslatedFont.loadType3Data in evaluator.js:
const charProcs = dict.get("CharProcs");
const fontResources = dict.get("Resources") || resources;
const charProcOperatorList = new Map();
const [x0, y0, x1, y1] = font.bbox;
const fontBBoxSize = Math.hypot(x1 - x0, y1 - y0);
for (const key of charProcs.getKeys()) {Every character procedure is evaluated as an ordinary content stream to produce an OperatorList, and its IR (intermediate representation) is stored in the Map. This code shares the same path as ordinary page evaluation, so a Type3 glyph can contain any drawing operation, including nested text, images and colours.
fontBBoxSize is the diagonal length of the bounding box, used to decide whether a missing d1 argument needs to be "guessed". The related logic is in #guessType3FontBBox and is not expanded here.
The difference between d0 and d1
The specification requires character procedures to start with d0 or d1; both declare the glyph width, and d1 additionally declares the bounding box:
switch (operatorList.fnArray[0]) {
case OPS.setCharWidthAndBounds:
this.#removeType3ColorOperators(operatorList, fontBBoxSize);
break;
case OPS.setCharWidth:
if (!fontBBoxSize) {
this.#guessType3FontBBox(operatorList);
}
break;
}OPS.setCharWidth corresponds to d0, and OPS.setCharWidthAndBounds corresponds to d1. The bounding box of d1 is written back to font.bbox and sets isCharBBox = true, which the canvas layer uses for clipping.
The specification also states that a character procedure starting with d1 must not perform any colour-setting operations, and those operations must be ignored. #removeType3ColorOperators enforces this rule by removing colour-related operators from the IR.
Degrading when a single character procedure fails
} catch {
warn(`Type3 font resource "${key}" is not available.`);
charProcOperatorList.set(key, new OperatorList().getIR());
}When a character procedure fails to parse, no error is thrown; an empty operatorList is stored instead. That glyph cannot be drawn, but the others are fine. Compared with ordinary page evaluation, Type3 calls evaluator.clone with the ignoreErrors option set to false — errors inside a single glyph are not tolerated, because "half a glyph" is meaningless; glyphs, however, are isolated from one another.
The roles of fontMatrix and bbox
The Type3 /FontMatrix is mandatory and usually is not the default [0.001, 0, 0, 0.001, 0, 0]. It defines the transform from "glyph space" to "text space". The constructor keeps it as is and returns early in the Type3 branch, so adjustWidths never runs — Type3 widths are already defined in their own glyph space.
bbox matters especially for Type3: it is the default bounding box, and when a character procedure uses d0 without providing one, the canvas layer needs a range to clip against. At the end of loadType3Data, the source overwrites it with the _bbox obtained from actual evaluation.
Design note: Type3 turns "glyphs" from data into programs. The
Fonthandling of it is to keep only identity information (glyph names, encoding, widths, matrix, bounding box) and leave execution to the evaluator and canvas layer. This boundary lets Type3 reuse every capability of ordinary content streams, and keepsfonts.jsfrom having to understand any drawing operator.
Widths, Metrics and Spaces
Where widths and defaultWidth come from
properties.widths is assembled by extractWidths in the evaluator; Font is only a receiver. Simple fonts index by character code and composite fonts index by CID; when there is no /Widths, the evaluator tries to fill them from standard font metrics or the metrics of BaseFont:
const missingWidth = descriptor.get("MissingWidth");
defaultWidth = typeof missingWidth === "number" ? missingWidth : 0;
} else {
// Trying get the BaseFont metrics (see comment above).
const baseFontName = dict.get("BaseFont");
if (baseFontName instanceof Name) {
const metrics = this.getBaseFontMetrics(baseFontName.name);
glyphsWidths = this.buildCharCodeToWidth(metrics.widths, properties);
defaultWidth = metrics.defaultWidth;
}
}This is in evaluator.js, but it explains why Font can trust the widths it receives: the priority has already been resolved as "font dictionary /Widths, then /MissingWidth, then standard font metrics". Font has only one step left — fall back to defaultWidth when a lookup fails.
adjustWidths applies only to non-standard font matrices:
function adjustWidths(properties) {
if (!properties.fontMatrix) {
return;
}
if (properties.fontMatrix[0] === FONT_IDENTITY_MATRIX[0]) {
return;
}
// adjusting width to fontMatrix scale
const scale = 0.001 / properties.fontMatrix[0];
const glyphsWidths = properties.widths;
for (const glyph in glyphsWidths) {
glyphsWidths[glyph] *= scale;
}
properties.defaultWidth *= scale;
}It modifies defaultWidth as well, so the end of the constructor must re-read properties.defaultWidth. Scaling only widths and missing the default would make characters without a width suddenly wider or narrower.
Width codes and character codes are not the same thing
These three lines in _charToGlyph are the entire width calculation:
width = this.widths[widthCode];
if (typeof width !== 'number') {
width = this.defaultWidth;
}
const vmetric = this.vmetrics?.[widthCode] || this.defaultVMetrics;Note typeof width !== "number" rather than if (!width). A width of 0 is a legal value (invisible characters, zero-width joiners), and a truthiness check would replace it with the default width and misplace the text.
vmetrics and defaultVMetrics are the vertical metrics of vertical writing fonts. defaultVMetrics is constructed by the evaluator when /W2 and /DW2 are missing:
defaultVMetrics = [vmetrics[1], defaultWidth * 0.5, vmetrics[0]];The three values are w1y, vx and wy. vx takes half the default width, an approximation of "the vertical glyph roughly centred".
The three-level guess for _spaceWidth
The space width is missing in many fonts because space usually has no glyph of its own. The private getter makes a series of attempts:
get _spaceWidth() {
// trying to estimate space character width
const possibleSpaceReplacements = ["space", "minus", "one", "i", "I"];
let width;
for (const glyphName of possibleSpaceReplacements) {
// if possible, getting width by glyph name
if (glyphName in this.widths) {
width = this.widths[glyphName];
break;
}
const glyphsUnicodeMap = getGlyphsUnicode();
const glyphUnicode = glyphsUnicodeMap[glyphName];
// finding the charcode via unicodeToCID map
let charcode = 0;
if (this.composite && this.cMap.contains(glyphUnicode)) {
charcode = this.cMap.lookup(glyphUnicode);
if (typeof charcode === "string") {
charcode = convertCidString(glyphUnicode, charcode);
}
}
// ... via toUnicode map
if (!charcode && this.toUnicode) {
charcode = this.toUnicode.charCodeOf(glyphUnicode);
}
// setting it to unicode if negative or undefined
if (charcode <= 0) {
charcode = glyphUnicode;
}
// trying to get width via charcode
width = this.widths[charcode];
if (width) {
break; // the non-zero width found
}
}
return shadow(this, "_spaceWidth", width || this.defaultWidth);
}The candidate list space, minus, one, i, I is ordered by "common glyphs whose width is close to a space". A real space may have no separate CID in a composite font, hence the fallback to minus, one, i and I.
Each round makes three attempts: first check whether widths has an entry keyed by glyph name (rare, but some internal fonts do this), then resolve the Unicode to a CID through the CMap, then resolve it back to a character code with toUnicode.charCodeOf, and finally look up the width by character code. Only when all three fail does it move to the next candidate.
shadow freezes the result on the instance. This getter is called by _charToGlyph on the missing-font path, and _charToGlyph is itself a hot path, so it is computed only once.
The three sources of metric fields
The same ascent may come from three places, in this priority order:
| Source | Fields | When it is used |
|---|---|---|
| CSS font information | cssFontInfo.metrics | CSS-laid-out scenarios such as XFA |
Font program head/hhea | unitsPerEm, ascent, descent | An embedded font exists and its tables are readable |
| PDF font descriptor | /Ascent, /Descent, /CapHeight | No embedded font, or font program tables are missing |
The constructor starts with the third (divided by 1000), and after checkAndRepair succeeds it overwrites it with the second (divided by unitsPerEm). fallbackToSystemFont fills in standard font metrics only when a field is NaN, avoiding overwriting descriptor values that are already valid:
if (metrics) {
if (isNaN(this.ascent)) {
this.ascent = metrics.ascent / PDF_GLYPH_SPACE_UNITS;
}
if (isNaN(this.descent)) {
this.descent = metrics.descent / PDF_GLYPH_SPACE_UNITS;
}
if (isNaN(this.capHeight)) {
this.capHeight = metrics.capHeight / PDF_GLYPH_SPACE_UNITS;
}
}The check is isNaN rather than undefined, because NaN can appear in descriptors: the evaluator leaves NaN as an "unknown" marker when a field is missing or not numeric.
encodeString: encoding Unicode back to character codes
The text layer and forms often need "given a JS string, produce PDF character codes". encodeString is the reverse mapping:
encodeString(str) {
const buffers = [];
const currentBuf = [];
// buffers will contain: encoded, non-encoded, encoded, ...
// currentBuf is pushed in buffers each time there is a change.
// So when buffers.length is odd then the last string is an encoded one
// and currentBuf contains non-encoded chars.
const hasCurrentBufErrors = () => buffers.length % 2 === 1;
const getCharCode =
this.toUnicode instanceof IdentityToUnicodeMap
? unicode => this.toUnicode.charCodeOf(unicode)
: unicode => this.toUnicode.charCodeOf(String.fromCodePoint(unicode));The returned array is interleaved: even indices are "encoded segments" and odd indices are "original segments that cannot be encoded". Callers (the canvas layer's measureText, form default-value writing) can handle the two kinds separately.
for (let i = 0, ii = str.length; i < ii; i++) {
const unicode = str.codePointAt(i);
if (unicode > 0xffff) {
// unicode is represented by two uint16
i++;
}Supplementary-plane characters occupy two UTF-16 code units, and i++ skips the second half. When encoding is possible, the byte count is decided by this.cMap.getCharCodeLength(charCode) and bytes are written from high to low into currentBuf, preserving the byte order of multi-byte CMaps; a charCodeOf result of -1 means the Unicode does not exist in the font, and that text goes into the "cannot encode" segment.
How metric consistency is guaranteed
Width, metrics and line height must be mutually consistent, otherwise the text layer shows "selection ranges that do not line up with glyphs". The source enforces two rules:
- Widths and metrics are both in "relative to the em" units, normalized by thousandths or
unitsPerEm, never mixing design units; lineHeightis computed only after the final override, andlineGapandlineHeightcome either both from CSS or both fromhhea.
This is also why the constructor re-reads properties.fontMatrix, widths and defaultWidth after checkAndRepair: the repair may have changed all three, and updating only some of them would break consistency.
Design note: In width queries, "not found" and "width is 0" are strictly distinguished.
typeof width !== "number"preserves the real width of zero-width characters, whilewidth || this.defaultWidthonly covers "nothing found at all". Such checks look trivial, yet they decide whether complex typography (zero-width joins, combining accents, vertical punctuation) aligns correctly.
System Font Fallback and Export
When the fallback happens
Four routes lead to fallbackToSystemFont: the file is empty, the type is unsupported, repair throws, or /FontFile does not exist at all. The fallback is not "draw something roughly similar" but "reconstruct as faithfully as possible using a font the browser already has plus the correct character mapping".
Name normalization and standard font substitution
fallbackToSystemFont(properties) {
this.missingFile = true;
// The file data is not specified. Trying to fix the font name
// to be used with the canvas.font.
const { name, type } = this;
let fontName = normalizeFontName(name);
const stdFontMap = getStdFontMap(),
nonStdFontMap = getNonStdFontMap();
const isStandardFont = !!stdFontMap[fontName];
const isMappedToStandardFont = !!(
nonStdFontMap[fontName] && stdFontMap[nonStdFontMap[fontName]]
);
fontName = stdFontMap[fontName] || nonStdFontMap[fontName] || fontName;normalizeFontName strips the subset prefix and whitespace and folds the name into an uppercase, space-free canonical form. stdFontMap maps "PDF standard font name" to "one of the standard 14 fonts", for example Arial to Helvetica; nonStdFontMap handles more aliases. isMappedToStandardFont requires that "the alias first maps to some name, and that name is itself in the standard table", which identifies "a standard font disguised as a non-standard one".
The four style bits bold, italic, black and narrow are inferred directly from the name with regular expressions:
this.bold = /bold/i.test(fontName);
this.italic = /oblique|italic/i.test(fontName);
// Use 'name' instead of 'fontName' here because the original
// name ArialBlack for example will be replaced by Helvetica.
this.black = /Black/.test(name);
// Use 'name' instead of 'fontName' here because the original
// name ArialNarrow for example will be replaced by Helvetica.
const isNarrow = /Narrow/.test(name);bold and italic use the substituted fontName, because standard font names carry Bold and Italic suffixes themselves; black and narrow must use the original name, because ArialBlack would be substituted with Helvetica and the "black" information would be lost. The two comments state the reason clearly.
remeasure: when widths must be measured again
// if at least one width is present, remeasure all chars when exists
this.remeasure = (!isStandardFont || isNarrow) && Object.keys(this.widths).length > 0;remeasure means "the canvas layer must re-measure every character with measureText and must not trust the widths in the PDF". It is triggered by "a non-standard font or a narrow font" and "at least one width present". For a standard, non-narrow font, the PDF widths match the browser font metrics closely and can be used directly; as soon as the font family is substituted or the glyph widths are compressed, the PDF widths no longer match the browser.
Standard glyph mapping for composite fonts
Standard fonts are sometimes embedded as CID fonts without a /CIDToGIDMap. The mapping then has to be rebuilt from the "glyph order table of the standard fonts":
if (
(isStandardFont || isMappedToStandardFont) &&
type === "CIDFontType2" &&
this.cidEncoding.startsWith("Identity-")
) {
const cidToGidMap = properties.cidToGidMap;
// Standard fonts might be embedded as CID font without glyph mapping.
// Building one based on GlyphMapForStandardFonts.
const map = [];
if (/Trebuchet/i.test(name)) {
// TrebuchetMS doesn't share the glyph ordering of the standard fonts,
// hence using the latter would map e.g. "š" to "ž" (issue 21713).
applyStandardFontGlyphMap(map, getGlyphMapForMacOrderedFonts());
applyStandardFontGlyphMap(map, getSupplementalGlyphMapForTrebuchetMS());
} else {
applyStandardFontGlyphMap(map, getGlyphMapForStandardFonts());
if (/Arial-?Black/i.test(name)) {
applyStandardFontGlyphMap(
map,
getSupplementalGlyphMapForArialBlack()
);
} else if (/Calibri/i.test(name)) {
applyStandardFontGlyphMap(map, getSupplementalGlyphMapForCalibri());
}
}getGlyphMapForStandardFonts is the "character code to glyph index" table of the standard 14 fonts. applyStandardFontGlyphMap overlays that table onto map, with later writes overwriting on duplicate keys. Trebuchet, ArialBlack and Calibri each have a supplemental table because their glyph order differs from the standard fonts.
This block also handles the "/CIDToGIDMap is incomplete" case:
// When the /CIDToGIDMap is "incomplete", fallback to the included
// /ToUnicode-map regardless of its encoding (fixes issue11915.pdf).
if (
cidToGidMap.length !== this.toUnicode.length &&
properties.hasIncludedToUnicodeMap &&
this.toUnicode instanceof IdentityToUnicodeMap
) {
this.toUnicode.forEach(function (charCode, unicodeCharCode) {
const cid = map[charCode];
if (cidToGidMap[cid] === undefined) {
map[+charCode] = unicodeCharCode;
}
});
}A length mismatch means the mapping is incomplete; /ToUnicode is then used to fill in the unmapped entries. The condition requires IdentityToUnicodeMap, because only the forEach of an identity mapping can safely walk every CID without relying on a sparse array.
Symbol fonts and Dingbats
} else if (/Symbol/i.test(fontName)) {
// The non-embedded SymbolMT font in issue 21523 uses Identity encoding
// and an Identity CIDToGIDMap, hence its CIDs are glyph ids.
const isCidKeyed =
this.composite && this.cidEncoding.startsWith("Identity-");
this.toFontChar = buildToFontChar(
isCidKeyed ? getSymbolGlyphIdEncoding() : SymbolSetEncoding,
getGlyphsUnicode(),
this.differences
);
} else if (/Dingbats/i.test(fontName)) {
this.toFontChar = buildToFontChar(
ZapfDingbatsEncoding,
getDingbatsGlyphsUnicode(),
this.differences
);The character codes of symbol fonts are not Unicode but a private encoding. SymbolSetEncoding maps 256 code points to glyph names, which getGlyphsUnicode then turns into Unicode. The isCidKeyed branch serves a SymbolMT with "Identity encoding plus Identity CIDToGIDMap", where the CID is itself the glyph id and a different encoding table is needed.
Dingbats uses ZapfDingbatsEncoding and getDingbatsGlyphsUnicode, because its glyph names (such as a20) live in a different namespace than ordinary glyph names.
The generic branch and the Tahoma/Verdana patch
} else {
const glyphsUnicodeMap = getGlyphsUnicode();
const map = [];
this.toUnicode.forEach((charCode, unicodeCharCode) => {
if (!this.composite) {
const glyphName =
this.differences[charCode] || this.defaultEncoding[charCode];
const unicode = getUnicodeForGlyph(glyphName, glyphsUnicodeMap);
if (unicode !== -1) {
unicodeCharCode = unicode;
}
}
map[+charCode] = unicodeCharCode;
});
// Attempt to improve the glyph mapping for (some) composite fonts that
// appear to lack meaningful ToUnicode data.
if (this.composite && this.toUnicode instanceof IdentityToUnicodeMap) {
if (/Tahoma|Verdana/i.test(name)) {
// Fixes issue15719.pdf and issue11242_reduced.pdf.
applyStandardFontGlyphMap(map, getGlyphMapForStandardFonts());
}
}
this.toFontChar = map;
}The generic branch copies toUnicode directly into toFontChar; simple fonts get one extra correction using "glyph name to Unicode", because toUnicode may come from an imprecise embedded encoding.
The Tahoma and Verdana patch targets a specific phenomenon: the composite versions of these two fonts often have only an identity mapping, so the glyph order is treated as Unicode and the text layer shows garbage. Overwriting with the standard glyph table at least recovers the correct glyphs.
Rewriting loadedName
amendFallbackToUnicode(properties);
this.loadedName = fontName.split('-', 1)[0];After the fallback finishes, loadedName is changed to "the first segment of the normalized font name". It was docId_fontId, and now it becomes a real font name such as Helvetica or TimesNewRoman. The reason is that a fallback font has no embedded data, so the main thread does not need to fetch font bytes from commonobj by loadedName and instead uses the CSS font family directly; loadedName is also the key for debugging and font-substitution.
loadedName may still collide: fonts with the same name appearing several times in one document get the same loadedName. That is acceptable on the fallback path, since there is no font data to distinguish.
exportData: compressing fields into binary
The main thread cannot receive a Font instance directly, so exportData splits the fields into three parts:
exportData() {
return {
buffer: compileFontInfo(this.#getExportData(EXPORT_DATA_PROPERTIES)),
charProcOperatorList: this.charProcOperatorList,
extra: this.fontExtraProperties
? this.#getExportData(EXPORT_DATA_EXTRA_PROPERTIES)
: undefined,
};
}compileFontInfo comes from obj_bin_transform_core.js and writes ordinary fields as bytes in a fixed order; charProcOperatorList is a Map and does not fit in that binary stream, so it is transferred separately; extra carries debug fields only when fontExtraProperties is enabled. #getExportData skips fields whose value is undefined while walking the field table, so one export format serves several kinds of fonts: Type3 has no data and mimetype, and a fallback font has no charProcOperatorList, yet both can be reconstructed correctly by the main thread.
One entry in EXPORT_DATA_PROPERTIES deserves a note: data is the full OpenType bytes. It is placed in the "base fields" rather than in extra, because it is not debug information but the source of the FontFace. toUnicode, widths and vmetrics are in extra instead, because only the text layer and vertical layout need them.
ErrorFont: the final backstop
class ErrorFont {
constructor(error) {
this.error = error;
this.loadedName = 'g_font_error';
this.missingFile = true;
}
charsToGlyphs() {
return [];
}
encodeString(chars) {
return [chars];
}
exportData() {
return { error: this.error };
}
}ErrorFont implements the same minimal interface as Font, so the evaluator does not need a branch for "font failed completely". charsToGlyphs returns an empty array and the text layer draws nothing; encodeString returns its input unchanged and form writing does not crash; exportData carries only the error, which the main thread uses to report the problem on the console.
loadedName is fixed to g_font_error, so all error fonts share one CSS class. missingFile is true, telling the canvas layer that there is no need to wait for font loading.
Design note: The goal of the fallback path is not "restore the original font" but "keep the text readable, selectable and copyable". It therefore prioritizes three things: a correct character code to Unicode mapping, widths that can be re-measured, and a
loadedNameCSS can use directly. Differences in glyph appearance are accepted, because the original font data no longer exists.
Conclusion
fonts.js handles the conversion of "font data into a queryable interface plus loadable bytes" in the whole chain:
| Stage | Entry point | Output |
|---|---|---|
| Type determination | getFontFileType | The real type and subtype |
| Font program repair | checkAndRepair | Rebuilt OpenType bytes, missingGlyphs |
| Font program wrapping | convert | OpenType bytes with a CFF table, seacMap |
| Mapping construction | adjustMapping, buildToFontChar | toFontChar, a new cmap |
| Character queries | _charToGlyph, charsToGlyphs | Glyph and the two cache levels |
| Export | exportData | compileFontInfo bytes, charProcOperatorList |
| Backstop | fallbackToSystemFont, ErrorFont | System font metadata or an error marker |
A complete call chain can be written as:
// PartialEvaluator: read the font dictionary and /FontFile*
// → getFontFileType determine the real type
// → checkAndRepair / convert repair or wrap
// → adjustMapping move code points into the private use area
// → createCmapTable / createOS2Table / createNameTable
// → Font.charsToGlyphs content-stream string → Glyph array
// → _charToGlyph Glyph cache
// → Font.renderer FontRendererFactory compiles outlines
// → exportData compileFontInfo + charProcOperatorList
// → TranslatedFont.send → FontFaceObject → canvas.js drawingSix principles worth taking away:
- The file header outranks the dictionary declaration.
getFontFileTypereads magic numbers instead of trusting/Subtype; themaxpversion is inferred from the table length andindexToLocFormatfrom thelocalength. A structure proving itself is more reliable than a field describing itself. - Code points must move into the private use area.
adjustMappingmakes the browser treat glyphs as ordinary shapes, sidestepping the language-specific behaviour of Unicode areas; the cost is maintaining three pieces of information at once —toFontChar,toUnicodeandtoUnicodeExtraMap. - Repair first, degrade as a backstop. Broken tables are re-read, broken glyphs are zeroed, broken instructions are deleted, and even an entirely empty
glyfgets a placeholder glyph; if it cannot be repaired, fall back to a system font, and only if that also fails useErrorFont. - Metrics follow the font program. The ascent, descent and lineGap from
hheaoverride the PDF descriptor; descriptors and standard font metrics are used only when there is no embedded font, and only to fill fields that areNaN. - Query results are read-only and resident. Once a
Glyphenters#glyphCacheit never changes, and#charsCachereuses whole-string results directly;glyphCacheValuesexposes only an iterator, preventing external code from breaking the cache. - Layer boundaries are clear.
fonts.jsdoes not parse outlines (that is left toglyf.js,CFFFontand the browser) and does not execute Type3 character procedures (that is left to the evaluator and the canvas layer); it only owns "identity, mapping, metrics and container".
StreamPDF design principle: anything that can be handled ahead of time is absolutely not deferred to parse time; the server solves all performance problems once. The browser only fetches and renders the data it must.
Related articles
- PDF.js Deep Dive from Demo 10: CanvasGraphics and operatorList RenderingOctober 27, 2026
- PDF.js Deep Dive from Demo 9: Network Streams and Range RequestsOctober 27, 2026
- PDF.js Deep Dive from Demo 8: PDFWorker.create and the Real/Fake Worker HandshakeOctober 27, 2026
- PDF.js Deep Dive from Demo 7: WorkerTransport InitializationOctober 27, 2026
- PDF.js Deep Dive from Demo 6: MessageHandler Cross-Thread CommunicationOctober 27, 2026
- PDF.js Deep Dive from Demo 5: renderOctober 27, 2026
- PDF.js Deep Dive from Demo 4: getViewportOctober 27, 2026
- PDF.js Deep Dive from Demo 3: getPageOctober 27, 2026
- PDF.js Deep Dive from Demo 2: getDocumentOctober 27, 2026
- PDF.js Source Walkthrough: TextLayerOctober 27, 2026
- PDF.js Source Walkthrough: font_loaderOctober 27, 2026
- PDF.js Source Walkthrough: obj_bin_transform_displayOctober 27, 2026
- PDF.js Source Walkthrough: PDFObjectsOctober 27, 2026
- PDF.js Source Walkthrough: Network StreamsOctober 27, 2026
- PDF.js Source Walkthrough: internal_evt.jsOctober 27, 2026
- PDF.js Source Walkthrough: math_clamp.jsOctober 27, 2026
- PDF.js Source Walkthrough: css_utils.jsOctober 27, 2026
- PDF.js Source Walkthrough: scripting_utilsOctober 27, 2026
- PDF.js Source Walkthrough: MurmurHash3_64October 27, 2026
- PDF.js Source Walkthrough: obj_bin_transform_utilsOctober 27, 2026
- PDF.js Source Walkthrough: image_utils.jsOctober 27, 2026
- PDF.js Source Walkthrough: BasePDFStreamOctober 27, 2026
- PDF.js Source Walkthrough: Shared UtilitiesOctober 27, 2026
- PDF.js Source Walkthrough: MessageHandlerOctober 27, 2026
- PDF.js Source Walkthrough: apiOctober 27, 2026
- PDF.js Source Walkthrough: glyphlistOctober 27, 2026
- PDF.js Source Walkthrough: binary_cmapOctober 27, 2026
- PDF.js Source Walkthrough: cmapOctober 27, 2026
- PDF.js Source Walkthrough: encodingsOctober 27, 2026
- PDF.js Source Walkthrough: metricsOctober 27, 2026
- PDF.js Source Walkthrough: standard_fontsOctober 27, 2026
- PDF.js Source Walkthrough: fonts_utilsOctober 27, 2026
- PDF.js Source Walkthrough: default_appearanceOctober 27, 2026
- PDF.js Source Walkthrough: metadata_parserOctober 27, 2026
- PDF.js Source Walkthrough: name_number_treeOctober 27, 2026
- PDF.js Source Walkthrough: struct_treeOctober 27, 2026
- PDF.js Source Walkthrough: catalogOctober 27, 2026
- PDF.js Source Walkthrough: pdf_managerOctober 27, 2026
- PDF.js Source Walkthrough: object_loaderOctober 27, 2026
- PDF.js Source Walkthrough: obj_bin_transform_coreOctober 27, 2026
- PDF.js Source Walkthrough: cryptoOctober 27, 2026
- PDF.js Source Walkthrough: core_utilsOctober 27, 2026
- PDF.js Source Walkthrough: primitivesOctober 27, 2026
- PDF.js Source Walkthrough: worker_streamOctober 27, 2026
- PDF.js Source Walkthrough: ccitt_streamOctober 27, 2026
- PDF.js Source Walkthrough: jpx_streamOctober 27, 2026
- PDF.js Source Walkthrough: jbig2_streamOctober 27, 2026
- PDF.js Source Walkthrough: jpeg_streamOctober 27, 2026
- PDF.js Source Walkthrough: decrypt_streamOctober 27, 2026
- PDF.js Source Walkthrough: brotli_streamOctober 27, 2026
- PDF.js Source Walkthrough: run_length_streamOctober 27, 2026
- PDF.js Source Walkthrough: ascii_hex_streamOctober 27, 2026
- PDF.js Source Walkthrough: ascii_85_streamOctober 27, 2026
- PDF.js Source Walkthrough: lzw_streamOctober 27, 2026
- PDF.js Source Walkthrough: predictor_streamOctober 27, 2026
- PDF.js Source Walkthrough: flate_streamOctober 27, 2026
- PDF.js Source Walkthrough: decode_streamOctober 27, 2026
- PDF.js Source Walkthrough: chunked_streamOctober 27, 2026
- PDF.js Source Walkthrough: streamOctober 27, 2026
- PDF.js Source Walkthrough: base_streamOctober 27, 2026
- PDF.js Deep Dive 11: ParserOctober 26, 2026
- PDF.js Deep Dive 10: XRefOctober 26, 2026
- PDF.js Deep Dive 9: CanvasGraphicsOctober 26, 2026
- PDF.js Deep Dive 8: PDFDocumentOctober 26, 2026
- The AI Era: What Should We Do to Survive? (Reflections on Founding StreamPDF)October 26, 2026
- PDF.js Deep Dive 7: PartialEvaluatorOctober 25, 2026
- PDF.js Deep Dive 6: PDFManager.initDocumentOctober 25, 2026
- PDF.js Deep Dive 5: PDFFetchStream and PDFWorkerStreamOctober 25, 2026
- PDF.js Deep Dive 4: MessageHandlerOctober 22, 2026
- PDF.js Deep Dive 3: getViewport & renderOctober 22, 2026
- PDF.js Deep Dive from Demo 1: Rendering a PDF with a Custom ViewerOctober 19, 2026
- PDF.js Deep Dive 2: getDocument & getPageOctober 19, 2026
- PDF.js Deep Dive 1: The Architecture of PDF.jsOctober 16, 2026