HotPDF Delphi Component 从 v2.768.15 起,会去除伪粗体和叠印 logo 在 PDF 提取文本里留下的重复字符。当生成器把同一个字形以零点几磅的偏移画两遍时,ExtractLoadedPageText、ExtractLoadedPageTextLayout 和 ExtractLoadedPageTextColumns 现在只读一次该字符,“Bold words” 不会再变成 “Bold wordsBold words”
这是那种看起来像解码器故障、实则不是的 bug。每个字形都解码正确,文字确实在页面上出现了两次。修复必须判断两个完全合法的字形里哪个是副本,还不能吃掉合法的重复字母——比如 “fill” 里的双 l,或者表格里一模一样的单元格。本文其余部分讲清 HotPDF 用的规则、规则背后的阈值、它与其它提取工具刻意不同的地方,以及同一批发布里另一个不相干、同样会砍掉行尾文本的提取 bug
伪粗体为什么会让 PDF 提取文本翻倍
伪粗体让提取文本翻倍,是因为生成器把字符串画了两遍,而两份都是真实的文本显示操作。字体家族没有粗体面,或者创作工具懒得管时,很多生成器用 Tj 或 TJ 把同一字符串显示两遍来模拟粗体:先把文本位置右移零点几磅,再显示一次。屏幕上两份副本融成更粗的笔画;内容流里却是两段完整的字符码,而 ISO 32000-1 §9.4 没有给提取器任何“这份是装饰”的标志
叠印 logo 从另一个角度撞上同一个问题。一个标志文字可能先用填充画一遍,再用描边或略微偏移的阴影画一遍,字体相同、字符相同。按绘制顺序走内容流的朴素提取器会先输出完整的第一段,再输出完整的第二段,这就是为什么结果是 “Bold wordsBold words” 而不是 “BBoolldd”。v2.768.15 之前,HotPDF 的文本组装保留两份副本,而这个重复破坏了大多数调用者真正在意的东西:跨页面文本搜索 “Bold words” 可能失败,因为两段之间的词边界没了
真正的文本渲染模式 2(先填充后描边,ISO 32000-1 §9.3.6)没有这个问题,因为它是单次文本显示操作、只产生一组字形。重复只出现在生成器把文本发出两次的时候
HotPDF 如何判断哪个字形是副本
HotPDF 把两个字形判为同一个绘制字符的条件是:Unicode 值相同、书写方向相同、尺寸相差不超过 5%,且几乎完全重叠——沿基线方向相距不超过字形尺寸的 0.1,横跨基线方向不超过 0.2。每一对里,先画的字形保留,后画的丢弃。这些是 v2.768.15 实现里的取值,每个都有它的理由
- Unicode 相同,方向相同。只有相同字符才可能是副本,水平字形也绝不与竖排(WMode 1)字形比较
- 尺寸差在 5% 以内。恰好叠在一起的大标题字母和小图注字母不是同一段文本,尺寸不匹配就排除这一对
- 沿基线为尺寸的 0.1。伪粗体偏移只有零点几磅,是正常字号的百分之几。两个真实相邻的字母,哪怕紧排的 “ll”,也隔着大约一个完整的步进宽度,比这个容差大一个数量级
- 横跨基线为尺寸的 0.2。阴影和描边常常既右移也下移,所以纵向容差更宽,但仍远小于到下一行的距离
- 先画者优先。保留较早的字形就保住了第一段的阅读顺序,幸存的文本仍按生成器书写的方式流动
距离在字形自己的基线坐标系里测量,而不是原始页面 X 和 Y,所以旋转文本和斜基线文本用的容差与普通水平行相同。所用的尺寸是字形绘制到页面上的尺寸(由它的 quad 得出),不是给 Tf 的字号操作数;这一点对把 Tf 设成 1、全靠文本矩阵缩放的文档至关重要,用原始字号的话,所有容差都会失效
空格永远不会成为候选。到 U+0020 为止的码点、不换行空格 U+00A0 和表意空格 U+3000 一律跳过,因为空白字形没有可见墨迹可复制,丢掉一个就会把单词粘连起来
让检查在稠密页面上依然够快
HotPDF 的重复检查大约是 n log n 时间,不是 n 的平方,因为它先排序,只在小窗口内比较字形。逐对比较每个字形当然正确,但没法用:一页小字号的稠密页面轻松携带几万个字形,而这个检查在每次文本提取调用时都要跑
内部例程用稳定归并排序,把非空格字形按 Unicode 值、再按起点 X、再按绘制顺序排好。排序之后,一个字形的每个潜在副本都紧挨在它后面,所以扫描只在 Unicode 值相同、且 X 差保持在字形尺寸 0.25 以内时向前走。0.25 这个窗口刻意比沿基线的 0.1 容差宽,基线略斜的一对也能先落进窗口,再交给精确的基线坐标系测试。一个微妙情形:如果从字形 A 出发的扫描撞见一个画得更早的匹配字形,A 才是副本,于是丢弃 A 并停止扫描,让更早的那个字形继续与更后面的内容比较。正是这条规则让“先画者优先”在三连击时也成立,而不只是成对时
比较的是 Unicode 值,不是字体或字符码。经不同字体映射到同一 Unicode 的两个字形——比如填充在一套嵌入子集里、描边在另一套里——仍被识别为同一个字符。反过来说,字符码解析到 Unicode 0 的字形没有可靠的东西可比,这也是要盯住未解析计数的原因之一,详见用 HotPDF 从已加载 PDF 提取文本
哪些 HotPDF 提取调用会去除重复字形
HotPDF 里所有产出文本的调用都会去重,原始字形调用则不会。去重发生在 THotPDF.ExtractVisibleLoadedPageGlyphs 这一步,它同时丢弃页面可见框之外的字形;纯文本、分栏、版面和语义文本提取都从这里取字形。THotPDF.ExtractLoadedPageGlyphs 仍返回内容流画出的每一个字形,叠印也在内,因为做高亮、涂黑或取证工具的调用者需要完整绘制记录
对普通文本工作,调用代码毫无变化。v2.768.15 之前提取出翻倍文本的同一个循环,现在每个词只提取一次
var
Pdf: THotPDF;
PageCount, I: Integer;
PageText: UnicodeString;
begin
Pdf := THotPDF.Create(nil);
try
PageCount := Pdf.LoadFromFile('brochure.pdf');
for I := 0 to PageCount - 1 do
if Pdf.ExtractLoadedPageText(I, PageText) then
if Pos('Bold words', PageText) > 0 then
Writeln('Found on page ', I + 1);
finally
Pdf.Free;
end;
end;
版面和分栏输出同样受益,重复段以前会在字符网格上把一行的剩余部分挤偏。ExtractLoadedPageTextColumns 的可选间隙倍率照旧;重复过滤在分栏之前运行,与那个设置互不干扰
var
Grid, Columns: UnicodeString;
begin
if Pdf.ExtractLoadedPageTextLayout(0, Grid) then
TFile.WriteAllText('page1-layout.txt', Grid, TEncoding.UTF8);
// 间隙超过字形步进中位数 3 倍处切分栏
if Pdf.ExtractLoadedPageTextColumns(0, Columns, 3.0) then
TFile.WriteAllText('page1-columns.txt', Columns, TEncoding.UTF8);
end;
想知道一页里到底有没有叠印文本,就对比那两个字形调用。注意差值也把落在裁剪框之外的字形算了进去,所以把它当重复数的上限,别当精确值
var
AllGlyphs, Visible: THPDFGlyphArray;
begin
if Pdf.ExtractLoadedPageGlyphs(0, AllGlyphs) and
Pdf.ExtractVisibleLoadedPageGlyphs(0, Visible) then
Writeln(Format('%d painted, %d read as text (%d overprinted or off-page)',
[Length(AllGlyphs), Length(Visible),
Length(AllGlyphs) - Length(Visible)]));
end;
把这些字形变成文本的词间距和换行规则不因这个过滤器而改变;详见HotPDF 如何在页面文本里放置词空格与换行
这条规则与其它工具刻意不一致的地方
有些其它提取工具保留的完全重复,HotPDF 会丢弃,这个差异是有意的。v2.768.15 的修改对照参考语料库检查时,有一处页脚在完全相同的位置画了两遍,HotPDF 提取一次,参考提取器提取两次。画在相同字形之上的字形作为独立字符是不可见的,只读一次才是页面实际呈现的东西
诚实的局限在另一个方向。第二遍沿基线偏移超过字形尺寸 0.1 的伪粗体抓不到,用明显不同尺寸画的阴影也抓不到。这些版式在实践中罕见——偏移大到那种程度,视觉效果就不像粗体了——但如果某装饰性标题出现了重复文本,先比较 ExtractLoadedPageGlyphs 给出的字形 X 值,再下结论说过滤器坏了。过滤器对几何上不共位的重复同样无能为力,比如在页面上别处隐藏图层里重复出现的页眉;那是内容问题,不是叠印
CID 字体文本的行尾为什么被砍掉了
HotPDF v2.768.12 修复的另一个 bug,会丢掉用 CID 键控(Type 0)字体排版文本的行尾——前提是它的 /W 宽度数组没有按 CID 排序。ISO 32000-1 §9.7.4.3 定义了 /W 数组格式,却不要求条目按 CID 升序排列,而有些生成器就按字形在文档里首次使用的顺序写
文本提取解码器用二分查找宽度,而二分查找默认输入有序。在未排序数组上大多数查找落空,退回默认宽度 /DW——1000 单位,一个整 em。于是这种字体里的拉丁文本按每个字母都是全宽来排版,行远远跑出页面右缘,落到可见框之外的字形被丢弃,行尾就从提取文本和搜索里消失了。什么都不崩溃、什么都不报告,这正是它难被发现的原因
HotPDF 现在读完 /W 条目后立刻用稳定归并排序,两条目重叠时先写的仍然赢,与按文件顺序读数组的结果一致。页面渲染器从未受影响,因为它自己保留一份宽度数据并线性扫描,竖排宽度数组 /W2 也是线性扫描。页面看着完美、提取却一塌糊涂时,值得想起这条分工:PDF 库里的渲染和提取,共享的代码往往比你以为的少得多
FAQ
HotPDF 会去掉 “fill” 里双 l 这样的重复字母吗
不会。两个真实相邻的字母大约隔一个步进宽度,而 HotPDF 只合并沿基线 0.1、横跨基线 0.2 之内的相同字符,所以合法重复会保留
怎么拿到包括叠印副本在内的每个字形
调用 THotPDF.ExtractLoadedPageGlyphs,它按绘制顺序返回页面画出的每个字形。THotPDF.ExtractVisibleLoadedPageGlyphs 和所有文本提取调用返回的则是去重后的可见集合
哪个 HotPDF 版本修复了伪粗体的翻倍文本
HotPDF v2.768.15 为文本、版面、分栏和语义文本提取加入了重复字形去除,v2.768.12 修复了 CID 字体 /W 数组未排序导致的行尾丢失
两项修复都已在当前的 HotPDF Delphi PDF component 中提供,它在 Delphi 和 C++Builder 里从已加载 PDF 提取文本,同时具备 PDF 创建、编辑和渲染功能