Technický článek

Oprava ToUnicode: extrakce NBSP a soft hyphen v PDF v Delphi

PDFium Component pro Delphi vkládá systémová písma používaná TPdf.AddText jako CID fonty klíčované Unicode code pointem, takže každý CID nese přesně jedno ToUnicode mapování. Právě to zabraňuje tomu, aby extrahované mezery přicházely zpět jako U+00A0 (no-break space) a spojovníky jako U+00AD (soft hyphen), a to jak z živého dokumentu, tak z uloženého souboru

Symptom je zákeřný, protože je neviditelný. Vyhledávací index nenajde „two-x“, protože uložený řetězec obsahuje soft hyphen, CSV export se dělí jinak a diff nástroj označí řádky, které v každém prohlížeči vypadají identicky. Na vykreslené stránce není nic špatně; špatně je jen Unicode za glyfy

Proč se extrahované mezery vrací jako U+00A0?

Extrahované mezery se mění v U+00A0 proto, že ToUnicode CMap, kterou PDFium generuje v FPDFText_LoadFont, je klíčovaná glyfem a ke glyfu se dá dojít ze dvou code pointů. V Arial obsluhuje glyph 3 i U+0020, i U+00A0, a hyphen glyph i U+002D, i U+00AD. Generovaná CMap proto mapuje tentýž CID dvakrát, jednou přes položku bfchar a jednou přes array bfrange, a extrahovaným textem se stane ta položka, které dává přednost pravidlo priority čtečky

Proč jeden glyph Arial rozbil extrakci textu z PDF v Delphi: U+0020 a U+00A0 dosáhnou glyphu 3 a U+002D a U+00AD dosáhnou hyphen glyphu, takže generovaná ToUnicode CMap mapuje CID 0003 dvakrát, přes položku bfchar a array bfrange, a pravidlo priority čtečky rozhodne, který code point se extrahuje
Priorita nejnižší-vyhrává držela mezery rovné roky, dokud upstream přepnutí na poslední-vyhrává neudělalo z každé mezery z AddText NBSP a z každého spojovníku soft hyphen
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange

Dlouho byla tahle rozpornost neškodná, protože čtečka PDFium nechávala vyhrát nejnižší mapování. Upstream změna přepnula čtečku na poslední-vyhrává a od toho buildu se každá mezera zapsaná AddText extrahovala jako NBSP a každý spojovník jako soft hyphen. Všimněte si vzoru v párech: 0x20/0xA0 a 0x2D/0xAD se liší jen vysokým bitem, což je přesně to, co byste čekali od fontu, jehož cmap posílá latin-1 dvojníky na stejnou obrysovou dráhu. Pokud váš extrakční kód byl včera v pořádku a teď padá na neviditelných znacích, vypalte code pointy místo důvěřování pohledu debuggeru; základy vytahování textu rozebírá článek o extrakci textu z PDF dokumentů s PDFium v Delphi

uses
  SysUtils, PDFium;

const
  // Mezera/U+00A0 a spojovník/U+00AD sdílejí jeden glyph Arial, stejně jako
  // řecké 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 úplném uložení a znovunačtení
  finally
    Pdf.Free;
  end;

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

Proč záplatování CMap po uložení nestačilo

Záplata uloženého souboru opraví jen uložený soubor a jen když záplata nechá strukturu CMap bajt za bajtem intaktní. První oprava, RepairSubsetToUnicodeCMaps v unitu FPdfCompress, běží po každém neinkrementálním TPdf.SaveAs a rozliší každý konfliktní CID: položka bfchar vyhrává, pár lišící se jen vysokým bitem se rozliší na menší base-latin code point a cokoli jiného si drží své první mapování

Zajímavá jsou negativní zjištění. Čisté znovusestavení konfliktní CMap, v podobě start-code nebo array, vypadalo jako zjevný tah a PDFium odmítlo každou znovusestavenou CMap rovnou, s fallbackem na Identity. Jediný výstup, který nativní čtečka přijala, bylo nahrazení konfliktních hex hodnot stejně dlouhou náhradou na místě, s nezměněným rozložením bloků a pokrytím CID. Druhé poučení bylo skromnější: naše poznámka tehdy sváděla in-memory případ na to, že živý dokument nemá žádný ToUnicode stream. Přímé volání DLL to vyvrátilo, protože živý dokument nese tentýž dvojznačný stream — což znamenalo, že skutečná oprava se musí stát dřív, než PDFium CMap vůbec vygeneruje. Opravná rutina zůstává v knihovně jako obrana pro PDF produkovaná jinými nástroji založenými na 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
      // Jen editace stejné délky; soubory bez opravitelného konfliktu,
      // a soubory s cross-reference-stream nebo object-stream se kopírují as-is
      RepairSubsetToUnicodeCMaps(Source, Dest);
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end;

Klíčování fontu code pointem místo glyfem

Kořenová oprava je přestat žádat PDFium o generování CMap úplně. TPdf.LoadCachedFont teď předá systémové fontové byty TPdf.LoadUnicodeKeyedCidFont, která čte vlastní sfnt tabulku cmap fontu, s předností subtabulky formátu 12 a fallbackem na formát 4. Code pointy se vrátí setříděné a deduplikované a CID k+1 se přidělí k-tému code pointu, s CID 0 ponechaným jako .notdef. Explicitní CIDToGIDMap pošle každý CID ke svému glyfu, takže U+0020 a U+00A0 dostanou dva různé CID, které kreslí stejnou obrysovou dráhu, a ToUnicode CMap mapuje každý CID na jeden code point. Font se pak načte přes FPDFText_LoadCidType2Font, tentýž vstupní bod za zápisem na úrovni glyfů v článku o vkládání CID Type 2 fontů s explicitními CID-to-GID mapami

Oprava klíčovaná code pointem v PDFium Component: LoadUnicodeKeyedCidFont čte sfnt cmap fontu, přidělí CID k+1 každému setříděnému code pointu s CID 0 jako notdef, zapojí explicitní CIDToGIDMap, takže U+0020 a U+00A0 drží různé CID, a BuildUnicodeKeyedCidCMap dá každému CID přesně jeden code point
NBSP, soft hyphen i znak Ohm pak přežijou jako sami sobě pod kterýmkoli pravidlem priority, v živém dokumentu i po každém uložení, takže oprava CMap nenajde nic k opravě
// Zhuštěno 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);       // jeden CID, jeden code point
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
  PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));

Když FPDFText_SetText později zapisuje řetězec, zpětné vyhledání dopadne na jediný CID na znak, takže NBSP, soft hyphen i znak Ohm přežijí jako sami sobě pod kterýmkoli pravidlem priority, v paměti i po každém uložení. Protože uložený soubor nese vlastní ToUnicode stream komponenty místo enginem generovaného, RepairSubsetToUnicodeCMaps v něm nenajde nic k opravě

Jak dokáže jedna položka bfrange zahodit celý blok?

Jediný bfrange, jehož CID běh překročí hranici xxFF, donutí PDFium zahodit celý blok, ve kterém sedí. ISO 32000-1 §9.10.3 nechává v rámci range měnit jen poslední byte cíle, ale stran CID má vlastní past: HandleBeginBFRange PDFium odvodí vysoký CID jako (low and $FFFFFF00) or (high and $FF). Běh od CID 00FE do 0101 se tedy přečte jako 00FE do 0001, nízký větší než vysoký, a celý blok se označí za neplatný. Selhání je tiché: SetText uspěje, stránka se vykreslí dokonale a extrakce vrátí U+0000 pro každý znak v tom bloku

Tichá past bfrange při parsování PDF CMap: CID běh od 00FE do 0101 překračuje hranici xxFF, HandleBeginBFRange odvodí vysoký CID jako 0001, nízký-větší-než-vysoký označí celý blok za neplatný, SetText i vykreslení stále uspějí a extrakce vrací U+0000 pro každý znak bloku
BuildUnicodeKeyedCidCMap past obchází tím, že ukončí každý běh před nízkým bytem FF, drží bloky v limitu 100 položek a zapisuje code pointy doplňkových rovin jako samostatné položky bfchar

BuildUnicodeKeyedCidCMap ukončí běh dřív, než code point nebo CID dosáhne nízkého bytu FF, drží každý blok v limitu 100 položek gramatiky CMap a zapisuje code pointy doplňkových rovin jako samostatné položky bfchar s cíli v UTF-16 surrogate párech, protože inkrementace surrogate páru uvnitř range nemá definovaný význam; surrogate stránka toho příběhu je v článku o práci s emoji, CJK a surrogate páry v Delphi. CMap jen z bfchar by problém hranic obešla úplně, za několikanásobnou velikost

Co font klíčovaný code pointem nepokrývá?

Cesta klíčovaná code pointem pokrývá každé písmo, které vystavuje Unicode subtabulku cmap, a na zbytek spadne ke starému chování klíčovanému glyfem. Meze, které stojí za znát, než se na to spolehnete:

  • Symbolová písma s jen (3,0) cmap a jakékoli písmo, které CID cesta nedokáže načíst, jdou přes FPDFText_LoadFont jako dřív, takže glyf sdílený dvěma code pointy se i tam může extrahovat dvojznačně
  • Bez subtabulky formátu 12 je mapa omezená na BMP a počet položek má strop 65535, aby se každý CID vešel do dvou bytů nad nulou
  • Inkrementální úložby (saIncremental) vynechávají RepairSubsetToUnicodeCMaps ze zásady, protože inkrementální revize musí zůstat append-only; fonty klíčované code pointem dělají tu starost pro text, který si komponenta píše sama, irrelevantní
  • TrueType Collections potřebují extra péči: GDI GetFontData vrátí celé .ttc a FPDFText_LoadCidType2Font nemá parametr face index, takže žádost o NSimSun z simsun.ttc dřív vložila a renderovala SimSun, face 0. Komponenta teď páruje název rodiny proti name tabulce (nameID 1 a 16) a vytáhne požadovaný face jako samostatný sfnt, než se cmap parsuje; když parsování selže, projdou byty kolekce a chování se vrátí k face 0

Zápis textu, vkládání fontů a extrakce sdílejí jeden model stránky napříč Delphi, C++Builder a Lazarus a kompletní API popisuje produktová stránka PDFium Component pro Delphi