บทความเทคนิค

แก้ ToUnicode: NBSP กับ soft hyphen ในการดึงข้อความ PDF

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 เอนไปทางไหนกลายเป็นข้อความที่ถูกดึงออกมา

ทำไม glyph ตัวเดียวของ Arial ทำให้การดึงข้อความ PDF ใน Delphi พัง: U+0020 กับ U+00A0 ไปถึง glyph 3 และ U+002D กับ U+00AD ไปถึง glyph ยัติภังค์ CMap ToUnicode ที่ถูกสร้างจึง map CID 0003 สองครั้งผ่าน entry bfchar กับ bfrange แบบ array และกฎลำดับความสำคัญของ reader ตัดสินว่า code point ไหนถูกดึงออกมา
กฎตัวต่ำชนะคุมเว้นวรรคให้เป็นปกติมาหลายปี จนการเปลี่ยนฝั่ง upstream ไปเป็นตัวสุดท้ายชนะทำให้ทุกเว้นวรรคจาก AddText ถูกดึงออกมาเป็น NBSP และยัติภังค์ทุกตัวเป็น soft hyphen
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 อย่างชัดเจน

ทางแก้แบบ key ด้วย code point ใน PDFium Component: LoadUnicodeKeyedCidFont อ่าน sfnt cmap ของฟอนต์, assign CID k+1 ให้ code point ที่เรียงแล้วแต่ละตัวโดย CID 0 เป็น notdef, ต่อ CIDToGIDMap อย่างชัดเจนเพื่อให้ U+0020 กับ U+00A0 คง CID ต่างกัน และ BuildUnicodeKeyedCidCMap ให้ทุก CID มี code point พอดีหนึ่งตัว
NBSP, soft hyphen และสัญลักษณ์ Ohm รอดตัวเป็นตัวเองใต้กฎลำดับความสำคัญแบบไหนก็ได้ ทั้งในเอกสารที่ยังเปิดอยู่และหลัง save ครั้งใด ๆ การซ่อม 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 หนึ่ง 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 นั้น

กับดัก bfrange เงียบ ๆ ในการ parse CMap ของ PDF: ช่วง CID จาก 00FE ถึง 0101 ข้ามขอบเขต xxFF, HandleBeginBFRange derive CID บนเป็น 0001, low มากกว่า high mark block ทั้งก้อนว่า invalid, SetText กับการ render ยังสำเร็จ และการดึงข้อความคืน U+0000 ให้ทุกตัวอักษรใน block
BuildUnicodeKeyedCidCMap เลี่ยงกับดักโดยจบแต่ละช่วงก่อน byte ต่ำถึง FF, คงทุก block ให้อยู่ในลิมิต 100 entry และเขียน code point ระนาบเสริมเป็น entry bfchar รายตัว

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