Artikel Teknis

Perbaikan ToUnicode: NBSP dan Soft Hyphen di Delphi

PDFium Component for Delphi meng-embed font sistem yang dipakai TPdf.AddText sebagai CID font yang dikunci per code point Unicode, sehingga setiap CID membawa tepat satu pemetaan ToUnicode. Itulah yang menghentikan spasi hasil ekstraksi kembali sebagai U+00A0 (no-break space) dan hyphen sebagai U+00AD (soft hyphen), baik dari dokumen live maupun dari berkas tersimpan

Gejalanya menjengkelkan karena tak terlihat. Index pencarian melewatkan "two-x" karena string tersimpannya mengandung soft hyphen, export CSV memecahnya secara berbeda, tool diff menandai baris-baris yang tampak identik di semua viewer. Tak ada yang salah di halaman yang dirender; yang salah cuma Unicode di balik glyph-nya

Mengapa spasi hasil ekstraksi kembali sebagai U+00A0?

Spasi hasil ekstraksi berubah menjadi U+00A0 karena CMap ToUnicode yang dihasilkan PDFium di FPDFText_LoadFont dikunci per glyph, dan satu glyph bisa dicapai dari dua code point. Di Arial, glyph 3 melayani U+0020 sekaligus U+00A0, dan glyph hyphen melayani U+002D sekaligus U+00AD. CMap yang dihasilkan karena itu memetakan CID yang sama dua kali, sekali lewat entri bfchar dan sekali lewat bfrange berbentuk array, dan entri mana yang diutamakan aturan presedensi pembaca itulah yang menjadi teks hasil ekstraksi

Mengapa satu glyph Arial merusak ekstraksi teks PDF di Delphi: U+0020 dan U+00A0 mencapai glyph 3 dan U+002D serta U+00AD mencapai glyph hyphen, sehingga CMap ToUnicode yang dihasilkan memetakan CID 0003 dua kali lewat entri bfchar dan bfrange array, dan aturan presedensi pembaca menentukan code point mana yang terekstraksi
Presedensi terendah-menang menjaga spasi tetap polos bertahun-tahun, sampai perubahan upstream ke terakhir-menang membuat setiap spasi AddText terekstraksi sebagai NBSP dan setiap hyphen sebagai soft hyphen
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange

Lama sekali kontradiksi ini tak berbahaya, karena reader PDFium membiarkan pemetaan terendah menang. Perubahan upstream mengalihkan reader ke terakhir-menang, dan sejak build itu setiap spasi yang ditulis dengan AddText terekstraksi sebagai NBSP dan setiap hyphen sebagai soft hyphen. Perhatikan polanya di pasangan itu: 0x20/0xA0 dan 0x2D/0xAD hanya berbeda di bit tertinggi, persis yang bisa Anda duga dari font yang cmap-nya mengirim kembaran Latin-1 ke outline yang sama. Kalau kode ekstraksi Anda kemarin masih baik dan kini gagal pada karakter tak terlihat, dump code point-nya daripada percaya tampilan debugger; dasar-dasar menarik teks dibahas di mengekstrak teks dari dokumen PDF dengan PDFium di Delphi

uses
  SysUtils, PDFium;

const
  // Space/U+00A0 dan hyphen/U+00AD berbagi satu glyph Arial, begitu juga
  // Omega Yunani (U+03A9) dan tanda 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;                  // dokumen live, belum tersimpan
    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;              // setelah simpan penuh dan muat ulang
  finally
    Pdf.Free;
  end;

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

Mengapa menambal CMap setelah menyimpan tidak cukup

Menambal berkas tersimpan hanya memperbaiki berkas tersimpan itu, dan hanya kalau tambalannya menjaga struktur CMap tetap utuh byte demi byte. Perbaikan pertama, RepairSubsetToUnicodeCMaps di unit FPdfCompress, berjalan setelah setiap TPdf.SaveAs non-inkremental dan me-resolve setiap CID yang bertabrakan: entri bfchar menang, pasangan yang hanya berbeda di bit tertinggi di-resolve ke code point base-Latin yang lebih kecil, dan sisanya mempertahankan pemetaan pertamanya

Bagian menariknya justru hasil negatifnya. Membangun ulang CMap yang bertabrakan secara bersih, dalam bentuk start-code maupun array, tampak seperti langkah yang jelas, dan PDFium menolak setiap CMap hasil bangun ulang itu bulat-bulat, jatuh kembali ke Identity. Satu-satunya output yang diterima reader native adalah penggantian in-place sepanjang sama atas nilai hex yang bertabrakan, dengan tata letak blok dan cakupan CID tak tersentuh. Pelajaran kedua lebih membumi: catatan kami saat itu menyalahkan kasus in-memory pada dokumen live yang konon tak punya stream ToUnicode sama sekali. Memanggil DLL langsung membantahnya, karena dokumen live membawa stream ambigu yang sama, yang berarti perbaikan sesungguhnya harus terjadi sebelum PDFium sempat menghasilkan CMap-nya. Rutinitas perbaikan tetap tinggal di library sebagai pertahanan bagi PDF yang dihasilkan tool lain berbasis 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
      // Hanya suntingan sepanjang sama; berkas tanpa konflik yang bisa diperbaiki,
      // dan berkas cross-reference-stream atau object-stream, disalin apa adanya
      RepairSubsetToUnicodeCMaps(Source, Dest);
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end;

Mengunci font per code point alih-alih per glyph

Perbaikan akarnya adalah berhenti meminta PDFium menghasilkan CMap sama sekali. TPdf.LoadCachedFont kini menyerahkan byte font sistem ke TPdf.LoadUnicodeKeyedCidFont, yang membaca tabel sfnt cmap milik font itu sendiri, mengutamakan subtable format 12 dan jatuh ke format 4. Code point kembali terurut dan ter-deduplikasi, dan CID k+1 ditugaskan ke code point ke-k, dengan CID 0 dibiarkan sebagai .notdef. CIDToGIDMap eksplisit mengirim tiap CID ke glyph-nya, sehingga U+0020 dan U+00A0 mendapat dua CID berbeda yang menggambar outline yang sama, dan CMap ToUnicode memetakan tiap CID ke satu code point saja. Font lalu dimuat lewat FPDFText_LoadCidType2Font, entry point yang sama di balik penulisan level glyph di embedding font CID Type 2 dengan CID-to-GID map eksplisit

Perbaikan berkunci code point di PDFium Component: LoadUnicodeKeyedCidFont membaca sfnt cmap milik font, menugaskan CID k+1 ke tiap code point terurut dengan CID 0 sebagai notdef, memasang CIDToGIDMap eksplisit sehingga U+0020 dan U+00A0 mempertahankan CID berbeda, dan BuildUnicodeKeyedCidCMap memberi tiap CID tepat satu code point
NBSP, soft hyphen, dan tanda Ohm lalu selamat sebagai dirinya sendiri di bawah aturan presedensi mana pun, di dokumen live maupun setelah simpanan apa pun, sehingga perbaikan CMap tak menemukan apa pun yang tersisa untuk diperbaiki
// Diringkas dari 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);       // satu CID, satu code point
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
  PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));

Ketika FPDFText_SetText kemudian menulis sebuah string, lookup terbaliknya mendarat di satu CID per karakter, sehingga NBSP, soft hyphen, dan tanda Ohm masing-masing selamat sebagai dirinya sendiri di bawah aturan presedensi mana pun, di memori maupun setelah simpanan apa pun. Karena berkas tersimpan membawa stream ToUnicode milik komponen sendiri alih-alih hasil generasi engine, RepairSubsetToUnicodeCMaps tak menemukan apa pun untuk diperbaiki di dalamnya

Mengapa satu entri bfrange bisa menghapus satu blok utuh?

Satu bfrange yang rentang CID-nya melewati batas xxFF membuat PDFium membuang seluruh blok tempat ia berada. ISO 32000-1 §9.10.3 hanya membolehkan byte terakhir tujuan bervariasi di dalam satu rentang, tapi sisi CID punya jebakannya sendiri: HandleBeginBFRange milik PDFium menurunkan CID tinggi sebagai (low and $FFFFFF00) or (high and $FF). Rentang dari CID 00FE ke 0101 karena itu terbaca sebagai 00FE ke 0001, low lebih besar dari high, dan seluruh blok ditandai tidak valid. Kegagalannya senyap: SetText sukses, halaman tergambar sempurna, dan ekstraksi mengembalikan U+0000 untuk setiap karakter di blok itu

Jebakan bfrange yang senyap di parsing CMap PDF: rentang CID dari 00FE ke 0101 melewati batas xxFF, HandleBeginBFRange menurunkan CID tinggi sebagai 0001, low lebih besar dari high menandai seluruh blok tak valid, SetText dan rendering tetap sukses, dan ekstraksi mengembalikan U+0000 untuk setiap karakter di blok itu
BuildUnicodeKeyedCidCMap menghindari jebakan itu dengan mengakhiri tiap rentang sebelum low byte mencapai FF, menjaga blok tetap dalam batas 100 entri, dan menulis code point supplementary-plane sebagai entri bfchar individual

BuildUnicodeKeyedCidCMap mengakhiri rentang sebelum code point maupun CID mencapai low byte FF, menjaga setiap blok dalam batas 100 entri milik tata bahasa CMap, dan menulis code point supplementary-plane sebagai entri bfchar individual dengan tujuan pasangan surrogate UTF-16, karena menaikkan pasangan surrogate di dalam rentang tak punya makna yang terdefinisi; sisi surrogate dari kisah itu ada di penanganan emoji, CJK, dan surrogate pair di Delphi. CMap yang hanya bfchar bisa menghindari masalah batas sepenuhnya, dengan ukuran beberapa kali lipat

Apa yang tidak tercakup oleh font berkunci code point?

Jalur berkunci code point mencakup setiap font yang memaparkan subtable cmap Unicode, dan jatuh kembali ke perilaku berkunci glyph yang lama untuk sisanya. Batas-batas yang layak diketahui sebelum Anda mengandalkannya:

  • Font simbol yang hanya punya cmap (3,0), dan font mana pun yang gagal dimuat jalur CID-nya, tetap lewat FPDFText_LoadFont seperti dulu, sehingga glyph yang dibagi dua code point masih bisa terekstraksi ambigu di sana
  • Tanpa subtable format 12, peta terbatas ke BMP, dan jumlah entri dicapped di 65535 agar setiap CID muat dalam dua byte di atas nol
  • Simpanan inkremental (saIncremental) melewati RepairSubsetToUnicodeCMaps memang oleh desain, karena revisi inkremental harus tetap append-only; font berkunci code point membuat hal itu tak relevan untuk teks yang ditulis komponen sendiri
  • TrueType Collections butuh perhatian ekstra: GetFontData milik GDI mengembalikan seluruh .ttc, dan FPDFText_LoadCidType2Font tak punya parameter indeks face, sehingga meminta NSimSun dari simsun.ttc dulu meng-embed dan merender SimSun, face 0. Komponen kini mencocokkan nama keluarga terhadap name table (nameID 1 dan 16) dan mengekstrak face yang diminta sebagai sfnt berdiri sendiri sebelum cmap diurai; bila penguraian gagal, byte koleksi diteruskan dan perilakunya kembali ke face 0

Penulisan teks, embedding font, dan ekstraksi berbagi satu model halaman di Delphi, C++Builder, dan Lazarus, dan API lengkapnya dideskripsikan di halaman produk PDFium Component for Delphi