Tehnični članak

Popravek ToUnicode: NBSP in mehki vezaj pri izvlečenju PDF

PDFium Component za Delphi vgradi sistemske pisave, ki jih uporablja TPdf.AddText, kot pisave CID, ključane po kodni točki Unicode, tako da vsak CID nosi točno eno preslikavo ToUnicode. To je tisto, kar preprečuje, da bi izvlečeni presledki prihajali nazaj kot U+00A0 (neprelomni presledek) in vezaji kot U+00AD (mehki vezaj), tako iz živega dokumenta kot iz shranjene datoteke

Simptom je grd, ker je neviden. Iskalni indeks spregleda »two-x«, ker shranjeni niz vsebuje mehki vezaj, izvoz CSV se razcepi drugače, orodje za primerjavo označi vrstice, ki v vsakem pregledovalniku izgledajo identično. Nič na upodobljeni strani ni narobe; narobe je le Unicode za glifi

Zakaj izvlečeni presledki pridejo nazaj kot U+00A0?

Izvlečeni presledki se spremenijo v U+00A0, ker je CMap ToUnicode, ki ga PDFium ustvari v FPDFText_LoadFont, ključan po glifu in do enega glifa se je mogoče dokopati iz dveh kodnih točk. V Arialu glif 3 streže obema U+0020 in U+00A0, glif vezaja pa obema U+002D in U+00AD. Ustvarjeni CMap torej preslika isti CID dvakrat, enkrat skozi vnos bfchar in enkrat skozi bfrange v obliki tabele, vnos, ki mu nakloni pravilo prednosti bralca, pa postane izvlečeno besedilo

Zakaj je en glif Arial pokvaril izvlečenje besedila PDF v Delphiju: U+0020 in U+00A0 dosežeta glif 3, U+002D in U+00AD pa glif vezaja, zato ustvarjeni CMap ToUnicode preslika CID 0003 dvakrat, skozi vnos bfchar in tabelarični bfrange, pravilo prednosti bralca pa odloči, katera kodna točka se izvleče
Prednost najnižji-zmaga je leta ohranjala presledke navadne, dokler zgornji preklop na zadnji-zmaga ni naredil, da se je vsak presledek AddText izvlekel kot NBSP in vsak vezaj kot mehki vezaj
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange

Dolgo časa je bilo to protislovje nenevarno, saj je bralec PDFium pustil, da zmaguje najnižja preslikava. Zgornja sprememba je bralca preklopila na zadnji-zmaga in od te gradnje naprej se je vsak presledek, zapisan z AddText, izvlekel kot NBSP in vsak vezaj kot mehki vezaj. Poglejte vzorec v parih: 0x20/0xA0 in 0x2D/0xAD se razlikujeta le v visokem bitu, točno to pa bi pričakovali od pisave, katere cmap pošilja dvojnikom Latin-1 isti oris. Če je bila Vaša izvlečna koda včeraj v redu in zdaj odpoveduje na nevidnih znakih, odvrzite kodne točke, namesto da zaupate pogledu razhroščevalnika; osnove izvlečenja besedila pokriva izvlečenje besedila iz dokumentov PDF s PDFium v Delphiju

uses
  SysUtils, PDFium;

const
  // Presledka/U+00A0 in vezaj/U+00AD si delita en glif Arial, enako
  // grška Omega (U+03A9) in znak 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;                  // živ, neshranjen 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;              // po polnem shranjevanju in ponovnem nalaganju
  finally
    Pdf.Free;
  end;

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

Zakaj krpanje CMapa po shranjevanju ni zadoščalo

Krpanje shranjene datoteke popravi le shranjeno datoteko in to le, če krpa obdrži strukturo CMapa bajt za bajtom nedotaknjeno. Prvi popravek, RepairSubsetToUnicodeCMaps v enoti FPdfCompress, teče po vsakem ne-inkrementalnem TPdf.SaveAs in razreši vsak spor CID: vnos bfchar zmaga, par, ki se razlikuje le v visokem bitu, se razreši na manjšo osnovno-latinsko kodno točko, karkoli drugega pa obdrži svojo prvo preslikavo

Zanimiv del je negativen rezultat. Čista znovogradnja spornega CMapa, v obliki začetne kode ali tabele, je videla kot očitna poteza, PDFium pa je vsak znovograjeni CMap odklonil na mestu in padel nazaj na Identity. Edini izhod, ki ga je domači bralec sprejel, je bila zamenjava spornih šestnajstiških vrednosti na mestu, enake dolžine, s postavitvijo bloka in pokritostjo CID nedotaknjeni. Druga lekcija je bila skromnejša: naša takratna opomba je krivdo za primer v pomnilniku valila na živi dokument, ki naj ne bi imel sploh nobenega toka ToUnicode. Neposredni klic DLL je to ovrgel, saj živi dokument nosi isti dvoumen tok, kar je pomenilo, da se mora pravi popravek zgoditi, preden PDFium sploh ustvari CMap. Popravljalna rutina ostane v knjižnici kot obramba za PDF, ki jih ustvarijo druga orodja na osnovi 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
      // Le urejanja enake dolžine; datoteke brez popravljivega spora
      // in datoteke s tokovi navzkrižnih sklicev ali objektov se kopirajo, kakršne so
      RepairSubsetToUnicodeCMaps(Source, Dest);
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end;

Ključanje pisave po kodni točki namesto po glifu

Korenski popravek je, da prenehamo prositi PDFium, naj sploh ustvari CMap. TPdf.LoadCachedFont zdaj bajte sistemske pisave poda TPdf.LoadUnicodeKeyedCidFont, ki prebere lastno tabelo sfnt cmap pisave, pri čemer raje izbere podtabelo formata 12 in pade nazaj na format 4. Kodne točke pridejo nazaj razvrščene in brez dvojnikov, CID k+1 pa je dodeljen k-ti kodni točki, CID 0 pa ostane kot .notdef. Izrecni CIDToGIDMap pošlje vsak CID k njegovemu glifu, tako da U+0020 in U+00A0 dobita dva različna CID, ki rišeta isti oris, CMap ToUnicode pa preslika vsak CID na eno samo kodno točko. Pisava se nato naloži prek FPDFText_LoadCidType2Font, iste vstopne točke, ki stoji za pisanjem na ravni glifov v vgrajevanju pisav CID Type 2 z izrecnimi preslikavami CID-v-GID

Popravek ključan po kodni točki v PDFium Component: LoadUnicodeKeyedCidFont prebere sfnt cmap pisave, dodeli CID k+1 vsaki razvrščeni kodni točki s CID 0 kot notdef, poveže izrecni CIDToGIDMap, tako da U+0020 in U+00A0 obdržita različna CID, BuildUnicodeKeyedCidCMap pa da vsakemu CID točno eno kodno točko
NBSP, mehki vezaj in znak Ohm potem preživijo kot sami sebi pod katerim koli pravilom prednosti, v živem dokumentu in po vsakem shranjevanju, tako da popravek CMapa ne najde več ničesar za krpanje
// Skrčeno 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);       // en CID, ena kodna točka
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
  PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));

Ko FPDFText_SetText pozneje zapiše niz, obratno iskanje pristane na enem samem CID na znak, tako da NBSP, mehki vezaj in znak Ohm preživijo vsak kot sam sebi pod katerim koli pravilom prednosti, v pomnilniku in po vsakem shranjevanju. Ker shranjena datoteka nosi lasten tok ToUnicode komponente in ne pogonsko ustvarjenega, RepairSubsetToUnicodeCMaps v njej ne najde ničesar za krpanje

Zakaj lahko en sam vnos bfrange izbriše cel blok?

En sam bfrange, katerega zaporedje CID prečka mejo xxFF, naredi, da PDFium zavrne celoten blok, v katerem stoji. ISO 32000-1 §9.10.3 pusti variirati le zadnji bajt cilja znotraj razpona, stran CID pa ima svojo past: HandleBeginBFRange PDFium izpelje visoki CID kot (low and $FFFFFF00) or (high and $FF). Zaporedje od CID 00FE do 0101 se zato prebere kot 00FE do 0001, nižji večji od visokega, in celoten blok je označen kot neveljaven. Odpoved je tiha: SetText uspe, stran se izriše popolnoma, izvlečenje pa vrne U+0000 za vsak znak v tem bloku

Tiha past bfrange pri razčlenjevanju CMapa PDF: zaporedje CID od 00FE do 0101 prečka mejo xxFF, HandleBeginBFRange izpelje visoki CID kot 0001, nižji-večji-od-visokega označi celoten blok kot neveljaven, SetText in izrisovanje še vedno uspevata, izvlečenje pa vrne U+0000 za vsak znak v bloku
BuildUnicodeKeyedCidCMap se izogne pasti tako, da vsako zaporedje konča pred nizkim bajtom FF, bloke drži znotraj omejitve 100 vnosov in dopolnilnoravninske kodne točke zapiše kot posamezne vnose bfchar

BuildUnicodeKeyedCidCMap konča zaporedje, preden kodna točka ali CID doseže nizki bajt FF, drži vsak blok znotraj omejitve 100 vnosov slovnice CMap in dopolnilnoravninske kodne točke zapiše kot posamezne vnose bfchar s cilji surrogatnih parov UTF-16, saj povečevanje surrogatnega para znotraj razpona nima definiranega pomena; surrogatna stran te zgodbe je v ravnanju z emoji, CJK in surrogatnimi pari v Delphiju. CMap samo iz bfchar bi problem meje v celoti zaobšel, ob večkratni velikosti

Česa pisava ključana po kodni točki ne pokriva?

Pot ključana po kodni točki pokriva vsako pisavo, ki izpostavi podtabelo cmap Unicode, za preostanek pa pade nazaj na staro vedenje ključano po glifih. Meje, vredne poznavanja, preden se zanjo zanesete:

  • Simbolne pisave s samo (3,0) cmap in vsaka pisava, ki je pot CID ne zna naložiti, gredo skozi FPDFText_LoadFont kot prej, tako da lahko glif, ki si ga delita dve kodni točki, tam še vedno izvleče dvoumno
  • Brez podtabele formata 12 je preslikava omejena na BMP in števec vnosov je omejen na 65535, tako da se vsak CID prilega v dva bajta nad ničlo
  • Inkrementalna shranjevanja (saIncremental) preskočijo RepairSubsetToUnicodeCMaps po zasnovi, ker mora inkrementalna revizija ostati samo-dodajna; pisave ključane po kodni točki naredijo to nepomembno za besedilo, ki ga komponenta piše sama
  • Zbirke TrueType potrebujejo dodatno skrb: GDI GetFontData vrne celotno .ttc in FPDFText_LoadCidType2Font nima parametra za indeks face, zato je zahteva po NSimSun iz simsun.ttc vgrajevala in izrisovala SimSun, face 0. Komponenta zdaj ime družine ujema proti tabeli imen (nameID 1 in 16) in izvleče zahtevano face kot samostojni sfnt, preden se cmap razčleni; če razčlenjevanje spodleti, bajti zbirke gredo skozi in vedenje se vrne na face 0

Pisanje besedila, vgrajevanje pisav in izvlečenje si delijo en model strani skozi Delphi, C++Builder in Lazarus, celoten API pa je opisan na strani izdelka PDFium Component for Delphi