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,讀取器的優先權規則偏愛哪一條,哪一條就成了抽出來的文字
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 字型一文裡字形層級寫入背後相同的進入點
// 節錄自 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
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 產品頁