PDFium Component 通过 BuildReflowDocument 把固定版式的 PDF 转换为可重排的语义模型,并通过 ToHtml 把该模型导出为自包含的 HTML。标题依旧是标题,列表项依旧是列表项,页面上检测到的表格会以真正的表格标记形式输出,表头单元格和跨行跨列都会被保留。输出内容中没有任何一处引用外部脚本或样式表
之所以需要这项功能,是因为一个 PDF 页面本质上是一组已定位的字形集合,这恰恰是手机屏幕、屏幕阅读器或搜索索引最不需要的东西。每一种试图靠提取纯文本来解决问题的方案,都会丢失让文档变得可读的那部分结构;每一种试图靠把页面转成图像来解决问题的方案,又会把文本彻底丢掉。重排模型把两者都保留了下来:文字本身,以及文字之间的关系
语义信息从何而来?
一切都始于 GetStructuredText,这是组件中文本和语义的唯一来源。当 PDF 携带结构树,即 ISO 32000-1 第 14.7 条定义的带标记 PDF 时,模型会遵循生成方记录下来的逻辑层级。当它没有携带结构树时,而现实中大多数 PDF 确实没有,模型会退回到为阅读顺序目的早已计算好的物理版面顺序
这一选择保持了一条硬性边界:不会为了回答现有解析器/渲染引擎已经能回答的问题而引入第二个 PDF 解析器或第二个渲染引擎。其下的阅读顺序机制在 结构化文本块与阅读顺序 中有描述,重排模型是构建在其之上的一层语义层,而不是替代品
每个节点都会记录其信息的来源,因此使用方能够区分一个由文档自行声明的标题和一个由版面启发式推断出的标题。对置信度敏感的流水线应当读取这个字段,而不是把所有节点都当作同等权威
一棵扁平树,而且不是对象树
该模型是一棵前序遍历展开后的扁平树:一个节点数组,每个节点携带 ParentIndex 和 Depth,而不是一个递归记录或带所有权的对象图。页面、标题、段落、列表、列表项、图形、图注、表格、行和单元格全部存放在这一个线性数组中
由此带来两点好处。使用方可以按顺序流式处理这个数组而无需递归,这让生成 HTML、Markdown 或树形视图都成了简单的循环。而且这种布局在 Delphi、C++Builder 和 Free Pascal 之间保持可移植,这几个平台在跨 ABI 边界处理递归的托管类型方面各不相同。一个由动态数组组成的递归记录,正是那种能在各处编译通过、却在各处行为微妙不同的构造
uses
PDFium;
var
Pdf: TPdf;
Options: TPdfReflowOptions;
Doc: TPdfReflowDocument;
I: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'report.pdf';
Pdf.LoadDocument;
Options := TPdfReflowOptions.Default;
Options.FullDocument := True;
Options.DetectTables := True;
Options.IncludeCss := True; // 内联样式块,没有外部文件
Options.MaxNodes := 200000; // 失败即停的预算上限
Options.MaxCharacters := 4000000;
Doc := Pdf.BuildReflowDocument(Options);
for I := 0 to High(Doc.Nodes) do
case Doc.Nodes[I].Kind of
prnkHeading:
Writeln(Format('%sH%d: %s', [StringOfChar(' ', Doc.Nodes[I].Depth),
Doc.Nodes[I].HeadingLevel, Doc.Nodes[I].Text]));
prnkParagraph:
Writeln(Format('%sp: %s', [StringOfChar(' ', Doc.Nodes[I].Depth),
Copy(Doc.Nodes[I].Text, 1, 60)]));
prnkTable:
Writeln(Format('table on page %d', [Doc.Nodes[I].PageNumber]));
end;
Writeln(Format('%d node(s), %d table(s), %d character(s)',
[Length(Doc.Nodes), Doc.TableCount, Doc.CharacterCount]));
finally
Pdf.Free;
end;
end;
如何避免表格重复出现两次?
表格检测是在某一页的结构化文本收集完成之后才运行的,这带来一个明显的隐患:同一份单元格内容既存在于文本块中,又存在于检测出的表格中。两者都输出的话,每张表格后面都会再跟着一份内容,以松散段落的形式重复一遍
解决这个问题的规则是几何性的。当一张检测出的表格覆盖了某个文本块面积的一半以上时,表格节点会替换该文本块,而不是与之并存。行内的单元格索引是通过计数装入若干个桶来构建的,因此构建模型的开销随单元格数加行数线性增长,而不需要为每一行都重新扫描一次所有单元格,这在单页可能承载数百个单元格的财务文档上很重要
检测出的结构对自身作为"检测结果"这件事很坦诚。带边线的表格比纯靠留白对齐的表格识别得更可靠,节点的置信度也会体现这一点。对于宁要一个错误的表格也不要没有表格的内容,请保持检测开启;对于错误表格比没有表格更糟的档案转换场景,请按置信度做筛选
导出保持自包含的 HTML
ToHtml 遍历的是已经构建好的模型,不会再回头访问 PDFium,因此导出两次不会有额外开销,也不可能从同一个模型得出不同的结果。文本和属性值都用统一的方式转义,标题级别被限制在 HTML 实际定义的 h1 到 h6 范围内,表头单元格、RowSpan 和 ColumnSpan 会原样传递
可选的 CSS 是一个纯内联样式块。没有脚本,没有网络字体,也没有任何形式的外部资源,这正是让输出可以安全嵌入邮件、帮助查看器或沙箱化浏览器控件的原因:
var
Html: WideString;
Stream: TFileStream;
Bytes: TBytes;
begin
Options := TPdfReflowOptions.Default;
Options.FullDocument := True;
Options.IncludeCss := True;
Options.IncludePageSections := True; // 保持页面边界可见
Options.PreserveLineBreaks := False; // 让浏览器自行折行段落
Html := Pdf.BuildReflowDocument(Options).ToHtml;
Bytes := TEncoding.UTF8.GetBytes(string(Html));
Stream := TFileStream.Create('report.html', fmCreate);
try
if Length(Bytes) > 0 then
Stream.WriteBuffer(Bytes[0], Length(Bytes));
finally
Stream.Free;
end;
end;
PreserveLineBreaks 是最值得斟酌的一个选项。PDF 的换行是针对固定页宽做出的排版决定,在窄屏上保留它,恰好会重新制造出重排功能本就要解决的那个问题。诗歌、代码清单和地址应当保留换行;正文散文则应当去掉
预算、取消与页面状态
字符数、节点数、表格数和单元格数各自设有上限,并且每一项都在分配之前而不是之后被检查,因此一份畸形或恶意的文档会干净地失败,而不是把内存一直吃到别的地方先出问题。取消令牌会在页、块、表格、行和单元格边界处被检查,这让扫描一份千页文档在被取消时依然保持响应
有一个行为专门对 GUI 应用程序很重要:整个文档扫描运行在一个会恢复活动页面的作用域内,因此成功、预算失败和取消这三种结局都会让调用方原本所在的页面保持不变。一个允许用户在查看第 340 页时发起导出的查看器,导出结束后会发现自己仍停留在第 340 页
重排适合做什么,不适合做什么
重排输出是搜索索引、无障碍阅读视图、移动端展示和内容迁移的优秀输入。它不是一个保真度优先的转换器:绝对位置、精确字体、矢量图形和精确的页面几何信息,按设计都不在它的目标范围之内。当某项工作需要页面看起来一模一样时,请渲染它;当它需要在别处依然可读时,请重排它
具体到辅助技术,重排模型可以与 构建无障碍阅读器 中描述的阅读功能搭配使用,而携带真正结构树的文档会产生明显更好的模型,这也为在上游校验标记质量提供了一个充分理由,详见 PDF/UA 结构树校验
重排、结构化文本、标记校验和渲染在 Delphi、C++Builder 和 Lazarus 中共享同一个文档对象;完整 API 描述见 PDFium Component for Delphi 页面