HotPDF Component 可以在 Delphi 和 C++Builder 中搜索并替换现有 PDF 内部的文本。SearchLoadedPageText 和 SearchLoadedDocumentText 以字形级精度定位字符串的每一次出现,而 ReplaceLoadedPageText 和 ReplaceLoadedDocumentText 则是就地重写匹配的字节 — 前提是每个替换字符都可以通过原始字体进行重新编码,本文将坦诚地对待这一物理限制,而不是将其隐藏在脚注中
该功能背后的需求往往很平常。公司更名了,而三千份归档发票上仍带着旧名称。合同模板上印着去年的截止日期。某个产品代码退役了,提及它的每份数据表都需要换成替代代码。在文字处理器中,这些都是三十秒就能搞定的工作。但在 PDF 中,这是一个真正困难的问题,理解其中的原因,是在用好该 API 还是提交一个实际上是标准引用的缺陷报告之间的关键区别
为什么替换 PDF 中的文本如此困难?
替换 PDF 中的文本之所以困难,是因为 PDF 页面不包含可编辑的文本 — 它包含的是定位好的字形。在 ISO 32000-1 §9.4 的文本显示模型下,内容流驱动 Tj 和 TJ 等运算符在文本矩阵确定的坐标处绘制字符代码序列。这些代码并不是 Unicode;它们是进入页面字体声明的任何编码的索引,而映射回可读字符的关系可能存在于 /ToUnicode CMap、编码差异数组或 CID 映射链中。这里没有段落对象,没有文本流,也无法保证一个视觉上的单词是以单个字符串形式存储的
替换在解码的基础上又增加了第二层难度:您必须确切地知道原始流的哪些字节产生了每个字形,这样您才能将新字节精确地拼接进该跨度中,而不触及其他任何地方。文本提取器在提取出 Unicode 后可以将字节位置丢弃。但替换器不能。这就是为什么 HotPDF 将该工作拆分到两个版本中 — v2.251.0 构建了偏移追踪和搜索层,而 v2.252.0 在其上构建了重写层
寻找文本:带字节偏移追踪的字形级搜索
HotPDF 的 SearchLoadedDocumentText 通过与每个页面的解码 Unicode 字形序列进行匹配来寻找目标文本的每一次出现,而不是匹配原始流字节,因此无论字体如何对其进行编码,只要匹配就是有效命中。其底层基础架构在 v2.251.0 中引入:内容流标记器记录每个字符串操作数(包括其 ( ) 或 < > 定界符)的 StartOfs/EndOfs 字节跨度,并且每个解码字形都携带一个指向产生它的确切操作数、TJ 数组项和代码单元的 TokenIndex/ItemIndex/ByteOffset 三元组。相同的字形解释器为 在 Delphi 中从已加载 PDF 中提取文本中描述的提取 API 提供支持;搜索只是保留了提取时丢弃的渊源信息
每个匹配都作为 THPDFTextMatch 记录返回,其中携带页面索引、包含的字形范围、匹配的 X/Y 原点和宽度、源标记和项目索引以及匹配的文本本身。这足以支持高亮覆盖层、审核界面或替换步骤。搜索未找到任何内容时返回空数组,而不是失败,因此调用模式非常简单
var
Pdf: THotPDF;
Matches: THPDFTextMatchArray;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('invoices-2025.pdf') > 0 then
begin
if Pdf.SearchLoadedDocumentText('Acme Corp', False, Matches) then
for I := 0 to Length(Matches) - 1 do
WriteLn(Format('page %d at (%.1f, %.1f): "%s"',
[Matches[I].PageIndex, Matches[I].X, Matches[I].Y,
Matches[I].Text]));
end;
finally
Pdf.Free;
end;
end;
一个特意做出的设计选择值得注意。当 CaseSensitive 为 False 时,匹配比较在设计上仅折叠 ASCII 字符的大小写:在 HotPDF 支持的 Delphi 5 到 XE 工具链中,完整的 Unicode 大小写折叠行为会有所不同,而一个会根据构建您应用程序的编译器不同而找到不同匹配的搜索 API,要比一个具有文档说明的、可预测限制的 API 糟糕得多。对于拉丁文商业文本 — 名称、代码、日期 — ASCII 折叠已覆盖了实际情况
替换文本:逆向编码与外科手术式拼接
HotPDF v2.252.0 中新增的 ReplaceLoadedDocumentText 通过反向运行解码机制来重写针对于目标文本的每一次出现。HPDFEncodeUnicode 函数是字符代码解码器的逆过程:它反向遍历相同的策略链 — /ToUnicode 的 bfchar 和 bfrange 查找、编码流 CID 映射、Type0 标识映射以及预定义的 WinAnsi 和 MacRoman 表 — 以将每个替换字符转回原始字体所预期的字符代码字节。重新编码的字节随后被序列化为格式良好的字符串字面量或十六进制字符串,镜像了标记器自身的转义规则,从而保证“解析 → 重新序列化”的双向回转是稳定的
拼接本身是外科手术式的,而不是批量的。在字符串操作数中,只有匹配所覆盖的代码字节范围会被替换;同一操作数中未匹配的字节、标记之间的空白以及周围的每个运算符都逐字节原样保留。在 abcabc 内部替换 bca 会产生 a + 替换内容 + bc,而不是被破坏的操作数。替换内容可以比目标文本短或长 — 字面量会被重新序列化且流 of 的 /Length 会被刷新 — 并且多流页面的每个 /Contents 流都会隔离处理,从而保持页面的格式良好
var
Pdf: THotPDF;
ReplaceCount: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('contract-draft.pdf') > 0 then
begin
if Pdf.ReplaceLoadedDocumentText('2025-12-31', '2026-12-31',
True, ReplaceCount) then
WriteLn(Format('%d operand rewrites performed', [ReplaceCount]));
Pdf.SaveLoadedDocument('contract-final.pdf');
end;
finally
Pdf.Free;
end;
end;
请注意该 API 不会做的事情:它不会对页面进行重新排版。PDF 没有重排(reflow),因此在视觉上比原始文本更宽的替换内容将单纯占用更多水平空间,并可能会拥挤绘制在其右侧的任何内容。等长或接近等长的替换 — 日期、版本字符串、零件号、名称纠错 — 是最合适的应用场景。大规模的改写应该在源文档中进行,而不是在 PDF 中
为什么不能用字体子集从未包含的字符来替换文本?
您无法使用嵌入字体子集从未包含的字符来替换文本,因为能够选择该字符的字节序列在字体的映射表中根本不存在。当 PDF 生成器嵌入子集字体时,其 /ToUnicode CMap 和编码结构仅覆盖原始文档实际使用的字形。HPDFEncodeUnicode 仅能逆向解析存在的映射:如果该字体在文档中从未包含字母 E,那么就没有可供逆向的 E 字符代码。这是文件的一种物理属性,而不是任何特定库的限制 — 没有工具可以凭空变出从未嵌入过的字形映射
HotPDF 对待失败采取了保守的处理方式。如果替换内容的任何单个字符无法被重新编码,则整个目标文本的出现都会被跳过 — 没有异常,没有局部的垃圾文本,并且该出现也根本不会计入 ReplaceCount。实际的影响是:将 ReplaceCount 与先前搜索的匹配计数进行核对,并将差值视为信号。在上面的日期示例中,数字 6 必须以同一种字体出现在文档文本的某处,重写才能成功 — 在发票中这很可能满足,但一般情况下无法保证。当您需要的字符根本不可用,且目标是删除敏感文本而不是改写时,真正的物理内容擦除(redaction)才是更好的工具;请参阅在 Delphi 中擦除与重构已加载的 PDF 以获取该路径
var
Matches: THPDFTextMatchArray;
Expected, Replaced: Integer;
begin
Pdf.SearchLoadedDocumentText('Acme Corp', True, Matches);
Expected := Length(Matches);
Pdf.ReplaceLoadedDocumentText('Acme Corp', 'Apex Corp', True, Replaced);
if Replaced < Expected then
WriteLn(Format('%d occurrence(s) skipped: characters missing ' +
'from the font subset, or match spans multiple operands',
[Expected - Replaced]));
end;
该消息中的第二个跳过条件是另一个已记录的边界限制:跨越多个字符串操作数的目标文本 — 例如,Hello 被拆分在 [(He)(llo)] TJ 项中 — 会被搜索发现,因为搜索匹配解码后的字形序列,但在替换时会被跳过,因为跨操作数边界重写需要合并相邻的字节跨度。先搜索再验证可以使这两个限制显现出来,而不是保持静默
保存时文件中发生了什么变化?
替换后的 /Contents 流在保存时是不压缩的。FlateDecode 压缩的流会在编辑时被解压,并且当 HotPDF 写入重构 of 的字节时,它会丢弃流的 /Filter 条目并刷新 /Length,而不是进行重新压缩。生成的 PDF 是完全有效的,且在主流查看器中正常渲染;权衡的结果是每个编辑过的流文件体积会变大。对于处理数千个文档的批处理管道,应预算此体积增长,或在下游运行单独的压缩步骤。重写的对象在保存时如何与文档的交叉引用结构进行交互是其自身的主题,详见HotPDF 中的对象流与增量更新
文件中的其他所有内容都保持原样。未触及的流保持其压缩状态,字体和图像不被重写,并且操作数级别的拼接意味着即使是编辑过的流,也仅在匹配落下的地方与原始流有所不同。这种保守做法是有意为之:库对已加载文档重写的内容越多,打破它未预料到的生成器特异性行为的机会就越多
文本搜索与替换与提取、物理擦除和页面渲染一起构成了 HotPDF 已加载文档工具集,全部由同一个内容流解释器驱动,并且可在 Delphi 5 到当前的 RAD Studio 版本中免除外部依赖使用。完整的 API 参考和试用版下载位于 HotPDF Component 产品页面