Tehnički članak

ToUnicode fix: NBSP i soft hyphen u izvlačenju PDF teksta

PDFium Component za Delphi ugrađuje sistemske fontove koje koristi TPdf.AddText kao CID fontove ključane po Unicode code pointu, pa svaki CID nosi točno jedno ToUnicode mapiranje. To je ono što sprječava da izvučeni razmaci dolaze natrag kao U+00A0 (no-break space), a crtice kao U+00AD (soft hyphen), i iz živog dokumenta i iz spremljene datoteke

Simptom je gnjuskan jer je nevidljiv. Indeks pretraživanja promaši "two-x" jer spremljeni string sadrži soft hyphen, CSV izvoz dijeli drugačije, diff alat obilježava retke koji u svakom vieweru izgledaju identično. Ništa na renderiranoj stranici nije krivo; kriv je samo Unicode iza glifova

Zašto izvučeni razmaci dolaze natrag kao U+00A0?

Izvučeni razmaci pretvaraju se u U+00A0 jer je ToUnicode CMap koji PDFium generira u FPDFText_LoadFont ključan po glifu, a do jednog glifa može se doći iz dva code pointa. U Arialu glif 3 opslužuje i U+0020 i U+00A0, a glif crtice i U+002D i U+00AD. Generirani CMap zato preslikava isti CID dvaput, jednom kroz bfchar unos i jednom kroz bfrange u obliku polja, i koji god unos pravilu prednosti čitača odgovara, postaje izvučeni tekst

Zašto je jedan Arial glif slomio izvlačenje PDF teksta u Delphiju: U+0020 i U+00A0 dolaze do glifa 3, a U+002D i U+00AD do glifa crtice, pa generirani ToUnicode CMap preslikava CID 0003 dvaput, kroz bfchar unos i array bfrange, i pravilo prednosti čitača odlučuje koji se code point izvlači
Prednost najnižeg održavala je razmake običnima godinama, dok upstream prelazak na posljednji-pobjeđuje nije učinio da se svaki AddText razmak izvlači kao NBSP i svaka crtica kao soft hyphen
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange

Dugo je to proturječje bilo bezopasno, jer je PDFium čitač pustio da pobjedi najniže mapiranje. Upstream promjena prebacila je čitač na posljednji-pobjeđuje, i od tog builda svaki razmak zapisan s AddText izvlačio se kao NBSP i svaka crtica kao soft hyphen. Obratite pozornost na obrazac u parovima: 0x20/0xA0 i 0x2D/0xAD razlikuju se samo u visokom bitu, što je točno ono što biste očekivali od fonta čiji cmap šalje Latin-1 dvojnice na istu konturu. Ako je Vaš izvlačni kod bio u redu jučer, a sada pada na nevidljivim znakovima, ispišite code pointove umjesto da vjerujete debugger prikazu; osnove izvlačenja teksta pokriva izvlačenje teksta iz PDF dokumenata s PDFiumom u Delphiju

uses
  SysUtils, PDFium;

const
  // Razmak/U+00A0 i crtica/U+00AD dijele jedan Arial glif, kao i
  // grčko Omega (U+03A9) i Ohm znak (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;                  // živi, nespremljeni dokument
    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;              // nakon potpunog spremanja i ponovnog učitavanja
  finally
    Pdf.Free;
  end;

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

Zašto zakrpa CMapa nakon spremanja nije bila dovoljna

Zakrpa spremljene datoteke popravlja samo spremljenu datoteku, i to samo ako zakrpa zadržava strukturu CMapa netaknutom do bajta. Prvi popravak, RepairSubsetToUnicodeCMaps u jedinici FPdfCompress, izvršava se nakon svakog neinkrementalnog TPdf.SaveAs i razrješuje svaki sukobljeni CID: bfchar unos pobjeđuje, par koji se razlikuje samo u visokom bitu razrješuje se na manji bazični latinski code point, a sve ostalo zadržava svoje prvo mapiranje

Zanimljivi je dio negativni rezultat. Čista obnova sukobljenog CMapa, u obliku start-koda ili polja, izgledala je kao očigledan potez, i PDFium je odbio svaki obnovljeni CMap izravno, padajući na Identity. Jedini izlaz koji je nativni čitač primio bila je zamjena istih duljina na mjestu sukobljenih hex vrijednosti, s rasporedom blokova i CID pokrivenošću netaknutima. Druga je lekcija bila skromnija: naša bilješka tada je krivila slučaj u memoriji na to da živi dokument uopće nema ToUnicode stream. Izravni poziv DLL-a to je opovrgnuo, jer živi dokument nosi isti dvosmisleni stream, što je značilo da pravi popravak mora pasti prije nego PDFium uopće generira CMap. Rutina popravke ostaje u biblioteci kao obrana za PDF-ove proizvedene drugim alatima na PDFiumu

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
      // Samo izmjene istih duljina; datoteke bez popravljivog sukoba,
      // te datoteke s cross-reference ili object streamovima, kopiraju se kako jesu
      RepairSubsetToUnicodeCMaps(Source, Dest);
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end;

Ključanje fonta po code pointu umjesto po glifu

Korijenski popravak jest prestati uopće tražiti od PDFiuma da generira CMap. TPdf.LoadCachedFont sada predaje sistemske bajtove fonta TPdf.LoadUnicodeKeyedCidFont, koji čita vlastitu sfnt cmap tablicu fonta, preferirajući format 12 podtablicu i padajući na format 4. Code pointovi dolaze natrag sortirani i bez duplikata, i CID k+1 dodjeljuje se k-tom code pointu, s CID 0 ostavljenim kao .notdef. Eksplicitni CIDToGIDMap šalje svaki CID njegovom glifu, pa U+0020 i U+00A0 dobivaju dva različita CIDA koja crtaju istu konturu, a ToUnicode CMap preslikava svaki CID na samo jedan code point. Font se zatim učitava kroz FPDFText_LoadCidType2Font, isti ulaz iza pisanja na razini glifova u ugrađivanju CID Type 2 fontova s eksplicitnim CID-to-GID mapama

Popravak ključan po code pointu u PDFium Componentu: LoadUnicodeKeyedCidFont čita sfnt cmap fonta, dodjeljuje CID k+1 svakom sortiranom code pointu s CID 0 kao notdef, spaja eksplicitni CIDToGIDMap pa U+0020 i U+00A0 zadržavaju različite CID-ove, a BuildUnicodeKeyedCidCMap daje svakom CID-u točno jedan code point
NBSP, soft hyphen i Ohm znak tada prežive kao sami sebe pod bilo kojim pravilom prednosti, u živom dokumentu i nakon svakog spremanja, pa CMap popravak ne nalazi ništa ostalo za popraviti
// Sažeto iz 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);       // jedan CID, jedan code point
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
  PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));

Kad FPDFText_SetText kasnije piše string, povratna pretraga slijeće na jedan CID po znaku, pa NBSP, soft hyphen i Ohm znak svaki prežive kao sami sebe pod bilo kojim pravilom prednosti, u memoriji i nakon svakog spremanja. Budući da spremljena datoteka nosi vlastiti ToUnicode stream komponente umjesto engineom generiranog, RepairSubsetToUnicodeCMaps u njoj ne nalazi ništa za popraviti

Zašto jedan bfrange unos može izbrisati cijeli blok?

Jedan bfrange čiji CID niz prelazi xxFF granicu natjera PDFium da odbaci cijeli blok u kojem sjedi. ISO 32000-1 §9.10.3 pušta da unutar raspona varira samo zadnji bajt odredišta, ali CID strana ima vlastitu zamku: PDFium HandleBeginBFRange izvodi visoki CID kao (low and $FFFFFF00) or (high and $FF). Niz od CID 00FE do 0101 zato se čita kao 00FE do 0001, niži veći od višeg, i cijeli se blok označava nevaljanim. Kvar je tih: SetText uspije, stranica se savršeno renderira, i izvlačenje vraća U+0000 za svaki znak u tom bloku

Tiha bfrange zamka u parsiranju PDF CMapa: CID niz od 00FE do 0101 prelazi xxFF granicu, HandleBeginBFRange izvodi visoki CID kao 0001, niži veći od višeg označava cijeli blok nevaljanim, SetText i rendering i dalje uspijevaju, a izvlačenje vraća U+0000 za svaki znak u bloku
BuildUnicodeKeyedCidCMap izbjegava zamku završavajući svaki niz prije niskog bajta FF, drži blokove unutar granice od 100 unosa i piše code pointove dopunske ravnine kao pojedinačne bfchar unose

BuildUnicodeKeyedCidCMap završava niz prije nego code point ili CID dođe do niskog bajta FF, drži svaki blok unutar granice od 100 unosa CMap gramatike i piše code pointove dopunske ravnine kao pojedinačne bfchar unose s UTF-16 surrogate par odredištima, jer povećavanje surrogate para unutar raspona nema definiranog značenja; surrogate strana te priče je u rukovanju emoji, CJK i surrogate parovima u Delphiju. CMap samo od bfchar unosa zaobišao bi problem granice u cijelosti, uz nekoliko puta veću veličinu

Što font ključan po code pointu ne pokriva?

Putanja ključana po code pointu pokriva svaki font koji izlaže Unicode cmap podtablicu, i pada na staro ponašanje ključano po glifu za ostatak. Granice vrijedne znanja prije nego se na to oslonite:

  • Simbolni fontovi samo s (3,0) cmapom, i svaki font koji CID putanja ne uspije učitati, idu kroz FPDFText_LoadFont kao i prije, pa glif kojeg dijele dva code pointa i tamo može izvlačiti dvosmisleno
  • Bez podtablice formata 12 mapa je ograničena na BMP, a broj unosa ograničen je na 65535 da svaki CID stane u dva bajta iznad nule
  • Inkrementalna spremanja (saIncremental) preskaču RepairSubsetToUnicodeCMaps po dizajnu, jer inkrementalna revizija mora ostati samo-dodavala; fontovi ključani po code pointu čine to nebitnim za tekst koji komponenta sama piše
  • TrueType Collectioni trebaju dodatnu pažnju: GDI GetFontData vraća cijeli .ttc, a FPDFText_LoadCidType2Font nema parametar indeksa lica, pa je traženje NSimSun iz simsun.ttc nekad ugrađivalo i renderiralo SimSun, lice 0. Komponenta sada usklađuje ime obitelji s name tablicom (nameID 1 i 16) i izdvaja traženo lice kao samostalan sfnt prije nego se cmap parsira; ako parsiranje padne, bajtovi kolekcije prolaze i ponašanje se vraća na lice 0

Pisanje teksta, ugrađivanje fontova i izvlačenje dijele jedan model stranice kroz Delphi, C++Builder i Lazarus, a cijeli je API opisan na stranici proizvoda PDFium Component za Delphi