StreamPDFStreamPDF
Back to blog

PDF.js Source Walkthrough: metrics

October 27, 2026 · StreamPDF Team

PDF.js Source Walkthrough: metrics

Introduction

The previous articles have already walked the outer perimeter of the font layer: fonts_utils.js provides shared tools such as flags, glyph names and character mappings, standard_fonts.js keeps the standard font names, the serif table, the symbol table and several glyph maps, evaluator.js reads the font dictionary, and fonts.js decides the font type and constructs the Font object. This article fills in the smallest part of that chain, and the one that is looked up most often: src/core/metrics.js.

The file is 3057 lines, which looks substantial, but its structure is extremely monotonous: it exports only two functions, getMetrics and getFontBasicMetrics. The first is a "glyph name to width" table covering the 14 standard fonts of the PDF specification; the second is a "font name to ascent, descent, capHeight, xHeight" table, also covering those 14 fonts. The remaining 2900-odd lines are almost entirely numeric assignments.

First, let us pin down its place in the chain to avoid misunderstandings. metrics.js does not parse font programs, and has no CFF, TrueType or Type1 dispatch branches. It is called by two upstream sites:

  • getBaseFontMetrics in evaluator.js calls getMetrics() and looks the glyph name up in the table when a width has to be filled in;
  • Font#fallbackToSystemFont in fonts.js calls getFontBasicMetrics() to fill in ascent, descent and capHeight when the font file is missing or broken and the FontDescriptor did not provide numbers.

Downstream is layout: Font puts the widths into widths and divides the basic metrics by 1000 into ascent and descent; evaluator computes each glyph's user-space width as glyph.width * fontMatrix[0] * fontSize and hands it to textContent; the display layer then uses ascent and descent from textContent to position the text layer.

In one sentence: metrics.js is the fallback metric table for standard fonts in the font layer. It answers "how wide is this glyph, how tall is this font" with static data, takes no part in font-format parsing, and performs no computation beyond unit conversion.

Module Overview

metrics.js contains only two blocks, plus one dependency and one export:

BlockKey membersResponsibility
Table factory dependencygetLookupTableFactoryBuilds the table on first call, then returns the same cached object
Width tablegetMetricsGlyph name to width for the 14 standard fonts, in 1/1000 em
Basic metrics tablegetFontBasicMetricsascent, descent, capHeight, xHeight for the 14 standard fonts
ExportsgetFontBasicMetrics, getMetricsOnly two lookup entry points are exposed to the outside

The top of the file has a single import:

import { getLookupTableFactory } from './core_utils.js';

The dependency direction here is clean: metrics.js imports neither fonts.js, evaluator.js nor standard_fonts.js. Font-name normalisation and standard-name mapping both happen in the caller; this file accepts only an already normalised font name.

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

// Tj / TJ in the content stream
//   → evaluator.extractDataStructures     reads /BaseFont, /FontDescriptor
//   → evaluator.extractWidths             reads /Widths, falls back to getMetrics() when missing
//   → evaluator.getBaseFontMetrics        looks up getMetrics() after font-name normalisation
//   → core/metrics.js                     this article: widths and basic metrics of the core 14 fonts
//   → fonts.js Font.constructor           divides by 1000, writes widths / ascent / descent
//   → fonts.js fallbackToSystemFont       looks up getFontBasicMetrics() when metrics are still NaN
//   → evaluator.buildTextContentItem      glyph width × fontMatrix[0] × fontSize
//   → display/text_layer, font_loader     text-layer positioning and font metric reads

Note that this chain is entered from several places: widths travel through evaluator, vertical metrics travel through fonts.js. Both share the same batch of font names, yet different call sites trigger them only when they are needed.

Version note: This article follows exactly the version of metrics.js in the current repository. Its exports are only getFontBasicMetrics and getMetrics. The getGlyphWidth, getCharWidth, getCharPositions, getBaseFontMetrics and getStdFontMap functions commonly mentioned in older material or other branches are not in this file: width lookup is done by getBaseFontMetrics in evaluator.js, and standard font-name mapping lives in standard_fonts.js. Every entry of getFontBasicMetrics carries an xHeight field, but the only reader in the current repository, Font#fallbackToSystemFont, takes only ascent, descent and capHeight, so the xHeight values in the table are never read; the Courier entry even records a negative xHeight of -426, which is historical data inside the table. Do not go looking for APIs you remember from other versions when reading the source.

Coordinate Baseline: 1/1000 em in Glyph Space

Glyph Space and Text Space

The comment at the top of the file states the unit of the whole table clearly:

// The Metrics object contains glyph widths (in glyph space units).
// As per PDF spec, for most fonts (Type 3 being an exception) a glyph
// space unit corresponds to 1/1000th of text space unit.
const getMetrics = getLookupTableFactory(function (t) {
  t.Courier = 600;
  t["Courier-Bold"] = 600;
  t["Courier-BoldOblique"] = 600;
  t["Courier-Oblique"] = 600;

Two concepts must be kept apart here:

ConceptMeaningRole in this file
Glyph spaceThe coordinate space inside a font program that describes glyphs, usually 1000 units wideThe space the table values live in (such as 667 or 278)
Text spaceThe space where text lives in a PDF content stream, affected by the Tf font sizeThe space the table values enter after division by 1000
User spaceThe page coordinate space, transformed by the CTM and the text matrix togetherThe space used for final drawing and text-layer positioning

The PDF specification states that, except for Type3 fonts, one glyph space unit equals one thousandth of a text space unit. That is to say, the 667 written for Helvetica A in the table really means "0.667 wide at font size 1". This convention lets the metric table be completely independent of font size and become pure integer constants.

Type3 is the exception: its glyphs are described by PDF content streams and its /FontMatrix can be arbitrary, so its widths cannot be plugged into this table directly. PDF.js takes a separate path for Type3, and metrics.js contains no Type3 entry at all.

PDF_GLYPH_SPACE_UNITS and Font Construction

fonts.js writes this baseline as a constant:

const PDF_GLYPH_SPACE_UNITS = 1000;

Once the Font constructor receives properties, the first thing it does is divide the font's vertical metrics back into text space:

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 code explains two things:

  1. The integers in the table are neither "pixels" nor "points", only thousandths of an em. Only after dividing by 1000 is ascent a ratio relative to the em, ready to be multiplied by the current font size to obtain a user-space length.
  2. lineHeight is simply ascent minus descent. Helvetica's 718 and -207 become 0.718 and -0.207, giving a line height of 0.925 em. This is a rough but stable default; when an embedded font has a real hhea table, the line height switches to the font's own lineGap.

Widths do not need to be divided at construction time. Font keeps properties.widths as this.widths and does the multiplication only when a glyph is fetched:

this.widths = properties.widths;
this.defaultWidth = properties.defaultWidth;

Advance Width Is Not the Glyph Outline

The numbers in the table are advance widths, not the ink width of a glyph. The two are often confused:

ConceptMeaningWhere it lives in PDF.js
advance widthHow far the pen moves forward after a glyph is paintedmetrics.js, hmtx, charstrings
ink widthThe horizontal range a glyph actually covers with inkThe outline of the font program or the glyf table
bboxThe bounding box of every glyph in a font, a four-value tuple/FontDescriptor, FontBBox

Take Helvetica A, whose table value is 667. That 667 includes the whitespace on both sides of the glyph: the left side bearing and the right gap both count, because the next glyph has to start at position 667. If the ink width of A were treated as 667 and used for tight typesetting, characters would crowd together.

The PDF.js rendering path shows this division of labour exactly. Drawing a glyph on canvas uses the outline of the font program, and the outline coordinates decide where the ink lands; the advance of the text cursor uses glyph.width:

let glyphWidth = glyph.width;
if (font.vertical) {
  glyphWidth = glyph.vmetric ? glyph.vmetric[0] : -glyphWidth;
}
let scaledDim = glyphWidth * scale;

Rendering and typesetting each take what they need: the outline is responsible for "what it looks like", the advance for "where the next glyph starts". metrics.js supplies only the latter. Its table has no bbox and no side bearings, because that information cannot be recovered from a "glyph name to width" table.

This also explains the two fallbacks when a glyph is missing: a monospaced font such as Courier has a defaultWidth of 600, so the cursor advances by a sensible width and the grid stays intact; a proportional font such as Helvetica has a defaultWidth of 0, so a missing glyph takes zero width, which may look tight but keeps the following text from overlapping. Conversely, with an outline but no advance, every glyph would pile up at the same origin.

The Division of Labour Between the Text Matrix and fontMatrix

Turning a table value into page coordinates takes two layers of transformation. The first is fontMatrix, which describes the ratio from glyph space to text space; the second is the text matrix together with the CTM, which describes position and rotation from text space to device space.

fontMatrix lives on the font object and varies with the font type:

Font typeTypical fontMatrix[0]Source
Simple font0.001FONT_IDENTITY_MATRIX
Type10.001The header dictionary's /FontMatrix
CFF0.001 or otherFontMatrix in the Top DICT
TrueType0.001PDF.js normalises everything to thousandths of an em
Type3ArbitraryThe font dictionary's /FontMatrix

textState.fontMatrix is copied in when Tf sets the font, and is then multiplied by the font size:

const scale = textState.fontMatrix[0] * textState.fontSize;

fontSize is the second operand of Tf, and fontMatrix[0] is the baseline carried by the font itself. Multiplying the two gives "how many user-space units one glyph space unit corresponds to". translateTextMatrix afterwards multiplies in the linear part of the text matrix and finally lands on page coordinates.

This division has a practical consequence: the built-in 667 never needs to change for any font size or any rotation angle. Rotation is handled by the text matrix, scaling by the font size and fontMatrix, and the table value expresses only ratios internal to the font. Making the table value "the pixel width at 12pt" would break it the moment the font size changes.

The Actual Width Conversion Formula

What really turns a table value into a page length is the text layout loop in evaluator.js:

      const scale = textState.fontMatrix[0] * textState.fontSize;
 
      for (let i = 0, ii = glyphs.length; i < ii; i++) {
        const glyph = glyphs[i];
        // ...
        let glyphWidth = glyph.width;
        if (font.vertical) {
          glyphWidth = glyph.vmetric ? glyph.vmetric[0] : -glyphWidth;
        }
        let scaledDim = glyphWidth * scale;

For an ordinary simple font, fontMatrix is the thousandth form of the identity matrix:

const FONT_IDENTITY_MATRIX = [0.001, 0, 0, 0.001, 0, 0];

So scale = 0.001 * fontSize and scaledDim = glyph.width * 0.001 * fontSize. Walk through it with a few concrete values:

QuantityTable value or inputComputationUser-space result
Helvetica A width667667 × 0.001 × 128.004
Helvetica space width278278 × 0.001 × 123.336
Courier default width600600 × 0.001 × 127.2
Helvetica ascent718718 / 10000.718 (relative to em)
Helvetica descent-207-207 / 1000-0.207 (relative to em)

(The font size is 12, in the same unit as page user space, usually pt.)

This table clears up a common misunderstanding: the 667 returned by getMetrics does not need the caller to multiply by 0.001. The caller receives a width in glyph space; it first enters glyph.width and is then scaled by fontMatrix[0] together with fontSize. Multiplying here in advance would scale it a second time whenever fontMatrix is not the default or adjustWidths has run, and every width would come out too small.

adjustWidths: Normalising a Non-1000 Baseline

Embedded fonts do not necessarily use 1000 units per em. A TrueType head table may say 2048, and a CFF FontMatrix may not be 0.001. PDF.js handles this by folding widths back to the 1/1000 em baseline:

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

scale = 0.001 / fontMatrix[0]. If fontMatrix[0] is 0.002 (that is, 500 units per em), the scale is 0.5 and every width is halved, returning to the 1000 baseline; if fontMatrix[0] is 0.0005, the scale is 2 and every width doubles. The layer above then knows only one baseline and does not need a separate conversion for each font format.

adjustWidths is called in Font#constructor for both the CFF and TrueType branches, after the font program has been parsed:

const cff =
  subtype === 'Type1C' || subtype === 'CIDFontType0C'
    ? new CFFFont(file, properties)
    : new Type1Font(name, file, properties);
 
adjustWidths(properties);

What it modifies is properties.widths, not the tables inside metrics.js. This point matters: the built-in tables always keep their original 1/1000 em integers, and any scaling happens on the object newly created for each font construction.

The Lazy Table Factory: getLookupTableFactory

The Factory Is Only Ten Lines

Neither table is an ordinary object literal; both are functions wrapped by getLookupTableFactory from core_utils.js:

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

The behaviour of this code can be described in four steps:

  1. When the anonymous function returned to the caller is invoked for the first time, lookup is created as an empty object (or empty array), initializer runs and writes the data into it;
  2. immediately after it has run, initializer is set to null, releasing the reference to the initialisation function;
  3. from the second call onwards, if (initializer) is false and the already built lookup is returned directly;
  4. the function returned by a given factory always returns the same object instance.

metrics.js uses the default useArray = false, so both tables are prototype-less objects at the top level.

Why Prototype-Less Objects

Object.create(null) creates an object without Object.prototype, and therefore without inherited properties such as toString, constructor, hasOwnProperty and __proto__. When a lookup is written as lookup[name], if name happens to be one of those strings, an ordinary object would return the inherited function or object, producing a false hit.

This is not a theoretical problem. The keys of the width table come from the font's /Differences and /Encoding, and buildCharCodeToWidth is implemented as:

      if (charCode in differences && widthsByGlyphName[differences[charCode]]) {
        widths[charCode] = widthsByGlyphName[differences[charCode]];
        continue;
      }

differences[charCode] is a string supplied by the PDF file and can perfectly well be anything. With a prototype-less object, a miss is just undefined and prototype members are never picked up by accident. The same reasoning appears for NameCache and CmdCache in primitives.js: as soon as keys come from untrusted input, the prototype chain should not take part in lookups.

Two Levels of Lazy Loading

getMetrics has one more layer than a plain lookup table. The top level holds only 14 keys, and for 10 of them the value is another factory function:

  // eslint-disable-next-line no-shadow
  t.Helvetica = getLookupTableFactory(function (t) {
    t.space = 278;
    t.exclam = 278;

When the outer initializer runs for the first time it performs only 14 assignments. The four Courier faces are assigned numbers directly; the other ten are assigned inner factory functions. The actual 315 glyph widths are not expanded until the caller writes Metrics["Helvetica"]().

Which means:

AccessWhat is expandedWhat stays unexpanded
The first call to getMetrics()the 14 top-level keys, 4 Courier scalarsthe glyph tables of the 10 families
A call to Metrics["Helvetica"]()Helvetica's 315 entriesthe glyph tables of the other 9 families
A call to Metrics["Symbol"]()Symbol's 190 entriesthe other families

For a PDF that uses only Helvetica, the Times, Symbol and ZapfDingbats tables are never built. For a PDF that uses no standard font at all, getMetrics() does not even need to be called. This nesting carries lazy loading by font name inside the table itself, not only at module level.

Design note: A lookup table is not better the earlier it is built. Splitting the 14 families into 14 closures means only the one accessed first is expanded, which lowers the latency of a single font parse and avoids constructing nearly three thousand records for a page that uses a single font. The price is that a family value may be a number or a function, so the caller must check the type first; the typeof glyphWidths === "number" test in getBaseFontMetrics exists for exactly this design.

Where the Cache Key Lives

Neither getMetrics nor getFontBasicMetrics has any cache-by-font-name logic. The cache granularity is the whole table: the lookup inside the factory closure is the cache, and the key is whether it has been initialised, not a font name.

Lookup by font name happens in the caller:

  • evaluator.getBaseFontMetrics first does fontName = stdFontMap[fontName] || fontName, then writes Metrics[fontName];
  • fonts.js fallbackToSystemFont first does stdFontMap[fontName] || nonStdFontMap[fontName] || fontName, then writes fontBasicMetricsMap[fontName].

So what really decides which line gets looked up is the normalised font name, while the job of mapping vendor names to standard names belongs to standard_fonts.js and fonts_utils.normalizeFontName, not to this article. metrics.js only recognises canonical names such as Helvetica and Times-Bold.

getMetrics: A Two-Level Table from Glyph Name to Width

Font Families on Top, Glyphs Below

Listing the top-level keys of getMetrics makes its organisation visible:

Top-level keyValue typeNote
Couriernumber 600Monospaced font, a single default width
Courier-Boldnumber 600Same as above
Helveticafactory function315 glyph widths
Helvetica-Boldfactory function315 entries, same key set as Helvetica
Times-Romanfactory function315 entries
Symbolfactory function190 entries
ZapfDingbatsfactory function202 entries

(Seven rows are shown; the full table has 14 top-level keys.) Those 14 keys are exactly the core 14 fonts of the PDF specification: four Courier, four Helvetica, four Times, one Symbol and one ZapfDingbats.

Helvetica: 315 Widths

Helvetica's table starts at space and follows the Adobe glyph-name convention:

  t.Helvetica = getLookupTableFactory(function (t) {
    t.space = 278;
    t.exclam = 278;
    t.quotedbl = 355;
    t.numbersign = 556;
    t.quoteright = 222;
    t.ampersand = 667;
    t.plus = 584;
    t.zero = 556;

(Eight lines shown, 315 in total, ending at Euro. What is omitted in between is the remaining punctuation, letters and symbols.)

These keys are neither Unicode code points nor character encodings, but PostScript / Adobe Glyph List glyph names. space, exclam and quotedbl are the canonical names of printable ASCII characters; quoteright is the right single quote; Euro is the euro sign. Case matters too: A is the uppercase letter and a the lowercase one, and they are different keys.

Helvetica's table ends on the Euro line:

    t.onesuperior = 333;
    t.imacron = 278;
    t.Euro = 556;
  });

(The last three lines shown.) The whole table runs from space to Euro, 315 entries in total.

The Same Keys, Different Values

All four Helvetica faces share the same set of glyph names, but the width values differ. First the beginning of Helvetica:

  t.Helvetica = getLookupTableFactory(function (t) {
    t.space = 278;
    t.exclam = 278;
    t.quotedbl = 355;

Now the beginning of Helvetica-Bold:

  t["Helvetica-Bold"] = getLookupTableFactory(function (t) {
    t.space = 278;
    t.exclam = 333;
    t.quotedbl = 474;

space is 278 in both, while exclam goes from 278 to 333. Comparing all 315 records of Helvetica and Helvetica-Bold one by one, 115 values differ and the other 200 are identical. This shows that the two tables are the result of independent measurement, not one table plus a global scale factor. Bold has thicker stems, so punctuation and narrow characters gain advance width, while quite a few letters keep theirs.

Italics are different again: Helvetica-Oblique has exactly the same widths as Helvetica, with zero differing entries across all 315 keys. Slanting does not change advance width, so the width table can be reused as is; the slant only affects the transformation used to draw the outline.

315 Entries Use Only 39 Distinct Values

Counting Helvetica's 315 widths by value produces a rather interesting distribution:

Width valueOccurrencesTypical glyphs
55684Most lowercase letters and digits, e.g. a, e, zero
66735Most uppercase letters, e.g. A, B, E
33329Narrow punctuation and some lowercase letters, e.g. r, hyphen
27828space, comma, period, I and the like
72228Wider glyphs such as C, D, N, w
50023J, c, k, s and the ligatures fi, fl
77813Round capitals such as G, O, Q
61111Medium-width glyphs such as F, T, Z

(The eight most frequent values; counted from the Helvetica table in the current source.)

The 315 records use only 39 distinct width values, 556 being the most common and covering 84 glyphs. This distribution is no coincidence: the advance widths of the Latin alphabet were designed to fall into a few width classes so that body text has a steady rhythm. Truly unique widths are concentrated in symbols, punctuation and a few wide letters.

This also explains how the table can be so long and still so monotonous: most lines simply assign the same integer to a different glyph name. Compression algorithms and bundlers handle that repetition well; the source spells it out line by line for readability and diffability, not for the runtime structure.

The Same Tables, Different Diff Counts

Comparing the four Helvetica faces and the four Times faces entry by entry, the number of differences is not the same:

ComparisonDiffering entriesNote
Helvetica and Helvetica-Oblique0Slant does not change advance
Helvetica and Helvetica-Bold115Bold widens some glyphs
Helvetica and Helvetica-BoldOblique115Bold oblique equals bold widths
Times-Roman and Times-Bold144Serif bold changes widths more
Times-Roman and Times-Italic114Italic widths are measured independently too

(The result of comparing all 315 keys entry by entry.) The difference between Times-Italic and Helvetica-Oblique shows that a serif italic is not a simple slant: its glyph design is different and the advance changes with it, whereas a sans-serif italic like Helvetica's is closer to a geometric slant. The width table records this difference faithfully instead of deriving it with a uniform rule.

How to Verify the Table Values Yourself

This table is pure data, so verification is cheap. Inside the worker context the exported functions can be called directly:

import { getMetrics, getFontBasicMetrics } from './metrics.js';
 
const metrics = getMetrics();
const helvetica = metrics.Helvetica();
 
helvetica.A; // 667
helvetica.space; // 278
metrics.Courier; // 600
 
const basic = getFontBasicMetrics();
basic.Helvetica.ascent; // 718
basic['Times-Roman'].capHeight; // 662

The first call to getMetrics() builds only the top level, metrics.Helvetica() expands the 315 entries, and a second call to getMetrics() returns the same object. getFontBasicMetrics() has no nested factories and builds all 14 entries on the first call.

Two things need attention when checking: the keys of the width table are glyph names, not characters; the keys of the basic metrics table are full font names, and a hyphenated name such as Times-Roman must be accessed with brackets. Writing metrics.Times-Roman with a dot fails at the syntax level, an error that is also common when reading the source.

Courier: One Number Is Enough

Courier is monospaced and every glyph's advance width is 600. Each of the four Courier faces therefore needs only one number:

t.Courier = 600;
t['Courier-Bold'] = 600;
t['Courier-BoldOblique'] = 600;
t['Courier-Oblique'] = 600;

This shape directly affects the caller. In getBaseFontMetrics:

if (typeof glyphWidths === 'number') {
  defaultWidth = glyphWidths;
  monospace = true;
} else {
  widths = glyphWidths(); // expand lazy widths array
}

A number is taken as defaultWidth and sets monospace = true; there is no glyph table, so widths stays an empty object. When any character width is fetched later, widths[widthCode] is undefined and the lookup falls back to the 600 in defaultWidth. A monospaced font does not need 315 repeated 600s, and this compression turns a table into a single constant.

Symbol: 190 Greek and Mathematical Glyphs

Symbol is not a Latin text font, and many of its keys are Greek letters and mathematical symbols:

  t.Symbol = getLookupTableFactory(function (t) {
    t.space = 250;
    t.exclam = 333;
    t.universal = 713;
    t.existential = 549;
    t.suchthat = 439;
    t.asteriskmath = 500;
    t.period = 250;
    t.zero = 500;

(Eight lines shown, 190 in total.) Note the names universal, existential, suchthat and asteriskmath: they correspond to mathematical symbols in the Symbol encoding, not to ASCII characters. The end of the file also contains:

t.bracerighttp = 494;
t.bracerightmid = 494;
t.bracerightbt = 494;
t.apple = 790;

(Excerpt.) apple is the Apple logo glyph in the Symbol font, 790 wide, part of the traditional AFM data. Symbol has only 190 entries because it covers only the glyphs the Symbol encoding needs, not the full Latin alphabet.

ZapfDingbats: 201 aN Names

ZapfDingbats has the most peculiar key form, the vast majority being a plus a number:

  t.ZapfDingbats = getLookupTableFactory(function (t) {
    t.space = 278;
    t.a1 = 974;
    t.a2 = 961;
    t.a202 = 974;
    t.a3 = 980;
    t.a119 = 790;
    t.a117 = 690;
    t.a11 = 960;

(Eight lines shown, 202 in total.) The aN names beginning with a1 are the canonical ZapfDingbats glyph names; the numbering runs from a1 to a206 with gaps, and the order does not increase numerically either. The table has 202 entries: 201 aN names plus one space. The last assignment in the file is a191 (that is source order, not the highest number):

    t.a189 = 927;
    t.a190 = 970;
    t.a191 = 918;
  });
});

(Excerpt.) This run of aN names also answers a common question: why ZapfDingbats widths cannot be looked up by character code. Its character encoding is the Dingbats encoding from 0x20 to 0xFF, while the table keys are glyph names; the conversion in between is done by encoding and glyphlist.js.

The Table Boundary: Miss and Fall Back

None of the three glyph tables has a default width field. When a glyph name is not found, widths[glyphName] is simply undefined. buildCharCodeToWidth never writes undefined into the result:

for (let charCode = 0; charCode < 256; charCode++) {
  if (charCode in differences && widthsByGlyphName[differences[charCode]]) {
    widths[charCode] = widthsByGlyphName[differences[charCode]];
    continue;
  }
  if (charCode in encoding && widthsByGlyphName[encoding[charCode]]) {
    widths[charCode] = widthsByGlyphName[encoding[charCode]];
    continue;
  }
}

A width is written into widths[charCode] only when the corresponding glyph name exists in the table and its width is truthy. The remaining code points have no entry, and when the glyph is fetched _charToGlyph runs:

if (typeof width !== 'number') {
  width = this.defaultWidth;
}

In other words, getMetrics is responsible only for the glyphs it knows, and the unknown part is covered by defaultWidth. For Courier defaultWidth is 600; for proportional fonts such as Helvetica and Times it is usually 0, meaning a missing glyph is treated as zero width. This choice leans towards not guessing: better a text layer that is slightly too narrow than a width with nothing behind it.

Coverage: the Core 14 Fonts and Their 315 Glyph Names

A Coverage Table

Putting the size of both tables side by side:

Font familyFacesgetMetrics shapeEntries (counted excerpt)Key form
Courier4a single number4 scalarsno glyph keys
Helvetica4glyph table315 × 4Latin glyph names
Times4glyph table315 × 4same key set as Helvetica
Symbol1glyph table190Greek letter and mathematical symbol names
ZapfDingbats1glyph table202201 aN names plus space

(Entry counts are the result of counting the current source line by line.)

14 faces in total. The eight text faces (four Helvetica, four Times) share the same set of 315 keys; Symbol and ZapfDingbats are independent. Fully expanded, getMetrics holds about 2916 width records, and together with the wrapper lines that explains why the file is three thousand lines long.

What 315 Means

315 is not a specification number but the actual number of glyphs contained in this AFM data. Its coverage is roughly:

  • ASCII printable characters;
  • Western European accented letters such as Eacute, Odieresis, Scaron and zcaron;
  • Common punctuation, currency and mathematical symbols such as degree, plusminus, logicalnot and Euro;
  • A few ligatures and superscripts such as onesuperior and onequarter.

It does not cover:

  • CJK characters. Chinese, Japanese and Korean glyph widths are in none of these tables;
  • The full Unicode range. 315 names are far fewer than the glyph count of a modern font;
  • Private glyphs of a font. Only glyphs that actually made it into the core 14 AFM data have entries.

This fits the role of the table exactly: it is the fallback metrics of the standard 14 fonts in the PDF specification, not a general-purpose font metric database.

The Character Ranges Covered

Listing the actual coverage by character range makes the boundary more visible:

Character rangeCoveredNote
ASCII printable charactersCoveredNames such as space, A, asciitilde
Western European accented lettersCoveredNames such as Eacute, Odieresis, Scaron
Common punctuation and currencyCoveredNames such as endash, bullet, Euro
Greek letters (text fonts)Not coveredHelvetica and Times have no Alpha or Beta entries
Greek letters (Symbol)CoveredNames such as alpha, beta, universal
CJK charactersNot coveredNo table has Chinese, Japanese or Korean glyph names
Emoji and colour glyphsNot coveredSuch glyphs did not exist in the core 14 era

(The table summarises the key names; it is not an exhaustive character-by-character survey.)

This table explains a common phenomenon: in a Chinese PDF typeset in Helvetica, the Chinese part often has the wrong widths. The cause is not a miscalculation in metrics.js but the fact that those glyphs are simply not in its tables, so widths misses and falls back to defaultWidth. A real Chinese PDF usually uses composite fonts with embedded CJK fonts, and widths come from /W or from the font program, never from this table.

Why It Is Not a General Metric Library

Extending these tables to CJK would immediately blow up their size. A Chinese font has between ten thousand and sixty thousand glyphs, one record per glyph name; expanding all 14 fonts would reach hundreds of thousands of entries, unacceptable in both source size and parse memory. On top of that, CJK fonts are almost always embedded or subset, so the PDF itself carries /W or the font program and the built-in table never comes into play.

The same trade-off shows up elsewhere in PDF.js: standard font data (pfb, ttf) is loaded on demand through standardFontDataUrl, CMaps are loaded on demand through cMapUrl, and neither is bundled into the main build. metrics.js dares to put its data straight into the source precisely because it covers only 14 Latin fonts and fewer than three thousand records.

Glyph Names Outside the 315

If a glyph name is not in the table, buildCharCodeToWidth skips it and the width falls back to defaultWidth. The glyph name itself may still have a Unicode mapping, since glyphlist.js maintains the glyph-name to code-point correspondence independently; the two do not depend on each other:

CaseWidth table hitUnicode mapping hitResult
Standard Latin glyph nameYesYesHas a width, selectable and copyable
Outside the table but in glyphlistNoYesWidth uses defaultWidth, text still extractable
Private glyph nameNoNoWidth uses defaultWidth, text may be lost
CID of a composite fontDoes not use this tableSupplied by ToUnicodeWidth comes from /W

This comparison shows that the width table and the text layer are two independent chains. A glyph's width in a PDF can be perfectly correct and yet the text cannot be selected, because what is missing is ToUnicode; conversely, text can be selectable while typesetting is too tight, perhaps simply because the width table missed. Keeping these two chains apart when investigating saves a lot of detours.

Why One Key Set Can Serve Four Faces

Helvetica, Helvetica-Bold, Helvetica-Oblique and Helvetica-BoldOblique have exactly the same key set and independently measured values. This brings three benefits:

  1. the caller needs only one glyph-name resolution, and does not remap characters when the style changes;
  2. buildCharCodeToWidth takes exactly the same encoding path for all four styles and only swaps the table object;
  3. width differences are concentrated in data and never enter code branches.

The four Times faces are the same. Times-Roman, Times-Bold, Times-Italic and Times-BoldItalic all have 315 entries and the same key set as Helvetica. The text-font group can therefore be handled uniformly in structure, with only the numbers differing.

Division of Labour with Other Standard Font Data

standard_fonts.js also holds a batch of names and mapping tables, which is easy to confuse with this article. Looking at responsibilities separately:

FileWhat it providesWidths?
metrics.jsglyph name to width, font name to vertical metricsYes
standard_fonts.jsstdFontMap, nonStdFontMap, serif table, symbol tableNo
glyphlist.jsglyph name to Unicode code pointNo
encodings.jsencoding name to a code-point-to-glyph-name mapNo
fonts_utils.jsfont-name normalisation, flags, glyph-name recoveryNo

A width lookup needs three tables working together: the encoding table turns bytes into glyph names, metrics.js turns glyph names into widths, and glyphlist.js turns glyph names into Unicode for the text layer. Each covers one segment, and no single table can compute a character width on its own.

Upstream Lookup: How getBaseFontMetrics Chooses a Table

On the Normal Path It Is Only Reached When /Widths Is Missing

All call sites of getMetrics are in evaluator.js, and there is only one intermediary: getBaseFontMetrics. It has two entry points, one in extractWidths and one in the descriptor-less compatibility branch of translateFont. The normal path is the first, and a simple font's widths take one of two branches:

    } else {
      const widths = dict.get("Widths");
      if (Array.isArray(widths)) {
        let j = properties.firstChar;
        for (const w of widths) {
          const width = xref.fetchIfRef(w);
          if (typeof width === "number") {
            glyphsWidths[j] = width;
          }
          j++;
        }
        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 code has a very explicit priority:

PriorityData sourceConditionResult
1the /Widths arraypresent and an arraywidths filled by FirstChar, defaultWidth from MissingWidth or 0
2the built-in table from getMetrics/Widths is not an arraywidths looked up by glyph name, defaultWidth from the table

The built-in table is the fallback, not the first choice. As long as the PDF writes its own /Widths, even if it covers only some characters, the file's values are used first; getBaseFontMetrics steps in only when the array is completely missing or of the wrong type. This agrees with the specification's assumption that standard fonts carry their own widths, and it is also PDF.js's compatibility with non-conforming files: many generators omit /Widths for the core 14 fonts.

The descriptor-less compatibility branch is the exception: translateFont calls getBaseFontMetrics once there unconditionally, because not even a FontDescriptor exists and it is needed to synthesise Flags. Widths still prefer the dictionary's /Widths though: the branch first tests Array.isArray(widths), and only when the array is absent does it use buildCharCodeToWidth(metrics.widths, ...) to move the table values into a character-code table. In other words, the fallback call happens, but the fallback data is not necessarily used.

The Full getBaseFontMetrics Implementation

This method is the hub of the entire width lookup chain:

  getBaseFontMetrics(name) {
    let defaultWidth = 0;
    let widths = Object.create(null);
    let monospace = false;
 
    let fontName = normalizeFontName(name);
    const stdFontMap = getStdFontMap();
    fontName = stdFontMap[fontName] || fontName;
    const Metrics = getMetrics();
 
    const glyphWidths =
      Metrics[fontName] ??
      // Use default fonts for looking up font metrics if the passed
      // font is not a base font
      Metrics[this.isSerifFont(name) ? "Times-Roman" : "Helvetica"];
 
    if (typeof glyphWidths === "number") {
      defaultWidth = glyphWidths;
      monospace = true;
    } else {
      widths = glyphWidths(); // expand lazy widths array
    }
 
    return {
      defaultWidth,
      monospace,
      widths,
    };
  }

It does five things, and the order cannot be changed:

  1. normalizeFontName replaces commas and underscores in the name with hyphens and strips whitespace;
  2. stdFontMap maps vendor names to standard ones, for example ArialMT becomes Helvetica;
  3. getMetrics() is called to obtain the top-level table;
  4. ?? selects the table; when the name misses, it falls back to Times-Roman or Helvetica depending on whether the font is serif;
  5. it checks whether the selection is a number or a function: a number takes the monospace branch, a function is called immediately to expand the glyph table.

Note step 5 against the two-level lazy loading described earlier: once a family is genuinely used, all 315 of its entries are constructed in that one call. What getMetrics saves is fonts that are not used, not glyphs that are not used inside a font that is used.

The return value has only three fields, with no caching and no copy protection:

return {
  defaultWidth,
  monospace,
  widths,
};

widths is the shared table object itself, not a copy. The design relies on the caller immediately converting it into a character-code table rather than modifying it. That is exactly what happens: buildCharCodeToWidth creates a new widths object and moves the glyph-name values onto character codes.

Name Normalisation and the Standard Font Map

normalizeFontName is extremely short, yet it decides whether a large number of fonts hit the table:

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

It unifies Arial,Bold and Arial_Bold into Arial-Bold, then removes all whitespace. A PDF /BaseFont may carry a subset prefix such as ABCDEF+Helvetica; normalizeFontName does not strip that prefix, and the Font constructor splits on + only when deciding whether the font is serif. The width lookup therefore still uses the full name, and if the prefix is not covered by the mapping table the lookup falls into the unknown-font fallback branch. metrics.js does no prefix handling itself; it accepts only names that are already canonical.

The mapping is left to getStdFontMap in standard_fonts.js. Its beginning maps the 14 standard names to themselves:

// The standard 14 fonts:
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';

After that come a large number of alias-to-standard-name entries:

t.Arial = 'Helvetica';
t['Arial-Bold'] = 'Helvetica-Bold';
t['Arial-BoldItalic'] = 'Helvetica-BoldOblique';
t['Arial-Italic'] = 'Helvetica-Oblique';
t.ArialMT = 'Helvetica';

(Excerpt.) This step collects looks-like-a-standard-font-but-is-called-something-else into the same table. Without it, Arial would be treated as an unknown font and wastefully fall back to Helvetica's default branch; with it, Arial's widths use Helvetica's data directly, with the same result but clearer semantics and without depending on the serif check.

Unknown Fonts: the Serif Check Decides the Fallback

The right-hand side of ?? is the most easily overlooked logic in this article:

Metrics[this.isSerifFont(name) ? 'Times-Roman' : 'Helvetica'];

If the font name is not in the table, PDF.js does not return empty widths; it guesses the closest standard font. The guess is based on isSerifFont:

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

It takes the first segment of the font name, dropping suffixes such as -Bold or -Italic, then looks it up in the getSerifFonts table or tests the name for serif with a regex. getSerifFonts is a long table of serif font names, from traditional faces such as Adobe Jenson, Baskerville and Bembo all the way to Times.

The effect of this fallback strategy:

Font nameAfter normalisationstdFontMap hitSelected table
ArialMTArialMTYes, maps to HelveticaHelvetica
TimesNewRomanPSMThyphenated after normalisationdepends on the mapTimes if it hits
SomeSerifFontunchangedNoTimes-Roman
SomeSansFontunchangedNoHelvetica
Helvetica-BoldunchangedYes, maps to itselfHelvetica-Bold

The last row deserves a note: stdFontMap maps the standard names to themselves too, so that mapping results are predictable. Whether the input is a standard name or a vendor alias, fontName points at a top-level key and there is no need for a use-the-original-name-if-it-misses branch. It does not affect isSerifFont, whose argument is the original name and which only takes the segment before - when deciding serif or sans.

Translating Glyph Names into Character Codes

The widths returned by getBaseFontMetrics is a table indexed by glyph name, whereas fetching a glyph needs widths indexed by character code. The conversion is done by buildCharCodeToWidth:

  buildCharCodeToWidth(widthsByGlyphName, properties) {
    const widths = Object.create(null);
    const differences = properties.differences;
    const encoding = properties.defaultEncoding;
    for (let charCode = 0; charCode < 256; charCode++) {
      if (charCode in differences && widthsByGlyphName[differences[charCode]]) {
        widths[charCode] = widthsByGlyphName[differences[charCode]];
        continue;
      }
      if (charCode in encoding && widthsByGlyphName[encoding[charCode]]) {
        widths[charCode] = widthsByGlyphName[encoding[charCode]];
        continue;
      }
    }
    return widths;
  }

It walks every byte code from 0 to 255, first checking whether /Differences assigns a glyph name to that code, then the default encoding. Both require the corresponding entry to exist in widthsByGlyphName; otherwise the code point is skipped.

There are two boundaries here:

  • Only 0 to 255 is handled. That is the single-byte limit of simple fonts; composite (Type0) fonts do not take this path, their widths come from /W or /DW, not from this table.
  • /Differences wins. The same character code may map to different glyph names in the two maps, and an explicit difference declared by the file takes precedence, an order that matches PDF encoding semantics.

After the conversion, the keys of widths become numeric strings, and _charToGlyph can fetch them directly with this.widths[widthCode]. This is also why the getMetrics table can use glyph names as keys: one encoding layer still stands between it and character codes, and that layer decides whether byte 65 is A or Alpha.

Composite Fonts and Type3: Two Paths That Bypass This Table

Everything discussed so far concerns simple fonts: one byte per character code, codes from 0 to 255. PDF also has two font categories that do not take this path, and understanding their differences shows where the boundary of this table lies.

The first is the composite font (Type0). Its widths are not looked up by glyph name but by CID through /W and /DW:

    if (properties.composite) {
      const dw = dict.get("DW");
      defaultWidth = typeof dw === "number" ? Math.ceil(dw) : 1000;
 
      const widths = dict.get("W");
      if (Array.isArray(widths)) {
        for (let i = 0, ii = widths.length; i < ii; i++) {
          let start = xref.fetchIfRef(widths[i++]);
          if (!Number.isInteger(start)) {
            break; // Invalid /W data.
          }
          const code = xref.fetchIfRef(widths[i]);
 
          if (Array.isArray(code)) {
            for (const c of code) {
              const width = xref.fetchIfRef(c);
              if (typeof width === "number") {
                glyphsWidths[start] = width;
              }
              start++;
            }
          } else if (Number.isInteger(code)) {
            const width = xref.fetchIfRef(widths[++i]);
            if (typeof width !== "number") {
              continue;
            }
            for (let j = start; j <= code; j++) {
              glyphsWidths[j] = width;
            }
          } else {
            break; // Invalid /W data.
          }
        }
      }

/W has two forms: an array giving widths one by one, or an interval form of start CID, end CID, uniform width. /DW defaults to 1000, exactly one em. The keys here are CIDs, not glyph names and not byte codes; the byte-to-CID mapping lives in the CMap, and metrics.js cannot take part at all.

The second is Type3. Its glyphs are described by the PDF's own content streams and its /FontMatrix can be arbitrary, so the 1/1000 em premise does not hold. translateFont reads the matrix like this:

const fontMatrix = lookupMatrix(dict.getArray('FontMatrix'), FONT_IDENTITY_MATRIX);
const bbox = lookupNormalRect(
  descriptor.getArray('FontBBox') || dict.getArray('FontBBox'),
  isType3Font ? [0, 0, 0, 0] : undefined,
);

When the matrix is missing it falls back to FONT_IDENTITY_MATRIX, that is [0.001, 0, 0, 0.001, 0, 0]; but for Type3 the specification already requires /FontMatrix, and allows it not to be thousandths. The specification also requires Type3 to carry its own /Widths, given directly in glyph space and then scaled by this matrix. For Type3, extractWidths still takes the simple-font branch, reads /Widths, and never consults the getMetrics table.

None of the 14 standard fonts in metrics.js is Type3, nor should any be: Type3 glyphs are document-private graphics, and there is no shareable standard metric for them.

The existence of these two categories explains why getBaseFontMetrics is only called in the simple-font branch where properties.composite === false. The width table is keyed by glyph name, while composite font widths are keyed by CID; the two indexing systems differ, and forcing one onto the other only produces wrong widths.

An Easily Missed Detail: Default Width Is Not a Byte Width

buildCharCodeToWidth writes only those code points that both maps agree on. For code points it did not write, fetching the glyph falls back to defaultWidth. For Helvetica that is 0; for Courier it is 600. The difference shows up when a glyph is missing:

ScenarioHelvetica defaultWidthCourier defaultWidth
The character code has a glyph in the tablethe table value600
The character code misses in the table0600
The font dictionary supplies MissingWidthMissingWidthMissingWidth

defaultWidth is not the average width of a character but the width when it is unknown. A proportional font taking 0 makes a missing glyph occupy nothing, while a monospaced font taking 600 keeps the grid aligned. Both choices serve the same goal: do not let one unknown glyph push a whole line of text out of place. Understanding this keeps you from treating 0 as a bug.

The Monospace Check and the FixedPitch Flag

Back to the monospace field of getBaseFontMetrics. It is set only when the built-in table returns a number, that is, for the four Courier faces. But monospace detection does not depend on it alone: extractWidths also scans the actual widths:

// Heuristic: detection of monospace font by checking all non-zero widths
let isMonospace = true;
let firstWidth = defaultWidth;
for (const glyph in glyphsWidths) {
  const glyphWidth = glyphsWidths[glyph];
  if (!glyphWidth) {
    continue;
  }
  if (!firstWidth) {
    firstWidth = glyphWidth;
    continue;
  }
  if (firstWidth !== glyphWidth) {
    isMonospace = false;
    break;
  }
}
if (isMonospace) {
  properties.flags |= FontFlags.FixedPitch;
} else {
  // Clear the flag.
  properties.flags &= ~FontFlags.FixedPitch;
}

This heuristic decides whether all non-zero widths are equal, sets FixedPitch when they are and clears it otherwise. It is more general than the monospace field: an embedded monospaced font is recognised even if it is not called Courier, and conversely, if a Courier /Widths array contains an outlier, the flag is cleared.

FixedPitch affects more than a typesetting hint. The Font constructor uses it to choose the CSS fallback name:

    } else if (this.isMonospace) {
      this.fallbackName = "monospace";
    } else if (this.isSerifFont) {
      this.fallbackName = "serif";
    } else {
      this.fallbackName = "sans-serif";
    }

Eventually fallbackName enters fontFamily in textContent.styles and decides which class of system font the browser uses when the font is missing.

properties When There Is No FontDescriptor

Before PDF 1.5 the core 14 fonts were not required to carry a /FontDescriptor. This compatibility path assembles properties by hand:

// Before PDF 1.5 if the font was one of the base 14 fonts, having a
// FontDescriptor was not required.
// This case is here for compatibility.
let baseFontName = dict.get('BaseFont');
if (!(baseFontName instanceof Name)) {
  throw new FormatError('Base font is not specified');
}
 
// Using base font name as a font name.
baseFontName = normalizeFontName(baseFontName.name);
const metrics = this.getBaseFontMetrics(baseFontName);

It puts metrics.widths and metrics.defaultWidth directly into properties and synthesises Flags from isSerifFont, metrics.monospace and getSymbolsFonts:

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

Then, immediately:

const properties = {
  type,
  name: baseFontName,
  loadedName: baseDict.loadedName,
  systemFontInfo: null,
  widths: metrics.widths,
  defaultWidth: metrics.defaultWidth,
  isSimulatedFlags: true,
  flags,
  firstChar,
  lastChar,
  toUnicode,
  xHeight: 0,
  capHeight: 0,
  italicAngle: 0,
  isType3Font,
};

Note that xHeight and capHeight are hard-coded to 0 here, while ascent and descent do not appear. As a result, the Font constructor computes properties.ascent / 1000 as NaN, and properties.descent / 1000 as NaN too. Filling in those two values is exactly the job of getFontBasicMetrics.

getFontBasicMetrics: ascent, descent and capHeight

Four Fields, Fourteen Fonts

The second table has a completely different shape from the width table. Its top level is no longer font family to glyph table but font name to a small object:

const getFontBasicMetrics = getLookupTableFactory(function (t) {
  t.Courier = {
    ascent: 629,
    descent: -157,
    capHeight: 562,
    xHeight: -426,
  };
  t["Courier-Bold"] = {
    ascent: 629,
    descent: -157,
    capHeight: 562,
    xHeight: 439,
  };

Helvetica and Times are the same:

t.Helvetica = {
  ascent: 718,
  descent: -207,
  capHeight: 718,
  xHeight: 523,
};
// ...
t['Times-Roman'] = {
  ascent: 683,
  descent: -217,
  capHeight: 662,
  xHeight: 450,
};

(Excerpt.) Listing representative values of the 14 records:

FontascentdescentcapHeightxHeight
Courier629-157562-426
Courier-Bold629-157562439
Helvetica718-207718523
Helvetica-Bold718-207718532
Times-Roman683-217662450
Times-Bold683-217676461
Times-Italic683-217653441
SymbolNaNNaNNaNNaN
ZapfDingbatsNaNNaNNaNNaN

(Excerpt, 14 records in total.) The meaning of the four fields:

  • ascent: distance from the baseline to the highest point of the font, positive;
  • descent: distance from the baseline to the lowest point of the font, negative;
  • capHeight: distance from the baseline to the top of uppercase letters, usually less than or equal to ascent;
  • xHeight: distance from the baseline to the top of the lowercase x, unused in this table.

The numbers are 1/1000 em as well. The Font constructor divides them by 1000 uniformly, giving ratios such as 0.629, 0.718 and 0.683.

Relationships Between the Values

Several typical relationships are visible in this table:

RelationshipExampleNote
ascent equals capHeightHelvetica 718 / 718The top of uppercase letters is the highest point of the font
ascent greater than capHeightTimes-Roman 683 / 662Some lowercase letters or symbols are taller than capitals
descent fixed within a familyall four Helvetica -207, all four Times -217The depth of descenders usually does not change with weight
xHeight grows with weightHelvetica 523, Helvetica-Bold 532Bold lowercase letters are taller
capHeight varies with weight in a familyTimes-Roman 662, Times-Bold 676Bold capitals are slightly taller

Courier's xHeight is -426, an obvious outlier: xHeight measures a height above the baseline and should not be negative by definition. Nothing reads it, so the error has never affected rendering; but it reminds the reader that the data here is transcribed from historical AFM files, and not every field can be assumed to be semantically correct.

Why Symbol and ZapfDingbats Are NaN

These two fonts also appear in the table, but all four fields are Math.NaN:

t.Symbol = {
  ascent: Math.NaN,
  descent: Math.NaN,
  capHeight: Math.NaN,
  xHeight: Math.NaN,
};
t.ZapfDingbats = {
  ascent: Math.NaN,
  descent: Math.NaN,
  capHeight: Math.NaN,
  xHeight: Math.NaN,
};

Math.NaN here is an explicit marker for unknown, not a placeholder. It makes the consumer's isNaN test true and therefore performs the assignment; but what gets assigned is still NaN / 1000, which remains NaN. So these two families do not gain usable vertical metrics simply by appearing in the table.

The design is deliberate: Symbol and ZapfDingbats are symbol fonts, and their cap height or x height has no typographic meaning. Better to keep them unknown than to invent a number. The text layer downstream handles NaN separately: with no usable ascent it falls back to another ratio and never carries NaN straight into style calculations.

The Only Consumer: fallbackToSystemFont

This table has exactly one call site, Font#fallbackToSystemFont in fonts.js:

fontName = stdFontMap[fontName] || nonStdFontMap[fontName] || fontName;
 
const fontBasicMetricsMap = getFontBasicMetrics();
const metrics = fontBasicMetricsMap[fontName];
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;
  }
}

There are three key points in this code:

  1. Map the font name first. Both stdFontMap and nonStdFontMap take part, one layer more than getBaseFontMetrics. The latter maps a batch of non-standard but common font names to standard ones, for example Calibri and CenturyGothic to Helvetica.
  2. Fill in NaN only. When isNaN(this.ascent) is false, the existing value is kept as is. This guarantees that real metrics from the font program or FontDescriptor take precedence.
  3. Fill in three fields only. There is no assignment for xHeight, so even when the table has a value it is never written into Font. This confirms the judgement in the earlier version note.

The conditions under which fallbackToSystemFont is called also deserve to be spelled out. The Font constructor takes it when the font file is missing, empty, or throws while parsing:

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

and in the catch branch after checkAndRepair throws:

    } catch (e) {
      warn(e);
      this.fallbackToSystemFont(properties);
      return;
    }

So the real semantics of this table are: when the font program is unavailable and the FontDescriptor provided no numbers, fill in the vertical metrics from standard font AFM data. It is not a path every font goes through, and it is not a default metric.

FontDescriptor Takes Precedence

The order in which translateFont reads the descriptor explains why only NaN is filled in:

let ascent = descriptor.get('Ascent');
if (typeof ascent !== 'number') {
  ascent = undefined;
}
let descent = descriptor.get('Descent');
if (typeof descent !== 'number') {
  descent = undefined;
}
let xHeight = descriptor.get('XHeight');
if (typeof xHeight !== 'number') {
  xHeight = 0;
}
let capHeight = descriptor.get('CapHeight');
if (typeof capHeight !== 'number') {
  capHeight = 0;
}

Note that the defaults are inconsistent: ascent and descent are undefined when missing, while xHeight and capHeight are 0. The Font constructor computes undefined / 1000 as NaN and 0 / 1000 as 0. Hence:

FieldWhen the descriptor is missingAfter entering FontFilled by the basic metrics table?
ascentundefinedNaNYes
descentundefinedNaNYes
capHeight00No
xHeight00No

This produces a less intuitive result: for a core 14 font with no /FontDescriptor, capHeight stays 0 instead of taking the 718 or 662 from the table. The reason is hard-coded in the compatibility branch of translateFont:

          toUnicode,
          xHeight: 0,
          capHeight: 0,
          italicAngle: 0,
          isType3Font,

xHeight and capHeight are explicitly set to 0 and ascent and descent do not appear. So only the latter two become NaN and get filled in by the table. The 0 for capHeight makes no difference on most typesetting paths, because the text layer mainly uses ascent and descent; but if some rendering branch needs capHeight, it receives 0 instead of the font's real value.

Design note: The principle here is to fill in only what is missing and never overwrite. fallbackToSystemFont gates on isNaN rather than assigning unconditionally; capHeight has already been given 0 upstream, so it is treated as a known value and left alone. This strategy preserves the priority of FontDescriptor and the embedded font program, at the cost that when upstream uses 0 for unknown, downstream cannot distinguish 0 from a real 0. Understanding this is the key to explaining why the same PDF has normal line heights in one reader and tight ones in another: the difference is usually not in the width table but in which source overwrote the vertical metrics.

lineHeight and the Text Layer

The Font constructor computes the line height from ascent and descent in properties right at the start:

this.ascent = properties.ascent / PDF_GLYPH_SPACE_UNITS;
this.descent = properties.descent / PDF_GLYPH_SPACE_UNITS;
this.lineHeight = this.ascent - this.descent;

Helvetica's 0.718 and -0.207 give 0.925; Times-Roman's 0.683 and -0.217 give 0.900. This line height contains no lineGap, because the built-in table has no such field. When a TrueType font has an hhea table, checkAndRepair changes the line height to ascent - descent + lineGap, which is more accurate.

Pay attention to the order of computation: lineHeight is computed at the very start of the constructor, while fallbackToSystemFont fills in only ascent, descent and capHeight and never recomputes lineHeight. So for a font that lacks a descriptor and relies on the built-in table, lineHeight stays at NaN while ascent and descent are valid. The main rendering path does not read lineHeight; only XFA layout uses it:

const lineHeight = pdfFont.lineHeight ? Math.max(real ? 0 : 1.2, pdfFont.lineHeight) : 1.2;

(Excerpt, core/xfa/fonts.js.) There, when pdfFont.lineHeight is NaN, the else branch is taken and 1.2 is used. In other words, a NaN line height never reaches canvas text layout; it is only replaced by a default inside XFA.

These values eventually enter the textContent style sheet:

textContent.styles[loadedName] = {
  fontFamily: font.fallbackName,
  ascent: font.ascent,
  descent: font.descent,
  vertical: font.vertical,
};

The display layer's text layer uses these two ratios to align text with the glyphs on canvas:

if (style.ascent) {
  ratio = style.ascent;
} else if (style.descent) {
  ratio = 1 + style.descent;
}

font_loader also provides getters for ascent and descent, so callers that need font metrics can read them directly. The 629, 718 and 683 in metrics.js therefore end up in the baseline calculation when the browser lays out the text layer; they are not just an intermediate value inside the worker.

ascent, descent and bbox

metrics.js contains no FontBBox. The bbox is read by translateFont from the descriptor or the font dictionary:

const bbox = lookupNormalRect(
  descriptor.getArray('FontBBox') || dict.getArray('FontBBox'),
  isType3Font ? [0, 0, 0, 0] : undefined,
);

The relationship between bbox and ascent/descent is not simple equality:

ConceptMeaningSource
bboxgeometric outline of the glyphs, [xMin, yMin, xMax, yMax]/FontDescriptor or the font program
ascentheight above the baseline used for typesettingdescriptor, font tables or the built-in table
descentdepth below the baseline used for typesettingdescriptor, font tables or the built-in table

When the descriptor lacks ascent and descent, PDF.js falls back to the bbox's yMax and yMin. createOS2Table says it plainly:

const typoAscent = override.ascent || Math.round(scale * (properties.ascent || bbox[3]));
let typoDescent = override.descent || Math.round(scale * (properties.descent || bbox[1]));

The Type1 and CFF parsers, on the other hand, convert the bbox directly into ascent and descent. Type1 header dictionary:

        case "FontBBox":
          const fontBBox = this.readNumberArray();
          properties.ascent = Math.max(fontBBox[3], fontBBox[1]);
          properties.descent = Math.min(fontBBox[1], fontBBox[3]);
          properties.ascentScaled = true;
          break;

CFF's Top DICT does the same:

if (fontBBox?.some(coord => coord !== 0)) {
  // adjusting ascent/descent
  properties.ascent = Math.max(fontBBox[3], fontBBox[1]);
  properties.descent = Math.min(fontBBox[1], fontBBox[3]);
  properties.ascentScaled = true;
}

This shows that when authoritative metrics are missing, ascent and descent are often just the top and bottom of the bbox. What metrics.js provides is a different kind of authority: the typographic metrics measured in Adobe AFM files for the core 14 fonts. Either can end up in properties.ascent, and which one wins is decided by the parse path.

How Vertical Metrics Are Written into the OS/2 Table

The values filled in by fallbackToSystemFont are never written into the font file: it is called when the font file is missing or broken and returns immediately afterwards, without triggering createOS2Table. What actually writes OS/2 is another path: the font file exists but the OS/2 table is missing or fails validation, so the font has to be rebuilt. Both paths share the same properties.ascent and properties.descent fields, and the unit conversion deserves a close look on its own.

createOS2Table first decides the scale factor, then takes the vertical metrics:

const unitsPerEm =
  override.unitsPerEm ||
  (properties.fontMatrix ? 1 / Math.max(...properties.fontMatrix.slice(0, 4).map(Math.abs)) : 1000);
 
// if the font units differ to the PDF glyph space units
// then scale up the values
const scale = properties.ascentScaled ? 1.0 : unitsPerEm / PDF_GLYPH_SPACE_UNITS;
 
const typoAscent = override.ascent || Math.round(scale * (properties.ascent || bbox[3]));
let typoDescent = override.descent || Math.round(scale * (properties.descent || bbox[1]));
if (typoDescent > 0 && properties.descent > 0 && bbox[1] < 0) {
  typoDescent = -typoDescent; // fixing incorrect descent
}
const winAscent = override.yMax || typoAscent;
const winDescent = -override.yMin || -typoDescent;

There are three levels of selection here, and the order cannot be reversed:

OrderValue of typoAscentWhen it applies
1override.ascentthe TrueType font already has hhea, use it directly
2scale * properties.ascentthe descriptor or parser supplied a value, convert by unit
3scale * bbox[3]neither is available, use the geometric upper bound

When properties.ascent is 0 or NaN, || falls through to the bbox; scale depends on ascentScaled. The Type1 and CFF parsers set it to true when they take the value from FontBBox, meaning the number is already in 1/1000 em glyph space, so scale is 1.0 and is not multiplied up by the target font's unitsPerEm; values from FontDescriptor or the bbox fallback usually lack that flag, and scale multiplies them by unitsPerEm / 1000.

There are two more details when writing the table:

os2.setInt16(typoAscent); // sTypoAscender
os2.setInt16(typoDescent); // sTypoDescender
os2.setArray([0x00, 0x64]); // sTypoLineGap (7%-10% of the unitsPerEM value)
os2.setInt16(winAscent); // usWinAscent
os2.setInt16(winDescent); // usWinDescent

winAscent and winDescent are clipping bounds for Windows, preferring yMax and yMin and using the typo values only when those are absent. sTypoLineGap is hard-coded as 0x64, that is 100 units, and the comment notes it is roughly 7% to 10% of unitsPerEm. These values affect the line box height when the browser renders a custom font.

So the two paths produce different artefacts: the OS/2 values of the rebuild path enter the font file and are handed to the browser with the font; the fallback values from metrics.js stay only in the Font instance and in textContent, for text-layer positioning. Both pass through the ascent and descent fields of Font, but they never overwrite each other, because the fallback path has already returned before the table is written.

Comparing Metric Sources: Built-in Tables, CFF, TrueType, Type1

metrics.js Does No Dispatching

This point must be stressed again and again: there is no if (type === "Type1") or if (cff) branch anywhere in getMetrics. Its only job is to return a table entry by font name. Font-format dispatch happens in two other places:

  • evaluator.extractWidths: deciding whether to use /Widths, a composite font's /W, or the base font table;
  • Font#constructor: entering the Type1, CFF, TrueType or Type3 branch by type and subtype.

The answer to a question such as where CFF metrics come from is therefore not in this article but in the respective parsers. The comparison table below puts the four sources side by side to make them easier to locate.

Comparing the Four Sources

SourceWidth sourceascent and descent sourceUnit baselineCode location
Built-in core 14 tablesthe glyph tables of getMetrics, or Courier's 600the AFM data of getFontBasicMetrics1/1000 emmetrics.js
TrueType / OpenTypehmtx advance width, scaled by scaleFactorshhea ascender, descender, lineGapunitsPerEmcheckAndRepair in fonts.js
CFFcharstring width plus defaultWidthX / nominalWidthXFontBBox in the Top DICTFontMatrixcff_parser.js, cff_font.js
Type1the wx of the charstring hsbwthe header dictionary's FontBBox, or /FontDescriptorFontMatrix (usually 0.001)type1_parser.js, type1_font.js

Every row of this table has a corresponding implementation in the source; we look at them one by one.

TrueType: hhea and hmtx

TrueType's vertical metrics come from four 16-bit signed integers in the hhea table:

// 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 says it plainly: PDF FontDescriptor metrics lie, so the real data inside the font wins. The unit is unitsPerEm, not 1000; only after dividing by unitsPerEm do you get a ratio relative to the em.

Widths come from hmtx. Each glyph takes 4 bytes, the first two an advance width and the second two a left side bearing:

      const metrics = tables.hmtx.data;
 
      for (let i = 0; i < numGlyphs; i++) {
        const j = 4 * i;
        const advanceWidth = Math.round(
          scaleFactors[i] * int16(metrics[j], metrics[j + 1])
        );
        metrics[j] = (advanceWidth >> 8) & 0xff;
        metrics[j + 1] = advanceWidth & 0xff;

The scaleFactors here fold the font's original units onto the target baseline, similar in effect to adjustWidths: first normalise the widths to a common scale, then hand them upward. TrueType widths are not looked up in a table; they are read out of the binary of the font program.

CFF: charstring Widths and FontBBox

A CFF width is embedded at the start of every charstring. The parser records state.width while executing a charstring and then reconstructs it from two private-dictionary parameters:

if (state.width !== null) {
  const nominalWidth = privateDictToUse.getByName('nominalWidthX');
  widths[i] = nominalWidth + state.width;
} else {
  const defaultWidth = privateDictToUse.getByName('defaultWidthX');
  widths[i] = defaultWidth;
}

This is the width encoding rule of a Type2 charstring: if the first operand is a width, it represents a difference relative to nominalWidthX; if there is no width operand, defaultWidthX is used. The resulting widths[i] is a width in glyph space.

The vertical metrics come from the Top DICT's FontBBox:

const fontMatrix = topDict.getByName('FontMatrix');
if (fontMatrix) {
  properties.fontMatrix = fontMatrix;
}
 
let fontBBox = topDict.getByName('FontBBox');

When the bbox is valid the parser uses it directly to set ascent and descent:

if (fontBBox?.some(coord => coord !== 0)) {
  // adjusting ascent/descent
  properties.ascent = Math.max(fontBBox[3], fontBBox[1]);
  properties.descent = Math.min(fontBBox[1], fontBBox[3]);
  properties.ascentScaled = true;
}

ascentScaled = true is a signal: these values have already been scaled by FontMatrix and should not be converted again as 1000-unit values. createOS2Table chooses scale = 1.0 rather than unitsPerEm / 1000 on that basis.

Type1: hsbw and the Header FontBBox

A Type1 charstring declares width and left side bearing with hsbw. The parser converts it into Type2 hmoveto while keeping the width:

          case 13: // hsbw
            if (this.stack.length < 2) {
              error = true;
              break;
            }
            // To convert to type2 we have to move the width value to the
            // first part of the charstring and then use hmoveto with lsb.
            wx = this.stack.pop();
            sbx = this.stack.pop();
            this.lsb = sbx;
            this.width = wx;
            this.stack.push(wx, sbx);
            error = this.executeCommand(2, COMMAND_MAP.hmoveto);
            break;

wx is the advance width, in a unit determined by the font's FontMatrix. Type1Font keeps these charstrings together with the widths, and Font later reads charstrings[i].width when constructing hmtx.

The vertical metrics come from the header dictionary's FontBBox, handled in exactly the same way as in CFF:

        case "FontMatrix":
          properties.fontMatrix = this.readNumberArray();
          break;
        case "FontBBox":
          const fontBBox = this.readNumberArray();
          properties.ascent = Math.max(fontBBox[3], fontBBox[1]);
          properties.descent = Math.min(fontBBox[1], fontBBox[3]);
          properties.ascentScaled = true;
          break;

If the header has no FontBBox, or the descriptor gives more explicit Ascent and Descent, the descriptor values read by translateFont enter properties first and are then overridden by the Font constructor. The priority is similar to TrueType: the font program and the descriptor first, the built-in table last.

Priority Summary

The four sources are not peers in a PDF; they descend layer by layer according to availability. Widths and vertical metrics must be considered separately too, because their priorities differ:

LayerWidthVertical metrics
Font programTrueType hmtx is used to repair and rebuild the font file; CFF and Type1 charstring widths serve the glyph programTrueType hhea overrides the ascent and descent of the Font instance directly; CFF and Type1 write FontBBox into properties
Font dictionarya simple font's /Widths, a composite font's /W and /DW decide layout widths/FontDescriptor's Ascent and Descent are written into properties first
Built-in tableprovides layout widths only when /Widths is missingfills in ascent, descent and capHeight only while the instance values are still NaN

This layering explains an easily confused point: although TrueType hmtx stores real advance widths, the layout width of a simple font still prefers /Widths; hmtx is used more for repairing and rebuilding the font file. CFF and Type1, by contrast, write their charstring widths back into /Widths when widths are missing, as the "Attempt to replace missing widths" passage in type1_parser.js shows. The built-in table is always the last layer, appearing only when neither the dictionary nor the program can supply a usable value.

metrics.js sits at the third layer. It does not override the first two layers, nor is it modified by them. This fallback-only position is what lets it be completely static, with no concern for the lifetime of a document, page or font object.

A Complete Numerical Trace

The conclusions above are rather abstract, so this section walks the numbers through one concrete piece of text. Suppose a page contains a 12pt Helvetica A, and the font dictionary has no /Widths and no /FontDescriptor.

Step One: Font-Name Normalisation and Table Selection

The /BaseFont is Helvetica. translateFont enters the descriptor-less compatibility branch, and getBaseFontMetrics("Helvetica") runs in order:

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'];

normalizeFontName("Helvetica") returns it unchanged; stdFontMap["Helvetica"] is "Helvetica"; Metrics["Helvetica"] is a function, not a number. So glyphWidths() is invoked and Helvetica's 315 entries are constructed in that single moment. The returned defaultWidth is the initial 0 and monospace is false.

Step Two: Glyph Names to Character Codes

In the descriptor-less compatibility branch the conversion happens inside translateFont:

newProperties.widths = this.buildCharCodeToWidth(metrics.widths, newProperties);

The normal path does the same thing in extractWidths:

glyphsWidths = this.buildCharCodeToWidth(metrics.widths, properties);
defaultWidth = metrics.defaultWidth;

For every code point from 0 to 255 it checks /Differences first and the default encoding second. When the default encoding is StandardEncoding, code point 65 maps to the glyph name A and widthsByGlyphName["A"] is 667. Entry 65 of the character-code table is therefore written as 667.

Then properties is assembled. ascent and descent are not assigned, xHeight and capHeight are 0, widths is the freshly built character-code table and defaultWidth is 0.

KeyValueSource
widths[65]667A in the Helvetica table
widths[32]278space in the Helvetica table
defaultWidth0the default width of a proportional font
ascentundefinedno descriptor
descentundefinedno descriptor
xHeight0hard-coded in the compatibility branch
capHeight0hard-coded in the compatibility branch

One comparison by the way: for the same glyph name A, the Helvetica table says 667 while Helvetica-Bold says 722. The mapping from character code to glyph name is unchanged; only the table being consulted is swapped. This also shows why the four Helvetica faces must have exactly the same key set.

(This example makes one thing clear: the mapping from glyph names to character codes has to be checked in the source, not recalled from memory. The encoding table is in encodings.js and the glyph names are in metrics.js, and the two must agree before a width exists.)

Step Three: Font Construction and Unit Conversion

new Font("Helvetica", file, properties, options) reaches the early part of the constructor:

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;

Substituting the values above gives:

ExpressionResult
properties.capHeight / 10000
properties.ascent / 1000NaN
properties.descent / 1000NaN
ascent - descentNaN

If a usable font file is available at this point (for example a pfb for Helvetica fetched from standardFontDataUrl), the constructor continues parsing: the TrueType branch overrides this.ascent and this.descent with hhea, while Type1 and CFF write FontBBox back into properties for the font rebuild. When there is no file or the file is broken, fallbackToSystemFont is called and fills them in with getFontBasicMetrics():

if (isNaN(this.ascent)) {
  this.ascent = metrics.ascent / PDF_GLYPH_SPACE_UNITS;
}
if (isNaN(this.descent)) {
  this.descent = metrics.descent / PDF_GLYPH_SPACE_UNITS;
}

Helvetica's 718 and -207 become 0.718 and -0.207. capHeight stays 0, because isNaN(0) is false. lineHeight does not update with them: it was computed at the start of the constructor, when ascent was still NaN, so lineHeight stays at NaN; typesetting on this path uses only ascent and descent, so it is unaffected.

Step Four: Glyph Widths Enter textContent

buildTextContentItem walks the glyphs:

      const glyphs = font.charsToGlyphs(chars);
      const scale = textState.fontMatrix[0] * textState.fontSize;
 
      for (let i = 0, ii = glyphs.length; i < ii; i++) {
        const glyph = glyphs[i];
        // ...
        let glyphWidth = glyph.width;
        let scaledDim = glyphWidth * scale;

fontMatrix[0] is 0.001, fontSize is 12, so scale is 0.012; glyph.width is 667, so scaledDim is 8.004. Then:

        if (
          !category.isZeroWidthDiacritic &&
          !compareWithLastPosition(scaledDim)
        ) {
          textState.translateTextMatrix(scaledDim * textState.textHScale, 0);
          continue;
        }

textHScale is usually 1, so the text matrix is shifted right by 8.004 user-space units. If the character spacing is not 0, charSpacing adds one more text-matrix shift after the glyph advance; for a space with wordSpacing set, one more still. The width table value determines only the glyph's own advance; it does not include these extra spacings.

Step Five: Vertical Alignment of the Text Layer

The textContent style sheet carries the ratios just filled in:

textContent.styles[loadedName] = {
  fontFamily: font.fallbackName,
  ascent: font.ascent,
  descent: font.descent,
  vertical: font.vertical,
};

The display layer's text_layer reads ascent = 0.718 and moves the text div's baseline 71.8% of the font height down from the top; if only descent is available, it uses 1 + descent. The glyphs drawn on canvas are then aligned with the HTML text layer, and the highlight box does not drift when text is selected.

Along this complete chain, metrics.js contributes just two numbers: 667 (the width) and 718 / -207 (the vertical metrics). Every other conversion, override and fallback is done by the caller. This division lets the width table be verified offline, and keeps errors in the basic metrics table from spreading beyond layout calculations.

Cache, Sharing and Lifecycle

The Layers of Caching

Looking at the two layers of caching together avoids the misunderstanding that tables are rebuilt repeatedly:

LayerCache carrierKeyLifetime
Top-level tablelookup in the getMetrics closureNone, only whether it has been initialisedthe worker module instance
Family tablelookup of each inner factoryNone, only whether it has been initialisedthe worker module instance
Basic metrics tablethe getFontBasicMetrics closureNonethe worker module instance
Font objectwidths on the Font instancecharacter codea single Font construction

There is no per-file or per-page cache. Parsing two PDFs in the same worker uses the same table objects as long as the font names match; different workers each hold their own, because module instances are not shared.

Expansion Cost

In the worst case, where a document uses all 14 fonts, both tables are fully expanded:

TableWhat is expandedOrder of records
getMetrics4 Courier scalars + 8 × 315 + 190 + 202about 2916
getFontBasicMetrics14 objects × 4 fields56

These records are integers and short strings, so the memory cost is small; construction is just a few hundred object-property assignments, with no parsing and no I/O. The real cost is source size: three thousand lines of data get bundled into the worker, which compresses to a sizeable but acceptable share.

The point of lazy loading is therefore mainly on the startup path: a document that uses only Helvetica never builds the Times and Symbol tables, so the first font parse does not pay assignment time for fonts it does not use. Across a batch of documents that difference accumulates.

The Risk of Shared Objects

The widths returned by getBaseFontMetrics is the table object itself:

widths = glyphWidths(); // expand lazy widths array

It is not copied. If some caller wrote metrics.widths["A"] = 0 directly, the pollution would last until the worker is destroyed. The current code does not do this: buildCharCodeToWidth creates a new object, adjustWidths modifies only properties.widths (which is the output of buildCharCodeToWidth, or a copy of /Widths), and fallbackToSystemFont only reads. This convention is upheld by code review, with no runtime protection.

Design note: Read-only data can be shared; mutable data must be copied. The tables in metrics.js are designed to be read-only and can therefore be shared safely; Font#widths is newly created at each construction and can therefore be modified safely. Keeping these two categories apart costs less than adding Object.freeze to a shared table and fits the minimalist principle better. The price is that every new caller must respect the do-not-write-the-table convention.

It Exists Only in the Worker

metrics.js lives in src/core/ and is only ever bundled into pdf.worker.js. src/display/ on the main thread does not import it, and api.js does not expose it to callers. There are only two real call sites in the whole repository:

Call siteNamePurpose
getBaseFontMetrics in evaluator.jsgetMetrics()width table
fallbackToSystemFont in fonts.jsgetFontBasicMetrics()vertical metrics

(src/core/xfa/fonts.js has another function with the same name, serving XFA form font metrics; it is unrelated to this article and easy to mistake during a global search.)

This position has two consequences:

  1. The tables are initialised once in the worker's module instance. The same worker handling several PDFs and several pages reuses the same tables; when the worker is destroyed the tables are released with it, with no explicit cleanup needed.
  2. What the main thread receives are the results converted by Font, not the tables themselves. textContent carries ascent, descent and the accumulated width of each glyph, and the display layer cannot and does not need to consult these tables again.

If an application moved Font construction to the main thread, or replaced the worker with its own implementation, the module-level cache of metrics.js would no longer be shared and every construction could re-expand. This is an inherent boundary of a module singleton, not a bug, but it needs to be recognised when building a custom bundle.

Relationship with Standard Font Data

The tables in metrics.js and the mapping tables in standard_fonts.js are both module-level singletons, but they become invalid in different ways. A mapping table converts names to names and does not change with the document; a metric table maps names to numbers and could in theory follow AFM updates across PDF versions. The current implementation has no version field and no external injection point; every value is hard-coded in the source.

This means upgrading the metric data requires changing the source and rebuilding. For a general-purpose library such as PDF.js that is acceptable: the AFM data of the core 14 fonts has been stable for decades and changes very rarely. For cases that need custom fonts, embedding the font program is the correct route; metrics.js should not be treated as a configurable metric database.

Boundaries: What This File Does Not Provide

Having read both tables, it is easy to get the impression that all font metrics live here. In fact it lacks quite a few common capabilities, and each gap has a clear alternative:

CapabilityProvided?Alternative source
Glyph widthProvidedtext fonts use this table, composite fonts use /W
ascent and descentProvidedembedded fonts use hhea or FontBBox
capHeightProvidedused only when filling NaN, not when the default 0 is left in place
Glyph outline bboxNot provided/FontDescriptor, FontBBox, the font program
Left and right side bearingsNot providedthe lsb of hmtx, the hsbw of a charstring
Kerning adjustmentsNot providedthe spacing numbers in TJ in the content stream
Vertical metrics vmetricNot provided/DW2, /W2 of a composite font
Unicode mappingNot providedglyphlist.js, the ToUnicode CMap
Default widthNot providedthe caller decides from a number or MissingWidth

This table shows the module's boundary best: it answers only how wide this glyph of a standard 14 font is and how tall the font is; every other question has to be asked elsewhere.

Kerning deserves a special mention. metrics.js has no glyph-pair data at all, so it cannot express rules such as "tighten a little when A is followed by V". PDF simple fonts do not do kerning through a font table anyway; typesetting spacing is given directly by the TJ array in the content stream, and buildTextContentItem reads the numbers in TJ and converts them into shifts, independent of the width table. Composite fonts can carry kerning inside the font program, but that belongs to the layout engine and is not on this worker path.

How TJ Spacing Is Converted

The numbers inside TJ are extracted separately, converted into text-space shifts and passed to the content-stream builder as extraSpacing:

const spaceFactor = ((textState.font.vertical ? 1 : -1) * textState.fontSize) / 1000;
for (const item of args[0]) {
  if (typeof item === 'string') {
    showSpacedTextBuffer.push(item);
  } else if (typeof item === 'number' && item !== 0) {
    // PDF Specification 5.3.2 states:
    // The number is expressed in thousandths of a unit of text
    // space.
    // This amount is subtracted from the current horizontal or
    // vertical coordinate, depending on the writing mode.
    // In the default coordinate system, a positive adjustment
    // has the effect of moving the next glyph painted either to
    // the left or down by the given amount.
    const str = showSpacedTextBuffer.join('');
    showSpacedTextBuffer.length = 0;
    buildTextContentItem({
      chars: str,
      extraSpacing: item * spaceFactor,
    });
  }
}
 
if (showSpacedTextBuffer.length > 0) {
  const str = showSpacedTextBuffer.join('');
  showSpacedTextBuffer.length = 0;
  buildTextContentItem({
    chars: str,
    extraSpacing: 0,
  });
}

Two points in this code are worth noting:

  1. The unit of the number is thousandths of an em as well; spaceFactor multiplies it by the font size and divides by 1000 to obtain a user-space shift. This matches the unit of the width table exactly, but the conversion happens in content-stream processing and does not pass through metrics.js.
  2. The sign convention is the opposite of widths. A positive number moves the next glyph to the left (horizontal writing) or down (vertical writing), so extraSpacing is a subtraction. A positive number in the width table always means a forward distance; the two must not be added together blindly.

metrics.js is responsible only for how wide the glyph itself is, while TJ is responsible for how much to move between two glyphs. In correctly typeset PDFs, the two added together are the real letter spacing.

Understanding these gaps is what lets you attribute a typesetting problem correctly: widths off, check getMetrics and /Widths; characters crowded, check TJ and letter spacing; line height wrong, check FontDescriptor and hhea; text not selectable, check ToUnicode. Investigating a problem in the wrong module tends to produce conclusions that do not hold, such as the table value being wrong.

Conclusion

metrics.js is the module in the PDF.js font layer that least looks like code: three thousand lines contain only two functions, one import and a few dozen lines of logic, and everything else is numbers. Yet it is precisely these numbers that decide whether a PDF without /Widths can be typeset correctly, and whether the text layer is displaced when the font file is missing.

Where It Sits in the Overall Chain

Placing it once more:

StageThis article's contributionDirect consumer
After font-name normalisationthe width table for 14 font namesevaluator.getBaseFontMetrics
Font object constructionnoneFont#constructor
Font program missing or brokenthe vertical metrics for 14 font namesFont#fallbackToSystemFont
Text layoutglyph widths, scaled by fontMatrixevaluator.buildTextContentItem
Text layer and font metric readsno direct call, values travel via Fontdisplay/text_layer, font_loader

Closing with a single chain:

// core/metrics.js
//   getMetrics()                 → { Helvetica: fn, Courier: 600, ... }
//   getFontBasicMetrics()        → { Helvetica: { ascent: 718, ... }, ... }
//
// evaluator.js
//   getBaseFontMetrics(name)     → { widths, defaultWidth, monospace }
//   buildCharCodeToWidth(...)    → { 65: 667, 32: 278, ... }
//
// fonts.js
//   Font#constructor             → ascent / descent / capHeight / widths
//   Font#fallbackToSystemFont    → fills only the NaN vertical metrics
//
// evaluator.js
//   buildTextContentItem         → glyph.width * fontMatrix[0] * fontSize

Six Design Principles

A few principles can be drawn from this minimal implementation:

  1. Fallback data only falls back; it never grabs priority. When /Widths exists, the file's values are used; when FontDescriptor gives numbers, they are not overridden; the built-in table appears only when something is unknown. This principle gives non-conforming PDFs a reasonable output and leaves conforming PDFs completely untouched by the table values.
  2. One unit baseline only, with scaling concentrated at the entrance. Every table value is 1/1000 em; adjustWidths folds non-1000 baselines back at font construction, and fontMatrix does the final scaling at layout time. The tables contain no decimals and perform no font-size-related computation.
  3. Expand on demand, sharded by font family. The two levels of getLookupTableFactory mean unused fonts and unused glyph families pay no construction cost; the cache granularity is the whole table, with no per-document or per-page entries to clean up.
  4. Read-only data is shared, mutable data is copied. A table object can be shared by any number of Font instances because nobody writes to it; each Font's widths is a newly created object and can therefore be safely modified by adjustWidths. This convention is implicit and every new caller must respect it.
  5. The module takes on no format dispatch. metrics.js knows nothing about CFF, TrueType or Type1; format differences are handled by the Font subclasses and their parsers, and the metric tables provide a unified answer only at the standard-14-font layer. A single responsibility is what lets three thousand lines of data stay purely static.
  6. Data tables stay faithful to the original AFM, with no editing. Courier's negative xHeight, the all-NaN fields of Symbol and ZapfDingbats, and fonts where ascent equals capHeight are all kept as they are. Downstream digests the anomalies with isNaN and truthiness tests instead of correcting them at the data layer into values that merely look reasonable.

Of these six, the first three determine its performance characteristics and the last three its maintainability. A module that holds only data and lookups can achieve both, not through cleverness but by drawing its boundary very narrowly: it does not parse, does not cache documents, does not judge formats and does not modify shared objects.

metrics.js also reminds us of one thing: the most expensive part of font typesetting is often not the lookup itself but the decision about where the table comes from. A width may come from /Widths, a composite font's /W, a CFF charstring, a Type1 hsbw or the built-in AFM data; a vertical metric may come from FontDescriptor, hhea, FontBBox or the built-in table. Getting the priority of these sources straight matters more than remembering any particular number.

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