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

Introduction
This is the article in the PDF.js source walkthrough series about default appearance. It covers src/core/default_appearance.js (495 lines).
PDF has two ways to express appearance. One is an appearance stream: a Form XObject containing complete drawing instructions that a viewer executes directly to paint an annotation or form field. The other is the default appearance string (/DA): a reduced content stream, usually only font, font size and fill color, for example /Helvetica 12 Tf 0 g. When a file does not provide a full appearance stream for a field, the viewer has to generate one from /DA.
default_appearance.js handles both ends:
- parsing
/DAinto font size, font name and color for forms and annotations; - evaluating an existing appearance stream into the same triple for editors to backfill;
- reassembling the triple into a
/DAstring; - when no usable font is available, measuring glyph widths on the spot with
OffscreenCanvasand fabricating a font and appearance stream that exist only in memory.
In one sentence: it is PDF.js's appearance parameter layer, handling the round trip from string to parameters to drawing instructions.
Module Overview
| Block | Key members | Responsibility |
|---|---|---|
| Default appearance eval | DefaultAppearanceEvaluator, parseDefaultAppearance | Parse the Tf, g, rg and k operators in a /DA string |
| Appearance stream eval | AppearanceStreamEvaluator, parseAppearanceStream | Read an existing appearance stream and take the font and color of the first text run |
| Color and string output | getPdfColor, createDefaultAppearance | Turn RGB color and parameters back into content stream syntax |
| Fake font and appearance | FakeUnicodeFont | Measure widths, build a Type0 font, generate a Form XObject |
Where it sits in the chain:
Annotation / WidgetAnnotation / FreeTextAnnotation
→ setDefaultAppearance
→ parseDefaultAppearance("/Helvetica 12 Tf 0 g")
→ { fontName, fontSize, fontColor }
→ _getFontData / _computeFontSize
→ createDefaultAppearance(...)
→ a new /DA string
Existing appearance stream
→ parseAppearanceStream(stream, xref, globalColorSpaceCache)
→ { fontName, fontSize, fontColor }
→ editor backfill
No usable font
→ new FakeUnicodeFont(xref, family)
→ createFontResources / createAppearance
→ Type0 font dictionary + Form XObjectVersion note: This article follows exactly the version of
default_appearance.jsin the current repository. This version has noDefaultAppearancedata class and noDefaultAppearanceFactory;parseDefaultAppearancereturns an ordinary object,createDefaultAppearanceis a pure function, andFakeUnicodeFontbuilds the synthetic font and appearance stream. There is also no#toNumberorgetColormethod: font size is filtered withtypeofand a positive-number check, and color is converted through the three device color spaces ofColorSpaceUtils. Standard font retrieval lives inPartialEvaluator.fetchStandardFontData, not in this file. ThesetFontName,setFontSizeandsetColormethods common in older versions have no equivalent here; callers read and write the parameters directly.
What a DA String Is
PDF specification 12.7.3.3 says form fields and annotations can carry a /DA string describing how to paint them when there is no dedicated appearance. Its syntax is a subset of the content stream, and the common operators fall into a few groups:
| Operator | Arguments | Meaning |
|---|---|---|
Tf | Font name, font size | Set the font and font size |
g | One gray value | Set fill color to a gray level |
rg | Red, green, blue | Set fill color to RGB |
k | Cyan, magenta, yellow, black | Set fill color to CMYK |
cs | Color space name | Set the fill color space |
sc | Several components | Set fill color in the current color space |
scn | Components or a pattern name | Same, allowing patterns |
A typical value looks like this:
'/Helvetica 12 Tf 0 g';It means: font Helvetica, size 12, fill color gray 0 (black). Font size 0 has a special meaning: it requests automatic scaling, with the exact size determined by the field rectangle. PDF.js preserves the fact that there is no valid font size during parsing, and _computeFontSize calculates one later.
/DA usually contains no stroke color, and no path, image or text-showing operators. It describes state, not content. Both evaluators in the source are designed on that premise: they care only about font and fill color and skip every other operator.
DefaultAppearanceEvaluator
The class that parses /DA has only a constructor and a parse method. It extends EvaluatorPreprocessor and reuses the content stream's lexer and argument-counting logic.
The Constructor
class DefaultAppearanceEvaluator extends EvaluatorPreprocessor {
constructor(str) {
super(new StringStream(str));
}StringStream wraps the string in a readable stream, and the EvaluatorPreprocessor constructor then creates a Parser, a Lexer and a default StateManager. A /DA string therefore needs no XRef and no page resources; it is a completely self-contained set of instructions.
The parse Method
parse() {
const operation = {
fn: 0,
args: [],
};
const result = {
fontSize: 0,
fontName: "",
fontColor: /* black = */ new Uint8ClampedArray(3),
};
try {
while (true) {
operation.args.length = 0; // Ensure that `args` it's always reset.
if (!this.read(operation)) {
break;
}
if (this.savedStatesDepth !== 0) {
continue; // Don't get info in save/restore sections.
}
const { fn, args } = operation;
switch (fn | 0) {
case OPS.setFont:
const [fontName, fontSize] = args;
if (fontName instanceof Name) {
result.fontName = fontName.name;
}
if (typeof fontSize === "number" && fontSize > 0) {
result.fontSize = fontSize;
}
break;
case OPS.setFillRGBColor:
ColorSpaceUtils.rgb.getRgbItem(args, 0, result.fontColor, 0);
break;
case OPS.setFillGray:
ColorSpaceUtils.gray.getRgbItem(args, 0, result.fontColor, 0);
break;
case OPS.setFillCMYKColor:
ColorSpaceUtils.cmyk.getRgbItem(args, 0, result.fontColor, 0);
break;
}
}
} catch (reason) {
warn(`parseDefaultAppearance - ignoring errors: "${reason}".`);
}
return result;
}This code splits into four parts.
First, reuse a single operation object. Each loop iteration only clears args.length and does not allocate a new array. The comment on read explains that it supports two modes: passing an empty array means write the arguments into this array, and passing null means allocate on demand. This code chooses the former, avoiding one array allocation per operator. /DA is short, so the optimization is not critical, but it keeps consistency with the content stream evaluator.
Second, skip save/restore sections. savedStatesDepth comes from StateManager.stateStack.length. read calls stateManager.save() / restore() when it processes q and Q, so the depth changes in real time. The source's rule is: whenever the depth is not 0, skip the current operator.
This means that if a /DA string contains q ... Q, the Tf and colors inside are not adopted. A specification-compliant /DA should have no q / Q; if a producer writes them, the source chooses to ignore nested state and accept only the outermost level. The cost is that an unclosed q causes every later operator to be skipped, ending with the default of empty font name, size 0 and black.
Third, accept only legal font arguments. fontName must be a Name instance and fontSize must be a number greater than 0. Anything else keeps the previous value:
| Argument situation | Result |
|---|---|
Font name is a Name | Take its name field |
Font name is not a Name | Keep the empty string |
| Font size is a number greater than 0 | Update the font size |
| Font size is 0, negative, a string or missing | Keep 0, meaning automatic or unset |
Font size 0 is not accepted because it is not a size that can be used for drawing. When the caller sees 0, it knows it must compute one from the field rectangle. This convention that 0 means automatic maps exactly to _computeFontSize.
Fourth, colors only handle fill color. g, rg and k map to setFillGray, setFillRGBColor and setFillCMYKColor. The getRgbItem of the three device color spaces writes the arguments into the same Uint8ClampedArray(3), so no matter how many channels the source color has, the result is three RGB bytes. Uint8ClampedArray rounds and clamps to 0 through 255 automatically, so out-of-range color values do not raise.
Stroke colors (G, RG, K), color space switching (cs) and generic color operators (sc, scn) have no branches. That means sc and scn in /DA are ignored by this evaluator. The vast majority of real /DA strings use g, rg or k, so this trade-off covers common cases well enough.
The whole loop is wrapped in try. Lexical errors, wrong argument counts and color space conversion errors do not reach the caller as exceptions; a warn is logged and the partial result parsed so far is returned. For information like default appearance, which can still be painted without, a partial result is more useful than total failure.
parseDefaultAppearance
The public entry point is just a wrapper:
function parseDefaultAppearance(str) {
return new DefaultAppearanceEvaluator(str).parse();
}Callers do not need to know the evaluator exists. The return value is an ordinary object with the fixed fields fontSize, fontName and fontColor.
Design note:
/DAparsing does not build its own lexer; it reusesEvaluatorPreprocessor. Argument counting, unknown operators,q/Qstate and EOF handling all share one implementation with page content streams. A default appearance is just a content stream that cares about three operators, not a separate grammar.
Color Operators and ColorSpaceUtils
The color branches of DefaultAppearanceEvaluator all go through the three device color spaces of ColorSpaceUtils: g corresponds to setFillGray and ColorSpaceUtils.gray, rg to setFillRGBColor and ColorSpaceUtils.rgb, and k to setFillCMYKColor and ColorSpaceUtils.cmyk. All three are lazy singletons cached with shadow:
static get gray() {
return shadow(this, "gray", new DeviceGrayCS());
}
static get rgb() {
return shadow(this, "rgb", new DeviceRgbCS());
}
static get cmyk() {
if (CmykICCBasedCS.isUsable) {
try {
return shadow(this, "cmyk", new CmykICCBasedCS());
} catch {
warn("CMYK fallback: DeviceCMYK");
}
}
return shadow(this, "cmyk", new DeviceCmykCS());
}The cmyk getter first checks whether the current environment supports ICC; if so it tries CmykICCBasedCS, and if construction fails it logs a warning and falls back to DeviceCmykCS. For this file the difference is hidden behind the uniform getRgbItem interface: the caller supplies four components and a target array, and the color space decides how to convert.
The four parameters of getRgbItem(args, 0, result.fontColor, 0) are: source array, source start index, target array, target start index. The target is always position 0 of result.fontColor, and three bytes are written. Grayscale writes the same value three times, RGB copies directly, and CMYK performs one conversion.
The choice of Uint8ClampedArray is practical: color conversion may produce fractions or out-of-range values, and an ordinary array would keep those dirty values for later string building. Uint8ClampedArray rounds and clamps on assignment, so getPdfColor always receives integers from 0 to 255.
AppearanceStreamEvaluator does one more layer here: it first handles cs to switch color space, then sc / g / rg / k. scn still has no branch, because pattern colors need pattern resources and cannot be represented by three bytes alone.
AppearanceStreamEvaluator
When an annotation already has its own appearance stream, PDF.js still needs to know what font, size and color that appearance uses. The editor's text box backfills from it, and FreeText annotations use it to stay visually consistent. AppearanceStreamEvaluator exists for this scenario.
The Constructor
class AppearanceStreamEvaluator extends EvaluatorPreprocessor {
constructor(stream, xref, globalColorSpaceCache) {
super(stream);
this.stream = stream;
this.xref = xref;
this.globalColorSpaceCache = globalColorSpaceCache;
this.resources = stream.dict?.get("Resources");
}Unlike the /DA evaluator, this needs three external dependencies: stream is the appearance stream itself and also supplies Resources, xref is used to fetch indirect objects when resolving color spaces, and globalColorSpaceCache is a color space cache shared across pages.
stream.dict?.get("Resources") uses optional chaining: an appearance stream's dictionary may have no Resources, in which case resources is undefined. If a color space contains a name that needs resources, such as DeviceN, resolution fails and enters the outer catch.
The parse Method
The evaluation result, besides fontSize, fontName and fontColor, temporarily has two more fields: scaleFactor starts at 1 and fillColorSpace starts at ColorSpaceUtils.gray. The former accumulates text matrix scaling; the latter stores the current fill color space. Below are the state and font related branches:
switch (fn | 0) {
case OPS.save:
stack.push({
scaleFactor: result.scaleFactor,
fontSize: result.fontSize,
fontName: result.fontName,
fontColor: result.fontColor.slice(),
fillColorSpace: result.fillColorSpace,
});
break;
case OPS.restore:
result = stack.pop() || result;
break;
case OPS.setTextMatrix:
const tm = Util.transform(this.stateManager.state.ctm, args);
result.scaleFactor *= Math.hypot(tm[0], tm[1]);
break;
case OPS.setFont:
const [fontName, fontSize] = args;
if (fontName instanceof Name) {
result.fontName = fontName.name;
}
if (typeof fontSize === "number" && fontSize > 0) {
result.fontSize = fontSize;
}
break;Beyond the three fill colors of the /DA evaluator, the color branches add setFillColorSpace and setFillColor: the former resolves the cs argument with ColorSpaceUtils.parse, and the latter converts the sc components using the resolved color space. A few text-showing operators at the end of the switch finish the job:
case OPS.showText:
case OPS.showSpacedText:
case OPS.nextLineShowText:
case OPS.nextLineSetSpacingShowText:
// The font (Tf) and the text matrix (Tm) can be set in any order,
// so the scale factor is applied here, when text is actually shown
// and both are known to be in effect.
result.fontSize *= result.scaleFactor;
breakLoop = true;
break;
}
}
} catch (reason) {
warn(`parseAppearanceStream - ignoring errors: "${reason}".`);
}
this.stream.reset();
delete result.scaleFactor;
delete result.fillColorSpace;
return result;
}Compared with the /DA evaluator, this one adds four things: a graphics state stack, text matrix scaling, color space resolution, and stopping at the first text.
State snapshots for q and Q
OPS.save copies five current fields into stack. Of these, scaleFactor, fontSize and fontName are numbers or strings, so copying is itself a snapshot; fontColor must be slice()d, otherwise the stack and the current result would share the same array; fillColorSpace reuses the reference directly because color space objects are immutable.
OPS.restore uses stack.pop() || result. If an appearance stream has an extra Q, pop returns undefined when the stack is empty, and || keeps the current result unchanged instead of clearing the state. This matches the overall fault-tolerance style: malformed structure is not escalated into an exception.
Tm and the scale factor
OPS.setTextMatrix takes a six-element text matrix. The source first applies the current CTM:
const tm = Util.transform(this.stateManager.state.ctm, args);
result.scaleFactor *= Math.hypot(tm[0], tm[1]);Util.transform multiplies the two matrices to get the combined matrix from text space to device space. Math.hypot(tm[0], tm[1]) takes the length of the first column, which is the horizontal scale. If the matrix only translates, the first column is [1, 0] and the length is 1, so the factor is unchanged; if the matrix scales by two, the factor is multiplied by 2.
Why multiply into scaleFactor instead of fontSize directly? Because Tf and Tm can appear in either order. An appearance stream may write Tm before Tf or the other way around. The source accumulates matrix scaling into a separate factor and multiplies it into the font size only when a text-showing operator actually appears. That way, regardless of order, the final font size is declared font size times text matrix scale times current CTM scale.
Color space and color
OPS.setFillColorSpace is a branch the /DA evaluator does not have. It calls ColorSpaceUtils.parse with the operator's first argument, the xref stored by the constructor, the appearance stream dictionary's Resources, a lazily created PDFFunctionFactory, and both the global and local color space caches.
The result is stored in result.fillColorSpace. The following OPS.setFillColor (that is, sc) converts its arguments with that color space directly:
const cs = result.fillColorSpace;
cs.getRgbItem(args, 0, result.fontColor, 0);The cs here can be any implementation, such as DeviceGray, DeviceRGB, DeviceCMYK, ICCBased, Indexed or Separation, but it is called the same way through getRgbItem. scn among sc and scn still has no branch; if an appearance stream fills text with a pattern, this keeps the previous color until the next recognizable operator.
Why stop at text
The four text-showing operators Tj, TJ, ' and " are merged into one case. Each first multiplies fontSize by scaleFactor and then sets breakLoop = true. The next loop iteration sees breakLoop and exits.
This matches the comment in the file: parseAppearanceStream returns only the font properties used by the first text object. An appearance stream may contain several runs of text and several fonts, but the editor only needs one initial value to backfill the text box; reading further is pointless and may pick up state unrelated to the first run.
Cleanup: reset and delete temporary fields
Cleanup has three steps: this.stream.reset() rewinds the stream to the beginning, because the caller still needs the stream for later rendering and the evaluator must not drain it; delete result.scaleFactor and delete result.fillColorSpace remove implementation details so the returned shape matches parseDefaultAppearance and has only fontSize, fontName and fontColor.
Lazy dependencies
The _localColorSpaceCache and _pdfFunctionFactory getters both use shadow for one-time initialization. They are created only when an appearance stream actually contains cs; an appearance stream with only Tf and g pays nothing for color spaces.
parseAppearanceStream
parseAppearanceStream(stream, xref, globalColorSpaceCache) is a one-line wrapper: create an evaluator and call parse. The entry point mirrors parseDefaultAppearance; callers only see stream in, parameters out.
Design note: The
/DAevaluator assumes no nested state, while the appearance stream evaluator fully handlesq/QandTm. The difference is not duplicated implementation but an accurate match to input complexity: the default appearance string is a simplified grammar, and an appearance stream is a full content stream. Sharing the base class keeps operator recognition consistent, and each set of branches handles only the state it needs.
getPdfColor and createDefaultAppearance
Parsing goes from string to parameters; generation goes from parameters to string. Two pure functions handle the reverse direction.
function getPdfColor(color, isFill) {
if (color[0] === color[1] && color[1] === color[2]) {
const gray = color[0] / 255;
return `${numberToString(gray)} ${isFill ? 'g' : 'G'}`;
}
return Array.from(color, c => numberToString(c / 255)).join(' ') + ` ${isFill ? 'rg' : 'RG'}`;
}It first checks whether the three channels are equal: when equal it outputs one gray value plus g or G, and otherwise it outputs three components plus rg or RG. isFill true selects the fill operator and false selects the stroke operator. In this file both createDefaultAppearance and FakeUnicodeFont.createAppearance pass true, so everything generated is actually fill color; the stroke branch is kept for possible reuse.
Color components are divided by 255 to get a float from 0 to 1, then passed to numberToString. That function:
- converts integers directly to strings;
- otherwise rounds to two decimal places;
- drops trailing zeros when possible.
So 128 becomes 0.5, 51 becomes 0.2 and 0 stays 0. The generated content stream is therefore compact and stable.
function createDefaultAppearance({ fontSize, fontName, fontColor }) {
return `/${escapePDFName(fontName)} ${fontSize} Tf ${getPdfColor(fontColor, /* isFill */ true)}`;
}The concatenation order is font name, font size, Tf, color, matching the specification example. The font name is prefixed with / and escaped through escapePDFName. The name comes from PDF name syntax and may contain spaces, slashes, hash marks or non-ASCII characters; escapePDFName writes every byte that needs escaping as # plus two hexadecimal digits.
For example the font name Helvetica needs no escaping, the size is 12 and the color is full black, so the output is "/Helvetica 12 Tf 0 g". If the font name is A B, the output becomes /A#20B ..., so the space is not taken as the end of the name.
fontSize is inserted directly into the template string. It comes from numeric computation in the caller and is usually an integer or two decimals; the source does not call numberToString again because _computeFontSize has already rounded it.
Design note: Color generation outputs only RGB or grayscale, never CMYK. The parameter layer's
fontColoris already three-byte RGB, so there is no need to convert back to four channels when returning to PDF syntax; collapsing to grayscale only makes common black-and-white text shorter. Escaping is delegated toescapePDFName, so the concatenation logic no longer cares what characters the name contains.
FakeUnicodeFont
Some form field fonts cannot encode the text the user enters. For example a field declares Helvetica but the user types Chinese or an emoji. When saving, PDF.js can choose to set NeedAppearances and let other software deal with it; when rendering it cannot simply draw nothing. The idea behind FakeUnicodeFont is: embed no real font file, measure glyph widths directly with the browser, build a Type0 font that exists only in memory, and write the text as a UTF-16 hexadecimal string.
What it generates is not correct typesetting but a fallback appearance that is visible, selectable and roughly aligned.
The Constructor
class FakeUnicodeFont {
static #fontNameId = 1;
constructor(xref, fontFamily) {
this.xref = xref;
this.widths = null;
this.firstChar = Infinity;
this.lastChar = -Infinity;
this.fontFamily = fontFamily;
const canvas = new OffscreenCanvas(1, 1);
this.ctxMeasure = canvas.getContext("2d", { willReadFrequently: true });
this.fontName = Name.get(
`InvalidPDFjsFont_${fontFamily}_${FakeUnicodeFont.#fontNameId++}`
);
}widths starts as null and later becomes a Map from character code to width in 1000-unit space; firstChar and lastChar start at Infinity and -Infinity and are updated to the smallest and largest character code during measurement; fontFamily is the sans-serif or monospace supplied by the caller; ctxMeasure is a 2D context; fontName is a newly created Name.
The font name carries an incrementing number so that several fake fonts on the same page do not collide. Name.get interns objects with the same name, and the number guarantees a new one each time. The InvalidPDFjsFont_ prefix also reminds debuggers that this font does not really exist in the file.
OffscreenCanvas is 1 by 1; it is used only to obtain a 2D context and never participates in the final drawing. willReadFrequently: true is a hint to the browser: this canvas will frequently read pixels or measure text and may be better served by a software implementation. Construction measures no characters, so widths stays null.
fontDescriptorRef
get fontDescriptorRef() {
if (!FakeUnicodeFont._fontDescriptorRef) {
const fontDescriptor = new Dict(this.xref);
fontDescriptor.setIfName("Type", "FontDescriptor");
fontDescriptor.set("FontName", this.fontName);
fontDescriptor.set("FontFamily", "MyriadPro Regular");
fontDescriptor.set("FontBBox", [0, 0, 0, 0]);
fontDescriptor.setIfName("FontStretch", "Normal");
fontDescriptor.set("FontWeight", 400);
fontDescriptor.set("ItalicAngle", 0);
FakeUnicodeFont._fontDescriptorRef =
this.xref.getNewPersistentRef(fontDescriptor);
}
return FakeUnicodeFont._fontDescriptorRef;
}Almost every field in the descriptor is a fixed value: type, family name, zero bounding box, normal stretch, weight 400, zero italic angle. The only value that comes from the instance is FontName, and it is written only when the descriptor is first created.
FakeUnicodeFont._fontDescriptorRef hangs off the class, not an instance, and is not declared static. The first call creates the dictionary and allocates a persistent reference with xref.getNewPersistentRef; later instances reuse it. That way a batch of fields does not build an identical descriptor for every fake font.
getNewPersistentRef puts the dictionary into the XRef cache and returns a Ref. The later descendantFont points at the descriptor through this reference rather than inlining the dictionary. This makes the generated font structure match a real PDF font, so the renderer can process it along a uniform path.
descendantFontRef
get descendantFontRef() {
const descendantFont = new Dict(this.xref);
descendantFont.set("BaseFont", this.fontName);
descendantFont.setIfName("Type", "Font");
descendantFont.setIfName("Subtype", "CIDFontType0");
descendantFont.setIfName("CIDToGIDMap", "Identity");
descendantFont.set("FirstChar", this.firstChar);
descendantFont.set("LastChar", this.lastChar);
descendantFont.set("FontDescriptor", this.fontDescriptorRef);
descendantFont.set("DW", 1000);
const widths = [];
const chars = [...this.widths].sort();
let currentChar = null;
let currentWidths = null;
for (const [char, width] of chars) {
if (!currentChar) {
currentChar = char;
currentWidths = [width];
continue;
}
if (char === currentChar + currentWidths.length) {
currentWidths.push(width);
} else {
widths.push(currentChar, currentWidths);
currentChar = char;
currentWidths = [width];
}
}
if (currentChar) {
widths.push(currentChar, currentWidths);
}
descendantFont.set("W", widths);The second half builds CIDSystemInfo and returns the reference, omitted here. The core is the compaction of the W array.
this.widths is a Map whose keys are character codes and whose values are widths in 1000-unit space. [...this.widths] expands it into an array of key-value pairs and sorts it. After sorting, consecutive character codes are merged into one run: when the current code equals the previous run's start plus the run length, the width is appended to the current run; when it is not consecutive, the current run ends and start plus width array is written; after the loop, any remaining run is written once more.
The W format alternates start, width array, start, width array. One run can cover only consecutive character codes, so the compression depends on how dense the codes are. CJK code points are usually scattered, giving modest compression; consecutive ASCII input can form a single run.
One implementation detail: Array.prototype.sort() without a comparator converts every key-value pair array to a string before comparing. The sort order is therefore lexicographic, not numeric. For example 65 sorts before 9. For the W array, each run's start and widths still correspond one to one, so widths are not paired with the wrong characters; the only effect is that runs may be split more finely and compress less.
DW is 1000, meaning characters not listed in W default to width 1000. CIDToGIDMap is Identity and Subtype is CIDFontType0; those two make the renderer map CIDs directly without an extra glyph lookup.
baseFontRef and resources
get baseFontRef() {
const baseFont = new Dict(this.xref);
baseFont.set("BaseFont", this.fontName);
baseFont.setIfName("Type", "Font");
baseFont.setIfName("Subtype", "Type0");
baseFont.setIfName("Encoding", "Identity-H");
baseFont.set("DescendantFonts", [this.descendantFontRef]);
baseFont.setIfName("ToUnicode", "Identity-H");
return this.xref.getNewPersistentRef(baseFont);
}
get resources() {
const resources = new Dict(this.xref);
const font = new Dict(this.xref);
font.set(this.fontName.name, this.baseFontRef);
resources.set("Font", font);
return resources;
}The outer font is Type0 with Identity-H encoding. That means each pair of bytes in a string is treated directly as a CID with no character set mapping. Together with the Identity CIDToGIDMap, the CID is also treated as a glyph index. The path from character code to glyph collapses into a straight line.
ToUnicode is set to the name Identity-H rather than a stream. That is part of the fake font: the text is already written into the content stream as UTF-16 hexadecimal, so the character codes are Unicode code points and no extra mapping table is needed. The source uses this marker to express that the relation should be understood as identity.
resources returns a dictionary containing only Font, with the font name pointing at baseFontRef. Hang it on the appearance stream dictionary's Resources and the renderer can find the font through the name in Tf.
createFontResources
createFontResources(text) first calls _createContext: reset widths to an empty Map, and set the context size to 1000px plus the font family. The 1000-pixel basis is used because PDF font widths are in thousandths of an em: measuring pixel widths at 1000 pixels gives values that can be used directly in the W array.
Then it traverses the text by line and then by UTF-16 code unit. Each character code is measured only once; the width returned by measureText is rounded up, written into the Map, and the smallest and largest character codes are updated.
firstChar and lastChar become the range of the font dictionary. Characters outside the range are never queried by the renderer, so measurement must cover every character that will eventually be written. The caller invokes this method with the complete text before generating a field appearance.
getFirstPositionInfo
static getFirstPositionInfo(rect, rotation, fontSize) {
// Get the position of the first char in the rect.
const [x1, y1, x2, y2] = rect;
let w = x2 - x1;
let h = y2 - y1;
if (rotation % 180 !== 0) {
[w, h] = [h, w];
}
const lineHeight = LINE_FACTOR * fontSize;
const lineDescent = LINE_DESCENT_FACTOR * fontSize;
return {
coords: [0, h + lineDescent - lineHeight],
bbox: [0, 0, w, h],
matrix:
rotation !== 0 ? getRotationMatrix(rotation, h, lineHeight) : undefined,
};
}rect is the annotation's rectangle in page coordinates. For 90 and 270 degree rotations the width and height swap, so later layout happens in rotated local coordinates.
The two constants come from shared/util.js: LINE_FACTOR is 1.35, meaning line height is 1.35 times the font size, and LINE_DESCENT_FACTOR is 0.35, the descent from baseline to line bottom.
The first line's baseline is h + descent - line height. h is the box height; adding the descent lifts the baseline off the bottom, and subtracting the line height puts the first line at the top. The returned bbox is the box in local coordinates, and matrix rotates local coordinates back into page coordinates.
If rotation is 0, matrix is undefined and the caller knows no extra transform is needed; otherwise getRotationMatrix produces the matrix for 90, 180 or 270 degrees.
createAppearance
createAppearance actually paints the text into a Form XObject. The first half measures and scales, and the second half assembles the content stream.
const hscale = maxWidth > w ? w / maxWidth : 1;
let vscale = 1;
const lineHeight = LINE_FACTOR * fontSize;
const lineDescent = LINE_DESCENT_FACTOR * fontSize;
const maxHeight = lineHeight * lines.length;
if (maxHeight > h) {
vscale = h / maxHeight;
}
const fscale = Math.min(hscale, vscale);
const newFontSize = fontSize * fscale;
const buffer = [
'q',
`0 0 ${numberToString(w)} ${numberToString(h)} re W n`,
`BT`,
`1 0 0 1 0 ${numberToString(h + lineDescent)} Tm 0 Tc ${getPdfColor(bgColor, /* isFill */ true)}`,
`/${this.fontName.name} ${numberToString(newFontSize)} Tf`,
];The measurement part first computes maxWidth. It measures the whole line with ctx.measureText(line).width rather than summing per-character widths; the source comment explains that languages such as Arabic have ligatures, so summing character widths would overestimate. Then maxWidth is multiplied by fontSize / 1000 to convert to the target size.
Scaling happens in two directions: horizontally it uses w / maxWidth when the text is wider than the box, otherwise 1; vertically it uses h / maxHeight when the total line height exceeds the box height, otherwise 1. The smaller of the two, fscale, is multiplied into the original font size. The text therefore neither overflows horizontally nor vertically; when both directions fit, the size is unchanged.
The first instructions of the content stream are, in order: q saves the graphics state, a rectangle re W n clips the text to the box, BT starts a text object, Tm sets the first baseline and Tc resets character spacing to zero, the color operator is written, and finally Tf sets the font.
Transparency is handled separately: strokeAlpha goes through a range check, and a value that is not a number, below 0 or above 1 is treated as 1. Only when translucency is really needed is an ExtGState generated, with both ca (fill alpha) and CA (stroke alpha) set, and then /R0 gs attaches that state to the current graphics state.
When outputting line by line, each line first moves down one line height with Td and then shows the UTF-16BE hexadecimal string with Tj. The first line also moves once, so its baseline lands exactly at box height plus descent minus line height, matching the calculation in getFirstPositionInfo. stringToUTF16HexString writes every JavaScript character as four hexadecimal digits, wrapped in angle brackets to form a hexadecimal string.
The appearance stream dictionary is built last:
const appearanceStreamDict = new Dict(this.xref);
appearanceStreamDict.setIfName('Subtype', 'Form');
appearanceStreamDict.setIfName('Type', 'XObject');
appearanceStreamDict.set('BBox', [0, 0, w, h]);
appearanceStreamDict.set('Length', appearance.length);
appearanceStreamDict.set('Resources', resources);
if (rotation) {
const matrix = getRotationMatrix(rotation, w, h);
appearanceStreamDict.set('Matrix', matrix);
}
return new StringStream(appearance, appearanceStreamDict);The dictionary's BBox is the box in local coordinates; when there is rotation a Matrix is added to map local coordinates back to the page. Length uses the string length rather than the byte length, which works because the generated content stream is entirely ASCII: operators, numbers, font names (possibly escaped to #xx) and hexadecimal strings contain no multi-byte characters.
StringStream wraps the string in a stream object together with the dictionary. Upper layers can attach it to an annotation's /AP like any ordinary appearance stream, and the renderer does not need to know it was generated on the fly.
Design note: The fake font downloads nothing, embeds nothing and parses no real font file. It measures widths with
OffscreenCanvason a 1000-pixel basis, builds the smallest possible Type0 structure, and writes text as an Identity-H encoded hexadecimal string. The cost is that layout quality depends on system fonts; the benefit is that when there is no font data and user input cannot be encoded, it still produces readable, selectable, printable content.
How real fonts and the fake font divide the work
This file does not fetch standard font data. PartialEvaluator.fetchStandardFontData(name) first checks standardFontDataCache and returns a wrapped Stream on a hit; when useSystemFonts is enabled it returns null directly for everything except Symbol and ZapfDingbats, leaving it to system fonts. When data is needed, the file name is built from standardFontDataUrl: if useWorkerFetch is true it is fetched inside the worker, otherwise a FetchBinaryData message asks the main thread, and the result is written back to the cache.
FakeUnicodeFont takes another path: it requests no font file and only measures with a system font. The boundary is clear: if real font data is usable, PartialEvaluator handles it; only when data is unavailable, or the field itself declares a font that cannot encode the input, does the fake font step in.
Comparison with the Old DefaultAppearanceFactory
Older versions of PDF.js commonly exposed an object-oriented set of interfaces: DefaultAppearance stored the font name, size and color, and DefaultAppearanceFactory provided mutators such as setFontName, setFontSize and setColor. The current version removes that state management in favor of an ordinary object, pure functions and one fake font class.
| Old concept | Current implementation |
|---|---|
DefaultAppearance data class | The ordinary object returned by parseDefaultAppearance |
DefaultAppearanceFactory | createDefaultAppearance plus FakeUnicodeFont |
setFontName / setFontSize | Callers read and write the object's fontName and fontSize directly |
setColor | getPdfColor and ColorSpaceUtils |
| Color conversion in the factory | getRgbItem inside parseDefaultAppearance |
| Standard font data retrieval | Not in this file; in PartialEvaluator.fetchStandardFontData |
The benefit of this change is less state: the parse result is an ordinary object that can be structured-cloned and passed between the worker and the main thread without carrying methods. The generation direction also depends only on the three fields passed in and keeps no internal mutable state. The cost is that callers must ensure the fields are complete; the source falls back with default values and ||=.
Call Chain: Annotations and Form Fields
The exports of default_appearance.js are used heavily by annotation.js. Understanding the call chain is the way to know when each function fires.
The setDefaultAppearance Method
The annotation base class reads /DA during initialization:
setDefaultAppearance(params) {
const { dict, annotationGlobals } = params;
const defaultAppearance =
getInheritableProperty({ dict, key: "DA" }) ||
annotationGlobals.acroForm.get("DA");
this._defaultAppearance =
typeof defaultAppearance === "string" ? defaultAppearance : "";
this.data.defaultAppearanceData = parseDefaultAppearance(
this._defaultAppearance
);
}The lookup order is: first walk the field's parent chain for an inheritable /DA, then fall back to the AcroForm /DA. If neither exists the result is an empty string. parseDefaultAppearance("") returns the default object: empty font name, size 0, black.
The typeof check ensures a non-string is never passed to the evaluator. /DA must be a string in PDF, but a damaged file may write something else; this chooses to silently degrade to an empty string.
Form fields: font size 0 and auto-scaling
When a Widget annotation generates an appearance and has no /DA, it explicitly supplies a default:
this.data.defaultAppearanceData = parseDefaultAppearance(
(this._defaultAppearance = '/Helvetica 0 Tf 0 g'),
);Font size 0 means automatic. _computeFontSize then computes one from the field rectangle:
| Condition | Calculation |
|---|---|
| Single-line text | Constrained by both width and height; take the largest size that fits |
| Multi-line text | Derive the size from the line count and line height, then check for overflow in width |
| Comb field | One cell per character; compute from the cell width |
After computing the size it calls createDefaultAppearance({ fontSize, fontName, fontColor }) to rebuild the /DA and writes it back to this._defaultAppearance. That is the full round trip of parse, compute and regenerate.
FreeText annotations: existing appearance and fallback
FreeTextAnnotation's construction shows how the two evaluators split:
this.setDefaultAppearance(params);
this._hasAppearance = !!this.appearance;
if (this._hasAppearance) {
const { fontColor, fontSize } = parseAppearanceStream(
this.appearance,
xref,
annotationGlobals.globalColorSpaceCache
);
this.data.defaultAppearanceData.fontColor = fontColor;
this.data.defaultAppearanceData.fontSize = fontSize || 10;
} else {
this.data.defaultAppearanceData.fontSize ||= 10;| Situation | Method used | Result |
|---|---|---|
| Has its own appearance stream | parseAppearanceStream | Take color and size from the first text run; use 10 when the size is 0 |
| No appearance stream | parseDefaultAppearance | Use the /DA parameters; use 10 when the size is 0 |
| Cannot encode input text | FakeUnicodeFont | Build a font and an appearance stream |
When there is no appearance stream and there is content, FakeUnicodeFont.getFirstPositionInfo computes the first line position and createAppearance generates the Form XObject into this._streams. When there is an appearance stream, parseAppearanceStream reads only the first run of text and does not rebuild the appearance; the original stream keeps rendering, and the parse result is only used for editor backfill.
Boundaries of the three
| Module | Input | Output | Reads resources |
|---|---|---|---|
DefaultAppearanceEvaluator | A /DA string | Font, size and color | No |
AppearanceStreamEvaluator | An appearance stream, XRef, caches | Font, size and color | Yes, color spaces need them |
FakeUnicodeFont | Text, rectangle, rotation, size | Font dictionary, appearance stream | No, measures with system fonts |
The input and output shapes of the three are deliberately aligned: both evaluators return fontName, fontSize and fontColor, and the fake font consumes the same parameters. Annotation code can therefore switch between parsing an existing appearance, parsing the default and generating on the fly without changing the data flow.
Design note: Both parsing and generation of the default appearance depend only on three fields, not on the annotation object. The annotation decides which source to use: an appearance stream if present,
/DAif present, otherwise a fixed default. The module boundary is therefore narrow, and tests can target strings or streams without constructing a full annotation.
Conclusion
default_appearance.js is PDF.js's appearance parameter layer: it restores /DA and appearance streams into font, size and color, turns those three back into a content stream, and when font data is missing, uses canvas measurement to build a usable substitute.
Where It Sits in the Overall Chain
| Stage | Module | Input | Output |
|---|---|---|---|
| Field initialization | Annotation.setDefaultAppearance | The field or AcroForm /DA | A /DA string |
| Default parse | parseDefaultAppearance | A /DA string | fontName, fontSize, fontColor |
| Appearance parse | parseAppearanceStream | An appearance stream, XRef, caches | Same as above |
| Font size compute | WidgetAnnotation._computeFontSize | Field rectangle, text, font | The actual font size |
| String generation | createDefaultAppearance | The three fields | A new /DA string |
| Fallback generation | FakeUnicodeFont | Text, rectangle, rotation, size | Type0 font and Form XObject |
| Rendering | PartialEvaluator | Appearance stream | operatorList |
In one sentence: parseDefaultAppearance splits a string into fields, _computeFontSize decides the final size, and createDefaultAppearance joins the fields back into a string; appearance streams go through parseAppearanceStream, and missing fonts go through FakeUnicodeFont.
Five Design Principles
- Reuse the content stream evaluator instead of writing a new grammar. Both evaluators extend
EvaluatorPreprocessor; argument counting, unknown operators,q/Qstate and EOF handling are all shared. A default appearance is just a content stream that cares about fewer operators. - Keep the parse result as plain data. The return value has only
fontName,fontSizeandfontColor, with no methods and no state on the prototype chain. It can be structured-cloned and passed directly between the worker and the main thread. - Font size 0 means automatic, not invalid. Parsing rejects 0, so the caller knows it must compute from the rectangle, and then generates a new
/DA. This convention keeps the default value and the real size from being confused. - Colors converge on three RGB bytes. Grayscale, RGB and CMYK all write into the same
Uint8ClampedArraythroughgetRgbItem, and generation collapses back to grayscale or RGB. Upper layers therefore only handle one color representation. - The fallback path is its own class.
FakeUnicodeFontmodifies no parser and depends on no real font file; it measures withOffscreenCanvas, carries the result in a minimal Type0 structure, and writes text in UTF-16 hexadecimal. Without it, fields that cannot encode input would render blank.
At this point the loop of what annotations and form fields look like by default is closed. It looks like just a string, but behind it are color spaces, font dictionaries, text matrices and appearance streams. The next article returns to lower-level object organization to see how PDF.js turns a mass of dictionaries and references into a page model.
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: metricsOctober 27, 2026
- PDF.js Source Walkthrough: standard_fontsOctober 27, 2026
- PDF.js Source Walkthrough: fonts_utilsOctober 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