PDFium Component สำหรับ Delphi ฝังฟอนต์ระบบที่ TPdf.AddText ใช้เป็น CID font ที่ถูก key ด้วย code point แบบ Unicode ทุก CID จึงพก ToUnicode mapping พอดีหนึ่งอัน นั่นคือสิ่งที่กันไม่ให้เว้นวรรคที่ดึงออกมากลายเป็น U+00A0 (no-break space) และยัติภังค์กลายเป็น U+00AD (soft hyphen) ทั้งจากเอกสารที่ยังเปิดอยู่และจากไฟล์ที่ save แล้ว
อาการนี้แสบเพราะมองไม่เห็น search index พลาด "two-x" เพราะสตริงที่เก็บไว้มี soft hyphen ปนอยู่, export CSV แตกไม่เหมือนเดิม, เครื่องมือ diff flag บรรทัดที่ดูเหมือนกันทุก viewer สิ่งไหนในหน้าที่ render ออกมาก็ไม่ผิด ผิดแค่ Unicode ที่อยู่หลัง glyph เท่านั้น
ทำไมเว้นวรรคที่ดึงออกมาถึงกลายเป็น U+00A0
เว้นวรรคที่ดึงออกมากลายเป็น U+00A0 เพราะ ToUnicode CMap ที่ PDFium สร้างใน FPDFText_LoadFont ถูก key ด้วย glyph และ glyph หนึ่งตัวไปถึงได้จากสอง code point ใน Arial glyph 3 รับใช้ทั้ง U+0020 กับ U+00A0 และ glyph ยัติภังค์รับใช้ทั้ง U+002D กับ U+00AD CMap ที่ถูกสร้างจึง map CID เดียวกันสองครั้ง หนึ่งครั้งผ่าน entry bfchar และหนึ่งครั้งผ่าน bfrange แบบ array แล้ว entry ไหนที่กฎลำดับความสำคัญของ reader เอนไปทางไหนกลายเป็นข้อความที่ถูกดึงออกมา
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange
เป็นเวลานานที่ความขัดแย้งนี้ไร้พิษภัย เพราะ reader ของ PDFium ให้ mapping ที่ต่ำกว่าชนะ การเปลี่ยนฝั่ง upstream สลับ reader ไปเป็นตัวสุดท้ายชนะ และจาก build นั้นเป็นต้นมา ทุกเว้นวรรคที่เขียนด้วย AddText ถูกดึงออกมาเป็น NBSP และยัติภังค์ทุกตัวเป็น soft hyphen สังเกต pattern ในคู่นี้: 0x20/0xA0 กับ 0x2D/0xAD ต่างกันแค่ bit สูง ซึ่งเป็นสิ่งที่ฟอนต์ที่ cmap ส่งตัวหลอก Latin-1 ไปที่ outline เดียวกันควรเป็นพอดี ถ้าโค้ดดึงข้อความของคุณเมื่อวานยังปกติแล้ววันนี้พังกับตัวอักษรล่องหน จง dump code point ออกมาดู อย่าเชื่อหน้าจอ debugger พื้นฐานการดึงข้อความออกจากเอกสารอยู่ในการดึงข้อความจากเอกสาร PDF ด้วย PDFium ใน Delphi
uses
SysUtils, PDFium;
const
// ช่องว่าง/U+00A0 กับยัติภังค์/U+00AD ใช้ glyph Arial ตัวเดียวกัน เช่นเดียวกับ
// Greek Omega (U+03A9) กับสัญลักษณ์ 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; // เอกสารที่ยังไม่ได้ save
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; // หลัง save เต็มรูปแบบแล้วโหลดใหม่
finally
Pdf.Free;
end;
if (Live <> Sample) or (Reloaded <> Sample) then
Writeln('Mismatch: ', CodePoints(Live), '/ ', CodePoints(Reloaded));
end;
ทำไมการแพตช์ CMap หลัง save ไม่พอ
การแพตช์ไฟล์ที่ save แล้วแก้แค่ไฟล์ที่ save แล้วเท่านั้น และต่อเมื่อแพตช์คงโครงสร้าง CMap ให้เหมือนเดิมทีละ byte แพตช์แรกคือ RepairSubsetToUnicodeCMaps ใน unit FPdfCompress รันหลัง TPdf.SaveAs แบบไม่ขยายทุกครั้ง และ resolve CID ที่ขัดแย้งแต่ละตัว: entry bfchar ชนะ คู่ที่ต่างกันแค่ bit สูง resolve ไปที่ code point base-Latin ที่เล็กกว่า และอย่างอื่นคง mapping แรกของมัน
ส่วนที่น่าสนใจคือผลลัพธ์ทางลบ การสร้าง CMap ที่ขัดแย้งขึ้นใหม่อย่างเนี้ยบ ทั้งแบบ start-code และแบบ array ดูเหมือนเป็นทางที่ชัดเจน แต่ PDFium ปฏิเสธ CMap ที่สร้างใหม่ทุกตัวเลย แล้วตกไปใช้ Identity ผลลัพธ์เดียวที่ reader เนทีฟรับคือการแทนที่ค่า hex ที่ขัดแย้งในที่เดิมด้วยความยาวเท่าเดิม โดยลำดับ block กับขอบเขต CID คงเดิมทุกอย่าง บทเรียนที่สองถ่อมตัวกว่า: โน้ตของเราตอนนั้นโทษเคสในหน่วยความจำว่าเอกสารที่ยังเปิดอยู่ไม่มี ToUnicode stream เลย การเรียก DLL ตรง ๆ หักล้างข้อนั้น เพราะเอกสารที่ยังเปิดอยู่พก stream ที่กำกวมเหมือนกัน แปลว่าการแก้จริงต้องเกิดก่อนที่ PDFium จะได้สร้าง CMap เสียอีก รูทีนซ่อมยังอยู่ในไลบรารีต่อไป เป็นการป้องกันสำหรับ PDF ที่ผลิตโดยเครื่องมืออื่นที่อิง PDFium
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
// แก้ได้เฉพาะแบบความยาวเท่าเดิม ไฟล์ที่ไม่มี conflict ที่ซ่อมได้
// และไฟล์แบบ cross-reference stream หรือ object stream ถูก copy ตามเดิม
RepairSubsetToUnicodeCMaps(Source, Dest);
finally
Dest.Free;
end;
finally
Source.Free;
end;
end;
key ฟอนต์ด้วย code point แทนที่จะใช้ glyph
ทางแก้ที่รากถึงรากคือเลิกให้ PDFium สร้าง CMap เสียเลย TPdf.LoadCachedFont ตอนนี้ส่ง byte ของฟอนต์ระบบต่อไปให้ TPdf.LoadUnicodeKeyedCidFont ซึ่งอ่านตาราง sfnt cmap ของฟอนต์เอง โดยเลือก subtable format 12 ก่อน แล้วตกไปที่ format 4 code point จะกลับมาเรียงลำดับและตัดซ้ำแล้ว CID k+1 ถูก assign ให้ code point ตัวที่ k โดย CID 0 ถูกปล่อยไว้เป็น .notdef CIDToGIDMap อย่างชัดเจนส่งแต่ละ CID ไปหา glyph ของมัน U+0020 กับ U+00A0 จึงได้ CID สองตัวต่างกันที่วาด outline เดียวกัน และ ToUnicode CMap map แต่ละ CID ไปยัง code point เดียวเท่านั้น ฟอนต์ถูกโหลดต่อผ่าน FPDFText_LoadCidType2Font จุดเข้าเดียวกับที่อยู่หลังการเขียนระดับ glyph ในการฝังฟอนต์ CID Type 2 พร้อม CID-to-GID map อย่างชัดเจน
// ย่อจาก 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 หนึ่ง code point
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));
เมื่อ FPDFText_SetText เขียนสตริงในภายหลัง การค้นย้อนกลับจะตกลงที่ CID ตัวเดียวต่อตัวอักษร NBSP, soft hyphen และสัญลักษณ์ Ohm จึงรอดเป็นตัวเองใต้กฎลำดับความสำคัญแบบไหนก็ได้ ทั้งในหน่วยความจำและหลัง save ครั้งใด ๆ เพราะไฟล์ที่ save แล้วพก ToUnicode stream ของ component เอง ไม่ใช่ของเอนจินสร้าง RepairSubsetToUnicodeCMaps จึงไม่เจออะไรให้แก้ในมันเลย
ทำไม entry bfrange ตัวเดียวถึงลบ block ทั้งก้อนหายได้
bfrange ตัวเดียวที่ช่วง CID ของมันข้ามขอบเขต xxFF ทำให้ PDFium ทิ้ง block ทั้งก้อนที่มันนั่งอยู่ ISO 32000-1 §9.10.3 อนุญาตให้เฉพาะ byte สุดท้ายของปลายทางแปรผันภายใน range แต่ฝั่ง CID มีกับดักของตัวเอง: HandleBeginBFRange ของ PDFium derive CID บนเป็น (low and $FFFFFF00) or (high and $FF) ช่วงจาก CID 00FE ถึง 0101 จึงถูกอ่านเป็น 00FE ถึง 0001 คือ low มากกว่า high และ block ทั้งก้อนถูก mark ว่า invalid ความพังนี้เงียบ: SetText สำเร็จ หน้า render ออกมาสวยงาม และการดึงข้อความคืน U+0000 ให้ทุกตัวอักษรใน block นั้น
BuildUnicodeKeyedCidCMap จบช่วงก่อนที่ code point หรือ CID จะไปถึง byte ต่ำเป็น FF, คงทุก block ให้อยู่ในลิมิต 100 entry ของไวยากรณ์ CMap และเขียน code point ระนาบเสริมเป็น entry bfchar รายตัวพร้อมปลายทางคู่ surrogate UTF-16 เพราะการบวกเพิ่มคู่ surrogate ภายใน range ไม่มีความหมายที่นิยามไว้ เรื่องฝั่ง surrogate อยู่ในการจัดการ emoji, CJK และ surrogate pair ใน Delphi CMap ที่ใช้ bfchar อย่างเดียวจะเลี่ยงปัญหาขอบเขตไปได้ทั้งหมด แลกกับขนาดที่โตหลายเท่า
ฟอนต์แบบ key ด้วย code point ไม่ครอบคลุมอะไร
เส้นทางแบบ key ด้วย code point ครอบคลุมฟอนต์ทุกตัวที่เปิดเผย subtable cmap แบบ Unicode และตกกลับไปพฤติกรรม key ด้วย glyph แบบเดิมสำหรับที่เหลือ ขอบเขตที่ควรรู้ก่อนพึ่งพามัน:
- ฟอนต์สัญลักษณ์ที่มีแค่ cmap (3,0) และฟอนต์ใดที่เส้นทาง CID โหลดไม่ได้ ยังเดินผ่าน
FPDFText_LoadFontเหมือนเดิม glyph ที่ใช้ร่วมกันระหว่างสอง code point จึงยังถูกดึงออกมาแบบกำกวมได้ที่นั่น - ถ้าไม่มี subtable format 12 map ถูกจำกัดอยู่แค่ BMP และจำนวน entry ถูกจำกัดที่ 65535 เพื่อให้ทุก CID ลงในสอง byte ที่มากกว่าศูนย์
- การ save แบบขยาย (
saIncremental) ข้ามRepairSubsetToUnicodeCMapsโดยตั้งใจ เพราะ revision แบบขยายต้องเป็น append-only ฟอนต์แบบ key ด้วย code point ทำให้เรื่องนี้ไม่เกี่ยวกับข้อความที่ component เขียนเอง - TrueType Collections ต้องใส่ใจพิเศษ: GDI
GetFontDataคืนทั้งไฟล์ .ttc และFPDFText_LoadCidType2Fontไม่มีพารามิเตอร์ face index การขอ NSimSun จาก simsun.ttc เคยฝังและ render SimSun face 0 ออกมา component ตอนนี้จับคู่ชื่อ family กับ name table (nameID 1 กับ 16) แล้วดึง face ที่ขอออกมาเป็น sfnt แยกต่างหากก่อน cmap จะถูก parse ถ้า parse ล้มเหลว byte ของ collection ถูกส่งต่อตามเดิมและพฤติกรรมกลับไปที่ face 0
การเขียนข้อความ, การฝังฟอนต์ และการดึงข้อความใช้โมเดลหน้าตัวเดียวกันทั้ง Delphi, C++Builder และ Lazarus และ API ฉบับเต็มมีคำอธิบายบนหน้าผลิตภัณฑ์ PDFium Component for Delphi