技術文章

PDFium Component 修復 NBSP 與軟性連字號萃取

Delphi 版 PDFium Component 把 TPdf.AddText 用到的系統字型內嵌成以 Unicode 碼位為索引的 CID 字型,所以每個 CID 恰好帶一條 ToUnicode 映射。這正是讓抽取出來的空格不再回傳 U+00A0(不換行空格)、連字符不再回傳 U+00AD(軟性連字號)的原因,從活文件到存出的檔案都一樣

症狀很惱人是因為它隱形。搜尋索引找不到「two-x」,因為存的字串裡有軟性連字號;CSV 匯出的切分不一樣;diff 工具標出一行行在每個檢視器裡看起來一模一樣的內容。渲染出來的頁面沒有任何不對;不對的是字形背後的 Unicode

抽取出來的空格為什麼會變成 U+00A0?

空格變成 U+00A0,是因為 PDFium 在 FPDFText_LoadFont 產生的 ToUnicode CMap 以字形為索引,而同一個字形可以從兩個碼位抵達。在 Arial 裡,字形 3 同時服務 U+0020 與 U+00A0,連字符字形同時服務 U+002D 與 U+00AD。產生的 CMap 於是把同一個 CID 映射兩次,一次透過 bfchar 項目、一次透過陣列形式的 bfrange,讀取器的優先權規則偏愛哪一條,哪一條就成了抽出來的文字

一個 Arial 字形為什麼會弄壞 Delphi 的 PDF 文字萃取:U+0020 與 U+00A0 都抵達字形 3、U+002D 與 U+00AD 都抵達連字符字形,所以產生的 ToUnicode CMap 透過一條 bfchar 項目與一條陣列 bfrange 把 CID 0003 映射兩次,讀取器的優先權規則決定抽出哪個碼位
最小者勝的優先權讓空格安分了很多年,直到上游改成最後者勝,每個 AddText 的空格都抽出成 NBSP、每個連字符都抽出成軟性連字號
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange

很長一段時間這個矛盾無害,因為 PDFium 的讀取器讓最小的映射勝出。上游一個變更把讀取器改成最後者勝,從那個建置起,每個用 AddText 寫出的空格都抽出成 NBSP、每個連字符都抽出成軟性連字號。注意成對的模式:0x20/0xA0 與 0x2D/0xAD 只差最高位元,這正是把 Latin-1 長相相同的字元送到同一個輪廓的字型會給你的結果。如果您的萃取程式碼昨天還好好的、今天開始在隱形字元上失敗,請把碼位傾印出來,別相信除錯器的顯示;把文字抽出來的基礎見用 Delphi 的 PDFium 從 PDF 文件萃取文字

uses
  SysUtils, PDFium;

const
  // 空格/U+00A0 與連字符/U+00AD 共用一個 Arial 字形,
  // 希臘 Omega(U+03A9)與歐姆符號(U+2126)也是
  Sample: WString = 'two-x'#$00A0'y'#$00AD'z '#$03A9#$2126;

function CodePoints(const S: WString): string;
var
  I: Integer;
begin
  Result := '';
  for I := 1 to Length(S) do
    Result := Result + 'U+' + IntToHex(Ord(S[I]), 4) + ' ';
end;

var
  Pdf: TPdf;
  Live, Reloaded: WString;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.CreateDocument;
    Pdf.AddPage(1, 595, 842);
    Pdf.AddText(Sample, 'Arial', 12, 72, 770);
    Live := Pdf.Text;                  // 活的、尚未存檔的文件
    Pdf.SaveAs('codepoints.pdf');
  finally
    Pdf.Free;
  end;

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'codepoints.pdf';
    Pdf.Active := True;
    Pdf.PageNumber := 1;
    Reloaded := Pdf.Text;              // 完整存檔再重新載入之後
  finally
    Pdf.Free;
  end;

  if (Live <> Sample) or (Reloaded <> Sample) then
    Writeln('Mismatch: ', CodePoints(Live), '/ ', CodePoints(Reloaded));
end;

為什麼存檔之後補 CMap 不夠

補丁只修補存出的檔案,而且前提是補丁讓 CMap 結構逐位元組原樣保留。第一個修法是 FPdfCompress 單元裡的 RepairSubsetToUnicodeCMaps,在每次非增量的 TPdf.SaveAs 之後執行,逐一解決衝突的 CID:bfchar 項目勝出,只差最高位元的一對解析成較小的基本 Latin 碼位,其他情況保留第一個映射

有趣的是那個否定結果。乾淨地重建衝突的 CMap——不管用起始碼形式還是陣列形式——看起來是顯而易見的做法,而 PDFium 把每個重建的 CMap 都整個拒收、退回 Identity。原生讀取器唯一接受的輸出,是把衝突的十六進位值做等長就地替換,區塊版面與 CID 覆蓋範圍原封不動。第二個教訓更謙遜:我們當時的筆記把記憶體內的情況怪罪到活文件根本沒有 ToUnicode 串流。直接呼叫 DLL 推翻了那個說法,因為活文件帶著同一條含糊的串流,這意味著真正的修法必須發生在 PDFium 產生 CMap 之前。修復常式留在函式庫裡,當成對其他 PDFium 系工具產出的 PDF 的防線

uses
  Classes, FPdfCompress;

var
  Source, Dest: TFileStream;
begin
  Source := TFileStream.Create('from-other-tool.pdf',
    fmOpenRead or fmShareDenyWrite);
  try
    Dest := TFileStream.Create('repaired.pdf', fmCreate);
    try
      // 只做等長編輯;沒有可修復衝突的檔案,
      // 還有交叉參照串流與物件串流的檔案,原樣複製
      RepairSubsetToUnicodeCMaps(Source, Dest);
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end;

改以碼位而非字形為字型索引

根治的辦法是根本不要 PDFium 產生 CMap。TPdf.LoadCachedFont 現在把系統字型位元組交給 TPdf.LoadUnicodeKeyedCidFont,後者讀字型自己的 sfnt cmap 表,偏好 format 12 子表、退回 format 4。碼位排序後去重,CID k+1 指派給第 k 個碼位,CID 0 留給 .notdef。一個明確的 CIDToGIDMap 把每個 CID 送到它的字形,所以 U+0020 與 U+00A0 拿到兩個畫同一個輪廓的不同 CID,而 ToUnicode CMap 把每個 CID 只映射到一個碼位。字型接著透過 FPDFText_LoadCidType2Font 載入——與以明確 CID 對 GID 映射內嵌 CID Type 2 字型一文裡字形層級寫入背後相同的進入點

PDFium Component 以碼位為索引的修法:LoadUnicodeKeyedCidFont 讀字型的 sfnt cmap、把 CID k+1 指派給每個排序後的碼位且 CID 0 是 notdef、接上明確的 CIDToGIDMap 讓 U+0020 與 U+00A0 保有不同 CID,而 BuildUnicodeKeyedCidCMap 給每個 CID 恰好一個碼位
NBSP、軟性連字號與歐姆符號從此在任何優先權規則下都以原樣存活,活文件裡與任何存檔之後都一樣,CMap 修復再也找不到東西可修
// 節錄自 TPdf.LoadUnicodeKeyedCidFont
SetLength(CidToGidMap, (Length(Entries) + 1) * 2);   // CID 0 = .notdef
for I := 0 to High(Entries) do
begin
  CidToGidMap[(I + 1) * 2]     := Byte(Entries[I].GlyphID shr 8);
  CidToGidMap[(I + 1) * 2 + 1] := Byte(Entries[I].GlyphID and $FF);
end;
ToUnicode := BuildUnicodeKeyedCidCMap(Entries);       // 一個 CID,一個碼位
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
  PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));

之後 FPDFText_SetText 寫字串時,反向查找落在每個字元單一的 CID 上,所以 NBSP、軟性連字號與歐姆符號在任何優先權規則下都以原樣存活,記憶體裡與任何存檔之後都一樣。因為存出的檔案帶的是元件自己的 ToUnicode 串流而不是引擎產生的,RepairSubsetToUnicodeCMaps 在裡面找不到任何要修的

為什麼一條 bfrange 能滅掉整個區塊?

CID 連續段跨過 xxFF 邊界的單一 bfrange 會讓 PDFium 丟棄它所在的整個區塊。ISO 32000-1 §9.10.3 只允許目的端的最後一個位元組在範圍內變動,但 CID 這邊有自己的陷阱:PDFium 的 HandleBeginBFRange 把高位 CID 推導成 (low and $FFFFFF00) or (high and $FF)。於是從 CID 00FE 到 0101 的連續段被讀成 00FE 到 0001,低大於高,整個區塊被標成無效。失敗是無聲的:SetText 成功、頁面渲染完美,而萃取對那個區塊裡的每個字元回傳 U+0000

PDF CMap 解析裡無聲的 bfrange 陷阱:從 00FE 到 0101 的 CID 連續段跨過 xxFF 邊界,HandleBeginBFRange 推導出高位 CID 0001,低大於高把整個區塊標成無效,SetText 與渲染照樣成功,而萃取對區塊裡每個字元回傳 U+0000
BuildUnicodeKeyedCidCMap 靠在低位元組 FF 之前結束每個連續段避開陷阱,把區塊保持在 100 項上限之內,並把補充平面碼位寫成個別的 bfchar 項目

BuildUnicodeKeyedCidCMap 在碼位或 CID 任一邊抵達低位元組 FF 之前結束連續段,把每個區塊保持在 CMap 文法的 100 項上限之內,並把補充平面的碼位寫成個別的 bfchar 項目、目的端用 UTF-16 代理對,因為在範圍內遞增一個代理對沒有定義的意義;代理對那部分的故事見Delphi 裡的 emoji、CJK 與代理對處理。純 bfchar 的 CMap 可以完全繞開邊界問題,代價是體積大上好幾倍

以碼位為索引的字型不涵蓋什麼?

以碼位為索引的路徑涵蓋每個暴露 Unicode cmap 子表的字型,其餘退回舊的以字形為索引的行為。依賴它之前值得知道的邊界:

  • 只有 (3,0) cmap 的符號字型,以及 CID 路徑載入失敗的任何字型,照舊走 FPDFText_LoadFont,所以兩個碼位共用的字形在那裡仍可能含糊地抽出
  • 沒有 format 12 子表時,映射限於 BMP,項目數上限 65535,讓每個 CID 都塞得進兩個位元組且大於零
  • 增量存檔(saIncremental)依設計跳過 RepairSubsetToUnicodeCMaps,因為增量修訂必須保持只附加;對元件自己寫的文字,以碼位為索引的字型讓這件事無關緊要
  • TrueType Collections 需要額外照顧:GDI 的 GetFontData 回傳整個 .ttc,而 FPDFText_LoadCidType2Font 沒有 face 索引參數,所以向 simsun.ttc 要求 NSimSun 曾經內嵌並渲染成 face 0 的 SimSun。元件現在把家族名稱對到 name 表(nameID 1 與 16),在解析 cmap 之前把要求的 face 抽成獨立的 sfnt;解析失敗時,collection 位元組原樣通過、行為退回 face 0

文字寫入、字型內嵌與萃取在 Delphi、C++Builder 與 Lazarus 之間共用同一個頁面模型,完整 API 見 PDFium Component for Delphi 產品頁