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

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:
getBaseFontMetricsinevaluator.jscallsgetMetrics()and looks the glyph name up in the table when a width has to be filled in;Font#fallbackToSystemFontinfonts.jscallsgetFontBasicMetrics()to fill in ascent, descent and capHeight when the font file is missing or broken and theFontDescriptordid 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:
| Block | Key members | Responsibility |
|---|---|---|
| Table factory dependency | getLookupTableFactory | Builds the table on first call, then returns the same cached object |
| Width table | getMetrics | Glyph name to width for the 14 standard fonts, in 1/1000 em |
| Basic metrics table | getFontBasicMetrics | ascent, descent, capHeight, xHeight for the 14 standard fonts |
| Exports | getFontBasicMetrics, getMetrics | Only 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 readsNote 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.jsin the current repository. Its exports are onlygetFontBasicMetricsandgetMetrics. ThegetGlyphWidth,getCharWidth,getCharPositions,getBaseFontMetricsandgetStdFontMapfunctions commonly mentioned in older material or other branches are not in this file: width lookup is done bygetBaseFontMetricsinevaluator.js, and standard font-name mapping lives instandard_fonts.js. Every entry ofgetFontBasicMetricscarries anxHeightfield, but the only reader in the current repository,Font#fallbackToSystemFont, takes onlyascent,descentandcapHeight, so thexHeightvalues in the table are never read; the Courier entry even records a negativexHeightof-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:
| Concept | Meaning | Role in this file |
|---|---|---|
| Glyph space | The coordinate space inside a font program that describes glyphs, usually 1000 units wide | The space the table values live in (such as 667 or 278) |
| Text space | The space where text lives in a PDF content stream, affected by the Tf font size | The space the table values enter after division by 1000 |
| User space | The page coordinate space, transformed by the CTM and the text matrix together | The 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:
- The integers in the table are neither "pixels" nor "points", only thousandths of an em. Only after dividing by 1000 is
ascenta ratio relative to the em, ready to be multiplied by the current font size to obtain a user-space length. lineHeightis 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 realhheatable, the line height switches to the font's ownlineGap.
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:
| Concept | Meaning | Where it lives in PDF.js |
|---|---|---|
| advance width | How far the pen moves forward after a glyph is painted | metrics.js, hmtx, charstrings |
| ink width | The horizontal range a glyph actually covers with ink | The outline of the font program or the glyf table |
| bbox | The 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 type | Typical fontMatrix[0] | Source |
|---|---|---|
| Simple font | 0.001 | FONT_IDENTITY_MATRIX |
| Type1 | 0.001 | The header dictionary's /FontMatrix |
| CFF | 0.001 or other | FontMatrix in the Top DICT |
| TrueType | 0.001 | PDF.js normalises everything to thousandths of an em |
| Type3 | Arbitrary | The 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:
| Quantity | Table value or input | Computation | User-space result |
|---|---|---|---|
Helvetica A width | 667 | 667 × 0.001 × 12 | 8.004 |
Helvetica space width | 278 | 278 × 0.001 × 12 | 3.336 |
| Courier default width | 600 | 600 × 0.001 × 12 | 7.2 |
| Helvetica ascent | 718 | 718 / 1000 | 0.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:
- When the anonymous function returned to the caller is invoked for the first time,
lookupis created as an empty object (or empty array),initializerruns and writes the data into it; - immediately after it has run,
initializeris set tonull, releasing the reference to the initialisation function; - from the second call onwards,
if (initializer)is false and the already builtlookupis returned directly; - 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:
| Access | What is expanded | What stays unexpanded |
|---|---|---|
The first call to getMetrics() | the 14 top-level keys, 4 Courier scalars | the glyph tables of the 10 families |
A call to Metrics["Helvetica"]() | Helvetica's 315 entries | the glyph tables of the other 9 families |
A call to Metrics["Symbol"]() | Symbol's 190 entries | the 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 ingetBaseFontMetricsexists 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.getBaseFontMetricsfirst doesfontName = stdFontMap[fontName] || fontName, then writesMetrics[fontName];fonts.js fallbackToSystemFontfirst doesstdFontMap[fontName] || nonStdFontMap[fontName] || fontName, then writesfontBasicMetricsMap[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 key | Value type | Note |
|---|---|---|
Courier | number 600 | Monospaced font, a single default width |
Courier-Bold | number 600 | Same as above |
Helvetica | factory function | 315 glyph widths |
Helvetica-Bold | factory function | 315 entries, same key set as Helvetica |
Times-Roman | factory function | 315 entries |
Symbol | factory function | 190 entries |
ZapfDingbats | factory function | 202 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 value | Occurrences | Typical glyphs |
|---|---|---|
| 556 | 84 | Most lowercase letters and digits, e.g. a, e, zero |
| 667 | 35 | Most uppercase letters, e.g. A, B, E |
| 333 | 29 | Narrow punctuation and some lowercase letters, e.g. r, hyphen |
| 278 | 28 | space, comma, period, I and the like |
| 722 | 28 | Wider glyphs such as C, D, N, w |
| 500 | 23 | J, c, k, s and the ligatures fi, fl |
| 778 | 13 | Round capitals such as G, O, Q |
| 611 | 11 | Medium-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:
| Comparison | Differing entries | Note |
|---|---|---|
| Helvetica and Helvetica-Oblique | 0 | Slant does not change advance |
| Helvetica and Helvetica-Bold | 115 | Bold widens some glyphs |
| Helvetica and Helvetica-BoldOblique | 115 | Bold oblique equals bold widths |
| Times-Roman and Times-Bold | 144 | Serif bold changes widths more |
| Times-Roman and Times-Italic | 114 | Italic 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; // 662The 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 family | Faces | getMetrics shape | Entries (counted excerpt) | Key form |
|---|---|---|---|---|
| Courier | 4 | a single number | 4 scalars | no glyph keys |
| Helvetica | 4 | glyph table | 315 × 4 | Latin glyph names |
| Times | 4 | glyph table | 315 × 4 | same key set as Helvetica |
| Symbol | 1 | glyph table | 190 | Greek letter and mathematical symbol names |
| ZapfDingbats | 1 | glyph table | 202 | 201 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,Scaronandzcaron; - Common punctuation, currency and mathematical symbols such as
degree,plusminus,logicalnotandEuro; - A few ligatures and superscripts such as
onesuperiorandonequarter.
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 range | Covered | Note |
|---|---|---|
| ASCII printable characters | Covered | Names such as space, A, asciitilde |
| Western European accented letters | Covered | Names such as Eacute, Odieresis, Scaron |
| Common punctuation and currency | Covered | Names such as endash, bullet, Euro |
| Greek letters (text fonts) | Not covered | Helvetica and Times have no Alpha or Beta entries |
| Greek letters (Symbol) | Covered | Names such as alpha, beta, universal |
| CJK characters | Not covered | No table has Chinese, Japanese or Korean glyph names |
| Emoji and colour glyphs | Not covered | Such 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:
| Case | Width table hit | Unicode mapping hit | Result |
|---|---|---|---|
| Standard Latin glyph name | Yes | Yes | Has a width, selectable and copyable |
| Outside the table but in glyphlist | No | Yes | Width uses defaultWidth, text still extractable |
| Private glyph name | No | No | Width uses defaultWidth, text may be lost |
| CID of a composite font | Does not use this table | Supplied by ToUnicode | Width 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:
- the caller needs only one glyph-name resolution, and does not remap characters when the style changes;
buildCharCodeToWidthtakes exactly the same encoding path for all four styles and only swaps the table object;- 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:
| File | What it provides | Widths? |
|---|---|---|
metrics.js | glyph name to width, font name to vertical metrics | Yes |
standard_fonts.js | stdFontMap, nonStdFontMap, serif table, symbol table | No |
glyphlist.js | glyph name to Unicode code point | No |
encodings.js | encoding name to a code-point-to-glyph-name map | No |
fonts_utils.js | font-name normalisation, flags, glyph-name recovery | No |
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:
| Priority | Data source | Condition | Result |
|---|---|---|---|
| 1 | the /Widths array | present and an array | widths filled by FirstChar, defaultWidth from MissingWidth or 0 |
| 2 | the built-in table from getMetrics | /Widths is not an array | widths 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:
normalizeFontNamereplaces commas and underscores in the name with hyphens and strips whitespace;stdFontMapmaps vendor names to standard ones, for exampleArialMTbecomesHelvetica;getMetrics()is called to obtain the top-level table;??selects the table; when the name misses, it falls back toTimes-RomanorHelveticadepending on whether the font is serif;- 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 name | After normalisation | stdFontMap hit | Selected table |
|---|---|---|---|
ArialMT | ArialMT | Yes, maps to Helvetica | Helvetica |
TimesNewRomanPSMT | hyphenated after normalisation | depends on the map | Times if it hits |
SomeSerifFont | unchanged | No | Times-Roman |
SomeSansFont | unchanged | No | Helvetica |
Helvetica-Bold | unchanged | Yes, maps to itself | Helvetica-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
/Wor/DW, not from this table. /Differenceswins. 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:
| Scenario | Helvetica defaultWidth | Courier defaultWidth |
|---|---|---|
| The character code has a glyph in the table | the table value | 600 |
| The character code misses in the table | 0 | 600 |
The font dictionary supplies MissingWidth | MissingWidth | MissingWidth |
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:
| Font | ascent | descent | capHeight | xHeight |
|---|---|---|---|---|
Courier | 629 | -157 | 562 | -426 |
Courier-Bold | 629 | -157 | 562 | 439 |
Helvetica | 718 | -207 | 718 | 523 |
Helvetica-Bold | 718 | -207 | 718 | 532 |
Times-Roman | 683 | -217 | 662 | 450 |
Times-Bold | 683 | -217 | 676 | 461 |
Times-Italic | 683 | -217 | 653 | 441 |
Symbol | NaN | NaN | NaN | NaN |
ZapfDingbats | NaN | NaN | NaN | NaN |
(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:
| Relationship | Example | Note |
|---|---|---|
| ascent equals capHeight | Helvetica 718 / 718 | The top of uppercase letters is the highest point of the font |
| ascent greater than capHeight | Times-Roman 683 / 662 | Some lowercase letters or symbols are taller than capitals |
| descent fixed within a family | all four Helvetica -207, all four Times -217 | The depth of descenders usually does not change with weight |
| xHeight grows with weight | Helvetica 523, Helvetica-Bold 532 | Bold lowercase letters are taller |
| capHeight varies with weight in a family | Times-Roman 662, Times-Bold 676 | Bold 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:
- Map the font name first. Both
stdFontMapandnonStdFontMaptake part, one layer more thangetBaseFontMetrics. The latter maps a batch of non-standard but common font names to standard ones, for exampleCalibriandCenturyGothictoHelvetica. - 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 orFontDescriptortake precedence. - Fill in three fields only. There is no assignment for
xHeight, so even when the table has a value it is never written intoFont. 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:
| Field | When the descriptor is missing | After entering Font | Filled by the basic metrics table? |
|---|---|---|---|
ascent | undefined | NaN | Yes |
descent | undefined | NaN | Yes |
capHeight | 0 | 0 | No |
xHeight | 0 | 0 | No |
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.
fallbackToSystemFontgates onisNaNrather than assigning unconditionally;capHeighthas already been given 0 upstream, so it is treated as a known value and left alone. This strategy preserves the priority ofFontDescriptorand 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:
| Concept | Meaning | Source |
|---|---|---|
| bbox | geometric outline of the glyphs, [xMin, yMin, xMax, yMax] | /FontDescriptor or the font program |
| ascent | height above the baseline used for typesetting | descriptor, font tables or the built-in table |
| descent | depth below the baseline used for typesetting | descriptor, 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:
| Order | Value of typoAscent | When it applies |
|---|---|---|
| 1 | override.ascent | the TrueType font already has hhea, use it directly |
| 2 | scale * properties.ascent | the descriptor or parser supplied a value, convert by unit |
| 3 | scale * 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); // usWinDescentwinAscent 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 bytypeandsubtype.
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
| Source | Width source | ascent and descent source | Unit baseline | Code location |
|---|---|---|---|---|
| Built-in core 14 tables | the glyph tables of getMetrics, or Courier's 600 | the AFM data of getFontBasicMetrics | 1/1000 em | metrics.js |
| TrueType / OpenType | hmtx advance width, scaled by scaleFactors | hhea ascender, descender, lineGap | unitsPerEm | checkAndRepair in fonts.js |
| CFF | charstring width plus defaultWidthX / nominalWidthX | FontBBox in the Top DICT | FontMatrix | cff_parser.js, cff_font.js |
| Type1 | the wx of the charstring hsbw | the header dictionary's FontBBox, or /FontDescriptor | FontMatrix (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:
| Layer | Width | Vertical metrics |
|---|---|---|
| Font program | TrueType hmtx is used to repair and rebuild the font file; CFF and Type1 charstring widths serve the glyph program | TrueType hhea overrides the ascent and descent of the Font instance directly; CFF and Type1 write FontBBox into properties |
| Font dictionary | a 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 table | provides layout widths only when /Widths is missing | fills 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.
| Key | Value | Source |
|---|---|---|
widths[65] | 667 | A in the Helvetica table |
widths[32] | 278 | space in the Helvetica table |
defaultWidth | 0 | the default width of a proportional font |
ascent | undefined | no descriptor |
descent | undefined | no descriptor |
xHeight | 0 | hard-coded in the compatibility branch |
capHeight | 0 | hard-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:
| Expression | Result |
|---|---|
properties.capHeight / 1000 | 0 |
properties.ascent / 1000 | NaN |
properties.descent / 1000 | NaN |
ascent - descent | NaN |
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:
| Layer | Cache carrier | Key | Lifetime |
|---|---|---|---|
| Top-level table | lookup in the getMetrics closure | None, only whether it has been initialised | the worker module instance |
| Family table | lookup of each inner factory | None, only whether it has been initialised | the worker module instance |
| Basic metrics table | the getFontBasicMetrics closure | None | the worker module instance |
| Font object | widths on the Font instance | character code | a 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:
| Table | What is expanded | Order of records |
|---|---|---|
getMetrics | 4 Courier scalars + 8 × 315 + 190 + 202 | about 2916 |
getFontBasicMetrics | 14 objects × 4 fields | 56 |
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 arrayIt 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.jsare designed to be read-only and can therefore be shared safely;Font#widthsis newly created at each construction and can therefore be modified safely. Keeping these two categories apart costs less than addingObject.freezeto 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 site | Name | Purpose |
|---|---|---|
getBaseFontMetrics in evaluator.js | getMetrics() | width table |
fallbackToSystemFont in fonts.js | getFontBasicMetrics() | 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:
- 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.
- What the main thread receives are the results converted by
Font, not the tables themselves. textContent carriesascent,descentand 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:
| Capability | Provided? | Alternative source |
|---|---|---|
| Glyph width | Provided | text fonts use this table, composite fonts use /W |
| ascent and descent | Provided | embedded fonts use hhea or FontBBox |
| capHeight | Provided | used only when filling NaN, not when the default 0 is left in place |
| Glyph outline bbox | Not provided | /FontDescriptor, FontBBox, the font program |
| Left and right side bearings | Not provided | the lsb of hmtx, the hsbw of a charstring |
| Kerning adjustments | Not provided | the spacing numbers in TJ in the content stream |
| Vertical metrics vmetric | Not provided | /DW2, /W2 of a composite font |
| Unicode mapping | Not provided | glyphlist.js, the ToUnicode CMap |
| Default width | Not provided | the 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:
- The unit of the number is thousandths of an em as well;
spaceFactormultiplies 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 throughmetrics.js. - 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
extraSpacingis 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:
| Stage | This article's contribution | Direct consumer |
|---|---|---|
| After font-name normalisation | the width table for 14 font names | evaluator.getBaseFontMetrics |
| Font object construction | none | Font#constructor |
| Font program missing or broken | the vertical metrics for 14 font names | Font#fallbackToSystemFont |
| Text layout | glyph widths, scaled by fontMatrix | evaluator.buildTextContentItem |
| Text layer and font metric reads | no direct call, values travel via Font | display/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] * fontSizeSix Design Principles
A few principles can be drawn from this minimal implementation:
- Fallback data only falls back; it never grabs priority. When
/Widthsexists, the file's values are used; whenFontDescriptorgives 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. - One unit baseline only, with scaling concentrated at the entrance. Every table value is 1/1000 em;
adjustWidthsfolds non-1000 baselines back at font construction, andfontMatrixdoes the final scaling at layout time. The tables contain no decimals and perform no font-size-related computation. - Expand on demand, sharded by font family. The two levels of
getLookupTableFactorymean 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. - Read-only data is shared, mutable data is copied. A table object can be shared by any number of
Fontinstances because nobody writes to it; eachFont'swidthsis a newly created object and can therefore be safely modified byadjustWidths. This convention is implicit and every new caller must respect it. - The module takes on no format dispatch.
metrics.jsknows nothing about CFF, TrueType or Type1; format differences are handled by theFontsubclasses 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. - Data tables stay faithful to the original AFM, with no editing. Courier's negative
xHeight, the all-NaNfields of Symbol and ZapfDingbats, and fonts where ascent equals capHeight are all kept as they are. Downstream digests the anomalies withisNaNand 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
- PDF.js Deep Dive from Demo 10: CanvasGraphics and operatorList RenderingOctober 27, 2026
- PDF.js Deep Dive from Demo 9: Network Streams and Range RequestsOctober 27, 2026
- PDF.js Deep Dive from Demo 8: PDFWorker.create and the Real/Fake Worker HandshakeOctober 27, 2026
- PDF.js Deep Dive from Demo 7: WorkerTransport InitializationOctober 27, 2026
- PDF.js Deep Dive from Demo 6: MessageHandler Cross-Thread CommunicationOctober 27, 2026
- PDF.js Deep Dive from Demo 5: renderOctober 27, 2026
- PDF.js Deep Dive from Demo 4: getViewportOctober 27, 2026
- PDF.js Deep Dive from Demo 3: getPageOctober 27, 2026
- PDF.js Deep Dive from Demo 2: getDocumentOctober 27, 2026
- PDF.js Source Walkthrough: TextLayerOctober 27, 2026
- PDF.js Source Walkthrough: font_loaderOctober 27, 2026
- PDF.js Source Walkthrough: obj_bin_transform_displayOctober 27, 2026
- PDF.js Source Walkthrough: PDFObjectsOctober 27, 2026
- PDF.js Source Walkthrough: Network StreamsOctober 27, 2026
- PDF.js Source Walkthrough: internal_evt.jsOctober 27, 2026
- PDF.js Source Walkthrough: math_clamp.jsOctober 27, 2026
- PDF.js Source Walkthrough: css_utils.jsOctober 27, 2026
- PDF.js Source Walkthrough: scripting_utilsOctober 27, 2026
- PDF.js Source Walkthrough: MurmurHash3_64October 27, 2026
- PDF.js Source Walkthrough: obj_bin_transform_utilsOctober 27, 2026
- PDF.js Source Walkthrough: image_utils.jsOctober 27, 2026
- PDF.js Source Walkthrough: BasePDFStreamOctober 27, 2026
- PDF.js Source Walkthrough: Shared UtilitiesOctober 27, 2026
- PDF.js Source Walkthrough: MessageHandlerOctober 27, 2026
- PDF.js Source Walkthrough: apiOctober 27, 2026
- PDF.js Source Walkthrough: fontsOctober 27, 2026
- PDF.js Source Walkthrough: glyphlistOctober 27, 2026
- PDF.js Source Walkthrough: binary_cmapOctober 27, 2026
- PDF.js Source Walkthrough: cmapOctober 27, 2026
- PDF.js Source Walkthrough: encodingsOctober 27, 2026
- PDF.js Source Walkthrough: standard_fontsOctober 27, 2026
- PDF.js Source Walkthrough: fonts_utilsOctober 27, 2026
- PDF.js Source Walkthrough: default_appearanceOctober 27, 2026
- PDF.js Source Walkthrough: metadata_parserOctober 27, 2026
- PDF.js Source Walkthrough: name_number_treeOctober 27, 2026
- PDF.js Source Walkthrough: struct_treeOctober 27, 2026
- PDF.js Source Walkthrough: catalogOctober 27, 2026
- PDF.js Source Walkthrough: pdf_managerOctober 27, 2026
- PDF.js Source Walkthrough: object_loaderOctober 27, 2026
- PDF.js Source Walkthrough: obj_bin_transform_coreOctober 27, 2026
- PDF.js Source Walkthrough: cryptoOctober 27, 2026
- PDF.js Source Walkthrough: core_utilsOctober 27, 2026
- PDF.js Source Walkthrough: primitivesOctober 27, 2026
- PDF.js Source Walkthrough: worker_streamOctober 27, 2026
- PDF.js Source Walkthrough: ccitt_streamOctober 27, 2026
- PDF.js Source Walkthrough: jpx_streamOctober 27, 2026
- PDF.js Source Walkthrough: jbig2_streamOctober 27, 2026
- PDF.js Source Walkthrough: jpeg_streamOctober 27, 2026
- PDF.js Source Walkthrough: decrypt_streamOctober 27, 2026
- PDF.js Source Walkthrough: brotli_streamOctober 27, 2026
- PDF.js Source Walkthrough: run_length_streamOctober 27, 2026
- PDF.js Source Walkthrough: ascii_hex_streamOctober 27, 2026
- PDF.js Source Walkthrough: ascii_85_streamOctober 27, 2026
- PDF.js Source Walkthrough: lzw_streamOctober 27, 2026
- PDF.js Source Walkthrough: predictor_streamOctober 27, 2026
- PDF.js Source Walkthrough: flate_streamOctober 27, 2026
- PDF.js Source Walkthrough: decode_streamOctober 27, 2026
- PDF.js Source Walkthrough: chunked_streamOctober 27, 2026
- PDF.js Source Walkthrough: streamOctober 27, 2026
- PDF.js Source Walkthrough: base_streamOctober 27, 2026
- PDF.js Deep Dive 11: ParserOctober 26, 2026
- PDF.js Deep Dive 10: XRefOctober 26, 2026
- PDF.js Deep Dive 9: CanvasGraphicsOctober 26, 2026
- PDF.js Deep Dive 8: PDFDocumentOctober 26, 2026
- The AI Era: What Should We Do to Survive? (Reflections on Founding StreamPDF)October 26, 2026
- PDF.js Deep Dive 7: PartialEvaluatorOctober 25, 2026
- PDF.js Deep Dive 6: PDFManager.initDocumentOctober 25, 2026
- PDF.js Deep Dive 5: PDFFetchStream and PDFWorkerStreamOctober 25, 2026
- PDF.js Deep Dive 4: MessageHandlerOctober 22, 2026
- PDF.js Deep Dive 3: getViewport & renderOctober 22, 2026
- PDF.js Deep Dive from Demo 1: Rendering a PDF with a Custom ViewerOctober 19, 2026
- PDF.js Deep Dive 2: getDocument & getPageOctober 19, 2026
- PDF.js Deep Dive 1: The Architecture of PDF.jsOctober 16, 2026