技术文章

Delphi 将 PDF 文本映射回内容流字节

发票编号中只错了一个字符,但手头唯一的编辑原语却要重写整段文本。PDF Library for Delphi 填补了这个缺口:GetTextBlockCharContentLocation 把每个提取出的 UTF-16 位置映射回生成它的内容流指令、操作数和编码字节范围,而 ReplaceTextBlockCharSourceBytes 只覆盖这一范围。文本提取通常会丢掉完成这件事所需的一切。你得到 Unicode、宽度和几何信息,来源关系却消失了,于是第 3 个文本块中的第 7 个字符就只是一个字符。它来自哪个流、哪个指令、哪个操作数、那个操作数中的哪个字节:全都没了。建立在这之上的每一种点编辑策略都只能猜,通常是搜索解码后的内容来找子串,并希望它恰好只出现一次。但在真实页面上并不会这样

为什么重写整段文本会破坏页面

因为文本段不只是文字。ISO 32000-1 第 9.4.3 节的文本显示操作符包括 TJ,它的操作数是一个在字符串和数值调整之间交错的数组,而这些数字就是排版信息。按 [(AB) -120 (CD)] TJ 排版的行,在两个字符串之间携带了千分之一 em 的 120 单位字距调整。重新发出一个把文本拼接起来的 Tj 后,字距调整消失,整行会细微重排,在表单中则可能让值漂出其框体。字体也有同样的问题:操作数字节是 Tf 选择的编码中的代码,而不是 Unicode;对于复合字体,它们可能是与提取字符没有关系的双字节 CID。重新生成文本段时,你必须同时正确处理字体编码、/ToUnicode 映射和字形覆盖范围。点编辑完全不离开字节域,因此避开了所有这些问题

GetTextBlockCharContentLocation 返回什么

该方法把一个字符解析成九字段记录,而且每个字段表示的都是地址而不是值。ContentLayer 是页面 /Contents 数组中的从 1 开始的索引;如果字符来自嵌套内容,则为 0。StreamObjectNumberStreamGeneration 标识包含它的流。InstructionIndex 是解码后内容程序中的从 0 开始的位置,OperandIndex 是文本字符串操作数,ArrayElementIndexTJ 数组中的元素;对于直接字符串操作数则为 -1。最后,SourceByteOffsetSourceByteLength 指出解码字符串内部的字节范围

Var
  Lib: TPDFlib;
  ListID, Block, CharPos: Integer;
  ContentLayer, StreamObjectNumber, StreamGeneration: Integer;
  InstructionIndex, OperandIndex, ArrayElementIndex: Integer;
  SourceByteOffset, SourceByteLength, Flags: Integer;
Begin
  Lib:= TPDFlib.Create;
  Try
    Lib.LoadFromFile('invoice.pdf', '');
    Lib.SelectPage(1);
    ListID:= Lib.ExtractPageTextBlocks(3);
    Try
      // Block 和 CharPos 来自你对 GetTextBlockText 的自行扫描
      If Lib.GetTextBlockCharContentLocation(ListID, Block, CharPos,
        ContentLayer, StreamObjectNumber, StreamGeneration,
        InstructionIndex, OperandIndex, ArrayElementIndex,
        SourceByteOffset, SourceByteLength, Flags)= 1 Then
      Begin
        // ContentLayer = 0 表示字形位于嵌套 Form XObject 中
        // ArrayElementIndex = -1 表示普通 Tj 操作数,而不是 TJ 数组
      End;
    Finally
      Lib.ReleaseTextBlocks(ListID);
    End;
  Finally
    Lib.Free;
  End;
End;

查询时无需额外付出解析成本。渲染器解码每个内容层时,会登记自己正在遍历的逻辑区间,因此位置查询是对有序区间列表执行二分搜索,而不是针对每个字符线性扫描所有内容区间。查询时不会重新解析任何内容;映射是在已经支付过成本的提取阶段构建的。如果你已经在使用返回命中坐标的 PDF 文本搜索枚举命中,为每个命中增加内容位置几乎没有额外成本

编辑字节,而不是 Unicode

ReplaceTextBlockCharSourceBytes 接受当前 PDF 字体编码中的原始替换字节 AnsiString。这就是整个设计,而且是有意如此。它不会转码、重新编码,也不会猜测字体。库会把你的字节拼接到目标字符串指定的范围上,然后重新发出包含它的内容层。同一个 TJ 数组中的相邻字符串及其之间的数字字距保持逐字节不变。以刚才的布局为例:定位 [(AB) -120 (CD)] TJ 中的 B 会得到 ArrayElementIndex 0、SourceByteOffset 1 和 SourceByteLength 1。用 Z 替换它后,生成的内容包含 (AZ),后面仍然是 -120(CD),两者都没有改动。回归套件正是这样断言的,因为“保留了字距”这种说法很容易在不知不觉中变得不再成立

Function EditableHere(Flags: Integer): Boolean;
Begin
  Result:= ((Flags and PDF_TEXT_CHAR_CONTENT_LOCATION_VALID)<> 0)and
    ((Flags and (PDF_TEXT_CHAR_CONTENT_LOCATION_GENERATED or
      PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT or
      PDF_TEXT_CHAR_CONTENT_LOCATION_NESTED or
      PDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER or
      PDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED))= 0);
End;

// ...
If EditableHere(Flags) Then
Begin
  If Lib.ReplaceTextBlockCharSourceBytes(ListID, Block, CharPos, 'Z')= 1 Then
  Begin
    // 旧列表中的每个位置现在都已过期,重新提取
    Lib.ReleaseTextBlocks(ListID);
    ListID:= Lib.ExtractPageTextBlocks(3);
  End
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_STALE Then
    // 自提取以来,该层在我们下方发生了变化
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_READ_ONLY Then
    // 我们漏检了一个标志,或后续版本新增了一个标志
End;

有两个操作细节值得记住。调用会临时切换到提取文本列表的页面,并且无论成功还是失败都会恢复之前选中的页面,因此不会悄悄移动你的游标。成功后还会清除页面元素快照,使你从之前枚举阶段持有的任何句柄失效

哪些字符不能编辑

共有六类,库会在 Flags 位掩码中逐一命名它们,而不是含糊地失败。这一点比成功路径更重要,因为在真实文档中无法映射的情况很常见,而且每一类背后的原因都不同

  • PDF_TEXT_CHAR_CONTENT_LOCATION_LIGATURE:多个提取出的 UTF-16 位置由一个源字形展开。/ToUnicode 中把一个代码映射到 fi 时,两个字符会共享一个字节范围,因此应把它们视为一个源字形,只编辑一次该范围
  • PDF_TEXT_CHAR_CONTENT_LOCATION_GENERATED:字符是在布局过程中合成的。推断出的单词空格是常见情况,它们根本没有源字节,因此 SourceByteOffset 返回 -1,SourceByteLength 返回 0
  • PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT:你读到的文本来自 /ActualText 替换。替换字符串与源字节之间没有唯一的反向映射,因此该位置只能用于诊断
  • PDF_TEXT_CHAR_CONTENT_LOCATION_NESTED:字形位于 Form XObject 内。字节可以定位,但 Form 可能由多个页面绘制,因此通过高级 API 编辑它会变成一次你没有要求的全局编辑
  • PDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED:操作数是携带 UTF-16BE 字节顺序标记的十六进制字符串,现有提取路径会在字体映射前先对它解码。解码结果中的偏移不再指向原始字节,因此有效标志会被清除
  • PDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER:字符串操作数和其文本显示操作符位于两个不同的流中

最后一类值得单独说一句,因为工程师经常以为它不可能发生。ISO 32000-1 第 7.8.2 节规定,页面 /Contents 数组中的流会被拼接,流之间的分界只需要位于词法边界即可。因此,一个流中出现 BT /F1 16 Tf 220 340 Td (CrossLayer),而下一个流中出现 Tj ET,完全是合法页面。映射会保留诊断位置,但将其标记为只读,因为操作符的指令索引属于与操作数不同的层,使用其中一个去寻址另一个会破坏文件

库如何知道映射仍然有效

靠指纹,并且在写入前立即检查。每个提取列表都会记录源页面,以及每个内容层的层长度和两个独立的滚动哈希:FNV-1a 哈希和 DJB2 风格的异或哈希。在 ReplaceTextBlockCharSourceBytes 做任何解析前,它会重新读取目标层并比较这三个值。该层任意位置发生任何字节变化,都会返回 PDFLIB_ERROR_TEXT_LOCATION_STALE,写入也不会发生。这是有意采取的保守策略:检查粒度是每层而不是每条指令,因此同一内容流中其他位置的不相关编辑也会使位置失效。这是正确取舍:一个哪怕只前移一个字节的流偏移,不是“差一点”,而是静默损坏。包括CTM 与裁剪状态的内容流状态跟踪器在内,其他编辑接口也遵守同样的纪律。每次成功替换后,都应丢弃列表并重新提取

通过 Direct Access 进行只读映射

DAGetTextBlockCharContentLocation 会为通过 Direct Access 路径打开的页面提供完全相同的记录和完全相同的标志词汇。它从设计上就是诊断接口:ReplaceTextBlockCharSourceBytes 操作的是选中的可编辑文档,而 Direct Access 是读取路径。文件句柄关闭后,位置数据仍保留在文本块列表中,因此可以用于离线审计

FileHandle:= Lib.DAOpenFileReadOnly('audit.pdf', '');
Try
  PageRef:= Lib.DAFindPage(FileHandle, 1);
  DirectList:= Lib.DAExtractPageTextBlocks(FileHandle, PageRef, 3);
  Try
    Lib.DAGetTextBlockCharContentLocation(DirectList, Block, 1,
      ContentLayer, StreamObjectNumber, StreamGeneration,
      InstructionIndex, OperandIndex, ArrayElementIndex,
      SourceByteOffset, SourceByteLength, Flags);
    // DACloseFile 之后位置仍然可读
  Finally
    Lib.DAReleaseTextBlocks(DirectList);
  End;
Finally
  Lib.DACloseFile(FileHandle);
End;

用它来回答问题,而不是用它来改变内容。哪些页面带有永远无法原地编辑的文本?这个语料库中有多少内容带有 /ActualText 覆盖?哪家厂商的输出会把操作符拆到多个内容层中?一旦每个字符都有地址,这些查询的成本都很低,而在投入修正流水线前执行它们很值得

点编辑的边界

点编辑是一把手术刀,不是文本引擎。它原地改变字节,因此比原文更宽或更窄的替换文本不会重排、不会换行,也不会更新周围的字距。在等宽字段中用一个数字替换另一个数字很合适,重新输入一段文字则不合适。而且它绝对不是安全工具:覆盖字形字节后,原始字节仍可从文件的修订历史中恢复,因此任何有保密要求的内容都属于真正的涂黑,即移除内容而不是遮盖内容。换来的好处是诚实。每个字符要么有一个可以操作的字节地址,要么有一个明确的标志说明它为什么没有地址,指纹检查还会把过期映射变成硬错误,而不是损坏页面。字符到内容字节的映射以及原地源字节替换,都是PDF Library for Delphi 文本提取和内容编辑接口的一部分;该库是面向 Delphi、C++Builder 和 Lazarus 的原生 Object Pascal PDF 库