StreamPDFStreamPDF
Back to blog

PDF.js Source Walkthrough: standard_fonts

October 27, 2026 · StreamPDF Team

PDF.js Source Walkthrough: standard_fonts

Introduction

The previous articles already laid out the shared foundation of the font layer: fonts_utils.js provides FontFlags, MacStandardGlyphOrdering and normalizeFontName, encodings.js defines several built-in encodings, glyphlist.js gives the glyph-name to Unicode table, and cmap.js translates character codes of composite fonts into CIDs or Unicode. Those modules answer "how to encode", "how to look up a glyph name" and "how to parse a CMap". But one class of question is still unanswered: when a font has no embedded file at all, how should the name be recognised, which substitute font should be chosen, and how should glyph numbers be guessed.

src/core/standard_fonts.js (1092 lines) is the data layer prepared for exactly this class of question. It contains almost no algorithms; its body is nine lookup tables lazily built by factory functions, plus two query functions. It does not parse font files and does not construct Font objects; it only gathers domain knowledge such as "which names belong to standard fonts", "who a given name should degrade to" and "which Unicode a given CID most likely corresponds to".

In one sentence: it is the standard font dictionary of the font layer, handing the result of a normalised font-name lookup to evaluator.js and fonts.js, so that a PDF without embedded files can still choose a usable substitute font and preserve as many glyphs as possible.

Calling it a "dictionary" rather than a "module" is deliberate: a dictionary's job is to collect and retrieve, not to use. What actually decides which font is used and which glyph is drawn is the parser and the font constructor that call it.

Module Overview

standard_fonts.js can be cut into nine blocks, each either an independent table or a very thin query function:

BlockKey constants or functionsResponsibility
Standard font tablegetStdFontMapThe standard 14 fonts, plus common aliases such as Arial and Times New Roman
File name mapgetFontNameToFileMapMapping from standard font names to built-in font files
Non-standard font tablegetNonStdFontMapDegradation targets for non-embedded fonts such as Calibri
Serif name setgetSerifFontsJudging serif tendency from the name, compensating for an untrustworthy /Flags
Symbol name setgetSymbolsFontsNames in the Symbol, Dingbats and Wingdings family
Mac-order glyph mapgetGlyphMapForMacOrderedFontsBuilds the base CID-to-Unicode table from the standard Macintosh glyph ordering
Standard glyph mapgetGlyphMapForStandardFontsCommon CID-to-Unicode for fonts such as Ghostscript's, which carry no CID mapping
Supplemental glyph mapsthe three getSupplementalGlyphMapFor...Sparse overrides for Trebuchet, ArialBlack and Calibri
Queries and exportsgetStandardFontName, isKnownFontNameLook up after normalising the name, and answer whether a font name is known

Looked at by entry count, the division of labour is even clearer:

TableEntriesKey typeValue typeGenerated?
getStdFontMap72Font name stringStandard font name or aliasNo
getFontNameToFileMap18Standard font nameFont file nameNo
getNonStdFontMap50Non-standard font nameSubstitute font nameNo
getSerifFonts134Family nameBooleanNo
getSymbolsFonts6Family nameBooleanNo
getGlyphMapForMacOrderedFontsat most 256glyph id numberUnicode code pointYes
getGlyphMapForStandardFonts496CID numberUnicode code pointNo
getSupplementalGlyphMapForTrebuchetMS75CID numberUnicode code pointNo
getSupplementalGlyphMapForArialBlack3CID numberUnicode code pointNo
getSupplementalGlyphMapForCalibri117CID numberUnicode code pointNo

The size difference between the name tables and the glyph tables tells us two things. The keys of the name tables are a limited set of vendor names, so a few dozen to a hundred plus entries cover the mainstream cases; the keys of the glyph tables are slots inside a font and could theoretically run to thousands, so PDF.js records only correspondences verified against real samples. The generated Mac-order table is the only exception: it derives Unicode from 258 standard glyph names, trading a loop for size.

Its place in the whole chain can be drawn like this:

// Font dictionary /BaseFont, /Subtype, /FontDescriptor
//   → evaluator.js                reads the font dictionary, decides whether to use a system font
//   → standard_fonts.js           this article: name tables, substitution tables, glyph maps
//   → fonts.js                    the Font family: fallbackToSystemFont, toFontChar
//   → font_substitutions.js       actually resolves names to system or built-in fonts
//   → canvas / text layer         draws glyphs, extracts Unicode

Version note: This article follows exactly the version of standard_fonts.js in the current repository. This version exports 12 members, among which getGlyphMapForMacOrderedFonts and getSupplementalGlyphMapForTrebuchetMS are two tables split out specifically around the TrebuchetMS glyph ordering; the Trebuchet branch in fonts.js applies the Mac-order base map first and then layers the supplemental table on top, and the source comment cites issue 21713. Older material that covers only getGlyphMapForStandardFonts misses this branch. Also, all tables are lazily built by getLookupTableFactory, but the source does not call Object.freeze; read-only is a module contract, not a runtime guarantee.

Dependency Direction

The file header imports only three things, all pointing at low-level shared modules:

import { MacStandardGlyphOrdering, normalizeFontName } from './fonts_utils.js';
import { getGlyphsUnicode } from './glyphlist.js';
import { getLookupTableFactory } from './core_utils.js';
DependencyCapability providedUsed in this file at
fonts_utilsMacStandardGlyphOrdering, normalisationthe generation loop of the Mac-order table, both query functions
glyphlistgetGlyphsUnicodetranslating Mac glyph names into Unicode code points
core_utilsgetLookupTableFactorythe lazy-loading wrapper for the nine tables

It does not import fonts.js, evaluator.js or cmap.js, and it imports no stream or parser. This one-way dependency lets several modules inside the Worker reference it safely without dragging font implementation details into the data layer. Conversely, both evaluator.js and fonts.js import from it, while font_substitutions.js receives an already computed standardFontName as a parameter and does not depend on this file directly.

The Lazy-Loading Factory and Data Shapes

All nine tables are created through getLookupTableFactory. That factory does not live in this file but in core_utils.js, and its implementation is only about ten lines:

function getLookupTableFactory(initializer, useArray = false) {
  let lookup;
  return function () {
    if (initializer) {
      lookup = useArray ? [] : Object.create(null);
      initializer(lookup);
      initializer = null;
    }
    return lookup;
  };
}

What it returns is not a table but a table-getter function. On the first call it creates the container, runs the initialiser and then sets initializer to null; every later call returns the same lookup. This is a "construct on first access, reuse forever" singleton, with the same purpose as the shadow target in Catalog, only scoped at module level.

A few details are easy to overlook:

DetailConcrete behaviourImpact
Container typeObject.create(null) by default, [] only in array modeThe table has no prototype chain, so names such as toString cannot produce false hits
Construction timeOn the first getter callNot built when a document is opened, but when a font is actually encountered
Construction countThe initialiser runs once and is then nulledExactly one table per module instance
MutabilityThe original object is returned, without wrapper or freezeCallers could in theory modify the table; in practice they only read
Numeric keyst[2] = 10 ends up as the string key "2"Both numbers and strings work for lookup, but iteration must convert back

The choice of Object.create(null) matches NameCache in primitives.js: font names come from PDF content and may be strings such as constructor or __proto__. A plain object literal inherits Object.prototype, so an in check or a direct subscript could return a non-entry; a prototype-less object reduces that risk to zero. Note that the getSerifFonts test in evaluator.js uses the in operator, which is exactly the pattern a prototype-less container can support safely.

The behaviour of numeric keys deserves its own note. getGlyphMapForStandardFonts writes t[2] = 10 and t[2424] = 7821, and JavaScript turns numeric keys into string properties. Later, for (const charCode in glyphMap) inside applyStandardFontGlyphMap yields strings, so downstream code must write map[+charCode] = glyphMap[charCode], using unary plus to convert back to a numeric index. This is not an incidental conversion but the necessary bridge between an object table and an array table.

Table Lifetime

These tables have nothing to do with any document. They register their initialisers when the module is first evaluated, are constructed on the first lookup, and then stay in the Worker's module scope. Opening a second PDF does not rebuild them, and closing the first PDF does not release them. This contrasts with fontCache and standardFontDataCache in Catalog:

Cache or tableScopeConstructed whenCleared when
The nine tables such as getStdFontMapModule levelFirst getter callNever; lives as long as the Worker
Catalog.fontCacheDocument levelFirst font encounteredDocument cleanup
Catalog.standardFontDataCacheDocument levelFirst font file fetchedDocument cleanup
The system cache of getFontSubstitutionDocument levelFirst need for a system substituteDocument cleanup

The distinction is reasonable: the nine tables are immutable domain knowledge, do not change with the document and hold no large binary blob; font objects and fetched file bytes are document-level resources and must be reclaimed per document. Making domain knowledge a module-level singleton avoids rebuilding the entire mapping set for every document opened.

The Tables Are Not Frozen

getLookupTableFactory returns the original initialised object, with no Object.freeze and no Object.seal. In other words, any caller that obtains a table can write new keys into it. The source relies on two layers of constraint to keep it read-only:

  1. The initialiser is nulled once it has run, so the factory never reconstructs the table and never rewrites it after construction;
  2. None of the table constants is exported directly; outsiders can only obtain a reference through a getter, and the module exposes no write interface.

This is contractual read-only, not runtime read-only. The benefit is an extremely short construction path with no freezing cost; the price is that an accidental write is not caught immediately. For a 496-entry table such as getGlyphMapForStandardFonts, freezing once means walking every key, while the table is read by only a few paths inside the Worker, so PDF.js chose the former.

Design note: The lazy-loading factory separates data definition from data instantiation. Loading the module only registers initialisers and allocates no memory for the nine tables; a table is constructed only when it is actually needed. Most documents use only two or three of them, so building on demand avoids meaningless allocation at startup and keeps unused tables out of the heap forever.

The Standard 14 Fonts and the Alias Table

getStdFontMap is the most central table in the whole file. It first lists the standard 14 fonts from the PDF specification, then appends a large set of commercial font aliases:

const getStdFontMap = getLookupTableFactory(function (t) {
  // The standard 14 fonts:
  t["Times-Roman"] = "Times-Roman";
  t.Helvetica = "Helvetica";
  t.Courier = "Courier";
  t.Symbol = "Symbol";
  t["Times-Bold"] = "Times-Bold";
  t["Helvetica-Bold"] = "Helvetica-Bold";
  t["Courier-Bold"] = "Courier-Bold";
  t.ZapfDingbats = "ZapfDingbats";
  t["Times-Italic"] = "Times-Italic";
  t["Helvetica-Oblique"] = "Helvetica-Oblique";
  t["Courier-Oblique"] = "Courier-Oblique";
  t["Times-BoldItalic"] = "Times-BoldItalic";
  t["Helvetica-BoldOblique"] = "Helvetica-BoldOblique";
  t["Courier-BoldOblique"] = "Courier-BoldOblique";

The standard 14 fonts are the set that a viewer must support without an embedded file according to the PDF specification: three families, Times, Helvetica and Courier, with four styles each, plus the two symbol fonts Symbol and ZapfDingbats, 14 in total. Most values in the table equal their keys, which is not redundant: it gives standard font lookup a uniform return shape, so a hit always yields a usable standard name.

FamilyRegularBoldItalicBold italic
TimesTimes-RomanTimes-BoldTimes-ItalicTimes-BoldItalic
HelveticaHelveticaHelvetica-BoldHelvetica-ObliqueHelvetica-BoldOblique
CourierCourierCourier-BoldCourier-ObliqueCourier-BoldOblique
SymbolSymbolnonenonenone
SymbolZapfDingbatsnonenonenone

The naming is not fully symmetric: Helvetica and Courier use Oblique for italic while Times uses Italic; Courier's bold italic is called BoldOblique, yet among the Arial aliases both BoldItalic and BoldOblique appear. These asymmetries are not typos but the historical naming of different font vendors, and the table must register real names rather than force them into one form.

The real value lies in the "Extra mappings" that follow. The table has 72 entries in total, and apart from the 14 standard names, everything else normalises aliases:

// Extra mappings
t.ArialNarrow = 'Helvetica';
t['ArialNarrow-Bold'] = 'Helvetica-Bold';
t['ArialNarrow-BoldItalic'] = 'Helvetica-BoldOblique';
t['ArialNarrow-Italic'] = 'Helvetica-Oblique';
t.ArialBlack = 'Helvetica';
t['ArialBlack-Bold'] = 'Helvetica-Bold';
t['ArialBlack-BoldItalic'] = 'Helvetica-BoldOblique';
t['ArialBlack-Italic'] = 'Helvetica-Oblique';
t['Arial-Black'] = 'Helvetica';
t['Arial-Black-Bold'] = 'Helvetica-Bold';
t['Arial-Black-BoldItalic'] = 'Helvetica-BoldOblique';
t['Arial-Black-Italic'] = 'Helvetica-Oblique';
t.Arial = 'Helvetica';
t['Arial-Bold'] = 'Helvetica-Bold';
t['Arial-BoldItalic'] = 'Helvetica-BoldOblique';
t['Arial-Italic'] = 'Helvetica-Oblique';
t.ArialMT = 'Helvetica';
t['Arial-BoldItalicMT'] = 'Helvetica-BoldOblique';
t['Arial-BoldMT'] = 'Helvetica-Bold';
t['Arial-ItalicMT'] = 'Helvetica-Oblique';

These aliases are not a simple claim that "Arial equals Helvetica". They handle four kinds of difference at once:

Kind of differenceExampleHandling
Different family nameArial, ArialMT, ArialUnicodeMSAll land on Helvetica
Different weight suffixBold, BoldMT, BlackAll land on Helvetica-Bold
Different italic namingItalic, Oblique, BoldItalicAll land on the Helvetica-Oblique series
Hyphenation differenceArial-Black and ArialBlackBoth spellings are registered

These aliases cover common commercial fonts such as Arial, ArialNarrow, ArialBlack, ArialUnicodeMS, CourierNew, CourierNewPS, TimesNewRoman, TimesNewRomanPS and HelveticaLTStd. What they share is that the PDF names them but embeds no glyph data, so the viewer has to draw them as some standard font.

Original familyRegular targetBold targetItalic targetBold italic target
ArialHelveticaHelvetica-BoldHelvetica-ObliqueHelvetica-BoldOblique
ArialNarrowHelveticaHelvetica-BoldHelvetica-ObliqueHelvetica-BoldOblique
ArialBlackHelveticaHelvetica-BoldHelvetica-ObliqueHelvetica-BoldOblique
ArialUnicodeMSHelveticaHelvetica-BoldHelvetica-ObliqueHelvetica-BoldOblique
TimesNewRomanTimes-RomanTimes-BoldTimes-ItalicTimes-BoldItalic
TimesNewRomanPSTimes-RomanTimes-BoldTimes-ItalicTimes-BoldItalic
CourierNewCourierCourier-BoldCourier-ObliqueCourier-BoldOblique
CourierNewPSCourierCourier-BoldCourier-ObliqueCourier-BoldOblique
HelveticaLTStdHelveticaHelvetica-Boldnonenone

This table also explains why getStdFontMap has 72 entries instead of 14: the same family can appear in a PDF with suffixes such as MT, PS, PSMT, UnicodeMS, Narrow and Black, and each must be registered separately. Miss one spelling and the font falls back to its original name, obtaining neither a built-in file nor standard metrics.

The Symbol family gets special treatment: Symbol-Bold, Symbol-BoldItalic and Symbol-Italic all map back to Symbol. Symbol fonts have no real weight or style variation, and preserving style names would only leave downstream code unable to find the matching file.

The Alias Table Is Not Exhaustive

This table is accumulated from PDFs that were actually encountered, not enumerated from a vendor catalogue. Of the 72 entries, 14 are standard fonts and 58 are aliases; the aliases concentrate in four families: Arial, Times New Roman, Courier New and Helvetica. It covers the names most common in desktop publishing and Office exports, and makes no claim to cover every commercial font.

GroupEntriesExamples
Standard 1414Times-Roman, Helvetica, Symbol
Arial family aliases27Arial, ArialMT, ArialUnicodeMS
Times family aliases15TimesNewRoman, TimesNewRomanPSMT
Courier family aliases10CourierNew, CourierNewPSMT
Helvetica family aliases3Helvetica-Italic, HelveticaLTStd-Bold
Symbol style aliases3Symbol-Bold, Symbol-Italic

When a name is not in the table, getStandardFontName returns undefined and the font goes to system substitution or the default fallback. This is not a defect but a deliberate boundary: guessing a substitute for every font name would make mistakes harder to find; registering only verified names means an unknown font at least keeps its original name and is handed to the platform's font matching.

normalizeFontName performs one clean-up pass before the lookup:

function normalizeFontName(name) {
  return name.replaceAll(/[,_]/g, '-').replaceAll(/\s/g, '');
}

Commas and underscores both become hyphens, and all whitespace is removed. So Times New Roman normalises to TimesNewRoman, which hits a table entry exactly; Times,New_Roman normalises to Times-New-Roman, which is not the same key, and the table has no such entry. Note that it does not lower-case anything, so table keys must match the actual casing in the PDF. That is also why ArialMT, Arial-BoldMT and TimesNewRomanPSMT-Bold all appear: normalisation can flatten whitespace and two separators, but it cannot flatten vendor naming habits.

Design note: getStdFontMap carries two meanings at once. For the standard 14 fonts it says "the specification guarantees this font is available"; for the Arial family it says "this is not part of the standard 14, but its glyphs and metrics can be borrowed from Helvetica". Sharing one table gives a single lookup entry point; the cost is that isStandardFont also counts Arial as standard, so downstream must use extra tests such as isNarrow and isMappedToStandardFont to correct the width recomputation.

Standard Font File Name Mapping

getFontNameToFileMap has only 18 entries and maps standard font names to the font files shipped with PDF.js:

// Contains mapping for standard fonts and xfa fonts.
const getFontNameToFileMap = getLookupTableFactory(function (t) {
  t.Courier = 'FoxitFixed.pfb';
  t['Courier-Bold'] = 'FoxitFixedBold.pfb';
  t['Courier-BoldOblique'] = 'FoxitFixedBoldItalic.pfb';
  t['Courier-Oblique'] = 'FoxitFixedItalic.pfb';
  t.Helvetica = 'LiberationSans-Regular.ttf';
  t['Helvetica-Bold'] = 'LiberationSans-Bold.ttf';
  t['Helvetica-BoldOblique'] = 'LiberationSans-BoldItalic.ttf';
  t['Helvetica-Oblique'] = 'LiberationSans-Italic.ttf';
  t['Times-Roman'] = 'FoxitSerif.pfb';
  t['Times-Bold'] = 'FoxitSerifBold.pfb';
  t['Times-BoldItalic'] = 'FoxitSerifBoldItalic.pfb';
  t['Times-Italic'] = 'FoxitSerifItalic.pfb';
  t.Symbol = 'FoxitSymbol.pfb';
  t.ZapfDingbats = 'FoxitDingbats.pfb';
  t['LiberationSans-Regular'] = 'LiberationSans-Regular.ttf';
  t['LiberationSans-Bold'] = 'LiberationSans-Bold.ttf';
  t['LiberationSans-Italic'] = 'LiberationSans-Italic.ttf';
  t['LiberationSans-BoldItalic'] = 'LiberationSans-BoldItalic.ttf';
});

This table has two obvious characteristics. First, Courier, Times, Symbol and ZapfDingbats use Foxit .pfb files while the Helvetica family uses Liberation Sans .ttf files. Second, four LiberationSans-* names are registered at the end, corresponding to font names that XFA forms may write directly. The comment "standard fonts and xfa fonts" refers to exactly this.

Its consumer is fetchStandardFontData in evaluator.js:

const standardFontNameToFileName = getFontNameToFileMap(),
  filename = standardFontNameToFileName[name];
let data;
 
try {
  if (this.options.useWorkerFetch) {
    data = await fetchBinaryData(
      `${this.options.standardFontDataUrl}${filename}`
    );
  } else {

Upstream reaches this path only when useSystemFonts is off and the name is neither Symbol nor ZapfDingbats. In other words, the table serves the path that renders standard fonts with its own font files; with system fonts enabled, names go to font_substitutions.js to be matched against local fonts, and no built-in file is needed. The file name is not a path: the actual directory comes from standardFontDataUrl, and the Worker only concatenates the full URL and fetches the bytes.

Entry sourceExampleFile typeTypical use
Serif family of the standard 14Times-Roman.pfbbuilt-in Type1 outlines
Sans-serif of the standard 14Helvetica.ttfbuilt-in TrueType outlines
Symbol fontsSymbol, ZapfDingbats.pfbspecial character sets
XFA fallback namesLiberationSans-Regular.ttfdirect hits from XFA font names

Fetching and Caching

The full entry point of fetchStandardFontData shows the table's role in the chain more clearly:

async fetchStandardFontData(name) {
  const cachedData = this.standardFontDataCache.get(name);
  if (cachedData) {
    return new Stream(cachedData);
  }
 
  if (
    this.options.useSystemFonts &&
    name !== "Symbol" &&
    name !== "ZapfDingbats"
  ) {
    return null;
  }
 
  const standardFontNameToFileName = getFontNameToFileMap(),
    filename = standardFontNameToFileName[name];
  let data;

The cache key is the standard font name, not the file name. The same Helvetica referenced by many pages is fetched only once; the bytes are then stored in Catalog.standardFontDataCache and released together when the document is cleaned up. When name is not in the table, filename is undefined, the request fails and returns null, and the font then falls through to a system font or the default substitute path.

There is also an easily missed condition: with useSystemFonts enabled, only Symbol and ZapfDingbats still use built-in files. The reason is that system fonts usually lack these two symbol fonts, and their glyphs cannot be replaced by an ordinary text font; the remaining standard fonts go to system font matching and save a network request.

ConditionFetch the built-in file?Reason
useSystemFonts is falseyesno system font is available, so the built-in file is required
useSystemFonts is true, name is Symbolyessystem fonts usually have no symbol glyphs
useSystemFonts is true, name is ZapfDingbatsyessame as above
useSystemFonts is true, other standard namesnohanded to font_substitutions.js to match a local font

The Four LiberationSans Names for XFA

The four LiberationSans-* entries at the end of the table are not standard 14 fonts but fallback names for XFA forms:

t['LiberationSans-Regular'] = 'LiberationSans-Regular.ttf';
t['LiberationSans-Bold'] = 'LiberationSans-Bold.ttf';
t['LiberationSans-Italic'] = 'LiberationSans-Italic.ttf';
t['LiberationSans-BoldItalic'] = 'LiberationSans-BoldItalic.ttf';

XFA forms are described in XML, and a font name may be written directly as LiberationSans-Regular instead of a PDF /BaseFont name. xfa_fonts.js brings such names in when constructing the font dictionary, and fetchStandardFontData then uses this table to fetch the corresponding file. Without these four entries, the default sans-serif font in an XFA form could only fall back to a system font or the default fallback, and the layout would drift from the design.

These four names point at the same .ttf files as Helvetica, but the keys must be registered separately because getFontNameToFileMap matches exactly and performs no alias inference. The Helvetica substitution lives in getStdFontMap; the file map only handles the name-to-file layer, not the name-to-name layer.

Non-Standard Fonts and the Substitution Chain

getNonStdFontMap holds 50 entries and handles names that are not part of the standard 14 yet are frequently written in PDFs as if they were standard fonts:

/**
 * Holds the map of the non-standard fonts that might be included as
 * a standard fonts without glyph data.
 */
const getNonStdFontMap = getLookupTableFactory(function (t) {
  t.Calibri = "Helvetica";
  t["Calibri-Bold"] = "Helvetica-Bold";
  t["Calibri-BoldItalic"] = "Helvetica-BoldOblique";
  t["Calibri-Italic"] = "Helvetica-Oblique";
  t.CenturyGothic = "Helvetica";
  t["CenturyGothic-Bold"] = "Helvetica-Bold";
  t["CenturyGothic-BoldItalic"] = "Helvetica-BoldOblique";
  t["CenturyGothic-Italic"] = "Helvetica-Oblique";
  t.ComicSansMS = "Comic Sans MS";
  t["ComicSansMS-Bold"] = "Comic Sans MS-Bold";
  t["ComicSansMS-BoldItalic"] = "Comic Sans MS-BoldItalic";
  t["ComicSansMS-Italic"] = "Comic Sans MS-Italic";
  t.GillSansMT = "Helvetica";
  t["GillSansMT-Bold"] = "Helvetica-Bold";
  t["GillSansMT-BoldItalic"] = "Helvetica-BoldOblique";
  t["GillSansMT-Italic"] = "Helvetica-Oblique";
  t.Impact = "Helvetica";

The difference from getStdFontMap is not one of form but of whether the result is still a standard font. Calibri, CenturyGothic, GillSansMT, Impact and TrebuchetMS all degrade to the Helvetica family; ComicSansMS degrades to the Comic Sans MS family with spaces; LucidaConsole degrades to the Courier family; MS Gothic, MS Mincho, MS PGothic and MS PMincho degrade to Japanese font families of the same name. The latter two groups do not necessarily hit getStdFontMap, so downstream treats them as "mapped to a standard font" rather than "being a standard font".

Original familySubstitute targetTarget is standard?Note
CalibriHelvetica familyyesOffice default font, often referenced without embedding
CenturyGothicHelvetica familyyesgeometric sans, shapes close to Helvetica
GillSansMTHelvetica familyyesBritish sans, Helvetica as the safety net
ImpactHelveticayesregular weight only, no italic or bold variants
TrebuchetMSHelvetica familyyesonly a name replacement here; glyph mapping has its own branch
ComicSansMSComic Sans MS familynokeeps the original name, left to system font matching
LucidaConsoleCourier familyyesmonospaced console font, lands on Courier
MS Gothic seriessame-named families such as MS GothicnoJapanese Windows fonts, family name kept
MS Mincho seriessame-named families such as MS Minchonosame as above
NuptialScriptTimes-Italicyesa script face cannot be substituted, so it degrades to italic serif

Of the 50 entries, the MS Gothic and MS Mincho series account for 16, the largest group; the four four-style families Calibri, CenturyGothic, GillSansMT and TrebuchetMS account for 16; ItcSymbol has 6, LucidaConsole 4 and ComicSansMS 4; the rest are single entries such as Impact, LucidaSans-Demi, NuptialScript and SegoeUISymbol. Registering all four weights keeps the weight of the substitute as well, instead of drawing bold body text as regular.

fallbackToSystemFont in fonts.js writes this chain very plainly:

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;

The lookup order is standard table first, non-standard table second, and finally the original name. isStandardFont looks only at the standard table; isMappedToStandardFont requires that the non-standard result itself hits the standard table once more. This double condition is necessary: Calibri maps to Helvetica, which is in the standard table, so it is true; ComicSansMS maps to Comic Sans MS, which is not in the standard table, so it is false. The two take different paths for width recomputation and for constructing toFontChar.

NameStandard table hitNon-standard resultName actually usedTreated as standard
HelveticayesnoneHelveticayes
ArialyesnoneHelveticayes
CalibrinoHelveticaHelveticayes (indirectly)
ComicSansMSnoComic Sans MSComic Sans MSno
MS-GothicnoMS GothicMS Gothicno
SomeUnknownFontnononeSomeUnknownFontno

Design note: The substitution chain splits recognition from replacement. getStdFontMap answers "is this name a standard font" and getNonStdFontMap answers "what should this name become". With the two separated, isMappedToStandardFont can return true only for substitutions that really land on a standard font, instead of treating Comic Sans MS as standard and applying metric corrections to it.

The Serif Name Set and the Symbol Name Set

The two boolean set tables do not substitute anything; they only tag fonts.

The Serif Name Set

getSerifFonts has 134 entries, all mapped to true:

const getSerifFonts = getLookupTableFactory(function (t) {
  t["Adobe Jenson"] = true;
  t["Adobe Text"] = true;
  t.Albertus = true;
  t.Aldus = true;
  t.Alexandria = true;
  t.Algerian = true;
  t["American Typewriter"] = true;
  t.Antiqua = true;
  t.Apex = true;
  t.Arno = true;
  t.Aster = true;
  t.Aurora = true;
  t.Baskerville = true;
  t.Bell = true;
  t.Bembo = true;
  t["Bembo Schoolbook"] = true;
  t.Benguiat = true;
  t["Berkeley Old Style"] = true;
  t["Bernhard Modern"] = true;
  t["Berthold City"] = true;
  t.Bodoni = true;
  t["Bauer Bodoni"] = true;
  t["Book Antiqua"] = true;
  t.Bookman = true;
  t["Bordeaux Roman"] = true;
  t["Californian FB"] = true;
  t.Calisto = true;
  t.Calvert = true;
  t.Capitals = true;
  t.Cambria = true;
  t.Cartier = true;
  t.Caslon = true;

The middle and the end are just as dense:

t.Garamond = true;
t.Gentium = true;
t.Georgia = true;
t.Gloucester = true;
t['Goudy Old Style'] = true;
t['Goudy Schoolbook'] = true;
t['Goudy Pro Font'] = true;
t.Granjon = true;
t['Guardian Egyptian'] = true;
t.Heather = true;
t.Hercules = true;
t['High Tower Text'] = true;
t.Hiroshige = true;
t['Hoefler Text'] = true;
t['Humana Serif'] = true;
t.Imprint = true;
t['Ionic No. 5'] = true;
t.Janson = true;
t.Joanna = true;
t.Korinna = true;
t.Lexicon = true;
t.LiberationSerif = true;
t['Liberation Serif'] = true;
t['Linux Libertine'] = true;
t.Literaturnaya = true;
t.Lucida = true;
t['Lucida Bright'] = true;
t.Melior = true;

The table contains traditional serifs such as Garamond, Baskerville, Bodoni and Caslon, modern or free fonts such as Cambria, Constantia, Georgia, Times, LiberationSerif and FreeSerif, and towards the end technical typesetting fonts such as Bitstream Vera, Vera Serif and XITS. Its unit of judgement is the family name with the style suffix removed, not the full font name.

CategoryExamplesCharacteristic
Old-style serifGaramond, Caslon, Janson, Bembolow stroke contrast, oblique axis, traditional book setting
Transitional and modernBaskerville, Bodoni, Bauer Bodonihigh stroke contrast, vertical axis, common in headlines
EgyptianRockwell, Guardian Egyptian, Clarendonheavy serifs, close to slab serif
Modern system fontsGeorgia, Cambria, Constantiaoptimised for screen reading, common on Windows
Free fontsLiberationSerif, FreeSerif, Gentiumopen licences, often used as Times substitutes
Technical typesettingComputer Modern, STIX, XITSmathematics and academic typesetting

Because the set stores only family names, full names such as Times-BoldItalic and TimesNewRomanPSMT never hit directly; they must first pass through normalisation and the substitution tables, and only then have the style suffix split off. This is also why the loop in fonts.js is longer than isSerifFont in evaluator.js: it has to handle subset prefixes, vendor aliases and style suffixes all at once.

isSerifFont in evaluator.js shows the usage:

isSerifFont(baseFontName) {
  // Simulating descriptor flags attribute
  const fontNameWoStyle = baseFontName.split("-", 1)[0];
  return fontNameWoStyle in getSerifFonts() || /serif/i.test(fontNameWoStyle);
}

split("-", 1)[0] takes only the first segment before the hyphen, so Times-BoldItalic becomes Times; the regular expression afterwards catches names containing serif. The test in fonts.js is more lenient: it first splits off the subset prefix by +, then normalises, then passes through the standard and non-standard tables, and only at the end splits and queries the set:

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;
    }
  }
}

The comment in this code explains the motivation: the Serif bit in /Flags is often wrong, and looking only at the flag would make the text layer choose the wrong family, affecting both selection and copying. A name lookup is a very cheap second opinion, executed only when the flag does not declare a serif.

Decision pathInputBasisUse of the result
The Serif bit in /Flagsfont descriptorthe bit written by the generatorprimary decision
getSerifFonts name lookupfamily name without stylethe 134-entry set in this filecorrecting a wrong flag
serif regular expressionfamily namewhether the name contains seriflast-resort recognition
getBaseFontMetrics fallbackno metric dataTimes if serif, otherwise Helveticachoosing the default width table

The Symbol Name Set

getSymbolsFonts has only 6 entries, yet covers the most common symbol font families in PDFs:

const getSymbolsFonts = getLookupTableFactory(function (t) {
  t.Dingbats = true;
  t.Symbol = true;
  t.ZapfDingbats = true;
  t.Wingdings = true;
  t['Wingdings-Bold'] = true;
  t['Wingdings-Regular'] = true;
});

It has two usage sites. The first is in evaluator.js when constructing a simulated font descriptor, where getSymbolsFonts()[fontNameWoStyle] decides between FontFlags.Symbolic and FontFlags.Nonsymbolic:

const flags =
  (this.isSerifFont(fontNameWoStyle) ? FontFlags.Serif : 0) |
  (metrics.monospace ? FontFlags.FixedPitch : 0) |
  (getSymbolsFonts()[fontNameWoStyle] ? FontFlags.Symbolic : FontFlags.Nonsymbolic);

The second is the glyph-mapping split in fonts.js: when the name matches Symbol or Dingbats, ordinary encodings are skipped in favour of the dedicated symbol encoding tables:

} else if (/Symbol/i.test(fontName)) {
  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 regular expressions here match the substituted fontName, not the original name. Symbol-Bold is first normalised to Symbol by the standard table and then enters this branch; Wingdings is not in the regular expression, but because it is in the symbol set it receives the Symbolic flag and later follows the system font or symbol path. The set table and the regular expressions each cover one part and do not overlap.

Note that the set does not contain names such as Wingdings 2 or Webdings. Its role is a hint of known symbol fonts, not an exhaustive list of symbol fonts; a symbol font that misses still gets recognised through the Symbolic bit in /Flags and through /Encoding, only without the name-based fallback.

Design note: The boolean set tables isolate name-to-category knowledge and take no part in substitution. Substitution tables change the font name; category tables only answer yes or no. Mixing the two would make it hard to distinguish Calibri (which becomes sans-serif after substitution) from Times (which is serif to begin with), and later metric and text-layer decisions would lose a stable input.

Glyph Mapping: Mac Ordering and Standard Fonts

When a font has no embedded file, the character codes in the PDF are often just CIDs or glyph ids, without a trustworthy CIDToGIDMap. PDF.js then has to guess from an empirical table which Unicode glyph a given number probably corresponds to. This file provides two base maps and three supplemental maps.

The Mac Standard Glyph Ordering Base Map

getGlyphMapForMacOrderedFonts is not a handwritten table; it is generated from MacStandardGlyphOrdering:

// Base glyph map for fonts whose glyph ids follow the standard Macintosh
// ordering implied by a `post` table of format 1. Fonts such as TrebuchetMS can
// use part of this ordering with supplemental overrides.
const getGlyphMapForMacOrderedFonts = getLookupTableFactory(function (t) {
  const glyphsUnicode = getGlyphsUnicode();
  t[2] = 10;
  for (let gid = 3; gid < MacStandardGlyphOrdering.length; gid++) {
    const unicode = glyphsUnicode[MacStandardGlyphOrdering[gid]];
    if (unicode !== undefined) {
      t[gid] = unicode;
    }
  }
});

MacStandardGlyphOrdering is a list of 258 standard Mac glyph names ordered by glyph id. The first few entries are:

const MacStandardGlyphOrdering = [
  ".notdef", ".null", "nonmarkingreturn", "space", "exclam", "quotedbl",
  "numbersign", "dollar", "percent", "ampersand", "quotesingle", "parenleft",
  "parenright", "asterisk", "plus", "comma", "hyphen", "period", "slash",
  "zero", "one", "two", "three", "four", "five", "six", "seven", "eight",
  "nine", "colon", "semicolon", "less", "equal", "greater", "question", "at",

Entries 0 to 2 are .notdef, .null and nonmarkingreturn. The loop starts at 3 because the first three have no usable Unicode; t[2] = 10 maps nonmarkingreturn alone to a line feed, a historical convention that keeps the text layer from treating a carriage-return slot as a missing glyph. space is at gid 3, corresponding to Unicode 32; exclam at gid 4 corresponds to 33; after that the order basically follows ASCII up to asciitilde at gid 97.

The handling of the first three entries is worth remembering separately: .notdef is the placeholder for a missing glyph, .null is an empty glyph, and nonmarkingreturn is a carriage return that leaves no ink. None of them should appear in visible text, but nonmarkingreturn needs to preserve line-break semantics, so the source gives it code point 10 instead of skipping it like the first two. Skipping it as well would make some fonts generated in Mac order lose paragraph breaks during text extraction.

The loop writes at most 255 entries, plus the separate t[2], for no more than 256 in total; the real number depends on whether glyphsUnicode contains the corresponding names. It produces a complete base map, suited to fonts whose glyph ids follow a format 1 post table on the Mac. fonts.js uses it only when the name matches Trebuchet, because most non-embedded standard fonts do not follow this ordering, and applying it blindly would map characters to the wrong glyphs.

The Standard Font Glyph Map

getGlyphMapForStandardFonts is the longest table in the file, with 496 entries whose keys are CIDs and values are Unicode:

// Glyph map for well-known standard fonts. Sometimes Ghostscript uses CID
// fonts, but does not embed the CID to GID mapping. The mapping is incomplete
// for all glyphs, but common for some set of the standard fonts.
const getGlyphMapForStandardFonts = getLookupTableFactory(function (t) {
  t[2] = 10;
  t[3] = 32;
  t[4] = 33;
  t[5] = 34;
  t[6] = 35;
  t[7] = 36;
  t[8] = 37;
  t[9] = 38;
  t[10] = 39;
  t[11] = 40;
  t[12] = 41;
  t[13] = 42;
  t[14] = 43;
  t[15] = 44;
  t[16] = 45;
  t[17] = 46;
  t[18] = 47;
  t[19] = 48;
  t[20] = 49;
  t[21] = 50;
  t[22] = 51;
  t[23] = 52;
  t[24] = 53;
  t[25] = 54;
  t[26] = 55;
  t[27] = 56;
  t[28] = 57;
  t[29] = 58;
  t[30] = 894;
  t[31] = 60;

The first 30 entries are almost a continuous ASCII range: CID 3 to 29 correspond to space through colon, and CID 30 jumps to 894, the "therefore" sign, after which 31 to 97 continue from the less-than sign to the tilde. The gap shows that this is not a table laid out directly by character code but one compiled from the actual glyph slots of some CID font.

The middle covers Latin-1 supplement characters and common punctuation, while the later part reaches into a broader character set:

t[130] = 8224;
t[131] = 176;
t[132] = 162;
t[133] = 163;
t[134] = 167;
t[135] = 8226;
t[136] = 182;
t[137] = 223;
t[138] = 174;
t[139] = 169;
t[140] = 8482;
t[141] = 180;
t[142] = 168;
t[143] = 8800;
t[144] = 198;
t[145] = 216;
t[146] = 8734;

These values are Unicode code points: for instance 8224 is a dagger, 8226 a bullet, 8482 a trade mark sign, 8800 not equal to, and 8734 infinity. The table also contains ranges for Greek, Cyrillic, Hebrew and Arabic:

t[493] = 1039;
t[494] = 1040;
t[570] = 1040;
t[571] = 1041;
t[672] = 1488;
t[673] = 1489;
t[759] = 1617;
t[761] = 1776;
t[2424] = 7821;
t[2425] = 7822;
t[3282] = 11799;
t[3379] = 42785;
t[3416] = 8377;

t[570] through t[633] is the Cyrillic uppercase range, t[672] onward is Hebrew, t[759] and t[761] sit in Arabic characters, and t[2424] lies in a much later supplemental range. The keys are not continuous; the end even contains a high CID such as 3416, which shows the table was accumulated from real font samples rather than generated completely from the specification.

CID rangeTypical Unicode contentNote
2 to 97control characters, basic Latin, ASCII punctuationthe most used segment, covering space through tilde
98 to 171Latin-1 supplement and common punctuationincludes accented letters, currency, copyright and trade mark
179 to 229quotes, dashes, mathematical symbolscurly quotes, ellipsis, not equal to, infinity
305 to 337Greek letters and box drawingincludes sum, product, integral and box drawing
493 to 633Cyrillicuppercase and lowercase ranges
672 to 711Hebrewconsecutive Alef through Tav
759 to 805Arabic diacritics and presentation formsincludes diacritics such as shadda and ligature forms
2424 to 3416Latin Extended, mathematical and currency supplementshigh CIDs seen in real samples, sparsely distributed

This distribution table shows it is not a linear mapping of the form "CID minus some constant equals Unicode" but a series of separate blocks with gaps between them. PDF.js made no attempt to fill the gaps, because the glyphs in the gaps have no reliable evidence in real samples; a single wrong key would make the text layer emit wrong characters, which is harder to diagnose than a missing one.

The Layering Order of the Four Mapping Layers

What fallbackToSystemFont in fonts.js finally constructs is not one table but the result of four layers stacked together:

LayerSourceEffectOverrides the previous layer
1getGlyphMapForStandardFonts or the Mac-order base mapgives the initial CID-to-Unicode guessinitial layer
2the Trebuchet, ArialBlack and Calibri supplemental tablescorrects known misaligned keysyes
3/CIDToGIDMapcorrects indices with the font's own mappingyes
4/ToUnicodecorrects with the Unicode mapping carried by the documentyes

The third layer appears directly in the code:

if (cidToGidMap) {
  for (const charCode in map) {
    const cid = map[charCode];
    if (cidToGidMap[cid] !== undefined) {
      map[+charCode] = cidToGidMap[cid];
    }
  }
}

It iterates the map that already has the base and supplemental tables applied, and translates the value for each CID once more through cidToGidMap. The fourth layer writes the charCode to unicodeCharCode mapping directly through toUnicode.forEach. The more layers, the closer the result is to the real font; but only the first two layers come from this file, and the last two come from the PDF itself.

Design note: This table is deliberately kept incomplete. A complete mapping would require finding the right glyph for every possible CID, while real PDFs use only a small part of them; collecting the known sample correspondences into a sparse table covers common fonts while keeping the size under five hundred entries. fallbackToSystemFont then overrides the table with cidToGidMap and ToUnicode, which shows that it is only a fallback starting point, not the final truth.

How Downstream Turns It Into an Array

applyStandardFontGlyphMap in fonts.js flattens the object table into an array:

function applyStandardFontGlyphMap(map, glyphMap) {
  for (const charCode in glyphMap) {
    map[+charCode] = glyphMap[charCode];
  }
}

map is an array indexed by CID, and glyphMap is an object with string keys. +charCode turns "2424" back into 2424 and writes it at the matching index. When the function is called several times, later writes overwrite earlier ones, which is exactly the mechanism that lets supplemental tables correct the base table.

Inside fallbackToSystemFont, the way base and supplemental tables combine is split by font name:

if (/Trebuchet/i.test(name)) {
  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());
  }
}

Trebuchet takes a completely different base table, while ArialBlack and Calibri patch the standard base table. The order of this split cannot be reversed: lay the large table first and stack the small table on top, and only then does the small table have a corrective effect; putting the supplemental table first would let the base table overwrite the same keys.

Supplemental Glyph Maps: Trebuchet, ArialBlack, Calibri

The three supplemental tables differ greatly in size but are structurally identical: they register only the keys that need correcting.

The TrebuchetMS Supplemental Map

The comment on TrebuchetMS explains its strategy:

// TrebuchetMS largely follows the standard Macintosh glyph ordering through
// glyph id 235, with the exceptions below. Additional mappings for the affected
// Latin glyph repertoire follow.
const getSupplementalGlyphMapForTrebuchetMS = getLookupTableFactory(
  function (t) {
    // A few of the standard Macintosh glyph slots hold a different, but
    // similar looking, glyph in TrebuchetMS.
    t[151] = 956;
    t[159] = 937;
    t[168] = 916;
    t[189] = 8364;
    t[195] = 8729;
    t[218] = 713;
 
    t[236] = 222;
    t[237] = 254;
    t[238] = 8722;
    t[239] = 185;
    t[240] = 178;
    t[241] = 179;

The first six entries are cases where "the same slot holds a similar-looking but different glyph": 956 is the Greek letter mu, 937 is Omega, 916 is Delta, 8364 is the euro sign, 8729 is the dot operator, and 713 is a macron. From 236 onward the table covers an extended range outside the Mac ordering, spanning Latin characters such as thorn, minus, superscript digits, fractions and hook forms.

The table has 75 entries and is used as "Mac-order base map first, then this map overrides". The "largely follows" in the comment is the key: Trebuchet agrees with the Mac ordering for the most part, so the base map can be reused, and only the places where it disagrees need per-item correction. The source comment in fonts.js gives a real consequence: if the standard font base map is used by mistake, š gets mapped to ž (issue 21713).

The ArialBlack Supplemental Map

getSupplementalGlyphMapForArialBlack has only 3 entries:

// The glyph map for ArialBlack differs slightly from the glyph map used for
// other well-known standard fonts. Hence we use this (incomplete) CID to GID
// mapping to adjust the glyph map for non-embedded ArialBlack fonts.
const getSupplementalGlyphMapForArialBlack = getLookupTableFactory(function (t) {
  t[227] = 322;
  t[264] = 261;
  t[291] = 346;
});

The three entries correct CIDs 227, 264 and 291: the value 322 is ł at U+0142, 261 is ą at U+0105, and 346 is Ś at U+015A. The table is small enough to read at a glance, yet enough to place a few key Latin Extended glyphs of ArialBlack correctly. It shows that supplemental tables need no system; they only need to cover known misalignments.

The Calibri Supplemental Map

getSupplementalGlyphMapForCalibri has 117 entries and is the largest supplemental table:

// The glyph map for Calibri (a Windows font) differs from the glyph map used
// in the standard fonts. Hence we use this (incomplete) CID to GID mapping to
// adjust the glyph map for non-embedded Calibri fonts.
const getSupplementalGlyphMapForCalibri = getLookupTableFactory(function (t) {
  t[1] = 32;
  t[4] = 65;
  t[5] = 192;
  t[6] = 193;
  t[9] = 196;
  t[17] = 66;
  t[18] = 67;
  t[21] = 268;
  t[24] = 68;
  t[28] = 69;
  t[29] = 200;
  t[30] = 201;
  t[32] = 282;
  t[38] = 70;
  t[39] = 71;
  t[44] = 72;
  t[47] = 73;
  t[48] = 204;
  t[49] = 205;
  t[58] = 74;
  t[60] = 75;
  t[62] = 76;
  t[68] = 77;
  t[69] = 78;
  t[75] = 79;
  t[76] = 210;
  t[80] = 214;
  t[87] = 80;
  t[89] = 81;
  t[90] = 82;
  t[92] = 344;

Its CID numbering starts at 1 rather than at 2 or 3 like the standard table; the letter A lands on 4, B on 17 and C on 18. This shows Calibri has its own CID ordering that cannot be solved by a few patches on the standard table; a larger table covering the whole basic Latin range is required. The second half also covers lowercase letters, digits and punctuation:

t[258] = 97;
t[259] = 224;
t[260] = 225;
t[263] = 228;
t[268] = 261;
t[271] = 98;
t[272] = 99;
t[273] = 263;
t[275] = 269;
t[282] = 100;
t[853] = 44;
t[855] = 58;
t[856] = 46;
t[876] = 47;
t[878] = 45;
t[882] = 45;
t[894] = 40;
t[895] = 41;
t[896] = 91;
t[897] = 93;
t[923] = 64;
t[940] = 163;
t[1004] = 48;
t[1005] = 49;
t[1006] = 50;

Lowercase a is at 258, comma at 853 and the digit 0 at 1004. The key positions span a huge range, which shows the later Calibri slots also mix in punctuation and digits; the table registers only the 117 entries it is confident about, and leaves the rest to cidToGidMap and ToUnicode.

Put together, the three supplemental tables reveal a common design thread:

FontBase tableSupplemental entriesTrigger conditionCorrection scope
TrebuchetMSMac standard glyph ordering75name matches Trebuchetsimilar-looking characters and the extended Latin range
ArialBlackstandard font glyph map3name matches Arial-?Blackthree misaligned letters
Calibristandard font glyph map117name matches Calibribasic Latin, digits and punctuation

Why the Branch Conditions Use Regular Expressions

All three branches match the original name, not the already substituted fontName. This matters: TrebuchetMS is replaced by Helvetica in getNonStdFontMap, so if the branch looked at the substituted name it would never enter the Trebuchet Mac-order base map. The original name preserves vendor information; the substituted name only selects a usable font, and the two have different purposes.

Object inspectedSourceKeeps vendor information?Purpose
nameoriginal name in the font dictionaryyeschoosing the glyph-map base and supplemental tables
fontNameafter normalisation and substitutionpartlychoosing a built-in file or a system font
loadedNamethe family name after the final splitnoCSS font name and internal identifier

The regular expressions in the branches are written loosely. /Trebuchet/i only requires Trebuchet to appear in the name and covers TrebuchetMS, Trebuchet MS-Bold and forms with a subset prefix; /Arial-?Black/i accepts both ArialBlack and Arial-Black, matching the two spellings registered in the standard table; /Calibri/i covers the four weights. The price of loose matching is that another font sharing the prefix could be caught, but commercial fonts beginning with these strings are extremely rare in the font-name space, so the practical risk is acceptable.

The priority of the three branches is also worth noting. ArialBlack and Calibri are mutually exclusive if / else if, so only one supplemental table is applied at a time; Trebuchet sits in its own outer if and never fires together with them. This order does not conflict with the aliases in getStdFontMap, because aliases only change fontName and never affect the regular-expression test on the original name.

Design note: Supplemental tables are not a patch collection but an overlay that can be stacked. The base table covers most keys and the supplemental table only the known differences; the stacking order is fixed as base table first, supplemental table after. This structure means adding a new font special case requires only a small table, with no need to copy or modify the whole base map, and it keeps the origin and scope of each table traceable.

Query Functions, Exports and the Call Surface

The end of the file has only two functions, both very thin:

function getStandardFontName(name) {
  const fontName = normalizeFontName(name);
  const stdFontMap = getStdFontMap();
  return stdFontMap[fontName];
}
 
function isKnownFontName(name) {
  const fontName = normalizeFontName(name);
  return !!(
    getStdFontMap()[fontName] ||
    getNonStdFontMap()[fontName] ||
    getSerifFonts()[fontName] ||
    getSymbolsFonts()[fontName]
  );
}

getStandardFontName queries only the standard table and returns a standard name or undefined. It does not query the non-standard table, because the caller wants to know whether this name maps directly to a built-in standard font. evaluator.js uses it to obtain standardFontName and then hands it to fetchStandardFontData or getFontSubstitution.

isKnownFontName is the union of four tables: the standard table, the non-standard table, the serif set and the symbol set; a hit in any of them returns true. It does not judge the substitution result, only whether the name is in the knowledge base. evaluator.js uses it to decide whether to trust the descriptor when the font name and the descriptor name disagree:

if (
  fontNameStr &&
  baseFontStr &&
  (baseFontStr.startsWith(fontNameStr) ||
    (!isKnownFontName(fontNameStr) && isKnownFontName(baseFontStr)))
) {
  fontName = null;
}
fontName ||= baseFont;

This check handles two real situations. The first is fontNameStr of Arial with baseFontStr of Arial,Bold: the descriptor name is a prefix of the base name, so the two are the same font and either can be used. The second is fontNameStr of wg09np with baseFontStr of Wingdings-Regular: the descriptor name is an internal name written casually by the generator, and the base name is the readable family name; here isKnownFontName concludes that the internal name is unknown and the base name is known, so the base name is used. If neither name is known, no substitution is made and the original is kept, avoiding the misclassification of an unknown font as a known one.

The Boundaries of Normalization

normalizeFontName performs only three replacements, so lookups have a clear line between hits and misses:

InputNormalised resultHits the standard tableReason
Times New RomanTimesNewRomanyeswhitespace removed, exactly equal to the entry
Times,New_RomanTimes-New-Romannocomma and underscore become hyphens, no such entry
Arial-BoldMTArial-BoldMTyesno whitespace, hyphens preserved
Arial Bold MTArialBoldMTnowhitespace removed, no longer matches the entry
Symbol-BoldSymbol-Boldyesthe style alias is registered separately
ABCDEF+Times-RomanABCDEF+Times-Romannothe subset prefix is not in the table and must be stripped first

The last row explains why fonts.js calls name.split("+") before looking up. The subset prefix is six uppercase letters followed by a plus sign, and PDF.js does not treat it as part of the font name; querying the full name directly would never hit. The query functions do not strip the prefix themselves because they do not know which parsing path the caller is on.

Why the Two Query Functions Are Not Merged

getStandardFontName and isKnownFontName look like they could be merged into one function: query four tables, then return a standard name or a boolean. The source does not do that, because the two return completely different semantics.

getStandardFontName returns a standard identity that can be passed on. After receiving it, the caller fetches the built-in file, looks up metrics and hands it to the system substitution module; the return value must be a name from the standard table, never true or the original name. isKnownFontName returns a trust decision. The caller only uses it in a conditional and needs neither the table that hit nor a standard name.

DimensiongetStandardFontNameisKnownFontName
Query scopestandard table onlystandard table, non-standard table, serif set, symbol set
Return valuea standard name string or undefineda boolean
Typical usefetching built-in files, metrics, system substitutionjudging whether a descriptor name is trustworthy
Meaning of a missnot a standard font that can be used directlycompletely unfamiliar; none of the four knowledge categories records it

After merging, a caller would either have to test whether the return value is a string, or query the standard table again when it needs a standard name, adding a step. Splitting into two small functions costs one duplicated normalisation and gives every call site a result it can use directly.

The export list divides the twelve members into three groups:

export {
  getFontNameToFileMap,
  getGlyphMapForMacOrderedFonts,
  getGlyphMapForStandardFonts,
  getNonStdFontMap,
  getSerifFonts,
  getStandardFontName,
  getStdFontMap,
  getSupplementalGlyphMapForArialBlack,
  getSupplementalGlyphMapForCalibri,
  getSupplementalGlyphMapForTrebuchetMS,
  getSymbolsFonts,
  isKnownFontName,
};
CategoryMembersCallers
Name tablesgetStdFontMap, getNonStdFontMap, getSerifFonts, getSymbolsFonts, getFontNameToFileMapevaluator.js, fonts.js
Glyph tablesgetGlyphMapForMacOrderedFonts, getGlyphMapForStandardFonts, the three supplemental tablesfonts.js
Query functionsgetStandardFontName, isKnownFontNameevaluator.js

Looking at the call surface, the file holds no state and receives no PDF objects. Every function is either pass a name, query a table, return a result, or get a table and let the caller stack it. That lets several font paths inside the Worker share it, and makes it easy to test: no Dict, no stream, only strings.

Design note: The query functions only normalise and look up; they never mix in the substitution chain, width metrics or glyph mapping. getStandardFontName does not know that Calibri degrades to Helvetica, and isKnownFontName does not know that Trebuchet needs the Mac-order base map. Clear responsibility boundaries are what let callers choose different tables at different stages instead of being tied to one universal query.

The Contract with Neighbouring Modules

The file holds no state and starts no asynchronous work, so its contract with neighbouring modules can be described completely as what goes in and what comes out.

Dependencies on Upstream Modules

Upstream moduleImportContract
fonts_utilsMacStandardGlyphOrderingprovides the 258-entry standard Mac glyph name array
fonts_utilsnormalizeFontNameprovides a pure function that removes whitespace and unifies separators
glyphlistgetGlyphsUnicodeprovides the lazily loaded glyph-name to Unicode code point table
core_utilsgetLookupTableFactoryprovides the construct-once, reuse-after wrapper

All four dependencies are pure data or pure functions; none reads a PDF, touches the network or mutates global state. That lets standard_fonts.js be called on any thread at any time, independent of the lifetime of XRef, PDFManager or PartialEvaluator.

Outputs to Downstream Modules

Downstream moduleMembers usedHow it is used
evaluator.js font name resolutiongetStandardFontName, isKnownFontNamedecides the standard name, judges whether a descriptor name is trustworthy
evaluator.js metricsgetStdFontMap, getSerifFontschooses the standard metric table, falls back to Times when serif
evaluator.js file fetchinggetFontNameToFileMapstandard name to a .pfb or .ttf file name
evaluator.js flagsgetSymbolsFontsdecides the Symbolic flag
fonts.js fallbackgetStdFontMap, getNonStdFontMap, getSerifFontscomputes isStandardFont, fontName and the serif tendency
fonts.js glyph mappingthe five glyph tablesconstructs toFontChar and ToUnicodeMap

getStandardFontName appears in several places in evaluator.js, covering the simple font, composite font and CFF font branches; the excerpt below is the simple font branch:

const standardFontName = getStandardFontName(baseFontName);
let file = null;
if (standardFontName) {
  file = await this.fetchStandardFontData(standardFontName);
  properties.isInternalFont = !!file;
}
if (!properties.isInternalFont && this.options.useSystemFonts) {
  properties.systemFontInfo = getFontSubstitution(
    this.systemFontCache,
    this.idFactory,
    this.options.standardFontDataUrl,
    baseFontName,
    standardFontName,
    type,
  );
}

This code links three paths: first obtain the standard name, then use it to fetch the built-in file; if that fails and system fonts are allowed, hand both the original name and the standard name to the substitution module. getFontSubstitution needs both: the original name to match a concrete family in the substitution table, and the standard name as a second fallback to a standard font. This interface shows that the output of standard_fonts.js is not a final decision but one input to a downstream decision.

getBaseFontMetrics shows the metric path:

let fontName = normalizeFontName(name);
const stdFontMap = getStdFontMap();
fontName = stdFontMap[fontName] || fontName;
const Metrics = getMetrics();
 
const glyphWidths =
  Metrics[fontName] ?? Metrics[this.isSerifFont(name) ? 'Times-Roman' : 'Helvetica'];

It normalises first, queries the standard table for the standard name, then fetches the width table from metrics.js; if that fails, it falls back to Times-Roman or Helvetica depending on the serif test. getNonStdFontMap is not used here, because the metric tables recognise only standard fonts; a non-standard name keeps its original form and then hits the default fallback. This detail differs from fallbackToSystemFont in fonts.js, which does use the non-standard table because it needs a drawable font name rather than standard metrics.

What It Does Not Do

To keep its responsibility from growing, here are the things it explicitly does not do:

What it does not doModule actually responsibleReason
reading /BaseFont and /Flagsevaluator.jsthis file never touches PDF objects
parsing font filestype1_font.js, cff_font.js, fonts.jsthis file only provides mapping data
deciding system fontsfont_substitutions.jsneeds platform and font-list information
caching fetched font bytesCatalog.standardFontDataCachedocument-level resources must be released per document
generating glyph outlinesfont_renderer.js, glyph.jsthis file does no drawing

This boundary lets the file stay in the shape of pure data plus two query functions. Anything requiring PDF context, asynchronous I/O or platform information is pushed to neighbouring modules; the data layer answers only domain questions.

How Missing Values Flow

The file throws no exceptions and performs no null checks. Every "do not know" appears as undefined or false, and the caller decides the next step:

CallReturn value on a missWhat the caller does
getStdFontMap()[name]undefinedcontinue with the non-standard table or keep the original name
getNonStdFontMap()[name]undefinedkeep the original name
getSerifFonts()[name]undefinedtreat as non-serif
getSymbolsFonts()[name]undefinedtreat as non-symbol
getFontNameToFileMap()[name]undefinedthe built URL lacks a file name, the fetch fails and returns null
getStandardFontName(name)undefinedskip the built-in file and go straight to the system font branch
isKnownFontName(name)falsedo not trust the name, keep the original

This convention matches the fault-tolerance approach of Catalog: the data layer does not judge whether something is an error, it only returns whether something exists. undefined is kept strictly apart from an empty string and from null; a value missing from a table is never filled with a default, because a default would hide a real spelling problem in the name.

The miss of getFontNameToFileMap deserves special attention. When filename is undefined, fetchBinaryData receives a URL ending in undefined, the request fails, and fetchStandardFontData catches the exception and returns null. The font does not raise an error; it simply follows the path where isInternalFont is false. This is a deliberate degradation: a missing file name for an unknown font should not make the whole page fail to render.

Conclusion

standard_fonts.js is the data dictionary of the PDF.js font layer: downward it depends on normalisation and the Mac glyph ordering from fonts_utils, and on the glyph name table from glyphlist; upward it provides name recognition and built-in font file names to evaluator.js, and glyph-map base and supplemental tables for non-embedded fonts to fonts.js. It parses no font file and draws no glyph; it only organises domain knowledge into lazily loaded lookup tables.

Where It Sits in the Overall Chain

StageModuleInputOutput
Dictionary readingevaluator.js/BaseFont, /Flags, /Encodingnormalised font name, font type
Name recognitionstandard_fonts.jsfont namestandard name, substitute name, serif or symbol tag
File selectiongetFontNameToFileMapstandard name.pfb or .ttf file name
System substitutionfont_substitutions.jsstandard and non-standard nameslocal font information or built-in font information
Glyph mappingfonts.jsCIDs, cidToGidMap, base and supplemental tablestoFontChar, ToUnicodeMap
Drawing and extractioncanvas and text layerglyphs and Unicodepage image, selectable text

Strung together in one sentence: evaluator.js turns the font dictionary into a name, standard_fonts.js turns the name into a standard identity and a substitution target, and fonts.js turns the identity into a drawable glyph mapping.

What the Data Volume Means

Adding the nine tables together, the number of entries resident in memory is roughly twelve hundred: 280 name entries, 691 glyph entries, plus at most 256 from the generated Mac-order table. That is small for a Worker, yet enough to cover the great majority of documents with non-embedded fonts.

Data categoryEntry countGrowth patternMaintenance cost
Font namesabout 280append when a new vendor name appearslow, a single line is enough
Glyph mappingsabout 690add keys from real samplesmedium, Unicode correctness must be verified
Generated Mac orderat most 256generated automatically from the ordering and the glyph name tablevery low, follows upstream table changes

The data volume also explains why these tables can be fully static. They need no database, no runtime download and no per-document update; once packaged into the Worker script they can be queried synchronously at any time. For a viewer that must open documents quickly, that is far more stable than building mapping tables on the fly during parsing.

The Full Path of a Typical Query

Suppose a PDF writes a non-embedded Arial,Bold and system fonts are disabled. The chain looks roughly like this:

// /BaseFont = "Arial,Bold", with no embedded font file
const baseFontName = 'Arial,Bold';
const standardFontName = getStandardFontName(baseFontName);
// normalizeFontName("Arial,Bold") → "Arial-Bold"
// getStdFontMap()["Arial-Bold"] → "Helvetica-Bold"
 
const fileName = getFontNameToFileMap()[standardFontName];
// → "LiberationSans-Bold.ttf"
 
// after the evaluator fetches the file, fonts.js builds the Font by name
// if it is CIDFontType2 + Identity encoding without a complete CIDToGIDMap:
applyStandardFontGlyphMap(map, getGlyphMapForStandardFonts());

There are three table lookups in this path, answering three questions: is this name a standard font, what is the corresponding built-in file called, and which Unicode values does a CID probably correspond to when there is no glyph mapping. None of the three depends on PDF objects, only on strings and a few large tables; the only truly asynchronous step is fetching the font file.

If baseFontName were Calibri, the first step would fail because Calibri is not in the standard table; evaluator.js would then hand the original name to getFontSubstitution, and font_substitutions.js would decide on a system substitute; with system fonts disabled the font finally goes through fallbackToSystemFont, where the non-standard table turns the name into Helvetica and the standard glyph map is applied. The path differs, but every step still queries only one table from this file.

Which Table to Check When Something Goes Wrong

QuestionTable to checkReturn shape
Is this name a standard fontgetStdFontMapa standard name or undefined
What should a non-embedded font becomegetNonStdFontMapa substitute name or undefined
Does this font name tend to be serifgetSerifFontsboolean
Is this font name a symbol fontgetSymbolsFontsboolean
Which built-in file does a standard font usegetFontNameToFileMapa file name or undefined
Which Unicode does a CID probably correspond togetGlyphMapForStandardFontsobject table
Do the glyphs follow the Mac standard orderinggetGlyphMapForMacOrderedFontsobject table
Is it Trebuchet, ArialBlack or Calibrithe three getSupplementalGlyphMapFor...overlay table

This index also works as a navigation aid while reading the source: first decide which kind of question you are answering, then go to the corresponding factory function, instead of looking in one table for another table's answer.

One more remark: there is no implicit priority between the tables. The order of the standard and non-standard tables is decided by the caller, the order of base and supplemental tables is decided by fonts.js, and the serif and symbol sets only provide boolean judgements. Keeping the order in the caller instead of in the data means the same table can be combined differently on different paths.

Six Design Principles

  1. Data and algorithm are separated: of the 1092 lines, the vast majority are table entries, and the real logic is only the factory wrapper, the normalised lookup and two query functions. Font knowledge can be reviewed and extended independently, without reading the parser.
  2. Lazy loading instead of startup construction: all nine tables are built by getLookupTableFactory on the first call and reused afterwards. Opening a document pays no memory or construction cost for unused fonts.
  3. Normalisation only flattens separators: normalizeFontName makes commas, underscores and whitespace uniform hyphens but preserves case. Table entries must be registered with the vendor's actual spelling for lookups to be stable.
  4. Standard identity and substitution target are separated: getStdFontMap answers whether a font is standard, and getNonStdFontMap answers what it should become. Keeping them apart is what lets isMappedToStandardFont judge accurately whether the substitution still lands on a standard font.
  5. Glyph mapping uses a base map plus overlays: the Mac ordering or the standard table provides most keys, and the supplemental tables for Trebuchet, ArialBlack and Calibri correct only the known differences, with the stacking order fixed as base first, supplemental after.
  6. The mapping tables are deliberately incomplete: getGlyphMapForStandardFonts has only 496 entries and the Calibri supplemental table only 117. They are the fallback starting point for non-embedded fonts, and the final result is still corrected by cidToGidMap, ToUnicode and the real font file.

Once you understand standard_fonts.js, looking again at fallbackToSystemFont in fonts.js, getBaseFontMetrics in evaluator.js and the font matching in font_substitutions.js makes it clear that they share the same names and mappings instead of each maintaining its own font knowledge. The next article stays in the font layer and moves into fonts.js, which actually constructs the Font family, to see how these tables become toFontChar, toUnicode and glyph widths.

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