每个几何式文本提取器都在猜。它读取页面绘制的字形,按基线与水平位置排序,然后寄希望于视觉排布与人类的阅读顺序一致。对单栏报告,这个猜法是对的。对双栏期刊文章、带侧栏的表单,或者按列逐个输出单元格的表格,它就错了,而且错得难以察觉、下游发现时代价高昂。HotPDF 用 ExtractLoadedPageStructureText 回答这个问题:它完全无视几何,按 ISO 32000-1 §14.8.4 定义的创作顺序遍历文档结构树,再按标记内容标识符重组页面字形。对标记 PDF 而言,这不是启发式,而是产出应用声明的顺序
页面没有可用结构树时,函数返回 False,这是回退到几何提取器而不是失败的信号。双路径设计比算法本身更重要:真实的文档摄入会在同一个文件夹里见到标记过的政府表单和扫描仪输出,只处理其一的管线称不上管线
几何式提取为什么会弄错阅读顺序?
因为 PDF 内容流根本不携带阅读顺序。它是一串绘图操作符,产出方可以按任何适合自己排版引擎的顺序输出。文字处理器通常按流顺序输出,几何排序看起来没问题。排版工具、表单设计器和报表生成器常常不是:页脚可以先于正文输出,表格可以按列主序填充,双栏页面可以交错两栏的行,因为排版器是一起解析它们的
这种失败模式悄无声息。几何式提取器从不报错,只是交回句子被两栏拼接起来的散文。任何消费该文本的东西——搜索索引、电子发票字段映射器、给语言模型供料的检索管线——都无声地继承了损伤。HotPDF 也提供面向已加载文档的几何式提取器,对未标记文件它们仍然是正确的工具;结构顺序路径的意义在于:当文档已经携带答案时,不再猜
结构树实际存储了什么
标记 PDF 保存着页面的第二份平行描述。catalog 指向一个 /StructTreeRoot,其 /K 子节点构成结构元素树:/Document、/Sect、/P、/Table、/TR、/TD 等等。树的叶子是标记内容引用——为页面内容流中的一段命名的整数。在内容一侧,这些段用携带 /MCID 的 BDC 操作符打开,用 EMC 关闭。每个结构元素还携带 /Pg 条目指明所属页面,这使结构树横跨数百页的文档也能做逐页遍历
HotPDF 以 128 层深度上限遍历该树,并按 /Pg 过滤,只让当前页贡献内容。遍历的输出不是文本,而是一个有序的 MCID 值列表:本页标记内容段的创作顺序。重组文本随之变成按该顺序重放字形
MCID 在字形提取时记录,而不是事后查找
这就是让该功能变得廉价的实现细节。HotPDF 在提取每个字形时已经记录了活跃的标记内容标识符,存于 THPDFGlyphRecord 的 MCID 字段,因为内容流解释器在处理每个 Tj 或 TJ 操作符的时刻,知道当前打开的是哪个 BDC 作用域。因此结构顺序提取无需对内容流做第二遍。它从结构树收集 MCID 序列,然后把已提取的字形按 MCID 分桶,按该序列输出
var
Pdf: THotPDF;
PageCount, I, Untagged: Integer;
PageText, AllText: UnicodeString;
Report: TStrings; // 调用方自有的诊断输出
begin
Pdf := THotPDF.Create(nil);
try
PageCount := Pdf.LoadFromFile('accessible-form.pdf');
AllText := '';
for I := 0 to PageCount - 1 do
begin
if Pdf.ExtractLoadedPageStructureText(I, PageText, Untagged) then
begin
// 直接来自结构树的创作顺序
if Untagged > 0 then
Report.Add(Format('page %d: %d glyphs outside the structure tree',
[I, Untagged]));
end
else
// 本页没有可用结构树:几何回退
Pdf.ExtractLoadedPageText(I, PageText);
AllText := AllText + PageText + #13#10;
end;
finally
Pdf.Free;
end;
end;
未标记字形被计数,绝不静默丢弃
页面可以只标记了一部分。产出方会在任何 BDC 作用域之外添加装饰线、页码或后期水印,这些字形不属于任何 MCID。丢弃它们是整洁的实现,却是错误的实现,因为同样的缺口也出现在产出方标记了正文却忘了表格的时候——你会在不知不觉中失去整张表
HotPDF 把无人认领的字形作为几何尾巴追加在结构顺序文本之后,并通过 UntaggedGlyphCount 输出参数报告其数量。这个数字是你可以据以行动的质量信号:两千字形的页面上寥寥几个是页面装饰,可以忽略;百分之四十的页面在结构树之外,意味着标记只是装饰性的,对该文件几何提取器才是更诚实的答案
function ExtractPageBestEffort(Pdf: THotPDF; PageIndex: Integer;
out AText: UnicodeString; out UsedStructure: Boolean): Boolean;
var
Untagged, TotalGlyphs: Integer;
Glyphs: THPDFGlyphArray;
begin
UsedStructure := False;
if Pdf.ExtractLoadedPageStructureText(PageIndex, AText, Untagged) then
begin
TotalGlyphs := 0;
if Pdf.ExtractLoadedPageGlyphs(PageIndex, Glyphs) then
TotalGlyphs := Length(Glyphs);
// 只有当结构树认领了页面大部分时才信任它
if (TotalGlyphs = 0) or (Untagged * 4 <= TotalGlyphs) then
begin
UsedStructure := True;
Result := True;
Exit;
end;
end;
Result := Pdf.ExtractLoadedPageText(PageIndex, AText);
end;
什么情况下函数返回 False
三种情形,值得区分,因为其中只有一种是文档缺陷。第一种是普通的未标记 PDF:没有 /StructTreeRoot,无可遍历,False 只是如实相告。第二种是扫描页,其文本来自从未标记的 OCR 层。第三种才有意思:内容携带带 /MCID 值的 BDC 操作符,但页面没有 /StructParents 条目,结构树也从不引用那些标识符。标记内容存在,结构侧不存在,没有顺序可恢复。HotPDF 报告 False,而不是编造一个
最后这种情形出现在手工编辑过的文件里,也出现在那些为可选内容或 artifact 目的输出标记内容却不构建结构树的工具的产物中。如果你自己生产标记 PDF,同样的不对称正是PDF/UA 验证所检查的,写入侧的对应物见输出标记化分页内容的布局 DOM
结构顺序在哪里物有所值
无障碍审计显而易见:如果你在按 PDF/UA 认证文档,屏幕阅读器将播报的阅读顺序正是结构顺序,提取它就是你不用屏幕阅读器审查它的方法。数据采集是更大的商业场景。标记过的政府表单、受监管披露文件和电子发票附件按声明顺序携带字段标签与值,按该顺序读取,消除了几何式提取在多栏布局上制造的一整类映射 bug
最新的消费者是面向语言模型的检索。为嵌入而切分文档,效果完全取决于文本顺序,一个拼接两栏的块会产出从未存在过的句子。结构顺序提取是眼下最便宜的解法,因为对标记文档,正确顺序早已在文件里,只等着被读取
HotPDF 是面向 Delphi 与 C++Builder 的原生 VCL 组件,结构树遍历与字形重放都在进程内对已加载文档执行,不涉及任何外部渲染器。已加载文档提取家族的完整 API 细节见 HotPDF Delphi PDF component 产品页