技术文章

Delphi 中支持 Unicode 的 PDF 文本搜索:NFC 与 NFD

PDF Library for Delphi 可以按规范等价而不是按代码单元来匹配文本,因此以预组合字符输入的查询能找到以基础字母加组合符号形式存储的内容,反过来也一样。两个搜索选项控制这一行为:soCanonicalEquivalent 在匹配过程中启用 Unicode 规范化,soGraphemeClusters 则把每一处命中和每一步通配符匹配都约束在完整的字位簇边界内

这解决的是文档搜索中报告最多、也最不被理解的一类缺陷。用户搜索一个名字,没有结果,把这个名字从文档里复制出来,粘贴进搜索框,却找到了。表面上看不出哪里坏了:两个字符串看起来一模一样,打印出来也一模一样,比较起来却不相等,因为一个是 U+00E9,另一个是 U+0065 后面跟着 U+0301

为什么同一个词比较起来会不相等?

Unicode 允许同一个抽象字符存在多种编码方式。带变音符号的拉丁字母既存在预组合码点形式,也存在基础字母加组合符号的序列形式。谚文音节既存在预组合音节形式,也存在分解后的字母形式。一份 PDF 里到底含有哪一种,取决于生成方、平台,有时还取决于字体,而这一切对做搜索的人来说都是不可见的

简单的大小写折叠解决不了这个问题,原因是结构性的,而不是偶然的。大小写折叠和变音符号折叠在代码单元层面是一一对应的:折叠后的字符串与原字符串长度相同,因此折叠文本中的匹配位置就是原文中的匹配位置。规范化不是一一对应的。一个预组合字符会变成两三个代码单元,一个分解序列又会折叠回一个,经过这种变换之后,位置就不再与你提取出的原文对齐了

让命中坐标始终指向原始文本

这一部分决定了规范化搜索是可用的,而不仅仅是正确的。规范化过程中产生的每一个代码单元,都会记录下产生它的原始 UTF-16 文本的起止位置。递归分解会继承其父节点的源区间,组合操作会合并其输入的区间,一旦找到匹配,库会在映射区间中扫描出最小的起点和最大的终点

由此带来的效果是,MatchStartMatchLength、上下文字符串以及两个替换入口,始终指向的是原始提取出的文本,而不是规范化后的中间形态。没有这层映射,一次规范化搜索或许能告诉你存在一处命中,却无法可靠地告诉你它在哪里,这会让高亮显示出错,也会让编校操作变得危险

规范化器本身是自包含的:紧凑的规范分解、组合和规范组合类表,取自 Unicode 15.1,谚文由算法规则处理,而不是靠表项。没有任何数据是从外部数据文件加载的,也不调用任何平台规范化 API,因此一个 Windows 服务、一个 Linux 守护进程和一个 FPC 构建版本,对同一份输入会产生完全相同的结果

用规范等价做搜索

选项是一个集合,因此规范等价可以与既有行为组合使用,比如整词匹配、通配符和不区分变音符号的折叠:

uses
  PDFlibrary;

var
  Lib: TPDFlib;
  Hits: array of TPDFlibSearchHit;
  Found, I: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('contracts.pdf', '');
    SetLength(Hits, 500);

    Found := Lib.SearchText('Bäcker', [soCanonicalEquivalent, soWholeWord],
      '', Hits);                       // 空页码范围表示整份文档

    for I := 0 to Found - 1 do
      Log(Format('page %d: "%s" at %d (%d chars)',
        [Hits[I].Page, Hits[I].MatchText, Hits[I].MatchStart,
         Hits[I].MatchLength]));
  finally
    Lib.Free;
  end;
end;

规范化是需要显式开启的,这是有原因的。构建 NFD 文本及其位置映射需要额外开销,而大多数只含 ASCII 内容的文档的搜索永远用不上它。启用该选项时,每个文本块会缓存两种转换后的形式,一种去掉了组合符号,一种保留了组合符号,因此对同一个文本块的一批查询只做一次规范化,而不是每次查询都做一次。大小写折叠则继续沿用开销更低的一一对应路径,不受影响

没有字位簇边界会出什么问题?

代码单元不是字符,字符也不是用户所感知的东西。一个国旗表情符号是两个区域指示符代码点。一个家庭表情符号是若干个由零宽连接符连起来的代码点。一个印度语系连字是一个辅音、一个维拉玛和另一个辅音。一个带两个叠加变音符号的字母是三个代码点。在其中任何一个的中间做匹配或切割,产生的都是渲染出来一团糟的片段

soGraphemeClusters 会把每一处命中的两端,无论是字面匹配还是通配符匹配,都约束在完整的扩展字位簇边界上。其分段逻辑实现了扩展规则:CR 与 LF 配对、控制字符、谚文音节类别、Extend 与 SpacingMark、Prepend、表情符号的 ZWJ 序列、区域指示符配对,以及印度语系连字断点。边界绝不会产生在一个代理对内部,单凭这一条就消除了在基本多文种平面之外的内容上出现损坏结果的整整一类问题

该选项还控制通配符的消耗方式,这正是朴素实现仍然会切错的地方。单字符通配符恰好前进一个完整的字位簇,而用于回溯的连续匹配通配符只会在字位簇边界之间移动:

// 不启用 soGraphemeClusters 时,"?" 可能只消耗半个字位簇,
// 返回一处结尾挂着一个悬空组合符号的命中文本
Found := Lib.SearchText('c?té',
  [soWildcards, soCanonicalEquivalent, soGraphemeClusters], '', Hits);

// 同样的边界也保护替换操作,因此编校和内容重写
// 永远不会切开一个表情符号或一个带变音符号的字母
Replaced := Lib.SearchAndReplaceText('naïve', 'plain',
  [soCanonicalEquivalent, soGraphemeClusters], '1-20');

为真实工作负载选择选项

三种组合能覆盖大多数场景。对于内部文档搜索框,soCanonicalEquivalentsoDiacriticInsensitive 能带来用户期望的宽容行为,同时匹配两种编码形式,也同时匹配带音符和不带音符的拼写。对于法务或合规搜索,一次误报的代价很高,应使用 soCanonicalEquivalent 搭配 soCaseSensitivesoWholeWord,并关闭变音符号折叠,让等价判定保持精确、且与编码无关

对任何会修改文档的操作,请无一例外都加上 soGraphemeClusters。一次返回了略微错误范围的搜索,顶多误导读者;而一次用了同样错误范围的替换或编校操作,则会把这个错误写进文件本身。删除范围出错的后果在 真正的编校与内容移除 中有描述

吞吐量要紧时,优先使用批量入口。SearchTextBatch 会在每页的文本块常驻内存期间运行所有非空查询,避免了每条查询都重新提取一次页面,也复用了已缓存的规范化结果,流式变体则无需调用方预先分配缓冲区即可发出命中结果。其底层的提取模型见 文本搜索与页面元素枚举

这一点在哪些文字上不是可选项

对韩语来说,规范等价决定了能否找到一个名字,因为预组合音节和分解后的字母在真实文档中都很常见。对越南语来说,叠加变音符号让组合形式完全取决于生成方。对印度语系文字来说,连字处理决定了命中边界是否落在一个有意义的位置上。对日语和中文来说,搜索这一侧相对简单,但版面这一侧并不简单,详见 日语和中文的竖排

经验法则很简单:只要语料中包含英语以外的任何语言,就打开规范等价,再去衡量成本是否真的高到不可接受。在大多数文档集合中并不高,而不这样做的代价,是一个在用户最在意能否找到的那些名字上悄悄失效的搜索功能

支持 Unicode 的搜索、提取、编校和文本重写共享适用于 Delphi、C++Builder 和 Free Pascal 的同一个引擎;完整功能列表见 PDF Library for Delphi 页面