Odborný článok

Oprava ToUnicode: NBSP a soft hyphen v extrakcii PDF

PDFium Component pre Delphi vkladá systémové fonty používané TPdf.AddText ako CID fonty kľúčované Unicode code pointom, takže každé CID nesie presne jedno ToUnicode mapovanie. Práve to bráni tomu, aby extrahované medzery prichádzali späť ako U+00A0 (no-break space) a spojovníky ako U+00AD (soft hyphen), a to aj z živého dokumentu, aj z uloženého súboru

Symptóm je zlý, pretože je neviditeľný. Search index nenájde „two-x“, pretože uložený reťazec obsahuje soft hyphen, CSV export sa rozdelí inak, diff nástroj označí riadky, ktoré vyzerajú v každom vieweri identicky. Nič na vyrenderovanej stránke nie je zlé; zlé je len Unicode za glyfmi

Prečo sa extrahované medzery vracajú ako U+00A0?

Extrahované medzery sa menia na U+00A0 preto, lebo ToUnicode CMap, ktorú PDFium generuje v FPDFText_LoadFont, je kľúčovaná glyfom a jeden glyf sa dá dosiahnuť z dvoch code pointov. V Ariali obsluhuje glyf 3 U+0020 aj U+00A0 a hyphen glyf obsluhuje U+002D aj U+00AD. Generovaná CMap preto mapuje to isté CID dvakrát, raz cez záznam bfchar a raz cez array bfrange, a to záznam, ktorému praje pravidlo precedencie čitateľa, sa stane extrahovaným textom

Prečo jeden glyf Arialu rozbil extrakciu PDF textu v Delphi: U+0020 a U+00A0 dosiahnu glyf 3 a U+002D a U+00AD dosiahnu hyphen glyf, takže generovaná ToUnicode CMap mapuje CID 0003 dvakrát cez záznam bfchar a array bfrange a pravidlo precedencie čitateľa rozhodne, ktorý code point sa extrahuje
Precedencia lowest-wins držala medzery obyčajné roky, kým upstream prepnutie na last-wins neurobilo z každej medzery AddText extrakt NBSP a z každého spojovníka soft hyphen
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange

Dlhý čas bola tá kontradikcia neškodná, keďže reader PDFium nechal vyhrať najnižšie mapovanie. Upstream zmena prepla readera na last-wins a od toho buildu sa každá medzera zapísaná AddText extrahovala ako NBSP a každý spojovník ako soft hyphen. Všimnite si vzor v pároch: 0x20/0xA0 a 0x2D/0xAD sa líšia len vysokým bitom, presne to, čo by ste čakali od fontu, ktorého cmap posiela Latin-1 dvojníky na ten istý outline. Ak bol váš extrakčný kód včera v poriadku a teraz zlyháva na neviditeľných znakoch, vypíšte code pointy namiesto dôvery debugger viewu; základy vytiahnutia textu pokrýva extrakcia textu z PDF dokumentov s PDFium v Delphi

uses
  SysUtils, PDFium;

const
  // Medzera/U+00A0 a spojovník/U+00AD zdieľajú jeden glyf Arialu, rovnako ako
  // grécke Omega (U+03A9) a 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ý, neuložený 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 úplnom uložení a znovunačítaní
  finally
    Pdf.Free;
  end;

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

Prečo záplata CMap po uložení nestačila

Záplata uloženého súboru opraví len uložený súbor, a to len vtedy, keď záplata drží štruktúru CMap bajt-za-bajtom nedotknutú. Prvá oprava, RepairSubsetToUnicodeCMaps v unit FPdfCompress, beží po každom neinkrementálnom TPdf.SaveAs a dorieši každé konfliktné CID: záznam bfchar vyhrá, pár líšiaci sa len vysokým bitom sa dorieši na menší base-Latin code point a čokoľvek iné si drží prvé mapovanie

Zaujímavá časť je negatívny výsledok. Čisté prestavanie konfliktné CMap, v tvare start-code alebo array, vyzeralo ako zjavný ťah a PDFium odmietlo každú prestavanú CMap rovno, s pádom na Identity. Jediný výstup, ktorý natívny reader prijal, bolo rovnako dlhé nahradenie konfliktných hex hodnôt na mieste, s layoutom blokov a CID pokrytím nedotknutým. Druhá lekcia bola pokornejšia: naša poznámka vtedy obvinila prípad v pamäti na tom, že živý dokument nemá žiadny ToUnicode stream. Priame volanie DLL to vyvrátilo, keďže živý dokument nesie ten istý dvojznačný stream, čo znamenalo, že skutočná oprava sa musela stať skôr, než PDFium vôbec CMap vygeneruje. Opravná rutina zostáva v knižnici ako obrana pre PDF produkované inými nástrojmi na báze 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
      // Len rovnako dlhé úpravy; súbory bez opraviteľného konfliktu
      // a súbory cross-reference-stream alebo object-stream sa kopírujú, ako sú
      RepairSubsetToUnicodeCMaps(Source, Dest);
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end;

Kľúčovanie fontu code pointom namiesto glyfom

Koreňová oprava je prestať PDFium vôbec žiadať o generovanie CMap. TPdf.LoadCachedFont teraz podá bajty systémového fontu TPdf.LoadUnicodeKeyedCidFont, ktorý číta vlastnú sfnt tabuľu cmap fontu s prednosťou subtabuľky formátu 12 a padom na formát 4. Code pointy sa vracajú zoradené a deduplikované a CID k+1 sa priradí k-tému code pointu, s CID 0 ponechaným ako .notdef. Explicitné CIDToGIDMap pošle každé CID na jeho glyf, takže U+0020 a U+00A0 dostanú dve rôzne CID kresliace ten istý outline a ToUnicode CMap mapuje každé CID na jediný code point. Font sa potom načíta cez FPDFText_LoadCidType2Font, ten istý vstupný bod za zápisom na úrovni glyfov v vkladaní fontov CID Type 2 s explicitnými CID-to-GID mapami

Oprava kľúčovaná code pointom v PDFium Component: LoadUnicodeKeyedCidFont číta sfnt cmap fontu, priradí CID k+1 ku každému zoradenému code pointu s CID 0 ako notdef, zapojí explicitné CIDToGIDMap, takže U+0020 a U+00A0 si držia rôzne CID, a BuildUnicodeKeyedCidCMap dáva každému CID presne jeden code point
NBSP, soft hyphen a znak Ohm potom prežijú ako samy sebou pod ktorýmkoľvek pravidlom precedencie, v živom dokumente aj po každom uložení, takže oprava CMap nenájde, čo by ešte opravila
// Skondenzované z 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);       // jedno CID, jeden code point
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
  PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));

Keď neskôr FPDFText_SetText zapisuje reťazec, spätné vyhľadanie pristane na jedinom CID na znak, takže NBSP, soft hyphen aj znak Ohm prežijú každý ako sám sebou pod ktorýmkoľvek pravidlom precedencie, v pamäti aj po každom uložení. Keďže uložený súbor nesie vlastný ToUnicode stream komponentu, nie engine-generovaný, RepairSubsetToUnicodeCMaps v ňom nenájde, čo by opravil

Prečo dokáže jeden záznam bfrange vymazať celý blok?

Jedno bfrange, ktorého CID beh prekračuje hranicu xxFF, donúti PDFium zahodiť celý blok, v ktorom sedí. ISO 32000-1 §9.10.3 povoľuje meniť vnútri range len posledný bajt cieľa, ale CID strana má vlastnú pascu: HandleBeginBFRange v PDFium odvodí vysoké CID ako (low and $FFFFFF00) or (high and $FF). Beh od CID 00FE po 0101 sa preto číta ako 00FE po 0001, low väčšie než high, a celý blok sa označí ako neplatný. Zlyhanie je tiché: SetText uspeje, stránka sa renderuje dokonale a extrakcia vracia U+0000 pre každý znak v tom bloku

Tichá pasca bfrange pri parsovaní PDF CMap: CID beh od 00FE po 0101 prekračuje hranicu xxFF, HandleBeginBFRange odvodí vysoké CID ako 0001, low väčšie než high označí celý blok ako neplatný, SetText aj renderovanie stále uspejú a extrakcia vracia U+0000 pre každý znak v bloku
BuildUnicodeKeyedCidCMap sa pasci vyhýba tým, že ukončí každý beh pred nízkym bajtom FF, drží bloky v limite 100 záznamov a zapisuje code pointy doplnkovej roviny ako jednotlivé záznamy bfchar

BuildUnicodeKeyedCidCMap ukončí beh skôr, než code point alebo CID dosiahne nízky bajt FF, drží každý blok v limite 100 záznamov gramatiky CMap a zapisuje code pointy doplnkovej roviny ako jednotlivé záznamy bfchar s cieľmi ako UTF-16 surrogate pair, keďže inkrementácia surrogate pairu vnútri range nemá definovaný význam; surrogate strana toho príbehu je v obsluhe emoji, CJK a surrogate párov v Delphi. CMap len z bfchar by problém hranice obišla úplne, za niekoľkonásobnú veľkosť

Čo nepokrýva font kľúčovaný code pointom?

Cesta kľúčovaná code pointom pokrýva každý font, ktorý vystavuje Unicode cmap subtabuľku, a padá späť na staré správanie kľúčované glyfom pre zvyšok. Hranice, ktoré stoja za poznanie, skôr než sa na ňu spoľahnete:

  • Symbolové fonty len s (3,0) cmap a každý font, ktorého CID cesta nedokáže načítať, idú cez FPDFText_LoadFont ako doteraz, takže glyf zdieľaný dvomi code pointmi môže aj tam extrahovať dvojznačne
  • Bez subtabuľky formátu 12 je mapa obmedzená na BMP a počet záznamov má strop 65535, aby každé CID sa zmestilo do dvoch bajtov nad nulou
  • Inkrementálne ukladania (saIncremental) preskočia RepairSubsetToUnicodeCMaps zámerne, pretože inkrementálna revízia musí ostať append-only; fonty kľúčované code pointom urobia to pre text, ktorý komponent sám zapisuje, nedôležitým
  • TrueType Collections potrebujú zvýšenú pozornosť: GDI GetFontData vracia celé .ttc a FPDFText_LoadCidType2Font nemá parameter face index, takže požiadavka NSimSun z simsun.ttc vkladala a renderovala SimSun, face 0. Komponent teraz porovná meno rodiny s name tabuľou (nameID 1 a 16) a vyextrahuje požadovanú tvár ako samostatné sfnt skôr, než sa cmap parsuje; keď parsovanie zlyhá, bajty kolekcie prejdú a správanie sa vráti na face 0

Zápis textu, vkladanie fontov a extrakcia zdieľajú jeden model stránky naprieč Delphi, C++Builder a Lazarus a kompletné API je popísané na produktovej stránke PDFium Component for Delphi