Teknik Makale

ToUnicode Düzeltmesi: NBSP ve Soft Hyphen Metin Çıkarma

Delphi için PDFium Component, TPdf.AddText'in kullandığı sistem fontlarını Unicode kod noktasına göre anahtarlanmış CID fontlar olarak gömer; böylece her CID tam olarak bir ToUnicode eşlemesi taşır. Çıkarılan boşlukların U+00A0 (bölünemez boşluk) olarak ve tirelerin U+00AD (soft hyphen) olarak geri dönmesini önleyen şey budur — hem canlı belgede hem kaydedilen dosyada

Belirti iğrençtir, çünkü görünmezdir. Bir arama indeksi "two-x"i bulamaz, çünkü saklanan string soft hyphen içerir; bir CSV dışa aktarımı başka türlü böler; bir diff aracı her görüntüleyicide birebir aynı görünen satırları işaretler. Render edilen sayfada yanlış olan hiçbir şey yoktur; yanlış olan, glyph'lerin ardındaki Unicode'dur

Çıkarılan boşluklar neden U+00A0 olarak dönüyor?

Çıkarılan boşluklar U+00A0'ya dönüşür, çünkü PDFium'un FPDFText_LoadFont içinde ürettiği ToUnicode CMap glyph'e göre anahtarlanmıştır ve bir glyph'e iki kod noktasından ulaşılabilir. Arial'de 3 numaralı glyph hem U+0020'yi hem U+00A0'yı karşılar, tire glyph'i de hem U+002D'yi hem U+00AD'yi karşılar. Üretilen CMap bu yüzden aynı CID'yi iki kez eşler: bir kez bir bfchar girdisiyle, bir kez dizi biçimli bir bfrange ile; okuyucunun öncelik kuralının hangisini tuttuğu, çıkarılan metin olur

Tek bir Arial glyph'in Delphi'de PDF metin çıkarmasını nasıl bozduğu: U+0020 ile U+00A0 3 numaralı glyph'e, U+002D ile U+00AD tire glyph'ine ulaşır; üretilen ToUnicode CMap CID 0003'ü bir bfchar girdisiyle ve dizi bfrange'le iki kez eşler ve hangi kod noktasının çıkarılacağına okuyucunun öncelik kuralı karar verir
En-düşük-kazanır önceliği yıllarca boşlukları düz tuttu; upstream'in en-son-kazanır geçişi, her AddText boşluğunun NBSP ve her tirenin soft hyphen olarak çıkarılmasına dek
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange

Uzun süre bu çelişki zararsızdı, çünkü PDFium okuyucusu en düşük eşlemenin kazanmasına izin veriyordu. Upstream bir değişiklik okuyucuyu en-son-kazanır'a çevirdi ve o derlemeden itibaren AddText ile yazılan her boşluk NBSP, her tire soft hyphen olarak çıkarıldı. Çiftlerdeki örüntüye dikkat: 0x20/0xA0 ile 0x2D/0xAD yalnızca yüksek bittte farklıdır; bu, cmap'i Latin-1 benzerlerini aynı kontura gönderen bir fonttan tam olarak beklenecek şeydir. Çıkarma kodunuz dün iyi çalışıyorduysa ve şimdi görünmez karakterlerde başarısız oluyorsa debugger görünümüne güvenmek yerine kod noktalarını dökün; metin çekmenin temelleri Delphi'de PDFium ile PDF belgelerinden metin çıkarmak yazısında işleniyor

uses
  SysUtils, PDFium;

const
  // Space/U+00A0 ve hyphen/U+00AD tek bir Arial glyph'i paylaşır,
  // Yunan Omega (U+03A9) ile Ohm işareti (U+2126) de paylaşır
  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;                  // canlı, kaydedilmemiş belge
    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;              // tam kaydetme ve yeniden yüklemeden sonra
  finally
    Pdf.Free;
  end;

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

Kaydettikten sonra CMap'i yamak neden yetmedi

Kaydedilen dosyayı yamak yalnızca kaydedilen dosyayı düzeltir ve yama, CMap yapısını bayt-bayt sağlam tutarsa. İlk düzeltme, FPdfCompress unit'indeki RepairSubsetToUnicodeCMaps, artımlı olmayan her TPdf.SaveAs'ten sonra çalışır ve çelişen her CID'yi çözer: bfchar girdisi kazanır, yalnızca yüksek bittte farklı olan bir çift daha küçük taban-Latin kod noktasına çözülür, geri kalan her şey ilk eşlemesini korur

İlginç olan kısmı olumsuz sonuç. Çelişen CMap'i, ister başlangıç-kodu ister dizi biçiminde olsun, temizce yeniden kurmak bariz hamle gibi görünüyordu ve PDFium her yeniden kurulan CMap'i düpedüz reddedip Identity'ye döndü. Yerli okuyucunun kabul ettiği tek çıktı, çelişen hex değerlerinin blok düzeni ile CID kapsamı dokunulmadan eşit-uzunluklu yerinde değişimiydi. İkinci ders daha mütevazıydı: o günkü notumuz bellek-içi vakayı, canlı belgenin hiç ToUnicode stream'i olmamasına bağlıyordu. DLL'i doğrudan çağırmak bunu çürüttü, çünkü canlı belge aynı belirsiz stream'i taşıyordu; gerçek düzeltmenin PDFium CMap'i daha üretmeden önce olması gerekiyordu. Onarım rutini, diğer PDFium tabanlı araçların ürettiği PDF'lere karşı savunma olarak kütüphanede kalıyor

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
      // Yalnızca eşit-uzunluklu düzenlemeler; onarılabilir çelişkisi olmayan dosyalar
      // ve çapraz-referans-stream ya da object-stream dosyaları olduğu gibi kopyalanır
      RepairSubsetToUnicodeCMaps(Source, Dest);
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end;

Fontu glyph yerine kod noktasına göre anahtarlama

Kök düzeltme, PDFium'dan CMap üretmesini hiç istememeyi gerektiriyor. TPdf.LoadCachedFont artık sistem font baytlarını TPdf.LoadUnicodeKeyedCidFont'a veriyor; bu da fontun kendi sfnt cmap tablosunu okuyor, format 12 alt tablosunu tercih edip format 4'e düşüyor. Kod noktalar sıralanmış ve tekilleştirilmiş olarak geri gelir, CID k+1 k. kod noktasına atanır ve CID 0 .notdef olarak bırakılır. Açık bir CIDToGIDMap her CID'yi kendi glyph'ine gönderir; U+0020 ile U+00A0 böylece aynı konturu çizen iki farklı CID alır ve ToUnicode CMap her CID'yi yalnızca bir kod noktasına eşler. Font sonra FPDFText_LoadCidType2Font üzerinden yüklenir — açık CID-to-GID map'lerle CID Type 2 font gömme yazısındaki glyph-düzeyi yazımın arkasındaki aynı giriş noktası

PDFium Component'ta kod-noktası-anahtarlı düzeltme: LoadUnicodeKeyedCidFont fontun sfnt cmap'ini okur, her sıralı kod noktasına CID k+1 atar (CID 0 notdef), açık bir CIDToGIDMap bağlar, böylece U+0020 ile U+00A0 farklı CID'leri korur ve BuildUnicodeKeyedCidCMap her CID'ye tam olarak bir kod noktası verir
NBSP, soft hyphen ve Ohm işareti sonra her iki öncelik kuralı altında kendileri olarak hayatta kalır — canlı belgede ve her kayıttan sonra; CMap onarımının düzeltilecek bir şey bulamamasının nedeni budur
// TPdf.LoadUnicodeKeyedCidFont'tan yoğunlaştırıldı
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);       // tek CID, tek kod noktası
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
  PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));

FPDFText_SetText sonra bir string yazdığında ters arama karakter başına tek bir CID'ye düşer; NBSP, soft hyphen ve Ohm işareti böylece her iki öncelik kuralı altında kendileri olarak hayatta kalır — bellekte ve her kayıttan sonra. Kaydedilen dosya, motor-üretili bir stream yerine component'in kendi ToUnicode stream'ini taşıdığı için RepairSubsetToUnicodeCMaps onda düzeltilecek bir şey bulamaz

Tek bir bfrange girdisi bütün bir bloğu nasıl silebilir?

CID dizisi bir xxFF sınırını aşan tek bir bfrange, PDFium'u içinde bulunduğu bloğun tamamını çöpe atmaya iter. ISO 32000-1 §9.10.3 yalnızca hedefin son baytının bir aralık içinde değişmesine izin verir ama CID tarafının kendi tuzağı vardır: PDFium'un HandleBeginBFRange'si yüksek CID'yi (low and $FFFFFF00) or (high and $FF) olarak türetir. CID 00FE'den 0101'e giden bir dizi bu yüzden 00FE'den 0001'e okunur — düşük büyüktür yüksekten — ve bloğun tamamı geçersiz işaretlenir. Başarısızlık sessizdir: SetText başarılı olur, sayfa kusursuz render edilir ve çıkarma o bloktaki her karakter için U+0000 döndürür

PDF CMap parse'ındaki sessiz bfrange tuzağı: 00FE'den 0101'e giden bir CID dizisi xxFF sınırını aşar, HandleBeginBFRange yüksek CID'yi 0001 olarak türetir, düşüğün yüksekten büyük olması bloğun tamamını geçersiz işaretler, SetText ile render hâlâ başarılıdır ve çıkarma bloktaki her karakter için U+0000 döndürür
BuildUnicodeKeyedCidCMap tuzağı, her diziyi düşük baytı FF olmadan bitirerek, blokları 100 girdilik sınırın içinde tutarak ve tamamlayıcı-düzlem kod noktalarını tek tek bfchar girdileri olarak yazarak önler

BuildUnicodeKeyedCidCMap, kod noktası ya da CID düşük baytı FF'ye ulaşmadan önce diziyi bitirir, her bloğu CMap gramerinin 100 girdilik sınırının içinde tutar ve tamamlayıcı-düzlem kod noktalarını UTF-16 surrogate çifti hedefli tek tek bfchar girdileri olarak yazar; bir aralık içinde bir surrogate çiftini artırmanın tanımlı bir anlamı yoktur. O hikâyenin surrogate tarafı Delphi'de emoji, CJK ve surrogate çifti işleme yazısında. Yalnızca-bfchar bir CMap sınır sorununu bütünüyle atlatırdı — birkaç kat büyüklük pahasına

Kod-noktası-anahtarlı font neleri kapsamaz?

Kod-noktası-anahtarlı yol, Unicode cmap alt tablosu açığa veren her fontu kapsar ve geri kalanlar için eski glyph-anahtarlı davranışa döner. Buna güvenmeden önce bilinmeye değer sınırlar:

  • Yalnızca (3,0) cmap'i olan symbol fontlar ve CID yolunun yükleyemediği her font eskisi gibi FPDFText_LoadFont'tan geçer; iki kod noktasının paylaştığı bir glyph orada hâlâ belirsiz çıkarılabilir
  • Format 12 alt tablosu olmadan harita BMP ile sınırlıdır ve girdi sayısı 65535'te tavanlanır; böylece her CID, sıfırın üstünde iki bayta sığar
  • Artımlı kaydetmeler (saIncremental) tasarım gereği RepairSubsetToUnicodeCMaps'i atlar; artımlı bir revizyon yalnızca-ekleme kalmalıdır. Kod-noktası-anahtarlı fontlar, component'in kendi yazdığı metin için bunu önemsiz kılar
  • TrueType Collections ekstra özen ister: GDI GetFontData .ttc'nin tamamını döndürür ve FPDFText_LoadCidType2Font'un face index parametresi yoktur; simsun.ttc'den NSimSun istemek eskiden SimSun'u — 0. face'i — gömer ve render ederdi. Component artık aile adını name tablosuyla (nameID 1 ve 16) eşleştirir ve istenen face'i, cmap parse edilmeden önce bağımsız bir sfnt olarak çıkarır; parse başarısız olursa koleksiyon baytları olduğu gibi geçer ve davranış 0. face'e döner

Metin yazımı, font gömme ve çıkarma, Delphi, C++Builder ve Lazarus boyunca tek bir sayfa modelini paylaşır; tam API Delphi için PDFium Component ürün sayfasında anlatılır