PDFiumPas 把页面文本作为一个结构返回,而不是一个字符串。GetStructuredText 会产出一个 TPdfStructuredTextPage,其中包含若干区块,每个区块包含若干行,每一行又包含若干带样式的文本片段,每一层都带有页面空间坐标边界,并且保留了源字符索引,因此任何片段都能被映射回底层的文本页
大多数代码最初都会用到的纯字符串提取方式仍然存在,用于它本来的用途时也依然正确。但一旦你需要知道哪些词是标题、哪些属于左边那一栏,或者一处匹配在页面上具体位于哪个位置,纯字符串就不够用了
为什么纯字符串对大多数任务来说是错误的输出形式?
因为人们对提取出的文本提出的问题,几乎从来都不是"这一页上有哪些字符",而是"标题是什么""这是不是一张表格""这段话是不是属于第 4 节""高亮应该画在哪里"。一个单一的字符串对这些问题一个都答不上来,而你从中重新构建出的每一个答案,都是一份你现在要自己维护的启发式规则
双栏版式能把这一点说得很具体。把一篇双栏文章提取成字符串,根据生成端写内容流的方式不同,你可能得到"先第一栏、再第二栏"的结果,也可能得到"第一栏第一行、第二栏第一行、第一栏第二行"这样一路交替下去的结果。两种结果都能来自一份合规的 PDF。在格式层面二者都不算错,因为 PDF 描述的是页面上的标记,而不是一份文档大纲。基于区块的模型让提取器可以显式地做出排序决策,并明确告诉你它做的是哪种决策
按内容顺序,还是按物理版面?
TPdfStructuredTextOptions.ReadingOrder 在 roContentOrder 和 roPhysicalLayout 之间选择,正确答案取决于你更信任谁——生成端,还是几何位置
内容顺序按内容流绘制文本的次序返回文本。这种方式速度快,对于由行为规范的生成端产出的文档来说,通常也就是预期的阅读顺序。物理版面则无视内容流的顺序,根据字符实际所在的位置重建顺序,先聚类成行,再聚类成栏。这正是你在处理先扫描后 OCR 的页面、处理那些按字体顺序而不是阅读顺序输出文本的工具产物,以及处理任何只能依赖视觉结果的场景时想要的方式
uses
PDFium;
var
Pdf: TPdf;
Options: TPdfStructuredTextOptions;
Page: TPdfStructuredTextPage;
B, L: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'article.pdf';
Pdf.LoadDocument;
Pdf.PageNumber := 1; // 从 1 开始计数
Options := TPdfStructuredTextOptions.Default;
Options.ReadingOrder := roPhysicalLayout;
Options.IncludeFontInfo := True;
Options.IncludeSemantics := True;
Options.MaxCharacters := 200000; // 失败即关闭的预算
Page := Pdf.GetStructuredText(Options);
for B := 0 to High(Page.Blocks) do
begin
if Page.Blocks[B].Kind = cfHeading then
Emit(Format('H%d: %s',
[Page.Blocks[B].HeadingLevel, Page.Blocks[B].Text]))
else
for L := 0 to High(Page.Blocks[B].Lines) do
Emit(Page.Blocks[B].Lines[L].Text);
end;
finally
Pdf.Free;
end;
end;
标签能带来几何位置无法提供的什么信息?
意图。启用 IncludeSemantics 后,来自带标签 PDF 的区块会携带一个源自结构树的 Kind,因此一个标题之所以是标题,是因为生成端明确这样声明的,而不是因为它的字体比平均值大。这些类型涵盖了对内容复用来说重要的各种形态:cfParagraph、带 HeadingLevel 的 cfHeading、cfListItem、cfTableCell、cfCaption、cfFigure,以及未打标签时的兜底类型 cfPlain
Source 字段记录着每一次分类的信息来源,结构树对应 rosStructure,推断对应 rosHeuristic——当你要判断在一批文档上到底应该多大程度地信任一条提取管道时,就该记录这个字段。图形是一个值得了解的特殊情况:对于 cfFigure 区块,文本来自替代描述,而不是来自任何字形,因为图形本身没有字符可言。没有匹配上任何图形的替代文本仍然会被呈现出来,而不是被丢弃,这样无障碍审计就能看到某个描述确实存在,即使页面上没有任何东西把它画出来。标签模型本身在PDF/UA 结构树校验一文中有说明
文本片段携带样式和来源信息
每个 TPdfStructuredTextSpan 都携带自己的文本、页面空间坐标边界、FontName、FontSize、FontWeight 和 Angle,再加上 SourceStartIndex 和 SourceCharacterCount。文本片段会在样式变化的地方断开,因此一句带有三个加粗单词的句子会变成三个片段,在 HTML 或 Markdown 里重建强调效果,只需要读取属性即可,不用再靠猜字体名称
这两个源索引字段,正是把"提取"从一份报告变成一项真正功能的关键。它们指回页面的字符序列,这意味着你在搜索中匹配到的一个区块,可以直接转换成字符级别的选中几何形状或高亮矩形,不需要再对文本做第二次、顺序还不一样的遍历;具体机制在基于字符框的可视化文本行选中一文中有说明。Angle 字段的重要性超出它看起来的样子:图章或水印里旋转过的文字,会落在和正文相同的坐标空间里,一条忽略角度的处理管道,会心安理得地把一个斜着的"DRAFT"合并到某段正文的中间
预算,以及两个质量计数器
MaxCharacters 是一个失败即关闭的预算,不是一个截断设置:超出这个预算的页面会直接停止,而不是静默返回部分内容。在不受信任的接入路径上,这正是你想要的行为,因为一个有一百万个字符的页面,要么是机器生成的怪物文档,要么是想让你的提取器变成整个系统里最慢的一环
返回页面上的两个计数器直接描述了提取质量。UnmappedCharacterCount 统计没有可用 Unicode 映射的字符数量,这是子集字体没有嵌入 /ToUnicode CMap 时的典型症状;这样的文本渲染完全正常,提取出来却毫无用处。GeometryFailureCount 统计无法确定边界框的字符数量,这会降低物理版面排序的质量。这两个都应该记录下来。一批数值持续接近零的文档集,可以放心建立索引;数值不接近零的文档集,则说明你管道里的某些生成端需要关注,之后才能信任任何下游结果
var
Page: TPdfStructuredTextPage;
B, S, L: Integer;
Emphasised: Boolean;
begin
Page := Pdf.GetStructuredText(Options);
if Page.UnmappedCharacterCount > 0 then
Log(Format('page %d: %d characters without a Unicode mapping',
[Page.PageNumber, Page.UnmappedCharacterCount]));
if Page.GeometryFailureCount > 0 then
Log(Format('page %d: %d characters without geometry',
[Page.PageNumber, Page.GeometryFailureCount]));
for B := 0 to High(Page.Blocks) do
for L := 0 to High(Page.Blocks[B].Lines) do
for S := 0 to High(Page.Blocks[B].Lines[L].Spans) do
begin
Emphasised := Page.Blocks[B].Lines[L].Spans[S].FontWeight >= 600;
AppendRun(Page.Blocks[B].Lines[L].Spans[S].Text, Emphasised,
Page.Blocks[B].Lines[L].Spans[S].SourceStartIndex);
end;
end;
在真实页面上的性能表现
物理版面提取是开销较大的模式,实现上是针对真正大的页面设计的:字符排序按 O(n log n) 运行,而不是靠重复扫描;行和文本片段缓冲区按几何级数增长,而不是每个字符都重新分配;Unicode 文本在缓冲区中构建,而不是靠字符串拼接;相邻文本对象的字体查找结果会被缓存。正是这几项结合在一起,才让一个有 5,000 个字符的密集页面表现出可预测的开销,而不是变成二次方级增长
对于页面数量庞大的任务,能选更便宜的模式时依然值得去选。对你信任的带标签文档使用启用了语义信息的 roContentOrder,把 roPhysicalLayout 留给扫描件和遗留材料使用——在那些场景里几何位置是唯一可用的信号。如果你只需要一个纯字符串,从 PDF 文档中提取文本一文中描述的更简单的 API 依然是更快的路径;当你需要把文本追溯回标记内容标识符时,读写 BDC 和 MCID 标记内容一文覆盖了这一层
这套区块模型同样能干净地映射到检索管道想要的东西:一个带着若干段落的标题,就是一个带标题的分块,而边界信息让一次引用能定位到页面上的一个具体位置,而不只是定位到一份文档。PDFiumPas 是基于 PDFium 引擎的 Delphi 和 Lazarus 组件,配有示例的文档见 PDFium Delphi 组件页面