PDF 文本提取看起来很简单,直到您遇到一个文本层缺失、损坏或被拆分为几十个没有意义顺序的微小字符运行的文档;PDFium 组件为您提供了两个入口点:通过基于索引的 Character[] 数组对页面上的每个字形进行原始访问,以及 ReadablePageContent,用于从 PDF 的标记树或启发式分析中重建段落和标题的结构化视图;这二者都并非总是正确的选择,因此理解每个入口点暴露的内容是重要的
打开文档与静默失败陷阱
TPdf 通过设置 FileName 并翻转 Active := True 来打开文件;关键细节是: Active := True 绝不会引发异常;如果文件丢失、受密码保护或损坏,PDFium 会在内部捕获该错误,而 Active 只是简单地保持为 False;这意味着每个提取循环都必须对此进行防护:
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'report.pdf';
Pdf.Active := True;
if not Pdf.Active then
begin
ShowMessage('Could not open PDF (damaged or wrong password)');
Exit;
end;
// extraction follows here
finally
Pdf.Active := False;
Pdf.Free;
end;
受密码保护的文件需要在 Active := True 之前设置 Pdf.Password := '...';没有第二次机会:一旦 Active 失败,您就必须关闭并使用正确的密码重新打开
使用 Character[] 进行逐页提取
最底层的方法是遍历每一页上的每个字符;设置 Pdf.PageNumber 以加载该页面的文本层,然后使用 Character[] 属性迭代 CharacterCount 条目;每个条目上的两个标志值得检查: CharacterGenerated[i] 标记了由渲染器插入的合成字形(例如换行符处的软连字符),它们没有实际的 Unicode 值; CharacterMapError[i] 则表明 PDFium 无法将该字形映射到码点,这发生在缺少 ToUnicode 表的字体编码中
procedure ExtractAllText(Pdf: TPdf; Output: TStrings);
var
Page, I: Integer;
Line: string;
Ch: WideChar;
begin
for Page := 1 to Pdf.PageCount do
begin
Pdf.PageNumber := Page;
Line := '';
for I := 0 to Pdf.CharacterCount - 1 do
begin
if Pdf.CharacterGenerated[I] or Pdf.CharacterMapError[I] then
Continue;
Ch := Pdf.Character[I];
if Ch = #13 then
Ch := #10; // normalize CR to LF
Line := Line + Ch;
end;
Output.Add(Line);
end;
end;
结果是按照 PDFium 枚举它们的顺序排列的 Unicode 码点的扁平字符串,这是它们出现在内容流中的顺序,并不一定是自左向右的阅读顺序;对于由标准办公工具制作的大多数拉丁文本文档,这没有问题;对于使用异常字形序列进行 OCR 的扫描 PDF,或者对于自右向左的文本,顺序可能是错的;这时候 ReadablePageContent 就会派上用场
使用 ReadablePageContent 进行结构化提取
ReadablePageContent 向上走了一级:它返回一个 TPdfReadableContent 记录,其 Fragments 数组携带了标记的内容片段,每个片段都带有一个 Kind 标识段落、标题、列表项、表格单元格等;当 PDF 携带结构树时(检查 Pdf.IsTagged),源是 rosStructure,并且阅读顺序是权威的;对于未标记的文件,PDFium 会退回到 rosHeuristic,它通过包围盒将字符分组为看似合理的阅读单元,但无法保证准确性
procedure ExtractStructured(Pdf: TPdf; Output: TStrings);
var
Page: Integer;
Content: TPdfReadableContent;
Fragment: TPdfContentFragment;
begin
for Page := 1 to Pdf.PageCount do
begin
Content := Pdf.ReadablePageContent(Page);
for Fragment in Content.Fragments do
begin
case Fragment.Kind of
cfHeading : Output.Add('# ' + Fragment.Text);
cfParagraph : Output.Add(Fragment.Text);
cfListItem : Output.Add('- ' + Fragment.Text);
else
Output.Add(Fragment.Text);
end;
end;
end;
end;
如果 Content.Source = rosHeuristic并且您的输出看起来是乱码,那么文档的文本层在编写时可能没有考虑到阅读顺序;此时,唯一可靠的修复方法是从源应用程序重新导出并进行正确的标记,或者运行一个按 Y 轴再按 X 轴排序字符原点的后处理步骤
CharacterOrigin 和 CharacterRectangle 能带给您什么
这两个属性都返回字符在页面空间中的位置(点,原点在左下角,Y 轴向上递增); CharacterOrigin[i] 是字形的基线锚点; CharacterRectangle[i] 是完整的包围盒;这些是除了纯文本之外的任何内容的构建块:检测列边界、通过在公差范围内比较 Y 坐标将字符分组为行,或者在查看器中构建用于文本选择的碰撞测试图;如果您需要找到鼠标点击下坐落着哪个字符, CharacterIndexAtPos(X, Y, ToleranceX, ToleranceY) 会直接执行该查找,而无需您迭代矩形
使 DLL 就位
PDFium 组件将所有的 PDF 解析委托给原生的 DLL,根据您的目标平台,它可能是 pdfium32.dll 或 pdfium64.dll;组件附带了一个 CopyDlls.bat 脚本,可以将正确的文件复制到 Windows 系统目录中;在开发机上以管理员身份运行一次就足够了,对于部署,您需要将 DLL 复制到应用程序可执行文件旁;启用 V8 的变体(pdfium32v8.dll、pdfium64v8.dll)要大得多,只有在您的 PDF 包含必须执行的 JavaScript 时才需要;对于纯文本提取,标准构建是正确的选择
如果运行时缺少 DLL, Active := True 会像文件丢失一样静默失败,因为组件在内部捕获了加载错误;在发售前务必在干净的机器上进行测试
在布局分析中将 FontSize[] 与 Character[] 结合使用
除了纯文本,字符级 API 还公开了 FontSize[i],它返回每个字形的渲染点大小(Point size);结合 CharacterOrigin[i] 和 CharacterRectangle[i],这使您无需依赖结构树即可区分正文和标题;在未标记的文档中,字号跳转到阈值以上的字符运行几乎可以肯定是一个标题;相同的技术也适用于检测说明文字(图像包围盒下方的微小文字)或脚注(靠近页面底部的微小文字);所有这些都不需要进行渲染,这三个属性都直接从 PDFium 在 Active := True 期间构建 the 文本层中读取
一个细微差别: FontSize[i] 反映了应用页面 CTM(当前变换矩阵)后的大小,因此作者缩放了整个页面的文档将报告按比例调整的大小;如果您在不同页面尺寸的页面之间比较大小,请在做出阈值决策前对照每个页面的 MediaBox 高度进行规范化
将输出写入文件
Delphi’s TStringList 自 XE 起能干净地处理 UTF-8 输出;如果需要无 BOM 文件(许多下游消费者会因前导 BOM 而卡住),请设置 WriteBOM := False:
var
Lines: TStringList;
begin
Lines := TStringList.Create;
try
ExtractAllText(Pdf, Lines);
Lines.WriteBOM := False;
Lines.SaveToFile('output.txt', TEncoding.UTF8);
finally
Lines.Free;
end;
end;
对于需要关注内存的非常大型文档,可以在页面循环内部直接写入带有 TEncoding.UTF8 的 TStreamWriter,而不是首先将所有内容累积到列表中
这里显示的 Character[]、 CharacterCount、 CharacterOrigin[]、 CharacterRectangle[]、 ReadablePageContent 以及 CharacterIndexAtPos API 都是适用于 Delphi 和 C++Builder 的 PDFium Component 的一部分