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交叉引用表的解析过程。如果有任何具体细节仍不清楚,请指出,我可以进一步深入。

相关推荐
Boop_wu10 小时前
[Java 算法] 字符串
linux·运维·服务器·数据结构·算法·leetcode
m0_6948455711 小时前
Dify部署教程:从AI原型到生产系统的一站式方案
服务器·人工智能·python·数据分析·开源
码云数智-大飞12 小时前
C++ RAII机制:资源管理的“自动化”哲学
java·服务器·php
白毛大侠12 小时前
理解 Go 接口:eface 与 iface 的区别及动态性解析
开发语言·网络·golang
SkyXZ~12 小时前
Jetson有Jtop,Linux有Htop,RDK也有Dtop!
linux·运维·服务器·rdkx5·rdks100·dtop
黑牛儿13 小时前
MySQL 索引实战详解:从创建到优化,彻底解决查询慢问题
服务器·数据库·后端·mysql
杨云龙UP14 小时前
Oracle Data Pump实战:expdp/impdp常用参数与导入导出命令整理_20260406
linux·运维·服务器·数据库·oracle
想唱rap14 小时前
线程池以及读写问题
服务器·数据库·c++·mysql·ubuntu
萌萌哒草头将军16 小时前
CloudDock(云仓):新一代开源NAS网络代理工具
服务器·网络协议·docker