Tehnički članak

ToUnicode fix: NBSP i meki crtičić u PDF izvlačenju

PDFium Component za Delphi ugrađuje sistemske fontove koje koristi TPdf.AddText kao CID fontove ključane po Unicode kodnoj tački, pa svaki CID nosi tačno jedno ToUnicode mapiranje. To je ono što sprečava da izvučeni razmaci budu vraćeni kao U+00A0 (no-break space), a crtice kao U+00AD (soft hyphen), i iz živog dokumenta i iz sačuvanog fajla

Simptom je gadan jer je nevidljiv. Indeks pretrage promaši „two-x“ jer sačuvani string sadrži meki crtičić, CSV izvoz secka drugačije, diff alat označi linije koje izgledaju identično u svakom viewer-u. Ništa na renderovanoj stranici nije pogrešno; pogrešan je samo Unicode iza glifova

Zašto se izvučeni razmaci vraćaju kao U+00A0?

Izvučeni razmaci postaju U+00A0 jer je ToUnicode CMap koji PDFium generiše u FPDFText_LoadFont ključan po glifu, a do jednog glifa može se doći iz dve kodne tačke. U Arial-u, glif 3 opslužuje i U+0020 i U+00A0, a crtični glif i U+002D i U+00AD. Generisani CMap zato mapira isti CID dvaput, jednom kroz unos bfchar i jednom kroz bfrange u obliku niza, i unos koji pravilo prednosti čitača favorizuje postaje izvučeni tekst

Zašto je jedan Arial glif slomio izvlačenje PDF teksta u Delphi-ju: U+0020 i U+00A0 dosežu glif 3, a U+002D i U+00AD crtični glif, pa generisani ToUnicode CMap mapira CID 0003 dvaput, kroz bfchar unos i nizovni bfrange, i pravilo prednosti čitača odlučuje koja se kodna tačka izvlači
Pravilo prednosti lowest-wins držalo je razmake običnim godinama, dok nadogradnja uzvodno na last-wins nije učinila da se svaki AddText razmak izvuče kao NBSP i svaka crtica kao meki crtičić
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange

Dugo je to protivrečje bilo bezopasno, jer je PDFium čitač pustio da najniže mapiranje pobedi. Uzvodna izmena prebacila je čitača na last-wins, i od tog build-a svaki razmak napisan sa AddText izvlačio se kao NBSP a svaka crtica kao meki crtičić. Primetite obrazac u parovima: 0x20/0xA0 i 0x2D/0xAD razlikuju se samo u višem bitu, što je baš ono što biste očekivali od fonta čiji cmap šalje Latin-1 dvojnice na istu konturu. Ako je Vaš kod izvlačenja bio ispravan juče a sada pada na nevidljivim znakovima, izbacite kodne tačke umesto da verujete prikazu debugger-a; osnove vađenja teksta pokrivene su u izvlačenju teksta iz PDF dokumenata sa PDFium-om u Delphi-ju

uses
  SysUtils, PDFium;

const
  // Razmak/U+00A0 i crtica/U+00AD dele 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;                  // živ, nesačuvan 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;              // posle punog čuvanja i ponovnog učitavanja
  finally
    Pdf.Free;
  end;

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

Zašto zakrpljivanje CMap-a posle čuvanja nije bilo dovoljno

Zakrpljivanje sačuvanog fajla popravlja samo sačuvani fajl, i to samo ako zakrpa drži strukturu CMap-a netaknutom bajt po bajt. Prva popravka, RepairSubsetToUnicodeCMaps u jedinici FPdfCompress, radi posle svakog ne-inkrementalnog TPdf.SaveAs-a i razrešava svaki konflikti CID: unos bfchar pobedi, par koji se razlikuje samo u višem bitu razrešava se na manju base-Latin kodnu tačku, a sve ostalo čuva svoje prvo mapiranje

Zanimljivi deo je negativan rezultat. Obnavljanje konfliktnog CMap-a čisto, u obliku početnog koda ili niza, delovalo je kao očigledan potez, i PDFium je odbio svaki obnovljeni CMap odmah, vraćajući se na Identity. Jedini izlaz koji je nativni čitač prihvatio bila je zamena konfliktnih hex vrednosti na mestu, istih dužina, sa rasporedom blokova i CID pokrivenošću netaknutim. Druga lekcija bila je skromnija: naša beleška tada je krivila slučaj u memoriji na to što živi dokument uopšte nema ToUnicode stream. Direktno zvanje DLL-a opovrglo je to, pošto živi dokument nosi isti dvosmislen stream, što je značilo da prava popravka mora da se desi pre nego što PDFium ikada generiše CMap. Popravljačka rutina ostaje u biblioteci kao odbrana za PDF-ove proizvedene drugim alatima na PDFium bazi

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 izmene istih dužina; fajlovi bez popravljivog konflikta,
      // i cross-reference-stream ili object-stream fajlovi, kopiraju se kakvi jesu
      RepairSubsetToUnicodeCMaps(Source, Dest);
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end;

Ključanje fonta po kodnoj tački umesto po glifu

Korenska popravka je prestati tražiti od PDFium-a da uopšte generiše CMap. TPdf.LoadCachedFont sada predaje sistemske bajtove fonta TPdf.LoadUnicodeKeyedCidFont-u, koji čita sopstvenu sfnt cmap tabelu fonta, više voleći podtabelu formata 12 i vraćajući se na format 4. Kodne tačke vraćaju se sortirane i bez duplikata, i CID k+1 dodeljuje se k-toj kodnoj tački, pri čemu CID 0 ostaje .notdef. Eksplicitan CIDToGIDMap šalje svaki CID njegovom glifu, pa U+0020 i U+00A0 dobijaju dva različita CID-a koja crtaju istu konturu, i ToUnicode CMap mapira svaki CID u tačno jednu kodnu tačku. Font se zatim učitava kroz FPDFText_LoadCidType2Font, isti ulazni punkt iza pisanja na nivou glifova u CID Type 2 ugrađivanju fontova sa eksplicitnim CID-to-GID mapama

Popravka ključana po kodnoj tački u PDFium Component-u: LoadUnicodeKeyedCidFont čita sfnt cmap fonta, dodeljuje CID k+1 svakoj sortiranoj kodnoj tački sa CID 0 kao notdef, povezuje eksplicitan CIDToGIDMap pa U+0020 i U+00A0 zadržavaju različite CID-ove, i BuildUnicodeKeyedCidCMap daje svakom CID-u tačno jednu kodnu tačku
NBSP, meki crtičić i Ohm znak onda prežive kao sami sebe pod bilo kojim pravilom prednosti, u živom dokumentu i posle bilo kog čuvanja, pa CMap popravka 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, jedna kodna tačka
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
  PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));

Kad FPDFText_SetText kasnije napiše string, obrnuta pretraga doskoči na jedan CID po znaku, pa NBSP, meki crtičić i Ohm znak svaki prežive kao sami sebe pod bilo kojim pravilom prednosti, u memoriji i posle bilo kog čuvanja. Pošto sačuvani fajl nosi sopstveni ToUnicode stream komponente umesto engine-generisanog, RepairSubsetToUnicodeCMaps ne nalazi ništa za popraviti u njemu

Zašto jedan bfrange unos može obrisati ceo blok?

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

Tiha bfrange zamka u PDF CMap parsiranju: CID niz od 00FE do 0101 prelazi xxFF granicu, HandleBeginBFRange izvodi visoki CID kao 0001, niži veći od višeg označava ceo blok nevažećim, SetText i renderovanje i dalje uspevaju, i izvlačenje vraća U+0000 za svaki znak u bloku
BuildUnicodeKeyedCidCMap izbegava zamku završavajući svaki niz pre niskog bajta FF, držeći blokove unutar granice od 100 unosa i pišući kodne tačke dopunskog plana kao pojedinačne bfchar unose

BuildUnicodeKeyedCidCMap završava niz pre nego što kodna tačka ili CID dođe do niskog bajta FF, drži svaki blok unutar granice od 100 unosa CMap gramatike, i piše kodne tačke dopunskog plana kao pojedinačne bfchar unose sa UTF-16 surrogate-pair odredištima, pošto uvećavanje surrogate para unutar raspona nema definisano značenje; surrogate strana te priče je u rukovanju emoji, CJK i surrogate parovima u Delphi-ju. CMap samo sa bfchar-ovima bi zaobišao problem granice potpuno, uz nekoliko puta veću veličinu

Šta font ključan po kodnoj tački ne pokriva?

Putanja ključana po kodnoj tački pokriva svaki font koji izlaže Unicode cmap podtabelu, i vraća se na staro ponašanje ključano po glifu za ostatak. Granice vredne znanja pre nego što se na to oslonite:

  • Symbol fontovi sa samo (3,0) cmap-om, i svaki font koji CID putanja ne uspe da učita, idu kroz FPDFText_LoadFont kao i pre, pa glif deljen između dve kodne tačke može i tamo da se izvuče dvosmisleno
  • Bez podtabele formata 12 mapa je ograničena na BMP, a broj unosa je pokriven na 65535 da svaki CID stane u dva bajta iznad nule
  • Inkrementalna čuvanja (saIncremental) preskaču RepairSubsetToUnicodeCMaps po dizajnu, jer inkrementalna revizija mora ostati samo-dodajuća; fontovi ključani po kodnoj tački čine to nebitnim za tekst koji komponenta sama piše
  • TrueType kolekcije traže posebnu pažnju: GDI GetFontData vraća ceo .ttc, a FPDFText_LoadCidType2Font nema parametar indeksa faca, pa je traženje NSimSun iz simsun.ttc ugrađivalo i renderovalo SimSun, facu 0. Komponenta sada poklapa ime familije sa name tabelom (nameID 1 i 16) i izdvaja traženu facu kao samostalan sfnt pre nego što se cmap parsira; ako parsiranje padne, bajtovi kolekcije prođu kroz i ponašanje se vraća na facu 0

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