PDF交叉引用表解析:极致详解

文章目录

  • PDF交叉引用表解析:极致详解
    • 目录
    • [1. PDF文件整体结构(逐字节分析)](#1. PDF文件整体结构(逐字节分析))
      • [1.1 文件头 (Header)](#1.1 文件头 (Header))
      • [1.2 对象体 (Body)](#1.2 对象体 (Body))
      • [1.3 交叉引用表 (XRef Table) - 传统V4格式](#1.3 交叉引用表 (XRef Table) - 传统V4格式)
      • [1.4 文件尾 (Trailer)](#1.4 文件尾 (Trailer))
    • [2. 传统XRef表(V4)的二进制布局(极致细节)](#2. 传统XRef表(V4)的二进制布局(极致细节))
      • [2.1 整体结构](#2.1 整体结构)
      • [2.2 子节 (Subsection) 结构](#2.2 子节 (Subsection) 结构)
      • [2.3 XRef条目(20字节固定)](#2.3 XRef条目(20字节固定))
      • [2.4 条目解析算法](#2.4 条目解析算法)
    • [3. XRef流(V5)的二进制布局(极致细节)](#3. XRef流(V5)的二进制布局(极致细节))
      • [3.1 流对象结构](#3.1 流对象结构)
      • [3.2 W数组](#3.2 W数组)
      • [3.3 Index数组](#3.3 Index数组)
      • [3.4 二进制条目布局](#3.4 二进制条目布局)
      • [3.5 变长整数读取(GetVarInt)](#3.5 变长整数读取(GetVarInt))
    • [4. 代码前置:关键数据结构](#4. 代码前置:关键数据结构)
      • [4.1 CPDF_SyntaxParser](#4.1 CPDF_SyntaxParser)
      • [4.2 CPDF_Parser中的对象信息映射](#4.2 CPDF_Parser中的对象信息映射)
      • [4.3 常量定义](#4.3 常量定义)
    • [5. StartParse:入口函数逐行解析](#5. StartParse:入口函数逐行解析)
      • [5.1 函数签名与初始检查](#5.1 函数签名与初始检查)
      • [5.2 查找文件头](#5.2 查找文件头)
      • [5.3 初始化语法解析器](#5.3 初始化语法解析器)
      • [5.4 读取版本号](#5.4 读取版本号)
      • [5.5 定位到文件末尾附近](#5.5 定位到文件末尾附近)
      • [5.6 向后搜索startxref](#5.6 向后搜索startxref)
      • [5.7 读取XRef偏移量](#5.7 读取XRef偏移量)
      • [5.8 加载交叉引用表](#5.8 加载交叉引用表)
      • [5.9 没有startxref的情况](#5.9 没有startxref的情况)
      • [5.10 设置加密处理器](#5.10 设置加密处理器)
      • [5.11 加载文档并验证](#5.11 加载文档并验证)
      • [5.12 根对象编号检查](#5.12 根对象编号检查)
      • [5.13 Metadata特殊处理](#5.13 Metadata特殊处理)
    • [6. LoadAllCrossRefV4:加载传统XRef链](#6. LoadAllCrossRefV4:加载传统XRef链)
      • [6.1 设计意图](#6.1 设计意图)
      • [6.2 代码逐行解析](#6.2 代码逐行解析)
    • [7. LoadCrossRefV4:解析单个XRef表](#7. LoadCrossRefV4:解析单个XRef表)
      • [7.1 函数签名](#7.1 函数签名)
      • [7.2 定位与关键字验证](#7.2 定位与关键字验证)
      • [7.3 记录位置](#7.3 记录位置)
      • [7.4 循环解析子节](#7.4 循环解析子节)
      • [7.5 解析子节头](#7.5 解析子节头)
      • [7.6 处理条目数据(如果bSkip==false)](#7.6 处理条目数据(如果bSkip==false))
      • [7.7 移动到下一个子节](#7.7 移动到下一个子节)
      • [7.8 处理关联的XRef流](#7.8 处理关联的XRef流)
    • [8. LoadAllCrossRefV5:加载XRef流链](#8. LoadAllCrossRefV5:加载XRef流链)
    • [9. LoadCrossRefV5:解析单个XRef流](#9. LoadCrossRefV5:解析单个XRef流)
      • [9.1 解析流对象](#9.1 解析流对象)
      • [9.2 验证为流对象](#9.2 验证为流对象)
      • [9.3 处理Trailer](#9.3 处理Trailer)
      • [9.4 解析Index数组](#9.4 解析Index数组)
      • [9.5 解析W数组](#9.5 解析W数组)
      • [9.6 加载流数据](#9.6 加载流数据)
      • [9.7 遍历所有子节](#9.7 遍历所有子节)
      • [9.8 遍历子节中的每个条目](#9.8 遍历子节中的每个条目)
    • [10. 辅助函数与对象映射表](#10. 辅助函数与对象映射表)
      • [10.1 GetObjectType](#10.1 GetObjectType)
      • [10.2 GetObjectOffset](#10.2 GetObjectOffset)
      • [10.3 ShrinkObjectMap](#10.3 ShrinkObjectMap)
      • [10.4 GetVarInt](#10.4 GetVarInt)
    • [11. 总结:数据流图](#11. 总结:数据流图)
    • [12. 常见问题与边界情况](#12. 常见问题与边界情况)
      • [Q1: 为什么传统XRef条目是20字节?](#Q1: 为什么传统XRef条目是20字节?)
      • [Q2: 为什么需要`m_SortedOffset`?](#Q2: 为什么需要m_SortedOffset?)
      • [Q3: 如何处理损坏的XRef表?](#Q3: 如何处理损坏的XRef表?)
      • [Q4: 为什么`LoadCrossRefV4`中要检查偏移为0时的数字有效性?](#Q4: 为什么LoadCrossRefV4中要检查偏移为0时的数字有效性?)
      • [Q5: 压缩对象(type=2)为什么要设置对象流的type=255?](#Q5: 压缩对象(type=2)为什么要设置对象流的type=255?)

PDF交叉引用表解析:极致详解

目录

  1. PDF文件整体结构(逐字节分析)
  2. 传统XRef表(V4)的二进制布局
  3. XRef流(V5)的二进制布局
  4. 代码前置:关键数据结构
  5. StartParse:入口函数逐行解析
  6. LoadAllCrossRefV4:加载传统XRef链
  7. LoadCrossRefV4:解析单个XRef表
  8. LoadAllCrossRefV5:加载XRef流链
  9. LoadCrossRefV5:解析单个XRef流
  10. 辅助函数与对象映射表

1. PDF文件整体结构(逐字节分析)

一个完整的PDF文件由四个物理部分组成:

复制代码
[Header] [Body] [Cross-Reference Table] [Trailer]

1.1 文件头 (Header)

位置:文件的最开头(可能有前导垃圾数据,但规范要求%PDF-1.x在文件前1024字节内)

复制代码
偏移(hex)  内容(ASCII)         说明
0x00       '%'                 25h
0x01       'P'                 50h
0x02       'D'                 44h
0x03       'F'                 46h
0x04       '-'                 2Dh
0x05       '1'                 31h      ← 主版本号
0x06       '.'                 2Eh
0x07       '4'                 34h      ← 次版本号
0x08       '\r'                0Dh
0x09       '\n'                0Ah
0x0A       '%'                 25h
0x0B       '\xE2'              (二进制标记)
0x0C       '\xE3'              
0x0D       '\xCF'              
0x0E       '\xD3'              
0x0F       '\r'                
0x10       '\n'                

1.2 对象体 (Body)

由一系列间接对象组成。每个间接对象格式:

复制代码
<objnum> <gennum> obj
  << ... >>          (字典)
  stream
    ...数据...
  endstream
endobj

示例:

复制代码
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj

2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj

3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] >>
stream
...页面内容流...
endstream
endobj

1.3 交叉引用表 (XRef Table) - 传统V4格式

位置:通常在文件末尾附近,但可以由startxref指定任意位置。

复制代码
xref
0 3
0000000000 65535 f 
0000000016 00000 n 
0000000081 00000 n 
trailer
<< /Size 3 /Root 1 0 R >>
startxref
200
%%EOF

1.4 文件尾 (Trailer)

包含字典、startxref和%%EOF。字典中关键字段:

字段 含义
/Size 文件中对象总数(最大对象号+1)
/Root 根目录对象引用
/Info 信息字典引用
/Prev 上一版本XRef表位置(增量更新)
/ID 文件标识符数组
/Encrypt 加密字典

2. 传统XRef表(V4)的二进制布局(极致细节)

2.1 整体结构

复制代码
"xref" LF?          ← 关键字,后面可跟空格或换行
<subsection1>
<subsection2>
...
"trailer" LF?
<dictionary>
"startxref" LF?
<number> LF?
"%%EOF"

2.2 子节 (Subsection) 结构

每个子节行:

复制代码
<start> <count> LF
  • <start>: 十进制整数,本子节第一个对象号
  • <count>: 十进制整数,本子节连续对象的个数
  • 中间用一个空格分隔,末尾可以是LF或CR+LF

2.3 XRef条目(20字节固定)

每个条目占用恰好20字节(包括换行符)。PDF规范ISO 32000-1:2008 表17定义:

复制代码
偏移(字节)  长度  内容                    示例
0-9        10    对象偏移量,十进制,右对齐,前导空格    "       123"
10         1     空格                                " "
11-15      5     生成号,十进制,右对齐,前导空格       "    0"
16         1     空格                                " "
17         1     状态:'n' 或 'f'                    "n"
18-19      2     行结束符:空格+换行 或 回车+换行      "\r\n"

重要:偏移量是相对于文件开头的字节数,从0开始计数。

示例条目

复制代码
"       123 00000 n\r\n"
 0       9 10        16 17   19

2.4 条目解析算法

给定一个指向条目开始的指针p(char*):

  • 偏移量 = atoi§ // 前10个字符,忽略前导空格
  • 生成号 = atoi(p+11) // 从第12个字符开始(0索引11)
  • 状态 = p[17] // 第18个字符

3. XRef流(V5)的二进制布局(极致细节)

3.1 流对象结构

pdf 复制代码
<objnum> 0 obj
<<
/Type /XRef
/Size <integer>
/Prev <integer>
/W [<int> <int> <int>]
/Index [<int> <int> ...]
/DecodeParms << /Columns <int> /Predictor <int> >>   (可选)
>>
stream
...二进制数据...
endstream
endobj

3.2 W数组

W数组定义每个XRef条目的字段宽度(单位:字节)。长度固定为3(类型、字段2、字段3)。例如:

  • /W [1 4 1] → 每个条目1+4+1=6字节
  • /W [0 8 0] → 每个条目8字节(类型字段宽度0表示该字段不使用,默认为0)

字段含义

  • 字段1:类型 (Type)
    • 0 = 空闲对象
    • 1 = 未压缩对象(字段2 = 文件偏移)
    • 2 = 压缩对象(字段2 = 包含该对象的对象流编号)
  • 字段2:根据类型不同,含义不同
  • 字段3:生成号(通常为0,除非该对象被重用时增加)

3.3 Index数组

Index数组定义哪些对象号范围在流中有条目。格式:
/Index [ start1 count1 start2 count2 ... ]

如果没有Index数组,则默认从0到Size-1。

3.4 二进制条目布局

条目按顺序连续存储,每个条目使用W数组定义的宽度。

示例:W=[1,4,1],条目数据(十六进制):

复制代码
01 00 00 01 00 00      ← 类型1,偏移256(0x100),生成号0
02 00 00 00 01 00      ← 类型2,对象流编号1,生成号0
00 00 00 00 00 00      ← 类型0,空闲对象

3.5 变长整数读取(GetVarInt)

由于字段宽度可变(可能是0,1,2,4,8字节),需要按大端序读取:

cpp 复制代码
uint32_t GetVarInt(const uint8_t* p, int32_t n) {
    uint32_t result = 0;
    for (int32_t i = 0; i < n; ++i)
        result = (result << 8) | p[i];
    return result;
}

示例:

  • p = {0x00, 0x01, 0x00}, n=3 → 0x000100 = 256
  • p = {0x12, 0x34}, n=2 → 0x1234 = 4660

4. 代码前置:关键数据结构

在解析代码前,必须理解以下几个核心数据结构。

4.1 CPDF_SyntaxParser

语法解析器,维护文件读取状态:

cpp 复制代码
class CPDF_SyntaxParser {
    FX_FILESIZE m_Pos;               // 当前读取位置(相对于文件头)
    FX_FILESIZE m_FileLen;           // 文件总长度
    CFX_RetainPtr<IFX_SeekableReadStream> m_pFileAccess; // 文件访问接口
    uint32_t m_MetadataObjnum;       // 特殊处理的Metadata对象号
};

4.2 CPDF_Parser中的对象信息映射

cpp 复制代码
struct {
    FX_FILESIZE pos;    // 文件偏移(type=1)或对象流编号(type=2)
    uint16_t gennum;    // 生成号
    uint8_t type;       // 0=空闲,1=正常,2=压缩,255=压缩流标记
} ObjectInfo;
std::map<uint32_t, ObjectInfo> m_ObjectInfo;   // 对象号 -> 信息
std::set<FX_FILESIZE> m_SortedOffset;          // 所有对象偏移的有序集合(用于计算对象大小)

4.3 常量定义

cpp 复制代码
const int32_t kMaxXRefSize = 1048576;   // 最大XRef表大小(1M个对象)
const uint32_t kMaxObjectNumber = 0x3FFFFFFF; // 最大对象号(约10亿)

5. StartParse:入口函数逐行解析

5.1 函数签名与初始检查

cpp 复制代码
CPDF_Parser::Error CPDF_Parser::StartParse(
    const CFX_RetainPtr<IFX_SeekableReadStream>& pFileAccess,
    CPDF_Document* pDocument) {

参数

  • pFileAccess:文件读取接口,支持Seek和Read
  • pDocument:要填充的文档对象

返回值:枚举Error(SUCCESS, FORMAT_ERROR, PASSWORD_ERROR等)

cpp 复制代码
  ASSERT(!m_bHasParsed);
  m_bHasParsed = true;      // 防止重复解析
  m_bXRefStream = false;    // 初始假设为传统XRef
  m_LastXRefOffset = 0;

5.2 查找文件头

cpp 复制代码
  int32_t offset = GetHeaderOffset(pFileAccess);
  if (offset == -1)
    return FORMAT_ERROR;

GetHeaderOffset实现(伪代码):

  • 读取文件前1024字节
  • 搜索"%PDF"字符串
  • 返回'%'的位置,如果没找到返回-1

为什么需要这个:有些PDF文件可能被包裹在其他数据中(如邮件附件、HTTP响应),所以PDF头不一定在文件开头。

5.3 初始化语法解析器

cpp 复制代码
  m_pSyntax->InitParser(pFileAccess, offset);

InitParser设置文件访问接口,并将当前读取位置设置为offset(即PDF头的起始位置)。这样后续所有GetCharGetWord等操作都从PDF头开始。

5.4 读取版本号

PDF版本号位于文件头的第6和第8个字符(0索引):

复制代码
位置: 0 1 2 3 4 5 6 7 8 9
字符: % P D F - 1 . 4 \r \n
                ↑   ↑
              主版本 次版本
cpp 复制代码
  uint8_t ch;
  if (!m_pSyntax->GetCharAt(5, ch))
    return FORMAT_ERROR;

  if (std::isdigit(ch))
    m_FileVersion = FXSYS_DecimalCharToInt(static_cast<wchar_t>(ch)) * 10;

  if (!m_pSyntax->GetCharAt(7, ch))
    return FORMAT_ERROR;

  if (std::isdigit(ch))
    m_FileVersion += FXSYS_DecimalCharToInt(static_cast<wchar_t>(ch));

GetCharAt(5, ch):从m_pSyntax当前基础位置(即offset)加上5字节处读取一个字符。

版本号存储为整数:例如"1.4" → 10+4=14。这样方便比较版本。

5.5 定位到文件末尾附近

cpp 复制代码
  if (m_pSyntax->m_FileLen < m_pSyntax->m_HeaderOffset + 9)
    return FORMAT_ERROR;

  m_pSyntax->SetPos(m_pSyntax->m_FileLen - m_pSyntax->m_HeaderOffset - 9);

计算

  • m_HeaderOffset是PDF头在文件中的绝对偏移(通常就是offset)
  • m_FileLen是文件总长度
  • m_pSyntax->SetPos设置的是相对于PDF头的偏移(即逻辑位置)
  • 因此逻辑位置 = 绝对位置 - m_HeaderOffset

m_FileLen - m_HeaderOffset - 9:文件末尾倒数第9个字节(因为"%%EOF"至少占5字节,留出空间)。这样我们可以向后搜索"startxref"。

5.6 向后搜索startxref

cpp 复制代码
  m_pDocument = pDocument;

  bool bXRefRebuilt = false;
  if (m_pSyntax->BackwardsSearchToWord("startxref", 4096)) {

BackwardsSearchToWord实现:

  • 从当前位置向文件开头方向扫描,每次读取一个字符
  • 匹配"startxref"字符串
  • 最多搜索4096字节(PDF规范要求startxref在文件最后1024字节内,这里多搜了4倍,更安全)

如果找到,函数返回true,并且当前解析器位置设置在"startxref"第一个字符处。

5.7 读取XRef偏移量

cpp 复制代码
    m_SortedOffset.insert(m_pSyntax->GetPos());
    m_pSyntax->GetKeyword();

    bool bNumber;
    CFX_ByteString xrefpos_str = m_pSyntax->GetNextWord(&bNumber);
    if (!bNumber)
      return FORMAT_ERROR;

    m_LastXRefOffset = (FX_FILESIZE)FXSYS_atoi64(xrefpos_str.c_str());
  • m_SortedOffset记录每个XRef表的位置,用于后续计算对象大小。
  • GetKeyword()读取"startxref"并消费它。
  • GetNextWord读取下一个token(应该是数字字符串),并判断是否为数字。
  • 转换为64位整数(文件可能大于4GB)。

5.8 加载交叉引用表

cpp 复制代码
    if (!LoadAllCrossRefV4(m_LastXRefOffset) &&
        !LoadAllCrossRefV5(m_LastXRefOffset)) {
      if (!RebuildCrossRef())
        return FORMAT_ERROR;

      bXRefRebuilt = true;
      m_LastXRefOffset = 0;
    }

先尝试V4传统格式,失败则尝试V5流格式,都失败则重建(通过扫描文件中的所有对象)。

5.9 没有startxref的情况

cpp 复制代码
  } else {
    if (!RebuildCrossRef())
      return FORMAT_ERROR;

    bXRefRebuilt = true;
  }

如果文件中找不到"startxref",直接重建。

5.10 设置加密处理器

cpp 复制代码
  Error eRet = SetEncryptHandler();
  if (eRet != SUCCESS)
    return eRet;

SetEncryptHandler会检查trailer中的/Encrypt字典,如果是标准加密则初始化安全处理器。

5.11 加载文档并验证

cpp 复制代码
  m_pDocument->LoadDoc();
  if (!m_pDocument->GetRoot() || m_pDocument->GetPageCount() == 0) {
    if (bXRefRebuilt)
      return FORMAT_ERROR;

    ReleaseEncryptHandler();
    if (!RebuildCrossRef())
      return FORMAT_ERROR;

    eRet = SetEncryptHandler();
    if (eRet != SUCCESS)
      return eRet;

    m_pDocument->LoadDoc();
    if (!m_pDocument->GetRoot())
      return FORMAT_ERROR;
  }

LoadDoc会解析根字典和页面树。如果根字典不存在或没有页面,且之前没有重建过XRef,则尝试重建后再加载。

5.12 根对象编号检查

cpp 复制代码
  if (GetRootObjNum() == 0) {
    ReleaseEncryptHandler();
    if (!RebuildCrossRef() || GetRootObjNum() == 0)
      return FORMAT_ERROR;

    eRet = SetEncryptHandler();
    if (eRet != SUCCESS)
      return eRet;
  }

GetRootObjNum返回trailer中/Root引用的对象号。如果为0,说明trailer无效,重建。

5.13 Metadata特殊处理

cpp 复制代码
  if (m_pSecurityHandler && !m_pSecurityHandler->IsMetadataEncrypted()) {
    CPDF_Reference* pMetadata =
        ToReference(m_pDocument->GetRoot()->GetObjectFor("Metadata"));
    if (pMetadata)
      m_pSyntax->m_MetadataObjnum = pMetadata->GetRefObjNum();
  }

如果存在安全处理器且元数据未加密,则记录Metadata的对象号。这样在解析时,即使文档加密,Metadata流也可以不被解密(因为其本身可能未加密)。

cpp 复制代码
  return SUCCESS;
}

6. LoadAllCrossRefV4:加载传统XRef链

6.1 设计意图

PDF支持增量更新:每次保存时,新内容追加到文件末尾,并创建一个新的XRef表,通过/Prev指向上一个XRef表。这样就形成了一个链表(最新的在文件末尾)。LoadAllCrossRefV4的任务是:

  1. 加载最新的XRef表(包括trailer)
  2. 沿着/Prev链回溯,加载所有历史XRef表
  3. 合并所有条目,较新的条目覆盖较旧的

6.2 代码逐行解析

cpp 复制代码
bool CPDF_Parser::LoadAllCrossRefV4(FX_FILESIZE xrefpos) {
  // 步骤1:加载最新的XRef表,bSkip=true表示只解析子节头,不解析具体条目
  if (!LoadCrossRefV4(xrefpos, 0, true))
    return false;

xrefpos是startxref给出的最新XRef表位置(绝对偏移)。LoadCrossRefV4会验证"xref"关键字,并跳过条目数据(只解析子节结构),同时也会读取紧随其后的trailer字典并存储在m_pTrailer中。

cpp 复制代码
  m_pTrailer = LoadTrailerV4();
  if (!m_pTrailer)
    return false;

LoadTrailerV4假设当前解析器位置正好在"trailer"关键字处,读取字典并返回。注意这里又调用了一次,但实际上LoadCrossRefV4已经读取了trailer并存储在了某个地方?让我们看LoadCrossRefV4的实现:当bSkip=true时,它不会读取trailer,而是解析完子节后退出,此时解析器位置应该在"trailer"之前。所以需要单独调用LoadTrailerV4

cpp 复制代码
  int32_t xrefsize = GetDirectInteger(m_pTrailer.get(), "Size");
  if (xrefsize > 0 && xrefsize <= kMaxXRefSize)
    ShrinkObjectMap(xrefsize);

ShrinkObjectMap(xrefsize)删除所有对象号 >= xrefsize的条目,因为Size声明了对象总数,超出部分是无效的。

cpp 复制代码
  std::vector<FX_FILESIZE> CrossRefList;
  std::vector<FX_FILESIZE> XRefStreamList;
  std::set<FX_FILESIZE> seen_xrefpos;

  CrossRefList.push_back(xrefpos);
  XRefStreamList.push_back(GetDirectInteger(m_pTrailer.get(), "XRefStm"));
  seen_xrefpos.insert(xrefpos);

XRefStm是PDF 1.5中引入的,允许混合使用传统XRef表和XRef流。如果存在,它指向一个XRef流的位置。这里先记录下来,后面会用到。

cpp 复制代码
  xrefpos = GetDirectInteger(m_pTrailer.get(), "Prev");
  while (xrefpos) {
    // 检查循环引用
    if (pdfium::ContainsKey(seen_xrefpos, xrefpos))
      return false;
    seen_xrefpos.insert(xrefpos);

    // 插入到链表开头(保持时间顺序从旧到新)
    CrossRefList.insert(CrossRefList.begin(), xrefpos);
    LoadCrossRefV4(xrefpos, 0, true);

    std::unique_ptr<CPDF_Dictionary> pDict(LoadTrailerV4());
    if (!pDict)
      return false;

    xrefpos = GetDirectInteger(pDict.get(), "Prev");
    XRefStreamList.insert(XRefStreamList.begin(),
                          pDict->GetIntegerFor("XRefStm"));
    m_Trailers.push_back(std::move(pDict));
  }

这个循环沿着Prev链回溯,直到Prev为0。注意CrossRefListXRefStreamList都是插入到开头,这样最终顺序是:索引0对应最旧的XRef,索引n-1对应最新的XRef。而m_Trailers则是按从旧到新的顺序存储(每次push_back)。

cpp 复制代码
  for (size_t i = 0; i < CrossRefList.size(); ++i) {
    if (!LoadCrossRefV4(CrossRefList[i], XRefStreamList[i], false))
      return false;
    if (i == 0 && !VerifyCrossRefV4())
      return false;
  }
  return true;
}

现在按从旧到新的顺序加载实际条目(bSkip=false)。最新的XRef表(i==0)会覆盖旧的条目。加载完成后,验证最新XRef表的一致性(VerifyCrossRefV4会抽查几个对象,检查对象号是否匹配)。


7. LoadCrossRefV4:解析单个XRef表

这是最核心的函数,处理一个传统XRef表(可能包含多个子节)。

7.1 函数签名

cpp 复制代码
bool CPDF_Parser::LoadCrossRefV4(FX_FILESIZE pos,
                                 FX_FILESIZE streampos,
                                 bool bSkip)
  • pos:XRef表起始位置(绝对偏移)
  • streampos:关联的XRef流位置(可为0)
  • bSkip:true时只解析子节头,不解析条目;false时解析条目

7.2 定位与关键字验证

cpp 复制代码
  m_pSyntax->SetPos(pos);
  if (m_pSyntax->GetKeyword() != "xref")
    return false;

SetPos设置逻辑位置(pos - m_HeaderOffset)。GetKeyword读取下一个token,忽略空白,必须为"xref"。

7.3 记录位置

cpp 复制代码
  m_SortedOffset.insert(pos);
  if (streampos)
    m_SortedOffset.insert(streampos);

m_SortedOffset存储所有交叉引用表的位置(包括XRef流的位置),用于后续计算每个对象的占用大小(通过查找下一个对象的位置)。

7.4 循环解析子节

cpp 复制代码
  while (1) {
    FX_FILESIZE SavedPos = m_pSyntax->GetPos();
    bool bIsNumber;
    CFX_ByteString word = m_pSyntax->GetNextWord(&bIsNumber);
    if (word.IsEmpty())
      return false;

    if (!bIsNumber) {
      m_pSyntax->SetPos(SavedPos);
      break;
    }

读取第一个token。如果是数字,则认为是子节起始对象号;如果不是数字(可能是"trailer"),则退出循环,回退位置。

7.5 解析子节头

cpp 复制代码
    uint32_t start_objnum = FXSYS_atoui(word.c_str());
    if (start_objnum >= kMaxObjectNumber)
      return false;

    uint32_t count = m_pSyntax->GetDirectNum();
    m_pSyntax->ToNextWord();
    SavedPos = m_pSyntax->GetPos();
    const int32_t recordsize = 20;

GetDirectNum读取下一个数字(对象数量)。ToNextWord跳过空白,使位置指向条目数据的开始。SavedPos保存条目数据的起始逻辑位置。

7.6 处理条目数据(如果bSkip==false)

cpp 复制代码
    m_dwXrefStartObjNum = start_objnum;
    if (!bSkip) {
      std::vector<char> buf(1024 * recordsize + 1);
      buf[1024 * recordsize] = '\0';

分配一个缓冲区,一次读取1024个条目(20KB)。加1是为了安全地放置结束符。

cpp 复制代码
      int32_t nBlocks = count / 1024 + 1;
      for (int32_t block = 0; block < nBlocks; block++) {
        int32_t block_size = block == nBlocks - 1 ? count % 1024 : 1024;
        m_pSyntax->ReadBlock(reinterpret_cast<uint8_t*>(buf.data()),
                             block_size * recordsize);

ReadBlock从当前文件位置读取指定字节数到缓冲区。注意:m_pSyntax的当前位置在上次调用ToNextWord后已经指向条目数据开始,并且每次读取后会自动移动位置。

cpp 复制代码
        for (int32_t i = 0; i < block_size; i++) {
          uint32_t objnum = start_objnum + block * 1024 + i;
          char* pEntry = &buf[i * recordsize];

计算对象号,并获取指向该条目的指针。

cpp 复制代码
          if (pEntry[17] == 'f') {
            m_ObjectInfo[objnum].pos = 0;
            m_ObjectInfo[objnum].type = 0;
          } else {

检查第18个字符(索引17)。PDF规范允许条目前面有空格,但偏移量固定10字节,所以pEntry[17]一定是'n'或'f',除非文件损坏。

cpp 复制代码
            FX_FILESIZE offset = (FX_FILESIZE)FXSYS_atoi64(pEntry);
            if (offset == 0) {
              for (int32_t c = 0; c < 10; c++) {
                if (!std::isdigit(pEntry[c]))
                  return false;
              }
            }

atoi64会跳过前导空格,所以即使偏移量字段有前导空格也能正确转换。如果偏移为0,需要额外验证前10个字符都是数字,防止将全空格字符串误认为0。

cpp 复制代码
            m_ObjectInfo[objnum].pos = offset;
            int32_t version = FXSYS_atoi(pEntry + 11);
            if (version >= 1)
              m_bVersionUpdated = true;
            m_ObjectInfo[objnum].gennum = version;
            if (m_ObjectInfo[objnum].pos < m_pSyntax->m_FileLen)
              m_SortedOffset.insert(m_ObjectInfo[objnum].pos);
            m_ObjectInfo[objnum].type = 1;
          }
        }
      }
    }

生成号从第12个字符(索引11)开始解析,长度为5。注意:atoi也会跳过前导空格,所以即使生成号字段有前导空格也没问题。

如果偏移小于文件长度,则将该偏移加入m_SortedOffset,以便后续计算对象大小。

7.7 移动到下一个子节

cpp 复制代码
    m_pSyntax->SetPos(SavedPos + count * recordsize);
  }

当前子节处理完后,将解析器位置设置为当前子节起始位置 + 条目数*20。这样下一个循环就能读取下一个子节的起始对象号。

7.8 处理关联的XRef流

cpp 复制代码
  return !streampos || LoadCrossRefV5(&streampos, false);
}

如果streampos非0,则调用LoadCrossRefV5加载该XRef流(bMainXRef=false)。这允许混合XRef表与XRef流(PDF 1.5+)。


8. LoadAllCrossRefV5:加载XRef流链

与V4类似,但处理的是XRef流(每个XRef流也是一个间接对象,通过/Prev链接)。

cpp 复制代码
bool CPDF_Parser::LoadAllCrossRefV5(FX_FILESIZE xrefpos) {
  if (!LoadCrossRefV5(&xrefpos, true))
    return false;

  std::set<FX_FILESIZE> seen_xrefpos;
  while (xrefpos) {
    seen_xrefpos.insert(xrefpos);
    if (!LoadCrossRefV5(&xrefpos, false))
      return false;
    if (pdfium::ContainsKey(seen_xrefpos, xrefpos))
      return false;
  }
  m_ObjectStreamMap.clear();
  m_bXRefStream = true;
  return true;
}
  • LoadCrossRefV5的第一个参数是指针,函数会修改它指向Prev的值。
  • bMainXRef=true表示这是最新的主XRef流,会设置m_pTrailer
  • bMainXRef=false表示是历史版本,trailer存入m_Trailers链表。
  • 清空m_ObjectStreamMap(因为XRef流不需要传统的对象流映射)。
  • 设置m_bXRefStream=true,后续解析知道使用流模式。

9. LoadCrossRefV5:解析单个XRef流

这是最复杂的函数,涉及流对象解析、W数组、Index数组、二进制数据读取。

9.1 解析流对象

cpp 复制代码
bool CPDF_Parser::LoadCrossRefV5(FX_FILESIZE* pos, bool bMainXRef) {
  std::unique_ptr<CPDF_Object> pObject(
      ParseIndirectObjectAt(m_pDocument, *pos, 0));
  if (!pObject)
    return false;

  uint32_t objnum = pObject->m_ObjNum;
  if (!objnum)
    return false;

  CPDF_Object* pUnownedObject = pObject.get();
  if (m_pDocument) {
    CPDF_Dictionary* pRootDict = m_pDocument->GetRoot();
    if (pRootDict && pRootDict->GetObjNum() == objnum)
      return false;
    if (!m_pDocument->ReplaceIndirectObjectIfHigherGeneration(
            objnum, std::move(pObject))) {
      return false;
    }
  }

ParseIndirectObjectAt解析指定位置的对象(可能是任何类型)。如果对象是根字典,则忽略(防止循环)。ReplaceIndirectObjectIfHigherGeneration检查如果已存在相同对象号但生成号更高,则替换;否则保留旧的。

9.2 验证为流对象

cpp 复制代码
  CPDF_Stream* pStream = pUnownedObject->AsStream();
  if (!pStream)
    return false;

  CPDF_Dictionary* pDict = pStream->GetDict();
  *pos = pDict->GetIntegerFor("Prev");
  int32_t size = pDict->GetIntegerFor("Size");
  if (size < 0)
    return false;

获取流字典,读取/Prev和/Size。注意*pos被更新为上一版本XRef流的位置。

9.3 处理Trailer

cpp 复制代码
  std::unique_ptr<CPDF_Dictionary> pNewTrailer = ToDictionary(pDict->Clone());
  if (bMainXRef) {
    m_pTrailer = std::move(pNewTrailer);
    ShrinkObjectMap(size);
    for (auto& it : m_ObjectInfo)
      it.second.type = 0;
  } else {
    m_Trailers.push_back(std::move(pNewTrailer));
  }

克隆字典作为trailer。如果是主XRef,则重置m_ObjectInfo中所有对象的type为0(准备用新条目填充)。注意:这里ShrinkObjectMap(size)会删除对象号>=size的条目,但不清除其余条目的type,所以紧接着手动将所有type设为0。

9.4 解析Index数组

cpp 复制代码
  std::vector<std::pair<int32_t, int32_t>> arrIndex;
  CPDF_Array* pArray = pDict->GetArrayFor("Index");
  if (pArray) {
    for (size_t i = 0; i < pArray->GetCount() / 2; i++) {
      CPDF_Object* pStartNumObj = pArray->GetObjectAt(i * 2);
      CPDF_Object* pCountObj = pArray->GetObjectAt(i * 2 + 1);

      if (ToNumber(pStartNumObj) && ToNumber(pCountObj)) {
        int nStartNum = pStartNumObj->GetInteger();
        int nCount = pCountObj->GetInteger();
        if (nStartNum >= 0 && nCount > 0)
          arrIndex.push_back(std::make_pair(nStartNum, nCount));
      }
    }
  }

  if (arrIndex.size() == 0)
    arrIndex.push_back(std::make_pair(0, size));

Index数组每两个元素一组:(start, count)。如果没有Index,则默认从0到size-1。

9.5 解析W数组

cpp 复制代码
  pArray = pDict->GetArrayFor("W");
  if (!pArray)
    return false;

  std::vector<uint32_t> WidthArray;
  FX_SAFE_UINT32 dwAccWidth = 0;
  for (size_t i = 0; i < pArray->GetCount(); ++i) {
    WidthArray.push_back(pArray->GetIntegerAt(i));
    dwAccWidth += WidthArray[i];
  }

  if (!dwAccWidth.IsValid() || WidthArray.size() < 3)
    return false;

  uint32_t totalWidth = dwAccWidth.ValueOrDie();

FX_SAFE_UINT32用于防止整数溢出。totalWidth是每个XRef条目的总字节数。

9.6 加载流数据

cpp 复制代码
  auto pAcc = pdfium::MakeRetain<CPDF_StreamAcc>(pStream);
  pAcc->LoadAllData();

  const uint8_t* pData = pAcc->GetData();
  uint32_t dwTotalSize = pAcc->GetSize();
  uint32_t segindex = 0;

CPDF_StreamAcc负责解压流(如果压缩了)并提供连续的内存数据。

9.7 遍历所有子节

cpp 复制代码
  for (uint32_t i = 0; i < arrIndex.size(); i++) {
    int32_t startnum = arrIndex[i].first;
    if (startnum < 0)
      continue;

    m_dwXrefStartObjNum = pdfium::base::checked_cast<uint32_t>(startnum);
    uint32_t count = pdfium::base::checked_cast<uint32_t>(arrIndex[i].second);
    
    FX_SAFE_UINT32 dwCaculatedSize = segindex;
    dwCaculatedSize += count;
    dwCaculatedSize *= totalWidth;
    if (!dwCaculatedSize.IsValid() ||
        dwCaculatedSize.ValueOrDie() > dwTotalSize) {
      continue;
    }

    const uint8_t* segstart = pData + segindex * totalWidth;
    FX_SAFE_UINT32 dwMaxObjNum = startnum;
    dwMaxObjNum += count;
    uint32_t dwV5Size = m_ObjectInfo.empty() ? 0 : GetLastObjNum() + 1;
    if (!dwMaxObjNum.IsValid() || dwMaxObjNum.ValueOrDie() > dwV5Size)
      continue;

segindex是当前已处理的条目数(在所有子节中累计)。segstart指向当前子节在流中的起始位置。检查计算出的总字节数不超过流总大小。同时检查对象号范围是否超出当前对象映射表的大小(如果之前ShrinkObjectMap已经设置了大小,则不能超出)。

9.8 遍历子节中的每个条目

cpp 复制代码
    for (uint32_t j = 0; j < count; j++) {
      int32_t type = 1;
      const uint8_t* entrystart = segstart + j * totalWidth;
      if (WidthArray[0])
        type = GetVarInt(entrystart, WidthArray[0]);

      if (GetObjectType(startnum + j) == 255) {
        FX_FILESIZE offset =
            GetVarInt(entrystart + WidthArray[0], WidthArray[1]);
        m_ObjectInfo[startnum + j].pos = offset;
        m_SortedOffset.insert(offset);
        continue;
      }

      if (GetObjectType(startnum + j))
        continue;

      m_ObjectInfo[startnum + j].type = type;
      if (type == 0) {
        m_ObjectInfo[startnum + j].pos = 0;
      } else {
        FX_FILESIZE offset =
            GetVarInt(entrystart + WidthArray[0], WidthArray[1]);
        m_ObjectInfo[startnum + j].pos = offset;
        if (type == 1) {
          m_SortedOffset.insert(offset);
        } else {
          if (offset < 0 || !IsValidObjectNumber(offset))
            return false;
          m_ObjectInfo[offset].type = 255;
        }
      }
    }
    segindex += count;
  }
  return true;
}

详细说明

  • GetVarInt读取指定宽度的整数(大端序)。

  • 如果当前对象的类型已经是255(之前被标记为压缩流对象),则跳过(保持原状)。

  • 如果对象类型已经非0(已被更早的XRef表定义),也跳过(新条目不覆盖旧条目?等等,这里逻辑有点反直觉。通常较新的XRef表应该覆盖旧的,但这里如果GetObjectType(startnum+j)返回非0,就直接continue。这意味着一旦某个对象号有了类型,就不会被后续子节覆盖。但注意,在主XRef加载时,之前已经将所有type设为0,所以实际上只有同一个XRef流内的多个Index段之间可能存在覆盖,但后面的段不会覆盖前面的?实际上arrIndex的顺序就是Index数组中定义的顺序,后面的段会覆盖前面的段吗?不会,因为遇到非0就跳过。这意味着Index数组中后面的段如果和前面有重叠,后面的会被忽略。这可能是设计上的缺陷,但实际使用中Index数组的段不应重叠。)

  • 类型0:空闲对象,偏移设0。

  • 类型1:普通对象,记录偏移,并将偏移加入m_SortedOffset

  • 类型2:压缩对象,offset实际是对象流编号。检查该编号有效后,将该对象流对象(编号为offset)的类型标记为255,表示它是一个对象流(容器)。注意:这里m_ObjectInfo[offset].type = 255,而offset是对象流编号,不是当前对象号。这建立了压缩对象到对象流的间接关系。


10. 辅助函数与对象映射表

10.1 GetObjectType

cpp 复制代码
uint8_t CPDF_Parser::GetObjectType(uint32_t objnum) const {
  ASSERT(IsValidObjectNumber(objnum));
  auto it = m_ObjectInfo.find(objnum);
  return it != m_ObjectInfo.end() ? it->second.type : 0;
}

通过map查找对象类型,如果不存在则返回0(空闲)。

10.2 GetObjectOffset

cpp 复制代码
FX_FILESIZE CPDF_Parser::GetObjectOffset(uint32_t objnum) const {
  if (!IsValidObjectNumber(objnum))
    return 0;

  if (GetObjectType(objnum) == 1)
    return GetObjectPositionOrZero(objnum);

  if (GetObjectType(objnum) == 2) {
    FX_FILESIZE pos = GetObjectPositionOrZero(objnum);
    return GetObjectPositionOrZero(pos);
  }
  return 0;
}
  • 类型1:直接返回存储的偏移。
  • 类型2:返回的pos是对象流编号,再查该对象流的位置(类型应为255)。
  • 类型0或255:返回0。

10.3 ShrinkObjectMap

cpp 复制代码
void CPDF_Parser::ShrinkObjectMap(uint32_t objnum) {
  if (objnum == 0) {
    m_ObjectInfo.clear();
    return;
  }

  auto it = m_ObjectInfo.lower_bound(objnum);
  while (it != m_ObjectInfo.end()) {
    auto saved_it = it++;
    m_ObjectInfo.erase(saved_it);
  }

  if (!pdfium::ContainsKey(m_ObjectInfo, objnum - 1))
    m_ObjectInfo[objnum - 1].pos = 0;
}

删除所有对象号 >= objnum 的条目。最后确保 objnum-1 存在(如果不存在则创建一个占位条目,pos=0)。这是为了后续GetLastObjNum()能正确返回最大对象号。

10.4 GetVarInt

cpp 复制代码
uint32_t GetVarInt(const uint8_t* p, int32_t n) {
  uint32_t result = 0;
  for (int32_t i = 0; i < n; ++i)
    result = result * 256 + p[i];
  return result;
}

大端序转换。例如n=4时,将p[0]<<24 + p[1]<<16 + p[2]<<8 + p[3]。


11. 总结:数据流图

复制代码
PDF文件
   │
   ▼
StartParse: 找到startxref → 获取最新XRef位置
   │
   ├─► LoadAllCrossRefV4 (传统模式)
   │       │
   │       ├─► LoadCrossRefV4 (bSkip=true) → 解析子节头,获取trailer和Prev
   │       │
   │       └─► 循环Prev链,收集所有XRef位置
   │              │
   │              └─► 从旧到新 LoadCrossRefV4 (bSkip=false)
   │                     │
   │                     └─► 解析20字节条目,填充 m_ObjectInfo
   │
   └─► LoadAllCrossRefV5 (流模式)
           │
           ├─► LoadCrossRefV5 (bMainXRef=true) → 解析XRef流对象
           │       │
           │       ├─► 读取/Size, /Prev, /W, /Index
           │       ├─► 加载流数据
           │       └─► 按Index分段解析二进制条目,填充 m_ObjectInfo
           │
           └─► 循环Prev链,加载历史XRef流

最终,m_ObjectInfo包含了所有对象的位置/类型信息,m_SortedOffset包含了所有对象偏移的有序列表,可用于计算每个对象的精确大小。


12. 常见问题与边界情况

Q1: 为什么传统XRef条目是20字节?

A: PDF 1.0设计时为了简单,固定长度便于随机访问。20字节可以容纳最大10位十进制偏移(2^32约10位),5位生成号,以及空格和换行。

Q2: 为什么需要m_SortedOffset

A: 为了计算对象的大小。一个对象的结束位置是下一个对象的开始位置。通过将所有对象偏移排序,可以快速得到每个对象占用的字节数。

Q3: 如何处理损坏的XRef表?

A: 如果LoadAllCrossRefV4LoadAllCrossRefV5都失败,会调用RebuildCrossRef,该函数会扫描整个文件,查找所有"obj"关键字,重建对象位置映射。

Q4: 为什么LoadCrossRefV4中要检查偏移为0时的数字有效性?

A: 有些损坏的文件可能将偏移字段写成全空格,导致atoi返回0。此时需要额外验证是否真的是数字0,还是无效数据。

Q5: 压缩对象(type=2)为什么要设置对象流的type=255?

A: 这是一种标记,表示该对象号对应的是一个对象流(而不是普通对象)。当解析压缩对象时,需要先找到对象流,然后从流中读取对象。


希望这个极致详细的解释能帮助您完全理解PDF交叉引用表的解析过程。如果有任何具体细节仍不清楚,请指出,我可以进一步深入。

相关推荐
网安情报局2 分钟前
除了 CDN,DDoS 攻击还有哪些更有效的防护方式?
网络
代码AI弗森6 分钟前
一文理清楚“算力申请 / 成本测算 / 并发评估”
java·服务器·数据库
Promise微笑31 分钟前
2026年国产替代油介损测试仪:油介损全场景解决方案与技术演进
大数据·网络·人工智能
^—app5668661 小时前
游戏运存小启动不起来临时解决方法
运维·服务器
志栋智能2 小时前
超自动化安全:构建智能安全运营的核心引擎
大数据·运维·服务器·数据库·安全·自动化·产品运营
AnalogElectronic3 小时前
linux 测试网络和端口是否连通的命令详解
linux·网络·php
Edward111111114 小时前
4月28日防火墙问题
linux·运维·服务器
想学后端的前端工程师4 小时前
【补充内外网突然不通的情况】
运维·服务器
Rust研习社4 小时前
使用 Axum 构建高性能异步 Web 服务
开发语言·前端·网络·后端·http·rust
灰子学技术4 小时前
Envoy HTTP 流量层面的 Metric 指标分析
网络·网络协议·http