StreamPDFStreamPDF
返回博客

PDF.js 深入浅出 8: Document 与 Page

2026年10月26日 · StreamPDF Team

PDF.js 深入浅出 8: Document 与 Page

前言

这是 PDF.js 系列文章的第 8 篇,介绍 src/core/document.js 中的 Page 与 PDFDocument 两个类。

上衔接第六篇文章的 PDFManager.initDocument —— xref(交叉引用表)已经解析完毕,本篇看 PDFDocument 如何基于 xref 构建整个文档模型(页数、表单、签名、指纹等),以及 Page 类如何封装单页数据、为第七篇的 PartialEvaluator 提供内容流与 Resources 输入。

document.js 全文约 2400 行,是 Worker 端的「文档数据中枢」:主线程的 PDFDocumentProxy.getPage()、getMetadata()、getAnnotations() 等 API,最终都路由到这里。

LETTER_SIZE_MEDIABOX 常量

const LETTER_SIZE_MEDIABOX = [0, 0, 612, 792];

美制 Letter 纸的默认 MediaBox(单位是 1/72 英寸,即 8.5×11 英寸)。PDF 规范要求每页必须有 /MediaBox(可继承),但坏文件可能缺失或给出空矩形——此时 Page.mediaBox 用它兜底,保证页面始终有一个合法的显示尺寸。

STARTXREF_SIGNATURE 常量

const STARTXREF_SIGNATURE = new Uint8Array([
  0x73, 0x74, 0x61, 0x72, 0x74, 0x78, 0x72, 0x65, 0x66,
]); // "startxref"

startxref 关键字的字节序列。PDF 文件尾部有一个 startxref 区块,记录交叉引用表的字节偏移。PDFDocument.startXRef 从文件末尾反向扫描这个签名来定位 xref 起点。

ENDOBJ_SIGNATURE 常量

const ENDOBJ_SIGNATURE = new Uint8Array([0x65, 0x6e, 0x64, 0x6f, 0x62, 0x6a]); // "endobj"

endobj 关键字的字节序列。对于线性化 PDF(Fast Web View),第一个对象就是线性化字典,其参数区结束位置即是第一页内容之后的 xref 偏移——startXRef getter 用它找「第一个对象的结尾」。

find 方法

function find(stream, signature, limit = 1024, backwards = false) {
  const signatureLength = signature.length;
  const scanBytes = stream.peekBytes(limit); // 偷看最多 limit 字节, 不移动 pos
  const scanLength = scanBytes.length - signatureLength;
  if (scanLength <= 0) {
    return false;
  }
  if (backwards) {
    const signatureEnd = signatureLength - 1;
    let pos = scanBytes.length - 1;
    while (pos >= signatureEnd) {
      let j = 0;
      while (
        j < signatureLength &&
        scanBytes[pos - j] === signature[signatureEnd - j]
      ) {
        j++;
      }
      if (j >= signatureLength) {
        stream.pos += pos - signatureEnd; // 命中: 把 stream.pos 移到签名起点
        return true;
      }
      pos--;
    }
  } else {
    let pos = 0;
    while (pos <= scanLength) {
      let j = 0;
      while (j < signatureLength && scanBytes[pos + j] === signature[j]) {
        j++;
      }
      if (j >= signatureLength) {
        stream.pos += pos;
        return true;
      }
      pos++;
    }
  }
  return false;
}

一个字节级签名扫描器,在流的前 limit 字节内查找某个字节签名,支持正向(backwards=false)与反向两个方向。命中时把 stream.pos 推进到签名起始处,未命中返回 false(pos 不变——因为用的是 peekBytes 偷看而非读取)。

两个典型用途:

  • 正向:checkHeader 在文件开头 1024 字节内找 %PDF-(PDF_HEADER_SIGNATURE),容忍文件头前有垃圾字节。
  • 反向:startXRef 从文件末尾以 1024 字节为步长反向找 startxref,因为规范允许签名出现在文件尾最后 1024 字节中的任意位置。

设计要点:find 只负责「定位并推进流指针」,把签名匹配与解析逻辑解耦——解析(读数字、读版本号)由调用方完成。这是典型的「扫描器 + 解析器」分层。

Page 类

Page 是单页数据的封装——持有页字典(pageDict)、页引用(ref)以及各类文档级缓存,对外提供内容流、Resources、页面框(Box)、旋转角度、注释等一切「页级」信息。它是 PDFDocument.getPage() 的产物,也是第七篇 PartialEvaluator 的直接调用方。

constructor 方法

constructor({
  pdfManager, xref, pageIndex, pageDict, ref,
  globalIdFactory, fontCache, builtInCMapCache, standardFontDataCache,
  globalColorSpaceCache, globalImageCache, systemFontCache,
  nonBlendModesSet, xfaFactory,
}) {
  this.pdfManager = pdfManager;
  this.pageIndex = pageIndex;
  this.pageDict = pageDict;
  this.xref = xref;
  this.ref = ref;
  this.fontCache = fontCache;
  /* ... 其余缓存原样保存 ... */
  this.evaluatorOptions = pdfManager.evaluatorOptions;
  this.xfaFactory = xfaFactory;
 
  const idCounters = { obj: 0 };
  this._localIdFactory = class extends globalIdFactory {
    static createObjId() {
      return `p${pageIndex}_${++idCounters.obj}`;
    }
    static getPageObjId() {
      return `p${ref.toString()}`;
    }
  };
}

与第七篇 PartialEvaluator 的 constructor 一样采用依赖注入:所有文档级缓存(fontCache、globalImageCache 等)都由外部(PDFDocument.getPage,取自 Catalog)传入,Page 只持有页级状态。

亮点是 _localIdFactory——通过匿名类继承 globalIdFactory,覆盖两个静态方法:

方法生成格式用途
createObjId()p${pageIndex}_${n}页内对象(图片等)的唯一 ID,带页号前缀实现按页隔离
getPageObjId()p${ref}页面对象本身的 ID,用于主线程按页管理/销毁对象

继承关系保证了 createFontId()、getDocId() 等文档级方法行为不变,只定制页级的两个。

_createPartialEvaluator 方法

_createPartialEvaluator(handler, pageIndex = this.pageIndex) {
  return new PartialEvaluator({
    xref: this.xref,
    handler,
    pageIndex,
    idFactory: this._localIdFactory,
    fontCache: this.fontCache,
    builtInCMapCache: this.builtInCMapCache,
    /* ... 其余缓存 ... */
    options: this.evaluatorOptions,
  });
}

Page 与 PartialEvaluator 之间的工厂方法——把 Page 持有的 xref、缓存、选项原样透传,唯一变化是 idFactory 换成页级的 _localIdFactory。第七篇 constructor 章节的「页面级」分支就是这里。

getOperatorList、extractTextContent、save、getAnnotationsData 等所有需要解析内容流的地方都通过它创建 evaluator,保证「每页一个独立求值实例、缓存全局共享」。

#getInheritableProperty 方法

#getInheritableProperty(key, getArray = false) {
  const value = getInheritableProperty({
    dict: this.pageDict,
    key,
    getArray,
    stopWhenFound: false, // 收集整条继承链上的所有值
  });
  if (!Array.isArray(value)) {
    return value;
  }
  if (value.length === 1 || !(value[0] instanceof Dict)) {
    return value[0];
  }
  return Dict.merge({ xref: this.xref, dictArray: value });
}

PDF 页面树的属性继承机制。/Resources、/MediaBox、/CropBox、/Rotate 等键可以定义在任意一层父节点(/Pages)上,子页缺省时沿树向上继承。

这里用 stopWhenFound: false 收集继承链上所有层级的值(而不只是最近一层),得到一个数组后分三种情况:

  1. 非数组 → 直接返回(继承链上只有一处定义)。
  2. 数组长度为 1,或元素不是 Dict → 取第一个(最近的一层胜出)。
  3. 多个 Dict → Dict.merge 合并,下层键覆盖上层键。

第 3 种情况针对的是多层都定义了 /Resources 的场景——例如父节点定义了公共字体,子页又补充了自己的图片资源,合并后两者都可用。

get content 方法

get content() {
  return this.pageDict.getArray("Contents");
}

读取页字典的 /Contents 项。返回值可能是:

  • Stream:单个内容流(最常见);
  • Array:多个内容流/引用(规范允许把一页内容拆成多段流);
  • undefined:空页面。

注意这是「原始」读法,真正的流组装在 getContentStream 里完成。

get resources 方法

get resources() {
  // 规范要求 \Resources 必须存在(可为空), 但有些文档干脆省略;
  // 此时返回空字典兜底.
  const resources = this.#getInheritableProperty("Resources");
  return shadow(
    this,
    "resources",
    resources instanceof Dict ? resources : Dict.empty
  );
}

获取页面的 /Resources 字典(字体、XObject、颜色空间、GState 等命名资源的家)。通过 #getInheritableProperty 支持页面树继承,缺失时降级为 Dict.empty。shadow 把结果固化成实例属性,后续访问不再走 getter。

getBoundingBox 方法

getBoundingBox(name) {
  if (this.xfaData) {
    return this.xfaData.bbox; // XFA 文档的页面框由模板计算
  }
  const box = lookupNormalRect(
    this.#getInheritableProperty(name, /* getArray = */ true),
    null
  );
  if (box) {
    if (box[2] - box[0] > 0 && box[3] - box[1] > 0) {
      return box;
    }
    warn(`Empty, or invalid, /${name} entry.`);
  }
  return null;
}

读取某个页面框(MediaBox/CropBox/BleedBox 等)的公共逻辑:

  1. XFA 文档优先走 xfaFactory 计算的 bbox;
  2. lookupNormalRect 把数组规范化为 [x1, y1, x2, y2](要求 x1 < x2、y1 < y2,否则交换);
  3. 校验宽高必须为正,空矩形/非法值返回 null,由调用方决定兜底。

get mediaBox 方法

get mediaBox() {
  // 非法 mediaBox 重置为 Letter 尺寸
  return shadow(
    this,
    "mediaBox",
    this.getBoundingBox("MediaBox") || LETTER_SIZE_MEDIABOX
  );
}

/MediaBox 定义页面的物理介质边界,是整套 Box 体系的根。缺失或非法时兜底为 LETTER_SIZE_MEDIABOX。

get cropBox 方法

get cropBox() {
  // 非法 cropBox 降级为 mediaBox
  return shadow(this, "cropBox", this.getBoundingBox("CropBox") || this.mediaBox);
}

/CropBox 定义页面的可见(裁剪)区域,规范规定其缺省值就是 MediaBox——这就是这里的降级链。

get userUnit 方法

get userUnit() {
  const obj = this.pageDict.get("UserUnit");
  return shadow(this, "userUnit", typeof obj === "number" && obj > 0 ? obj : 1.0);
}

/UserUnit(PDF 1.6+)是用户空间单位的缩放倍数,默认 1.0(即 1/72 英寸)。调大它可以让页面突破 PDF 默认的最大尺寸限制。非正数一律按 1.0 处理。

get view 方法

get view() {
  // 规范(第6版, p.963): crop/bleed/trim/art box 通常不应超出 mediaBox;
  // 超出时有效区域缩减为二者交集.
  const { cropBox, mediaBox } = this;
  if (cropBox !== mediaBox && !isArrayEqual(cropBox, mediaBox)) {
    const box = Util.intersect(cropBox, mediaBox);
    if (box && box[2] - box[0] > 0 && box[3] - box[1] > 0) {
      return shadow(this, "view", box);
    }
    warn("Empty /CropBox and /MediaBox intersection.");
  }
  return shadow(this, "view", mediaBox);
}

view 是主线程计算视口(viewport)真正使用的框。完整降级链:

CropBox (缺省=MediaBox) → 与 MediaBox 求交集 → 交集为空则退回 MediaBox

Util.intersect 返回两矩形的交集;isArrayEqual 短路避免无谓的交集计算。这是 PDF 规范 §10.10.1 的直接实现。

get rotate 方法

get rotate() {
  let rotate = this.#getInheritableProperty("Rotate") || 0;
  // 规范化: 必须是 90 的倍数, 且落在 [0, 270]
  if (rotate % 90 !== 0) {
    rotate = 0;
  } else if (rotate >= 360) {
    rotate %= 360;
  } else if (rotate < 0) {
    // 规范未覆盖负数, 按逆时针理解
    rotate = ((rotate % 360) + 360) % 360;
  }
  return shadow(this, "rotate", rotate);
}

/Rotate 是页面的顺时针旋转角度,规范只允许 0/90/180/270。这里的规范化逻辑:

  • 非 90 倍数 → 归零(非法值);
  • ≥360 → 取模;
  • 负数 → 用 ((x % 360) + 360) % 360 把「负角度」换算成等价的正角度(如 -90 → 270)。

可继承,所以走 #getInheritableProperty。

#onSubStreamError 方法

#onSubStreamError(reason, objId) {
  if (this.evaluatorOptions.ignoreErrors) {
    warn(`getContentStream - ignoring sub-stream (${objId}): "${reason}".`);
    return;
  }
  throw reason;
}

内容流数组中某个子流出错时的回调(传给 StreamsSequenceStream)。ignoreErrors 模式下警告并跳过该子流,否则抛错。这让「多段内容流中坏了一段」不至于整页打不开。

async getContentStream 方法

async getContentStream() {
  const content = await this.pdfManager.ensure(this, "content");
 
  if (content instanceof BaseStream && !content.isImageStream) {
    if (content.isAsync) {
      const bytes = await content.asyncGetBytes();
      if (bytes) {
        return new Stream(bytes, 0, bytes.length, content.dict);
      }
    }
    return content;
  }
  if (Array.isArray(content)) {
    const promises = [];
    for (let i = 0, ii = content.length; i < ii; i++) {
      const item = content[i];
      if (item instanceof BaseStream && item.isAsync) {
        promises.push(
          item.asyncGetBytes().then(bytes => {
            if (bytes) {
              content[i] = new Stream(bytes, 0, bytes.length, item.dict);
            }
          })
        );
      }
    }
    if (promises.length > 0) {
      await Promise.all(promises);
    }
    return new StreamsSequenceStream(content, this.#onSubStreamError.bind(this));
  }
  // 页面无内容时, 用空流代替
  return new NullStream();
}

把 /Contents 转成一个可同步消费的 Stream,三种形态:

形态处理
单个流若是异步流(isAsync,数据未完全到达),先 asyncGetBytes() 拉全量数据,重新包装成同步 Stream;图片流(isImageStream)直接忽略
流数组先把数组里所有异步流替换为同步流(Promise.all 并发拉取),再用 StreamsSequenceStream 把多段流拼接成一段逻辑流,子流读取错误时回调 #onSubStreamError
无内容返回 NullStream(空流),空白页也能正常走解析流程

为什么必须转成同步流:第七篇的 EvaluatorPreprocessor/Lexer 是同步读取字节的词法分析器,所以这里把所有「边下边解析」的异步流一次性物化成内存中的同步流。流式加载的增量收益在 xref/对象级,内容流这一层总是完整解析。

get xfaData 方法

get xfaData() {
  return shadow(
    this,
    "xfaData",
    this.xfaFactory ? { bbox: this.xfaFactory.getBoundingBox(this.pageIndex) } : null
  );
}

XFA(XML Forms Architecture)文档没有传统意义的 /MediaBox,页面尺寸由 XFA 模板计算。有 xfaFactory 时返回 { bbox },否则为 null。getBoundingBox 靠它区分走 XFA 路径还是传统路径。

async #replaceIdByRef 方法

async #replaceIdByRef(annotations, deletedAnnotations, existingAnnotations) {
  const promises = [];
  for (const annotation of annotations) {
    if (!annotation.id) continue;
    const ref = Ref.fromString(annotation.id);
    if (!ref) {
      warn(`A non-linked annotation cannot be modified: ${annotation.id}`);
      continue;
    }
    if (annotation.deleted) {
      deletedAnnotations.put(ref); // 标记删除
      if (annotation.popupRef) { /* 连带删除 popup */ }
      continue;
    }
    if (annotation.popup?.deleted) { /* 单独删除 popup */ }
    existingAnnotations?.put(ref);
    annotation.ref = ref;
    promises.push(
      this.xref.fetchAsync(ref).then(obj => {
        if (obj instanceof Dict) {
          annotation.oldAnnotation = obj.clone(); // 保存旧字典, 用于增量更新
        }
      }, () => warn(`Cannot fetch \`oldAnnotation\` for: ${ref}.`))
    );
    delete annotation.id;
  }
  await Promise.all(promises);
}

编辑器(annotation editor)保存流程的预处理步骤。主线程传来的注释以 id(字符串形式的引用,如 "123R")标识,这里统一转成 Ref 对象,并按注释状态分三类:

  • 删除(deleted):引用放入 deletedAnnotations,关联的 Popup 注释一并删除;
  • 修改:放入 existingAnnotations,并抓取旧字典克隆存到 annotation.oldAnnotation——保存时只写回差异字段;
  • 纯新建(无法解析成 Ref)警告跳过。

async saveNewAnnotations 方法

async saveNewAnnotations(handler, task, annotations, imagePromises, changes) {
  if (this.xfaFactory) {
    throw new Error("XFA: Cannot save new annotations.");
  }
  const partialEvaluator = this._createPartialEvaluator(handler);
 
  const deletedAnnotations = new RefSet();
  const existingAnnotations = new RefSet();
  await this.#replaceIdByRef(annotations, deletedAnnotations, existingAnnotations);
 
  const pageDict = this.pageDict;
  const annotationsArray = this.annotations.filter(
    a => !(a instanceof Ref && deletedAnnotations.has(a))
  );
  const newData = await AnnotationFactory.saveNewAnnotations(
    partialEvaluator, this.xref, task, annotations, imagePromises, changes
  );
 
  for (const { ref } of newData.annotations) {
    if (ref instanceof Ref && !existingAnnotations.has(ref)) {
      annotationsArray.push(ref); // 新增的注释 ref 挂到页面上
    }
  }
 
  const dict = pageDict.clone();
  dict.set("Annots", annotationsArray);
  changes.put(this.ref, { data: dict }); // 页字典本身也是变更项
 
  for (const deletedRef of deletedAnnotations) {
    changes.put(deletedRef, { data: null }); // data: null 表示删除该对象
  }
}

保存用户在编辑器里新建的注释(涂鸦、文本批注、印章等)。流程:

  1. #replaceIdByRef 把 id 转 ref、分离出删除集合与已有集合;
  2. AnnotationFactory.saveNewAnnotations 为每条新注释**生成外观流(Appearance Stream)**并写入 changes(变更集,后续用于增量保存);
  3. 更新页字典的 /Annots 数组——剔除已删除的、追加真正新增的(refToReplace 的不重复添加);
  4. 把修改后的页字典也放进 changes;删除的引用以 data: null 标记。

XFA 文档不支持该操作,直接抛错。

async save 方法

async save(handler, task, annotationStorage, changes) {
  const partialEvaluator = this._createPartialEvaluator(handler);
  const annotations = await this._parsedAnnotations;
 
  const promises = [];
  for (const annotation of annotations) {
    promises.push(
      annotation
        .save(partialEvaluator, task, annotationStorage, changes)
        .catch(reason => {
          warn(`save - ignoring annotation data during "${task.name}" task: "${reason}".`);
          return null;
        })
    );
  }
  return Promise.all(promises);
}

保存已有注释的修改——主要是交互表单(AcroForm)字段的填写值。遍历已解析的注释,逐个调 annotation.save 把 annotationStorage 中的新值序列化回 PDF 对象并登记到 changes。单条失败只警告,不影响其他注释。

async loadResources 方法

async loadResources(keys) {
  // TODO: add async `#getInheritableProperty` and remove this.
  await (this.#resourcesPromise ??= this.pdfManager.ensure(this, "resources"));
  await ObjectLoader.load(this.resources, keys, this.xref);
}

预加载 Resources 字典中指定键的所有间接对象。ObjectLoader.load 会递归遍历字典,把 keys(如 ["Font", "XObject", "ExtGState", ...])下所有 Ref 提前 xref.fetchAsync 拉取并缓存,使后续解析可以同步读取。

#resourcesPromise 缓存了 ensure 的 Promise,同一页只 ensure 一次。keys 有两组常量:RESOURCES_KEYS_OPERATOR_LIST(渲染用,含 ColorSpace/Pattern/Shading 等)和 RESOURCES_KEYS_TEXT_CONTENT(文本提取用,只需 Font 等少数字典)。

async #getMergedResources 方法

async #getMergedResources(streamDict, keys) {
  // 极少数情况下 /Contents 流字典里也带 /Resources, 需要优先采用
  // (见 issue18894.pdf)
  const localResources = streamDict?.get("Resources");
  if (!(localResources instanceof Dict && localResources.size)) {
    return this.resources;
  }
  await ObjectLoader.load(localResources, keys, this.xref);
  return Dict.merge({
    xref: this.xref,
    dictArray: [localResources, this.resources],
    mergeSubDicts: true,
  });
}

处理一个规范外但真实存在的边缘情况:内容流字典自己带 /Resources。此时把它与页级 Resources 合并,localResources 在前(优先),mergeSubDicts: true 表示连子字典(如 Font 下的条目)也递归合并。没有则直接用页级 Resources。

async getOperatorList 方法

async getOperatorList({
  handler, sink, task, intent, cacheKey,
  pageIndex = this.pageIndex,
  annotationStorage = null,
  modifiedIds = null,
}) {
  const contentStreamPromise = this.getContentStream();
  const resourcesPromise = this.loadResources(RESOURCES_KEYS_OPERATOR_LIST);
  const partialEvaluator = this._createPartialEvaluator(handler, pageIndex);
 
  const newAnnotsByPage = !this.xfaFactory
    ? getNewAnnotationsMap(annotationStorage)
    : null;
  const newAnnots = newAnnotsByPage?.get(this.pageIndex);
  /* ... 若有编辑器新注释: 收集缺失位图 → AnnotationFactory.generateImages
         → AnnotationFactory.printNewAnnotations 生成新注释的 opList ... */
 
  const pageListPromise = Promise.all([
    contentStreamPromise,
    resourcesPromise,
  ]).then(async ([contentStream]) => {
    const resources = await this.#getMergedResources(
      contentStream.dict,
      RESOURCES_KEYS_OPERATOR_LIST
    );
    const opList = new OperatorList(intent, sink);
    handler.send("StartRenderPage", {
      transparency: partialEvaluator.hasBlendModes(resources, this.nonBlendModesSet),
      pageIndex,
      cacheKey,
    });
    await partialEvaluator.getOperatorList({
      stream: contentStream,
      task,
      resources,
      operatorList: opList,
    });
    return opList;
  });
 
  let [pageOpList, annotations, newAnnotations] = await Promise.all([
    pageListPromise,
    this._parsedAnnotations,
    newAnnotationsPromise,
  ]);
  /* ... 合并新注释(refToReplace 替换旧注释) ... */
}

这是 Page 类的核心方法,第三篇 _pumpOperatorList 收到的 GetOperatorList 消息最终路由到这里。整体是一个三路并发的装配线:

  1. 页面内容流:getContentStream + loadResources 并发,合并资源后创建 OperatorList,先发 StartRenderPage 消息(携带 transparency 预检结果——hasBlendModes 检测是否有混合模式,主线程据此决定是否启用透明度合成),再调第七篇的 partialEvaluator.getOperatorList 完成字节流→算子列表的转换。
  2. 已有注释:this._parsedAnnotations 并发解析。
  3. 编辑器新注释(annotationStorage 里以 AnnotationEditorPrefix 开头的条目):处理位图依赖(一条注释可能引用另一条注释里的图片)后调 AnnotationFactory.printNewAnnotations 生成外观。

三路汇合后进入注释算子合并阶段:

const renderForms = !!(intent & RenderingIntentFlag.ANNOTATIONS_FORMS),
  isEditing = !!(intent & RenderingIntentFlag.IS_EDITING),
  intentAny = !!(intent & RenderingIntentFlag.ANY),
  intentDisplay = !!(intent & RenderingIntentFlag.DISPLAY),
  intentPrint = !!(intent & RenderingIntentFlag.PRINT);
 
for (const annotation of annotations) {
  if (
    intentAny ||
    (intentDisplay && annotation.mustBeViewed(...) && annotation.mustBeViewedWhenEditing(...)) ||
    (intentPrint && annotation.mustBePrinted(annotationStorage))
  ) {
    opListPromises.push(annotation.getOperatorList(partialEvaluator, task, intent, annotationStorage));
  }
}
const opLists = await Promise.all(opListPromises);
for (const { opList, separateForm, separateCanvas } of opLists) {
  pageOpList.addOpList(opList);
  form ||= separateForm;
  canvas ||= separateCanvas;
}
pageOpList.flush(true, { form, canvas });

intent 是位标志(RenderingIntentFlag),决定这次渲染的目的:

标志含义
DISPLAY屏幕显示——只渲染可见注释
PRINT打印——只渲染 printable 注释
ANY全部渲染(缩略图等场景)
ANNOTATIONS_FORMS渲染表单域
ANNOTATIONS_DISABLE完全跳过注释(flush 后直接返回)
IS_EDITING编辑模式——正在编辑的注释不画外观流

每条注释独立生成 operatorList 后通过 addOpList 追加进页面流;separateForm/separateCanvas 汇总后随 flush 一起发给主线程,告知主线程是否需要为表单/XFA 类注释准备独立的 canvas 分层。返回值只有 { length }——算子本体已通过 sink 流式发走。

async extractTextContent 方法

async extractTextContent({
  handler, task, includeMarkedContent, disableNormalization, sink, intersector = null,
}) {
  const contentStreamPromise = this.getContentStream();
  const resourcesPromise = this.loadResources(RESOURCES_KEYS_TEXT_CONTENT);
  const langPromise = this.pdfManager.ensureCatalog("lang");
 
  const [contentStream, , lang] = await Promise.all([...]);
  const resources = await this.#getMergedResources(
    contentStream.dict, RESOURCES_KEYS_TEXT_CONTENT
  );
  const partialEvaluator = this._createPartialEvaluator(handler);
 
  return partialEvaluator.getTextContent({
    stream: contentStream, task, resources,
    includeMarkedContent, disableNormalization, sink,
    viewBox: this.view, lang, intersector,
  });
}

文本提取入口,结构是 getOperatorList 的简化版:同样的「内容流 + 合并资源」准备流程,但用的是 RESOURCES_KEYS_TEXT_CONTENT(只需字体等少数资源),并额外取文档语言 lang。最终委托给第七篇的 PartialEvaluator.getTextContent,结果通过 sink 流式发回主线程,供文本层/搜索使用。intersector 用于高亮注释与文本的相交检测。

async getStructTree 方法

async getStructTree() {
  const structTreeRoot = await this.pdfManager.ensureCatalog("structTreeRoot");
  if (!structTreeRoot) return null;
  // 确保 structTree 包含本页注释
  await this._parsedAnnotations;
  try {
    const structTree = await this.pdfManager.ensure(this, "_parseStructTree", [structTreeRoot]);
    return await this.pdfManager.ensure(structTree, "serializable");
  } catch (ex) {
    warn(`getStructTree: "${ex}".`);
    return null;
  }
}

获取本页的逻辑结构树(Tagged PDF 的结构元素,用于无障碍/屏幕阅读器)。先取 catalog 级的 structTreeRoot,再确保注释已解析(注释也是结构树的叶子),最后 _parseStructTree 解析并把结果序列化(serializable)成可传输给主线程的纯数据。任一步失败返回 null,不影响渲染。

_parseStructTree 方法

_parseStructTree(structTreeRoot) {
  const tree = new StructTreePage(structTreeRoot, this.pageDict);
  tree.parse(this.ref);
  return tree;
}

同步的结构树解析:以文档级 structTreeRoot 和本页字典构造 StructTreePage,parse 按本页 ref 把属于本页的结构元素(通过 /StructParent//StructParents 关联)收集出来。

async getAnnotationsData 方法

async getAnnotationsData(handler, task, intent) {
  const annotations = await this._parsedAnnotations;
  if (annotations.length === 0) return annotations;
 
  const annotationsData = [], textContentPromises = [];
  let partialEvaluator;
  /* ... intent 标志拆解 ... */
  const highlightedAnnotations = [];
 
  for (const annotation of annotations) {
    const isVisible = intentAny || (intentDisplay && annotation.viewable);
    if (isVisible || (intentPrint && annotation.printable)) {
      annotationsData.push(annotation.data); // 序列化数据
    }
    if (annotation.hasTextContent && isVisible) {
      // 弹窗类注释需要提取其内容文本
      partialEvaluator ??= this._createPartialEvaluator(handler);
      textContentPromises.push(annotation.extractTextContent(partialEvaluator, task, [-Inf, -Inf, Inf, Inf]));
    } else if (annotation.overlaysTextContent && isVisible) {
      highlightedAnnotations.push(annotation);
    }
  }
 
  if (highlightedAnnotations.length > 0) {
    const intersector = new Intersector(highlightedAnnotations);
    textContentPromises.push(
      this.extractTextContent({ /* ..., */ intersector })
        .then(() => intersector.setText())
    );
  }
  await Promise.all(textContentPromises);
  return annotationsData;
}

主线程 getAnnotations() 的 Worker 端实现,返回注释的可序列化数据(annotation.data)而非 operatorList。两个增值点:

  • Popup/FreeText 等带文本的注释:调用 annotation.extractTextContent 提取其内容文本,供无障碍与复制使用;
  • 高亮注释(Highlight/Underline 等覆盖在文字上的):用 Intersector 做注释矩形与页面文本的相交检测,把被高亮的实际文字回填到注释数据里(setText())——这就是复制高亮内容时能拿到原文的原理。

get annotations 方法

get annotations() {
  const annots = this.#getInheritableProperty("Annots");
  return shadow(this, "annotations", Array.isArray(annots) ? annots : []);
}

读取 /Annots 数组(原始引用/字典列表)。注意它也走可继承路径——少数文档把注释挂在页面树父节点上。非数组降级为空数组。

get _parsedAnnotations 方法

get _parsedAnnotations() {
  const promise = this.pdfManager
    .ensure(this, "annotations")
    .then(async annots => {
      if (annots.length === 0) return annots;
 
      const [annotationGlobals, fieldObjects] = await Promise.all([
        this.pdfManager.ensureDoc("annotationGlobals"),
        this.pdfManager.ensureDoc("fieldObjects"),
      ]);
      if (!annotationGlobals) return [];
 
      const orphanFields = fieldObjects?.orphanFields;
      const annotationPromises = [];
      for (const annotationRef of annots) {
        annotationPromises.push(
          AnnotationFactory.create(
            this.xref, annotationRef, annotationGlobals,
            this._localIdFactory, false, orphanFields, null, this.ref
          ).catch(reason => {
            warn(`_parsedAnnotations: "${reason}".`);
            return null; // 单条失败不影响整体
          })
        );
      }
 
      const sortedAnnotations = [];
      let popupAnnotations, widgetAnnotations;
      // Popup 依赖其父注释在显示层的存在, 必须排到最后 (修复 issue 11362)
      for (const annotation of await Promise.all(annotationPromises)) {
        if (!annotation) continue;
        if (annotation instanceof WidgetAnnotation) {
          (widgetAnnotations ||= []).push(annotation);
          continue;
        }
        if (annotation instanceof PopupAnnotation) {
          (popupAnnotations ||= []).push(annotation);
          continue;
        }
        sortedAnnotations.push(annotation);
      }
      if (widgetAnnotations) sortedAnnotations.push(...widgetAnnotations);
      if (popupAnnotations) sortedAnnotations.push(...popupAnnotations);
      return sortedAnnotations;
    });
 
  return shadow(this, "_parsedAnnotations", promise);
}

把 /Annots 原始数组解析成注释实例列表,是注释体系的枢纽(getOperatorList、getAnnotationsData、save、getStructTree 都依赖它)。三个关键点:

  1. 文档级依赖:先并发取 annotationGlobals(注释全局数据:默认外观、字体等)与 fieldObjects(表单字段树)。orphanFields 用来把「父字段在 Fields 数组但 Widget 没挂 Parent」的孤儿注释归位。
  2. 容错:每条注释 AnnotationFactory.create 失败只返回 null,不拖垮整页。
  3. 排序:Widget → 普通注释 → Popup。Widget(表单域控件)与 Popup(弹窗)被刻意排到末尾——Popup 的显示依赖其父注释已在显示层注册,顺序错了会导致弹窗找不到宿主(issue 11362)。

整个 getter 返回的是 Promise 并用 shadow 固化,保证注释只解析一次。

get jsActions 方法

get jsActions() {
  const actions = collectActions(this.xref, this.pageDict, PageActionEventType);
  return shadow(this, "jsActions", actions);
}

收集页面级 JavaScript 动作——/AA(Additional Actions)字典里的 O(打开页面)/C(关闭页面)事件脚本。PageActionEventType 限定了只收这两类。无动作时为 null。

async collectAnnotationsByType 方法

async collectAnnotationsByType(handler, task, types, promises, annotationGlobals) {
  const { pageIndex } = this;
 
  if (Object.hasOwn(this, "_parsedAnnotations")) {
    // 已解析过: 直接从缓存按类型过滤
    const cachedAnnotations = await this._parsedAnnotations;
    for (const { data } of cachedAnnotations) {
      if (!types || types.has(data.annotationType)) {
        data.pageIndex = pageIndex;
        promises.push(Promise.resolve(data));
      }
    }
    return;
  }
 
  // 未解析: 流式创建, 在 create 时按类型过滤(避免完整解析所有注释)
  const annots = await this.pdfManager.ensure(this, "annotations");
  let partialEvaluator;
  for (const annotationRef of annots) {
    promises.push(
      AnnotationFactory.create(
        this.xref, annotationRef, annotationGlobals, this._localIdFactory,
        false, null, types, this.ref
      ).then(async annotation => {
        if (!annotation) return null;
        annotation.data.pageIndex = pageIndex;
        if (annotation.hasTextContent && annotation.viewable) {
          partialEvaluator ??= this._createPartialEvaluator(handler);
          await annotation.extractTextContent(/* ... */);
        }
        return annotation.data;
      })
      /* ... catch 容错 ... */
    );
  }
}

按类型批量收集注释(主线程「导出所有高亮/印章」之类的批量查询用)。两条路径:

  • _parsedAnnotations 已缓存 → 直接在内存里按 annotationType 过滤;
  • 未缓存 → 把 types 集合传给 AnnotationFactory.create 的 collectByType 参数,在创建阶段就过滤,非目标类型的注释不会被完整解析,省一次全量解析的开销。

这是「lazy 优先、缓存兜底」的典型例子:同一个数据,依据调用时机选择最优获取路径。

PDFDocument 类

源码注释写得很直白:The PDFDocument class holds all the (worker-thread) data of the PDF file.——它是 Worker 端的文档数据持有者:xref、Catalog、页缓存、表单/签名/XFA/指纹等所有文档级信息都汇聚在这个类上。第六篇 PDFManager.initDocument 里 new PDFDocument(pdfManager, stream) 创建的就是它。

constructor 方法

constructor(pdfManager, stream) {
  /* 测试构建下断言 stream 必须是 BaseStream */
  if (stream.length <= 0) {
    throw new InvalidPDFException("The PDF file is empty, i.e. its size is zero bytes.");
  }
  this.pdfManager = pdfManager;
  this.stream = stream;
  this.xref = new XRef(stream, pdfManager);
 
  const idCounters = { font: 0 };
  this._globalIdFactory = class {
    static getDocId() {
      return `g_${pdfManager.docId}`;
    }
    static createFontId() {
      return `f${++idCounters.font}`;
    }
    static createObjId() {
      unreachable("Abstract method `createObjId` called.");
    }
    static getPageObjId() {
      unreachable("Abstract method `getPageObjId` called.");
    }
  };
}

构造时做三件事:

  1. 防御:空文件直接抛 InvalidPDFException;
  2. 建 xref:new XRef(stream, pdfManager)——注意此时只是创建对象,真正解析在 parse;
  3. 建文档级 ID 工厂 _globalIdFactory:getDocId() 返回 g_${docId},createFontId() 返回递增的 f${n};而 createObjId/getPageObjId 是抽象方法,直接 unreachable——页级 ID 必须由 Page._localIdFactory(继承本类)提供,这从类型层面防止了「在全局上下文误用页级 ID」。

parse 方法

parse(recoveryMode) {
  this.xref.parse(recoveryMode);
  this.catalog = new Catalog(this.pdfManager, this.xref);
}

第六篇讲过的两步:xref.parse 解析交叉引用表(recoveryMode 为 true 时进入损坏恢复模式,全文件扫描对象),然后基于 xref 建 Catalog(文档目录,页面树/大纲/元数据的入口)。

get linearization 方法

get linearization() {
  let linearization = null;
  try {
    linearization = Linearization.create(this.stream);
  } catch (err) {
    if (err instanceof MissingDataException) {
      throw err; // 流式加载中数据未到, 向上抛让底层继续拉取
    }
    info(err); // 其他错误只记录, 按非线性化文档处理
  }
  return shadow(this, "linearization", linearization);
}

尝试读取线性化字典(Linearized PDF / Fast Web View)。线性化文档把第一页内容和一份「提示表」放在文件最前面,使浏览器无需下载完整文件即可渲染首页。

Linearization.create 检查文件第一个对象是否是 /Linearized 字典;是则返回 { numPages, pageFirst, objectNumberFirst, ... }。MissingDataException 要向上抛——这是流式传输的「数据还没到」信号;其余错误降级为非线性化文档。

get startXRef 方法

get startXRef() {
  const stream = this.stream;
  let startXRef = 0;
 
  if (this.linearization) {
    // 线性化文档: 找第一个对象的 endobj, 其后即是第一页区的 xref
    stream.reset();
    if (find(stream, ENDOBJ_SIGNATURE)) {
      stream.skip(6);
      let ch = stream.peekByte();
      while (isWhiteSpace(ch)) { stream.pos++; ch = stream.peekByte(); }
      startXRef = stream.pos - stream.start;
    }
  } else {
    // 普通文档: 从文件末尾反向找 startxref
    const step = 1024;
    const startXRefLength = STARTXREF_SIGNATURE.length;
    let found = false, pos = stream.end;
    while (!found && pos > 0) {
      pos -= step - startXRefLength;
      if (pos < 0) pos = 0;
      stream.pos = pos;
      found = find(stream, STARTXREF_SIGNATURE, step, true); // backwards
    }
    if (found) {
      stream.skip(9);
      let ch;
      do { ch = stream.getByte(); } while (isWhiteSpace(ch));
      let str = "";
      while (ch >= 0x20 && ch <= 0x39) {
        str += String.fromCharCode(ch);
        ch = stream.getByte();
      }
      startXRef = parseInt(str, 10);
      if (isNaN(startXRef)) startXRef = 0;
    }
  }
  return shadow(this, "startXRef", startXRef);
}

定位交叉引用表的起始偏移,两条路径对应两类文档:

  • 线性化:第一个对象(线性化字典)的 endobj 之后就是首页区的 xref 偏移,正向 find(ENDOBJ_SIGNATURE) 即可;
  • 普通文档:规范约定 startxref 在文件尾部,于是从 stream.end 以 1024 - 9 为步长反向分段扫描(减去签名长度保证跨段的签名也能命中),找到后跳过 startxref 关键字、读出一串数字字符 parseInt 得到偏移。

反向扫描是 PDF 解析的经典模式——文件可能很大,尾部 1KB 内几乎必有 startxref,避免全文件扫描。

checkHeader 方法

checkHeader() {
  const stream = this.stream;
  stream.reset();
 
  if (!find(stream, PDF_HEADER_SIGNATURE)) {
    // 可能不是 PDF 文件, 但不抛错, 让解析继续(尽量抢救)
    return;
  }
  stream.moveStart(); // 把流起点挪到签名处, 之后的偏移都以此为基准
 
  stream.skip(PDF_HEADER_SIGNATURE.length); // 跳过 "%PDF-"
  let version = "", ch;
  while ((ch = stream.getByte()) > 0x20 && version.length < 7) {
    version += String.fromCharCode(ch);
  }
  if (PDF_VERSION_REGEXP.test(version)) {
    this.#version = version; // 如 "1.7"
  } else {
    warn(`Invalid PDF header version: ${version}`);
  }
}

文件头检查:在前 1024 字节内找 %PDF-,找到后 moveStart() 把流的逻辑起点对齐到签名处——这一步很关键,因为 PDF 内的所有字节偏移都以 header 为基准(header 前有垃圾字节的文件靠这个修正)。随后读出版本号存入 #version(PDF_VERSION_REGEXP 校验形如 1.0–2.0)。

找不到 header 不抛错——PDF.js 的哲学是「能打开就打开」,让后续解析去处理坏文件。

parseStartXRef 方法

parseStartXRef() {
  this.xref.setStartXRef(this.startXRef);
}

把 startXRef getter 算出的偏移喂给 XRef,作为 xref 解析的入口偏移。一行代码,但它串起了「定位 → 解析」两个阶段。

get numPages 方法

get numPages() {
  let num = 0;
  if (this.catalog.hasActualNumPages) {
    num = this.catalog.numPages;
  } else if (this.xfaFactory) {
    num = this.xfaFactory.getNumPages(); // 返回 Promise
  } else if (this.linearization) {
    num = this.linearization.numPages;
  } else {
    num = this.catalog.numPages;
  }
  return shadow(this, "numPages", num);
}

页数获取的多来源降级链,优先级:

  1. catalog.hasActualNumPages:checkLastPage 修复过页数后设置的「真实页数」,最高优先;
  2. xfaFactory:XFA 文档的页数由模板计算(返回 Promise);
  3. linearization.numPages:线性化字典里声明的页数——流式加载时页面树可能还没下载完,先用它顶上;
  4. catalog.numPages:常规路径,遍历页面树根节点的 /Count。

#hasOnlyDocumentSignatures 方法

#hasOnlyDocumentSignatures(fields, recursionDepth = 0) {
  const RECURSION_LIMIT = 10;
  if (!Array.isArray(fields)) return false;
  return fields.every(field => {
    field = this.xref.fetchIfRef(field);
    if (!(field instanceof Dict)) return false;
    if (field.has("Kids")) {
      if (++recursionDepth > RECURSION_LIMIT) {
        warn("#hasOnlyDocumentSignatures: maximum recursion depth reached");
        return false;
      }
      return this.#hasOnlyDocumentSignatures(field.get("Kids"), recursionDepth);
    }
    const isSignature = isName(
      getInheritableProperty({ dict: field, key: "FT" }),
      "Sig"
    );
    const rectangle = field.get("Rect");
    const isInvisible = Array.isArray(rectangle) && rectangle.every(v => v === 0);
    return isSignature && isInvisible;
  });
}

递归检查 /Fields 数组是否只包含文档级签名。判定条件:FT 为 Sig(签名字段,可继承)且 Rect 为全零(不可见)。带 Kids 的中间节点递归下钻,深度限制 10 层防循环引用。

用途在 formInfo:有些 XFA 文档把不可见的文档签名塞进 Fields 里冒充 AcroForm,需要把它们识别出来,避免误判为「有交互表单」。

get _xfaStreams 方法

get _xfaStreams() {
  const { acroForm } = this.catalog;
  if (!acroForm) return null;
 
  const xfa = acroForm.get("XFA");
  const entries = new Map(
    ["xdp:xdp", "template", "datasets", "config", "connectionSet",
     "localeSet", "stylesheet", "/xdp:xdp"].map(e => [e, null])
  );
  if (xfa instanceof BaseStream && !xfa.isEmpty) {
    entries.set("xdp:xdp", xfa); // 单流形态: 整个 XDP 包在一个流里
    return entries;
  }
  if (!Array.isArray(xfa) || xfa.length === 0) return null;
 
  for (let i = 0, ii = xfa.length; i < ii; i += 2) {
    let name;
    if (i === 0) name = "xdp:xdp";
    else if (i === ii - 2) name = "/xdp:xdp";
    else name = xfa[i];
    if (!entries.has(name)) continue;
    const data = this.xref.fetchIfRef(xfa[i + 1]);
    if (!(data instanceof BaseStream) || data.isEmpty) continue;
    entries.set(name, data);
  }
  return entries;
}

读取 AcroForm 字典的 /XFA 项,规范化成「节名 → 流」的 Map。XFA 数据有两种存储形态:

  • 单个流:整个 XDP(XML Data Package)在一个流里,记到 xdp:xdp 键;
  • 数组:[name1, ref1, name2, ref2, ...] 交替的「名值对」,首尾两对固定视作 xdp:xdp 的开闭标签,中间是 template(表单模板)、datasets(数据)、config(配置)等节。

非流/空流的节保持 null,后续消费者按需判空。

get xfaDatasets 方法

get xfaDatasets() {
  const streams = this._xfaStreams;
  if (!streams) return shadow(this, "xfaDatasets", null);
  for (const key of ["datasets", "xdp:xdp"]) {
    const stream = streams.get(key);
    if (!stream) continue;
    try {
      const str = stringToUTF8String(stream.getString());
      return shadow(this, "xfaDatasets", new DatasetReader({ [key]: str }));
    } catch {
      warn("XFA - Invalid utf-8 string.");
      break;
    }
  }
  return shadow(this, "xfaDatasets", null);
}

从 XFA 流中取出 datasets 节(表单字段的当前数据值,XML 格式),包装成 DatasetReader 供查询字段值。优先读 datasets 键,没有则从整个 xdp:xdp 里取。

get xfaData 方法

get xfaData() {
  const streams = this._xfaStreams;
  if (!streams) return null;
  const data = new Map();
  for (const [key, stream] of streams) {
    if (!stream) continue;
    try {
      data.set(key, stringToUTF8String(stream.getString()));
    } catch {
      warn("XFA - Invalid utf-8 string.");
      return null;
    }
  }
  return data;
}

把 _xfaStreams 里所有节全量解码为 UTF-8 字符串,得到 Map<节名, XML文本>。这是 XFAFactory 的输入——XFA 渲染完全在 XML 层面进行(不走内容流)。

get xfaFactory 方法

get xfaFactory() {
  let data;
  if (
    this.pdfManager.enableXfa &&
    this.catalog.needsRendering &&
    this.formInfo.hasXfa &&
    !this.formInfo.hasAcroForm
  ) {
    data = this.xfaData;
  }
  return shadow(this, "xfaFactory", data ? new XFAFactory(data) : null);
}

创建 XFAFactory 的守卫条件,四个必须同时满足:

条件含义
enableXfa用户/API 允许 XFA 渲染(可关闭回退到「请用 Acrobat 打开」提示页)
catalog.needsRendering文档需要实际渲染(而非仅提取文本等场景)
formInfo.hasXfaAcroForm 里确实有 XFA 数据
!formInfo.hasAcroForm不同时有 AcroForm——二者共存时按 AcroForm 处理

XFAFactory 持有解析后的 XFA 数据,负责计算页数、bbox、生成 HTML 等。

get isPureXfa 方法

get isPureXfa() {
  return this.xfaFactory ? this.xfaFactory.isValid() : false;
}

是否为纯 XFA 文档(XFA 模板有效且文档没有传统页面内容)。纯 XFA 文档走完全独立的渲染管线(htmlForXfa),getPage 直接返回空页字典。

get htmlForXfa 方法

get htmlForXfa() {
  return this.xfaFactory ? this.xfaFactory.getPages() : null;
}

纯 XFA 文档的渲染产物:XFAFactory.getPages() 把 XFA 模板转成HTML 结构({ pages: [...], width, height }),主线程按 HTML 而非 canvas 渲染。这是 PDF.js 对 XFA 的策略——不实现 XFA 绘图,而是映射成 DOM。

async #loadXfaImages 方法

async #loadXfaImages() {
  const xfaImages = await this.pdfManager.ensureCatalog("xfaImages");
  if (!xfaImages) return;
  this.xfaFactory.setImages(xfaImages);
}

加载 XFA 模板引用的图片(catalog 的 /Names → XFAImages 名称树),解码后交给 XFAFactory,供 HTML 渲染时以 data URL 内嵌。

async #loadXfaFonts 方法

async #loadXfaFonts(handler, task) {
  const acroForm = await this.pdfManager.ensureCatalog("acroForm");
  if (!acroForm) return;
  const resources = await acroForm.getAsync("DR"); // 文档级默认资源
  if (!(resources instanceof Dict)) return;
  await ObjectLoader.load(resources, ["Font"], this.xref);
 
  const fontRes = resources.get("Font");
  if (!(fontRes instanceof Dict)) return;
 
  const options = Object.assign(
    Object.create(null),
    this.pdfManager.evaluatorOptions,
    { useSystemFonts: false } // XFA 字体必须用内嵌数据保证一致渲染
  );
  const { builtInCMapCache, fontCache, standardFontDataCache } = this.catalog;
 
  const partialEvaluator = new PartialEvaluator({
    xref: this.xref,
    handler,
    pageIndex: -1, // 全局上下文, 不属于任何页
    idFactory: this._globalIdFactory,
    fontCache, builtInCMapCache, standardFontDataCache,
    options,
  });
  const pdfFonts = [];
  const initialState = {
    get font() { return pdfFonts.at(-1); },
    set font(font) { pdfFonts.push(font); },
    clone() { return this; },
  };
  /* ... 遍历 fontRes, 按 FontDescriptor 构造 cssFontInfo, parseFont 加载 ... */
  await Promise.all(promises);
  const missingFonts = this.xfaFactory.setFonts(pdfFonts);
  /* ... 对缺失字体, 用 getXfaFontDict 生成 Regular/Bold/Italic/BoldItalic
         四种风格的替代字体再加载一轮, 最后 appendFonts ... */
}

XFA 字体加载管线——XFA 不经过内容流,字体需要单独处理。这就是第七篇 constructor 章节提到的「全局级 PartialEvaluator」的实际调用处,三个独特设计:

  1. pageIndex: -1 + _globalIdFactory:字体属于整个文档而非某页,生成的字体 ID 是文档级(g_${docId}_f${n}),走 commonobj 通道发送。
  2. useSystemFonts: false:强制加载嵌入字体数据——XFA 的 HTML 渲染需要精确的字体度量,系统字体差异会导致布局错乱。
  3. 假 initialState:handleSetFont 需要一个状态对象存「当前字体」,这里用一个 getter/setter 把每次设置的字体推进 pdfFonts 数组,巧妙地把「求值副作用」变成「收集结果」。

两轮加载:第一轮按 AcroForm /DR(默认资源)里的 Font 字典加载,把 FontDescriptor 的 FontFamily/FontWeight/ItalicAngle(注意 PDF 角度逆时针、CSS 顺时针,取负)转成 CSS 字体信息;xfaFactory.setFonts 返回模板实际用到但没加载到的字体名单,第二轮用 getXfaFontDict 的替代字体(Myriad 族)按 Regular/Bold/Italic/BoldItalic 四风格补齐。

loadXfaResources 方法

loadXfaResources(handler, task) {
  return Promise.all([
    this.#loadXfaFonts(handler, task).catch(() => {
      // 忽略错误, 保证文档能打开
    }),
    this.#loadXfaImages(),
  ]);
}

XFA 资源总入口:字体 + 图片并发加载。字体失败整体 catch 住——XFA 字体缺失不该阻断文档打开(有 Myriad 兜底)。

serializeXfaData 方法

serializeXfaData(annotationStorage) {
  return this.xfaFactory ? this.xfaFactory.serializeData(annotationStorage) : null;
}

把用户填写的 XFA 表单数据(存在 annotationStorage)序列化回 XML,用于保存文档时写回 XFA datasets。

get version 方法

get version() {
  return this.catalog.version || this.#version;
}

PDF 版本号的判定:规范 §7.5.2 规定 Catalog 里的 /Version 优先于文件头版本(增量更新可以在不改文件头的情况下升级版本)。#version 是 checkHeader 读到的头版本,作为兜底。

get formInfo 方法

get formInfo() {
  const formInfo = {
    hasFields: false, hasAcroForm: false, hasXfa: false, hasSignatures: false,
  };
  const { acroForm } = this.catalog;
  if (!acroForm) return shadow(this, "formInfo", formInfo);
 
  try {
    const fields = acroForm.get("Fields");
    const hasFields = Array.isArray(fields) && fields.length > 0;
    formInfo.hasFields = hasFields;
 
    const xfa = acroForm.get("XFA");
    formInfo.hasXfa =
      (Array.isArray(xfa) && xfa.length > 0) ||
      (xfa instanceof BaseStream && !xfa.isEmpty);
 
    // 有文档只含 XFA, 却把不可见的文档签名放在 Fields 里;
    // 用 SigFlags 第 1 位(规范 Table 219)检测签名存在
    const sigFlags = acroForm.get("SigFlags");
    const hasSignatures = !!(sigFlags & 0x1);
    const hasOnlyDocumentSignatures =
      hasSignatures && this.#hasOnlyDocumentSignatures(fields);
    formInfo.hasAcroForm = hasFields && !hasOnlyDocumentSignatures;
    formInfo.hasSignatures = hasSignatures;
  } catch (ex) {
    if (ex instanceof MissingDataException) throw ex;
    warn(`Cannot fetch form information: "${ex}".`);
  }
  return shadow(this, "formInfo", formInfo);
}

表单信息的总判定,返回四个布尔值:

字段判定
hasFields/Fields 是非空数组(原始字段存在)
hasXfa/XFA 是非空数组或非空流
hasAcroFormhasFields 且不是「只有文档签名」——即确有交互表单
hasSignatures/SigFlags 位 0 置 1(规范 Table 219:存在签名)

hasAcroForm 的修正逻辑很关键:XFA 文档常把不可见文档签名塞进 Fields,若只看 hasFields 会把纯 XFA 误判成 AcroForm,走错误的渲染管线。异常时除 MissingDataException(流式加载信号,继续上抛)外一律降级为全 false。

get documentInfo 方法

get documentInfo() {
  const docInfo = {
    PDFFormatVersion: this.version,
    Language: catalog.lang,
    EncryptFilterName: xref.encrypt?.filterName ?? null,
    IsLinearized: !!this.linearization,
    IsAcroFormPresent: formInfo.hasAcroForm,
    IsXFAPresent: formInfo.hasXfa,
    IsCollectionPresent: !!catalog.collection,
    IsSignaturesPresent: formInfo.hasSignatures,
  };
 
  let infoDict;
  try {
    infoDict = xref.trailer.get("Info");
  } catch (err) { /* MissingDataException 上抛, 其余 info 日志 */ }
  if (!(infoDict instanceof Dict)) return shadow(this, "documentInfo", docInfo);
 
  for (const [key, value] of infoDict) {
    switch (key) {
      case "Title": case "Author": /* ... 8 个标准键 ... */
        if (typeof value === "string") { docInfo[key] = stringToPDFString(value); continue; }
        break;
      case "Trapped":
        if (value instanceof Name) { docInfo[key] = value; continue; }
        break;
      default:
        // 自定义键: 只接受 string/number/boolean/Name 白名单类型,
        // 防止 Dict/Stream 这类不可序列化对象发去主线程
        /* ... */
        (docInfo.Custom ??= new Map()).set(key, customValue);
        continue;
    }
    warn(`Bad value, for key "${key}", in Info: ${value}.`);
  }
  return shadow(this, "documentInfo", docInfo);
}

组装主线程 getMetadata() 用的文档信息。分两层:

  1. 合成字段:版本、语言、加密过滤器名、是否线性化、表单类型、是否附件合集(/Collection)、是否有签名;
  2. Info 字典:trailer 的 /Info 里的 Title/Author/Subject/Keywords/Creator/Producer/CreationDate/ModDate(字符串经 stringToPDFString 解码 PDFDocEncoding)+ Trapped(Name);其余键按类型白名单收进 docInfo.Custom——Map 只接受可结构化克隆的类型,是 Worker→主线程传输的安全闸。

get fingerprints 方法

get fingerprints() {
  const FINGERPRINT_FIRST_BYTES = 1024;
  const EMPTY_FINGERPRINT = "\x00".repeat(16);
 
  function validate(data) {
    return typeof data === "string" && data.length === 16 && data !== EMPTY_FINGERPRINT;
  }
 
  const id = this.xref.trailer.get("ID");
  let hashOriginal, hashModified;
  if (Array.isArray(id) && validate(id[0])) {
    hashOriginal = stringToBytes(id[0]);
    if (id[1] !== id[0] && validate(id[1])) {
      hashModified = stringToBytes(id[1]);
    }
  } else {
    hashOriginal = calculateMD5(
      this.stream.getByteRange(0, FINGERPRINT_FIRST_BYTES), 0, FINGERPRINT_FIRST_BYTES
    );
  }
  return shadow(this, "fingerprints", [hashOriginal.toHex(), hashModified?.toHex() ?? null]);
}

计算文档指纹(主线程 loadingTask.docId 的来源,用于缓存键/文档标识)。双路径:

  • trailer 有合法 /ID(两个 16 字节字符串)→ 直接用,ID[0] 是原始指纹、ID[1] 是最后修改的指纹;
  • 否则 → 对文件前 1024 字节算 MD5 作为指纹,hashModified 为 null。

全零 ID(\x00×16)被视作无效——有些生成器会写空 ID。

async #getLinearizationPage 方法

async #getLinearizationPage(pageIndex) {
  /* 测试构建下断言 linearization.pageFirst === pageIndex */
  const ref = Ref.get(linearization.objectNumberFirst, 0);
  try {
    const obj = await xref.fetchAsync(ref);
    if (obj instanceof Dict) {
      let type = obj.getRaw("Type");
      if (type instanceof Ref) type = await xref.fetchAsync(type);
      if (
        isName(type, "Page") ||
        (!obj.has("Type") && !obj.has("Kids") && obj.has("Contents"))
      ) {
        // 顺手填充 Catalog 的两个缓存, 加速后续 getPageIndex
        if (!catalog.pageKidsCountCache.has(ref)) catalog.pageKidsCountCache.put(ref, 1);
        if (!catalog.pageIndexCache.has(ref)) catalog.pageIndexCache.put(ref, 0);
        return [obj, ref];
      }
    }
    throw new FormatError("The Linearization dictionary doesn't point to a valid Page dictionary.");
  } catch (reason) {
    warn(`_getLinearizationPage: "${reason.message}".`);
    return catalog.getPageDict(pageIndex); // 回退常规页面树遍历
  }
}

线性化首页优化:线性化字典的 objectNumberFirst 直接给出第一页页对象的 objId,省掉遍历页面树(页面树数据可能还没下载完)。取到对象后要做有效性验证——/Type /Page,或者(容错)没有 Type 也没有 Kids 但有 Contents 的字典也认作页面。

验证通过后顺手把 pageKidsCountCache/pageIndexCache 填上,让后续 Catalog.getPageIndex 命中缓存。失败则回退到 catalog.getPageDict 正常遍历。

getPage 方法

getPage(pageIndex) {
  const cachedPromise = this.#pagePromises.get(pageIndex);
  if (cachedPromise) return cachedPromise;
  const { catalog, linearization, xfaFactory } = this;
 
  let promise;
  if (xfaFactory) {
    promise = Promise.resolve([Dict.empty, null]); // 纯 XFA: 空页字典
  } else if (linearization?.pageFirst === pageIndex) {
    promise = this.#getLinearizationPage(pageIndex); // 线性化首页捷径
  } else {
    promise = catalog.getPageDict(pageIndex); // 常规: 遍历页面树
  }
  promise = promise.then(
    ([pageDict, ref]) =>
      new Page({
        pdfManager: this.pdfManager,
        xref: this.xref, pageIndex, pageDict, ref,
        globalIdFactory: this._globalIdFactory,
        fontCache: catalog.fontCache,
        /* ... Catalog 持有的全部文档级缓存 ... */
        nonBlendModesSet: catalog.nonBlendModesSet,
        xfaFactory,
      })
  );
 
  this.#pagePromises.set(pageIndex, promise);
  return promise;
}

主线程 getPage(pageNumber) 的 Worker 端实现。三条取页路径对应三种文档形态,然后统一包成 Page 实例——注意这里完成了 Page constructor 依赖注入的另一半:所有缓存都取自 Catalog(文档级共享),globalIdFactory 由 PDFDocument 提供。

#pagePromises(Map)缓存每页的 Promise——同一页只解析一次,重复 getPage 直接命中。这也是注释/operatorList 等页级缓存能成立的前提:同一个 Page 实例贯穿该页的所有操作。

async checkFirstPage 方法

async checkFirstPage(recoveryMode = false) {
  if (recoveryMode) return;
  try {
    await this.getPage(0);
  } catch (reason) {
    if (reason instanceof XRefEntryException) {
      // 清掉缓存, 防止残留的不一致状态破坏后续 getPage
      this.#pagePromises.delete(0);
      await this.cleanup();
      throw new XRefParseException();
    }
  }
}

首页预检:文档打开后立即尝试解析第 0 页。若撞上 XRefEntryException(xref 条目损坏),说明 xref 表不可信——清掉页缓存与全局缓存后抛 XRefParseException,上层(PDFManager)捕获后进入 recovery 模式重新扫描全文件重建 xref。recoveryMode 下直接跳过,避免无限递归。

async checkLastPage 方法

async checkLastPage(recoveryMode = false) {
  const { catalog, pdfManager } = this;
  catalog.setActualNumPages(); // 每次先重置
  let numPages;
  try {
    await Promise.all([
      pdfManager.ensureDoc("xfaFactory"),
      pdfManager.ensureDoc("linearization"),
      pdfManager.ensureCatalog("numPages"),
    ]);
    if (this.xfaFactory) return; // XFA 页数总是可靠的
    numPages = this.linearization ? this.linearization.numPages : catalog.numPages;
    if (!Number.isInteger(numPages)) {
      throw new FormatError("Page count is not an integer.");
    } else if (numPages <= 1) return;
    await this.getPage(numPages - 1); // 试着取最后一页
  } catch (reason) {
    this.#pagePromises.delete(numPages - 1);
    await this.cleanup();
    if (reason instanceof XRefEntryException && !recoveryMode) {
      throw new XRefParseException();
    }
    warn(`checkLastPage - invalid /Pages tree /Count: ${numPages}.`);
 
    let pagesTree;
    try {
      pagesTree = await catalog.getAllPageDicts(recoveryMode); // 全量遍历页面树
    } catch (reasonAll) {
      /* XRefEntryException 同上抛 recovery; 否则页数定为 1 */
      catalog.setActualNumPages(1);
      return;
    }
    // 用真实遍历结果重建全部页缓存, 修正页数
    for (const [pageIndex, [pageDict, ref]] of pagesTree) {
      let promise;
      if (pageDict instanceof Error) {
        promise = Promise.reject(pageDict);
        promise.catch(() => {}); // 防 "uncaught exception" 刷屏
      } else {
        promise = Promise.resolve(new Page({ /* ... */ }));
      }
      this.#pagePromises.set(pageIndex, promise);
    }
    catalog.setActualNumPages(pagesTree.size);
  }
}

末页预检 + 页数校验,比 checkFirstPage 更进一步:验证 /Pages 树根节点声明的 /Count 是否可信。做法是试着取 numPages - 1 页——取不到说明页数虚标。处置分两级:

  1. xref 条目损坏 → 同 checkFirstPage,抛 XRefParseException 触发 recovery;
  2. 只是 Count 虚标 → 全量遍历页面树(catalog.getAllPageDicts),用真实页数重建 #pagePromises(连损坏页的 rejected Promise 也登记进去,主线程能按页显示错误),最后 setActualNumPages(pagesTree.size) 修正页数——此后 numPages getter 的第一优先级 hasActualNumPages 就会命中这个修正值。

这两个 check 合起来是 PDF.js「宽容打开坏文件」策略的核心:宁可重扫全文件,也不让文档打不开。

async fontFallback 方法

async fontFallback(id, handler) {
  const { catalog, pdfManager } = this;
  for (const translatedFont of await Promise.all(catalog.fontCache)) {
    if (translatedFont.loadedName === id) {
      translatedFont.fallback(handler, pdfManager.evaluatorOptions);
      return;
    }
  }
}

主线程 FontFace 加载失败时回调的 Worker 端处理:遍历字体缓存(缓存的是 Promise,Promise.all 等全部 resolve),找到对应 loadedName 的 TranslatedFont,调其 fallback(第七篇讲过:disableFontFace = true,改发字形路径数据)。注意 catalog.fontCache 可直接迭代——它是 RefSetCache 风格结构。

async cleanup 方法

async cleanup(manuallyTriggered = false) {
  return this.catalog ? this.catalog.cleanup(manuallyTriggered) : clearGlobalCaches();
}

清理缓存入口。正常走 catalog.cleanup(清字体/图片/颜色空间等文档级缓存);catalog 还没建(打开失败早期)则只能 clearGlobalCaches 清全局缓存。manuallyTriggered 区分是主线程主动触发还是内存压力自动触发,影响清理的激进程度。

async #collectFieldObjects 方法

async #collectFieldObjects(
  name, parentRef, fieldRef, promises, annotationGlobals, visitedRefs, orphanFields
) {
  const { xref } = this;
  if (!(fieldRef instanceof Ref) || visitedRefs.has(fieldRef)) return;
  visitedRefs.put(fieldRef);
  const field = await xref.fetchAsync(fieldRef);
  if (!(field instanceof Dict)) return;
 
  let subtype = await field.getAsync("Subtype");
  subtype = subtype instanceof Name ? subtype.name : null;
  switch (subtype) {
    case "Link": return; // 跳过无关注释类型 (issue 19281)
  }
 
  if (field.has("T")) {
    const partName = stringToPDFString(await field.getAsync("T"));
    name = name === "" ? partName : `${name}.${partName}`; // 拼层级名 a.b.c
  } else {
    // 字段自己没有 /T: 沿 Parent 链向上找(带环检测)
    let obj = field;
    const walkedRefs = new RefSet();
    while (true) {
      obj = obj.getRaw("Parent") || parentRef;
      /* ... visitedRefs + walkedRefs 双集合防 Parent 环 ... */
      if (obj.has("T")) { /* 拼名并 break */ }
    }
  }
 
  if (parentRef && !field.has("Parent") && isName(field.get("Subtype"), "Widget")) {
    orphanFields.put(fieldRef, parentRef); // 父字段引用了它, 它却没挂 Parent
  }
 
  promises.getOrInsertComputed(name, makeArr).push(
    AnnotationFactory.create(xref, fieldRef, annotationGlobals, null, true, orphanFields, null, null)
      .then(annotation => annotation?.getFieldObject())
      .catch(/* 容错 null */)
  );
 
  if (!field.has("Kids")) return;
  const kids = await field.getAsync("Kids");
  if (Array.isArray(kids)) {
    for (const kid of kids) {
      await this.#collectFieldObjects(name, fieldRef, kid, promises, ...); // 递归下钻
    }
  }
}

递归遍历 AcroForm 字段树,把每个字段解析成可序列化的 FieldObject。要点:

  1. 字段全名拼接:PDF 字段名是层级的(T 沿祖先链用 . 连接,如 address.zip)。字段自身没 /T 时沿 Parent 链向上找——双 RefSet(visitedRefs 全局 + walkedRefs 局部)防 Parent 环。
  2. 孤儿字段修复:Fields 数组声称某 Widget 是子字段,但 Widget 自己没有 /Parent 回指——登记进 orphanFields(RefMap),供 _parsedAnnotations 归位。
  3. 按名分组的 Promise 收集:promises 是 Map<全名, Promise[]>——同名(同全名)字段的多个 Widget(如单选按钮组的多个选项)会聚合到一个数组里。

get fieldObjects 方法

get fieldObjects() {
  const promise = this.pdfManager.ensureDoc("formInfo").then(async formInfo => {
    if (!formInfo.hasFields) return null;
    const annotationGlobals = await this.annotationGlobals;
    if (!annotationGlobals) return null;
    const { acroForm } = annotationGlobals;
 
    const visitedRefs = new RefSet();
    const allFields = new Map();
    const fieldPromises = new Map();
    const orphanFields = new RefMap();
    for (const fieldRef of acroForm.get("Fields")) {
      await this.#collectFieldObjects("", null, fieldRef, fieldPromises,
        annotationGlobals, visitedRefs, orphanFields);
    }
 
    const allPromises = [];
    for (const [name, promises] of fieldPromises) {
      allPromises.push(
        Promise.all(promises).then(fields => {
          fields = fields.filter(Boolean);
          if (fields.length > 0) allFields.set(name, fields);
        })
      );
    }
    await Promise.all(allPromises);
    return {
      allFields: allFields.size ? allFields : null,
      orphanFields,
    };
  });
  return shadow(this, "fieldObjects", promise);
}

表单字段的总入口(主线程「填写表单」功能依赖它):从 acroForm.Fields 出发递归收集全部字段,按全名聚合成 Map<name, FieldObject[]>。返回 { allFields, orphanFields },后者反哺 _parsedAnnotations 的孤儿修复。整个结果是 Promise + shadow 固化,只收集一次。

async #collectSignatureFields 方法

async #collectSignatureFields(fields, out, visitedRefs) {
  if (!Array.isArray(fields)) return;
  for (const fieldRef of fields) {
    if (fieldRef instanceof Ref) {
      if (visitedRefs.has(fieldRef)) continue;
      visitedRefs.put(fieldRef);
    }
    const field = await this.xref.fetchIfRefAsync(fieldRef);
    if (!(field instanceof Dict)) continue;
    if (isName(await field.getAsync("FT"), "Sig")) {
      const sigDict = await field.getAsync("V"); // 签名字典
      if (sigDict instanceof Dict) {
        const parsed = await this.#parseSignatureDict(field, sigDict, fieldRef);
        if (parsed) out.push(parsed);
      }
    }
    if (field.has("Kids")) {
      // 终端字段的 Widget 子注释也要走, 所以先收自身签名再递归
      await this.#collectSignatureFields(await field.getAsync("Kids"), out, visitedRefs);
    }
  }
}

遍历字段树收集所有签名字段(FT === "Sig")的 /V 签名字典,交给 #parseSignatureDict 解析。注意注释:终端字段的孩子可能是 Widget 注释,所以「先收自身、再递归 Kids」的顺序不能反。

async #getByteRange 方法

async #getByteRange(begin, end) {
  try {
    return this.stream.getByteRange(begin, end);
  } catch (ex) {
    if (!(ex instanceof MissingDataException)) throw ex;
    await this.pdfManager.requestRange(begin, end); // 流式: 请求缺失区段后重试
    return this.#getByteRange(begin, end);
  }
}

从原始流里取字节区段,带流式加载重试:数据还没到(MissingDataException)时通过 pdfManager.requestRange 发起 HTTP Range 请求,到位后递归重试。签名字节区间可能落在文件任意位置,这一层封装让签名验证对流式文档透明。

async #coversWholeDocument 方法

async #coversWholeDocument(signedEnd, modificationsAfterSignature) {
  if (modificationsAfterSignature > 0) return false;
 
  const fileLength = this.stream.end;
  for (let begin = signedEnd; begin < fileLength; begin += SIGNATURE_TAIL_CHUNK_SIZE) {
    const end = Math.min(begin + SIGNATURE_TAIL_CHUNK_SIZE, fileLength);
    const tail = await this.#getByteRange(begin, end);
    for (const byte of tail) {
      if (byte !== 0x00 && byte !== 0x09 && byte !== 0x0a &&
          byte !== 0x0c && byte !== 0x0d && byte !== 0x20) {
        return false; // 签名区之后有非空白字节 → 未覆盖全文
      }
    }
  }
  return true;
}

判定签名是否覆盖整个文档:签名区间(ByteRange 的 c+d 处)之后只允许出现 PDF 空白字符(null/tab/LF/FF/CR/space),64KB 分块扫描。若 xref.countUpdatesAfter(signedEnd) 发现签名后有增量更新,直接判 false——文件被追加修改过,签名不再覆盖全文。

async #parseSignatureDict 方法

async #parseSignatureDict(field, sigDict, fieldRef) {
  const byteRange = await sigDict.getAsync("ByteRange");
  if (!Array.isArray(byteRange) || byteRange.length !== 4 ||
      byteRange.some(n => !Number.isInteger(n) || n < 0)) {
    return null;
  }
  // ByteRange = [a, b, c, d]: 已签名字节是 [a..a+b] 与 [c..c+d] 两段,
  // 中间的缺口正是 /Contents 的 PKCS#7 十六进制串本身
  const [a, b, c, d] = byteRange;
  const fileLength = this.stream.end || 0; // 用 stream.end 而非 length(偏移以原始字节为准)
  // 拒绝结构上不可能的 ByteRange: 必须从文件头开始、首段非空、
  // 两段间给 /Contents 留位、且整体不越界 —— 防伪造"覆盖全文"
  if (a !== 0 || b <= 0 || a + b > c || c + d > fileLength || fileLength === 0) {
    return null;
  }
 
  const contents = await sigDict.getAsync("Contents");
  if (typeof contents !== "string" || contents.length === 0) return null;
 
  const [filterName, subFilterName, t, name, reason, location, contactInfo, m] =
    await Promise.all([/* Filter/SubFilter/T/Name/Reason/Location/ContactInfo/M */]);
  let signatureType = null;
  if (subFilter === "adbe.pkcs7.detached") signatureType = 0;
  else if (subFilter === "adbe.pkcs7.sha1") signatureType = 1;
 
  const refKey = fieldRef instanceof Ref ? fieldRef.toString() : "inline";
  return {
    id: `${refKey}:${a}-${b}-${c}-${d}`,
    fieldName, signerName, reason, location, contactInfo, signingTime,
    filter, subFilter, signatureType, byteRange,
    pkcs7: stringToBytes(contents),
    revisionIndex: 0, parentId: null,
  };
}

解析单个签名字典成结构化对象。核心是 /ByteRange 的安全校验——四元组 [a, b, c, d] 描述两段已签字节(中间缺口是签名值本身),代码显式拒绝 a !== 0、b <= 0、越界等畸形值,注释说明原因:「没有这层校验,精心构造的 PDF 可以声称覆盖全文,实际只签了一小段序言」。

元数据(签名者、理由、位置、时间等)并发读取;signatureType 按 SubFilter 映射成枚举(0=detached,1=sha1)。pkcs7 是原始 PKCS#7 签名数据。

get signatures 方法

get signatures() {
  const promise = this.pdfManager.ensureDoc("formInfo").then(async formInfo => {
    if (!formInfo.hasSignatures || !formInfo.hasFields) return null;
    const annotationGlobals = await this.annotationGlobals;
    if (!annotationGlobals) return null;
    const fields = annotationGlobals.acroForm.get("Fields");
 
    const collected = [];
    await this.#collectSignatureFields(fields, collected, new RefSet());
 
    await Promise.all(
      collected.map(async signature => {
        const signedEnd = signature.byteRange[2] + signature.byteRange[3];
        signature.modificationsAfterSignature = this.xref.countUpdatesAfter(signedEnd);
        signature.coversWholeDocument = await this.#coversWholeDocument(
          signedEnd, signature.modificationsAfterSignature
        );
      })
    );
 
    // 按 ByteRange 覆盖范围排序(降序): 覆盖最全的是最外层修订;
    // 每个签名指向它之前第一个比它大的签名作为 parentId(修订链)
    collected.sort((a, b) => b.byteRange[2] + b.byteRange[3] - (a.byteRange[2] + a.byteRange[3]));
    for (let i = 0, ii = collected.length; i < ii; i++) {
      const sig = collected[i];
      sig.revisionIndex = i;
      for (let j = i - 1; j >= 0; j--) {
        const candidate = collected[j];
        if (candidate.byteRange[2] + candidate.byteRange[3] > sig.byteRange[2] + sig.byteRange[3]) {
          sig.parentId = candidate.id;
          break;
        }
      }
    }
 
    // pkcs7 与 byteRange 留在 Worker 端, 元数据瘦身;
    // 验证时主线程按 id 通过 getSignatureData 按需取回
    const signatureData = new Map();
    const metadata = collected.map(sig => {
      const { pkcs7, ...rest } = sig;
      signatureData.set(sig.id, { byteRange: sig.byteRange, pkcs7 });
      return rest;
    });
    this.#signatureData = signatureData;
    return metadata.length ? metadata : null;
  });
  return shadow(this, "signatures", promise);
}

签名信息总入口,在 #collectSignatureFields 之上做三件事:

  1. 补充完整性信息:每个签名算出 signedEnd,用 xref.countUpdatesAfter 数其后的增量更新次数,再 #coversWholeDocument 判定是否覆盖全文;
  2. 构建修订链:多次签名(增量更新场景)按覆盖范围降序排列,revisionIndex 标记次序,每个签名挂到「第一个比它大的前辈」上形成父子链——主线程据此展示「第 N 次签名后被修改」的层级;
  3. 元数据瘦身:PKCS#7 大对象和 byteRange 留在 Worker(#signatureData),发给主线程的只有轻量元数据,验证时才按需取——这是 Worker↔主线程传输量的典型优化。

async getSignatureData 方法

async getSignatureData(id) {
  await this.signatures; // 确保解析完成, #signatureData 已填充
  const signature = this.#signatureData?.get(id);
  if (!signature) return null;
  const { byteRange, pkcs7 } = signature;
  const [a, b, c, d] = byteRange;
  const data = await Promise.all([
    this.#getByteRange(a, a + b),
    this.#getByteRange(c, c + d),
  ]);
  return { data, pkcs7 };
}

主线程验证签名时按需调用:从流里切出两段已签字节(#getByteRange 自带流式重试),连同 PKCS#7 一起发回,供主线程跑密码学验证。一次只取一个签名,避免无用的大对象传输。

get hasJSActions 方法

get hasJSActions() {
  const promise = Promise.all([
    this.pdfManager.ensureCatalog("jsActions"),
    this.pdfManager.ensureDoc("fieldObjects"),
  ]).then(([catalogJsActions, fieldObjects]) => {
    if (catalogJsActions) return true;
    if (fieldObjects?.allFields) {
      return fieldObjects.allFields
        .values()
        .some(fieldObj => fieldObj.some(obj => obj.actions !== null));
    }
    return false;
  });
  return shadow(this, "hasJSActions", promise);
}

判定文档是否包含 JavaScript 动作。两级检查:catalog 级(文档打开/保存等全局动作,或页级 AA 已在 Catalog 聚合),以及字段级——任何一个表单字段挂有 actions 即算。主线程据此决定是否初始化 JS 沙箱引擎。

get calculationOrderIds 方法

get calculationOrderIds() {
  const calculationOrder = this.catalog.acroForm?.get("CO");
  if (!Array.isArray(calculationOrder) || calculationOrder.length === 0) {
    return shadow(this, "calculationOrderIds", null);
  }
  const ids = [];
  for (const id of calculationOrder) {
    if (id instanceof Ref) ids.push(id.toString());
  }
  return shadow(this, "calculationOrderIds", ids.length ? ids : null);
}

读取 AcroForm 的 /CO(Calculation Order)数组——表单字段计算脚本的执行顺序(如「总价 = 单价 × 数量」要求单价先算)。返回字段引用 ID 的有序数组,主线程的 scripting 引擎按此顺序触发计算。

get annotationGlobals 方法

get annotationGlobals() {
  return shadow(
    this,
    "annotationGlobals",
    AnnotationFactory.createGlobals(this.pdfManager)
  );
}

创建注释全局数据(AnnotationGlobals):AcroForm 字典、默认外观(/DA)、默认资源(/DR)、字段默认字体等——所有注释解析共享的上下文。_parsedAnnotations、fieldObjects、signatures 都以它为前置依赖。返回 Promise 并 shadow 固化。

async toJSObject 方法

async toJSObject(value, firstCall = true) {
  /* 仅 TESTING / INTERNAL_VIEWER 构建可用, 其他构建直接抛错 */
  const { InternalViewerUtils } = await import("./internal_viewer_utils.js");
 
  if (value === null && firstCall) {
    return this.toJSObject(this.xref.trailer, false); // 入口: 从 trailer 开始
  }
  if (value instanceof Dict) {
    const obj = Object.create(null);
    const isPage = isName(value.get("Type"), "Page");
    for (const [key, val] of value.getRawEntries()) {
      obj[key] = isPage && key === "Contents"
        ? InternalViewerUtils.getContentTokens(val, this.xref) // 页内容流转成 token 列表
        : await this.toJSObject(val, false);
    }
    return obj;
  }
  if (Array.isArray(value)) { /* 递归 map */ }
  if (value instanceof Ref) {
    if (firstCall) return this.toJSObject(this.xref.fetch(value), false);
    return { num: value.num, gen: value.gen }; // 深层 Ref 只留坐标不展开, 防循环
  }
  if (value instanceof BaseStream) {
    /* Subtype/Image → 解码为 imageData; Subtype/Form → 词法分析为指令序列;
       PostScript calculator 函数 → 转成 JS 代码; 其余 → 原始字节字符串 */
  }
  return value;
}

内部调试工具(仅在测试构建或内部查看器可用):把 PDF 对象树递归转成普通 JS 对象,供 internal viewer(调试面板)检查文档结构。三个专门化处理:

  • 页 Contents:不解码,转成 token 序列(算子名 + 参数),方便查看原始内容流;
  • 图片流:PDFImage.buildImage 解码成 imageData(宽高 + RGBA 数据);
  • Form XObject / PS calculator 函数:Form 流转成指令分组,PS 计算函数经 postScriptToJSCode 翻成等价 JS 代码——把逆波兰表达式变成可读代码。

深层 Ref 只输出 {num, gen} 不递归展开,避免循环引用爆栈。

总结

document.js 是 Worker 端的文档中枢,把第六篇解析出的 xref 变成可用的文档模型,并为第七篇的 PartialEvaluator 备好一切输入。

两个类的分工

类职责关键产出
PDFDocument文档级数据持有者:header/xref/线性化、页数、表单、签名、指纹、XFA、JS 动作catalog、getPage()、文档元数据
Page页级模型:页字典封装、属性继承、Box 降级链、内容流组装、注释解析内容流 + Resources + operatorList

关键设计原则

  1. 继承与降级无处不在。页面属性沿页面树继承(#getInheritableProperty),Box 按 CropBox∩MediaBox 降级,页数按 catalog → xfaFactory → linearization 多来源兜底——PDF 规范的「可继承」特性被一套统一机制消化。

  2. Promise 缓存 + shadow 固化。#pagePromises、_parsedAnnotations、fieldObjects、signatures 等所有昂贵操作都是「算一次、永久缓存」——Promise 直接被 shadow 写成实例属性,重复访问零开销。

  3. 宽容优先,recovery 兜底。checkHeader 找不到签名不抛错;checkFirstPage/checkLastPage 撞见 xref 损坏先清缓存再抛 XRefParseException 触发全文件重扫;页数虚标就全量遍历页面树修正。「能打开就打开」贯穿始终。

  4. 传输瘦身。签名的 PKCS#7、大对象留在 Worker,主线程按需取(getSignatureData);documentInfo 的自定义键走类型白名单;注释数据与 operatorList 分通道传输。Worker↔主线程的每一字节都被精打细算。

  5. 编辑器是双向的。Page 不只解析注释,还能 save/saveNewAnnotations 把编辑结果序列化回 PDF 对象树(changes 变更集),配合 #replaceIdByRef 的增量写回,支撑起浏览器里的注释编辑与表单填写。

至此,从文件字节(第 6 篇 xref)→ 文档模型(本篇)→ 算子列表(第 7 篇)的 Worker 端全链路已经完整。主线程拿到的,是随时可渲染、可搜索、可编辑的结构化文档。

StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.

相关文章