PDFium Component for Delphi nhúng các font hệ thống mà TPdf.AddText dùng dưới dạng CID font keyed theo code point Unicode, nên mỗi CID mang đúng một ánh xạ ToUnicode. Đó là thứ ngăn các space được trích ra quay về dạng U+00A0 (no-break space) và hyphen dạng U+00AD (soft hyphen), cả trên tài liệu đang sống lẫn file đã lưu
Triệu chứng thì khó chịu vì nó vô hình. Một search index bỏ sót “two-x” vì chuỗi được lưu chứa một soft hyphen, một bản export CSV tách khác đi, một công cụ diff gắn cờ những dòng trông giống hệt nhau trong mọi viewer. Chẳng có gì sai trên trang được render; chỉ có Unicode nằm sau các glyph là sai
Vì sao space được trích ra lại quay về U+00A0?
Space được trích ra hóa thành U+00A0 vì ToUnicode CMap mà PDFium sinh trong FPDFText_LoadFont được keyed theo glyph, và một glyph có thể chạm tới từ hai code point. Trong Arial, glyph 3 phục vụ cả U+0020 lẫn U+00A0, và glyph hyphen phục vụ cả U+002D lẫn U+00AD. CMap được sinh vì thế ánh xạ cùng một CID hai lần, một lần qua một entry bfchar và một lần qua một bfrange dạng mảng, và entry nào được luật ưu tiên của reader ưa thì thành text được trích ra
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange
Một thời gian dài mâu thuẫn đó vô hại, vì reader của PDFium cho ánh xạ thấp thắng. Một thay đổi thượng nguồn chuyển reader sang cao-thắng, và từ bản build đó, mọi space được viết bằng AddText được trích ra thành NBSP và mọi hyphen thành soft hyphen. Hãy để ý quy luật trong các cặp: 0x20/0xA0 và 0x2D/0xAD chỉ khác nhau ở bit cao, đúng những gì bạn sẽ mong đợi từ một font mà cmap đưa các look-alike Latin-1 về cùng một outline. Nếu code trích xuất của bạn hôm qua vẫn ngon và hôm nay hỏng với các ký tự vô hình, hãy dump các code point thay vì tin vào cửa sổ debugger; phần cơ bản của việc kéo text ra được trình bày trong trích xuất text từ tài liệu PDF với PDFium trong Delphi
uses
SysUtils, PDFium;
const
// Space/U+00A0 và hyphen/U+00AD dùng chung một glyph Arial, cũng như
// Omega Hy Lạp (U+03A9) và dấu Ohm (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; // tài liệu sống, chưa lưu
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; // sau một lần lưu và nạp lại trọn vẹn
finally
Pdf.Free;
end;
if (Live <> Sample) or (Reloaded <> Sample) then
Writeln('Mismatch: ', CodePoints(Live), '/ ', CodePoints(Reloaded));
end;
Vì sao vá CMap sau khi lưu là chưa đủ
Vá file đã lưu chỉ sửa đúng file đó, và cũng chỉ khi bản vá giữ nguyên cấu trúc CMap từng byte. Bản vá đầu tiên, RepairSubsetToUnicodeCMaps trong unit FPdfCompress, chạy sau mỗi TPdf.SaveAs không tăng dần và phân giải từng CID xung đột: entry bfchar thắng, một cặp chỉ khác bit cao được phân giải về code point base-Latin nhỏ hơn, và mọi thứ khác giữ ánh xạ đầu tiên của nó
Phần thú vị là kết quả phủ định. Dựng lại CMap xung đột một cách sạch sẽ, theo dạng start-code hay dạng mảng, nghe như nước đi hiển nhiên, và PDFium chối thẳng mọi CMap được dựng lại, fallback về Identity. Output duy nhất mà reader native chấp nhận là một phép thay thế tại chỗ cùng độ dài với các giá trị hex xung đột, giữ nguyên layout khối và độ phủ CID. Bài học thứ hai khiêm tốn hơn: ghi chú của chúng tôi lúc đó đổ lỗi cho ca in-memory là tài liệu sống không có stream ToUnicode nào cả. Gọi thẳng DLL đã bác bỏ điều đó, vì tài liệu sống mang cùng stream mơ hồ, nghĩa là bản vá thật phải xảy ra trước khi PDFium kịp sinh CMap. Routine sửa chữa vẫn ở lại trong thư viện như một lớp phòng thủ cho các PDF do các công cụ khác dựa trên PDFium sinh ra
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
// Chỉ sửa dài bằng nhau; các file không có xung đột sửa được,
// cùng các file cross-reference-stream hay object-stream, được sao chép nguyên trạng
RepairSubsetToUnicodeCMaps(Source, Dest);
finally
Dest.Free;
end;
finally
Source.Free;
end;
end;
Key font theo code point thay vì theo glyph
Bản sửa tận gốc là ngừng nhờ PDFium sinh CMap luôn. TPdf.LoadCachedFont giờ đưa các byte font hệ thống cho TPdf.LoadUnicodeKeyedCidFont, thứ đọc chính bảng sfnt cmap của font, ưa subtable format 12 và fallback về format 4. Các code point quay về đã sắp và đã khử trùng lặp, và CID k+1 được gán cho code point thứ k, với CID 0 để dành cho .notdef. Một CIDToGIDMap tường minh dẫn mỗi CID tới glyph của nó, nên U+0020 và U+00A0 nhận hai CID khác nhau vẽ cùng một outline, và ToUnicode CMap ánh xạ mỗi CID tới đúng một code point. Font sau đó được nạp qua FPDFText_LoadCidType2Font, đúng entry point đứng sau việc ghi cấp glyph trong nhúng font CID Type 2 với CID-to-GID map tường minh
// Rút gọn từ 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); // một CID, một code point
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));
Khi FPDFText_SetText viết một chuỗi, phép tra ngược đáp vào đúng một CID mỗi ký tự, nên NBSP, soft hyphen và dấu Ohm mỗi cái sống sót nguyên dạng dưới bất kỳ luật ưu tiên nào, trong bộ nhớ lẫn sau mọi lần lưu. Vì file đã lưu mang stream ToUnicode của chính component chứ không phải của engine sinh ra, RepairSubsetToUnicodeCMaps chẳng tìm thấy gì để sửa trong đó
Vì sao một entry bfrange xóa sổ cả một khối?
Một bfrange đơn mà chuỗi CID của nó vượt một biên xxFF khiến PDFium vứt bỏ toàn bộ khối nó nằm trong. ISO 32000-1 §9.10.3 chỉ cho byte cuối của đích được thay đổi trong một range, nhưng phía CID có cái bẫy riêng: HandleBeginBFRange của PDFium suy CID cao bằng (low and $FFFFFF00) or (high and $FF). Một chuỗi từ CID 00FE tới 0101 vì thế bị đọc thành 00FE tới 0001, low lớn hơn high, và cả khối bị đánh dấu không hợp lệ. Thất bại thì lặng lẽ: SetText thành công, trang render hoàn hảo, và trích xuất trả U+0000 cho mọi ký tự trong khối đó
BuildUnicodeKeyedCidCMap kết thúc một chuỗi trước khi hoặc code point hoặc CID chạm byte thấp FF, giữ mọi khối trong giới hạn 100 entry của ngữ pháp CMap, và viết các code point mặt phẳng bổ sung thành các entry bfchar riêng lẻ với đích là cặp surrogate UTF-16, vì việc tăng một cặp surrogate bên trong một range chẳng có nghĩa lý định nghĩa nào; phía surrogate của câu chuyện nằm trong xử lý emoji, CJK và surrogate pair trong Delphi. Một CMap chỉ toàn bfchar sẽ né hoàn toàn bài toán biên, với cái giá lớn gấp mấy lần
Font keyed theo code point không phủ những gì?
Đường keyed theo code point phủ mọi font phơi một subtable cmap Unicode, và fallback về hành vi keyed theo glyph cũ cho phần còn lại. Các biên đáng biết trước khi bạn dựa vào nó:
- Font symbol chỉ có cmap (3,0), và bất kỳ font nào đường CID không nạp nổi, đi qua
FPDFText_LoadFontnhư cũ, nên một glyph dùng chung bởi hai code point vẫn có thể trích xuất mơ hồ ở đó - Thiếu subtable format 12 thì map bị giới hạn trong BMP, và số entry bị trần 65535 để mọi CID nằm gọn trong hai byte khác 0
- Các lần save tăng dần (
saIncremental) bỏ quaRepairSubsetToUnicodeCMapsmột cách chủ ý, vì một revision tăng dần phải giữ append-only; các font keyed theo code point khiến điều đó chẳng còn ý nghĩa với text do chính component ghi - TrueType Collection cần chăm sóc thêm: GDI
GetFontDatatrả về cả file .ttc, vàFPDFText_LoadCidType2Fontkhông có tham số face index, nên đòi NSimSun từ simsun.ttc từng nhúng và render SimSun, face 0. Component giờ khớp tên họ với name table (nameID 1 và 16) và trích face được yêu cầu thành một sfnt đứng riêng trước khi cmap được parse; nếu parse thất bại, các byte collection đi xuyên qua và hành vi quay về face 0
Viết text, nhúng font và trích xuất dùng chung một model trang qua Delphi, C++Builder và Lazarus, và toàn bộ API được mô tả trên trang sản phẩm PDFium Component for Delphi