Technický článek

Extrakce textu z načteného PDF v Delphi pomocí HotPDF

Komponenta HotPDF Component extrahuje text v kódování Unicode z jakéhokoli PDF, které v Delphi načtete, a to pomocí dvou volání: ExtractLoadedPageText vrací text stránky v pořadí čtení a ExtractLoadedPageTextLayout (přidané ve verzi v2.263.0) rekonstruuje vizuální uspořádání stránky jako prostý text, takže ve výstupu zůstanou zachovány sloupce, odsazení i zarovnání tabulek. Obě metody fungují na dokumentech, které program HotPDF nevytvořil, což je případ, na kterém skutečně záleží: faktura, kterou vám zákazník poslal e-mailem, zpráva dodaná skenovací službou nebo smlouva vygenerovaná softwarem, jehož název už nikdo nezná

Dosažení tohoto cíle vyžadovalo více mechanismů, než naznačují signatury obou metod, protože PDF neukládá text stejným způsobem jako textový soubor. Tento článek vás provede oběma režimy extrakce a poté odhalí tajemství tří prvků pod kapotou — čtečky CMap, interpretu streamu obsahu a záložního řetězce pro dekódování písem — protože pochopení fungování tohoto mapování představuje rozdíl mezi krčením ramen nad poškozeným výstupem a jeho úspěšnou diagnostikou

Proč je extrakce textu těžší než jen čtení řetězců ze souboru?

Stream obsahu PDF zaznamenává kódy znaků, nikoli znaky. Operátory Tj a TJ (ISO 32000-1 §9.4.3) nesou řetězce bajtů, jejichž význam plně závisí na písmu zvoleném předchozím operátorem Tf: bajt 0x41 může v kódování WinAnsi představovat písmeno A, libovolný glyf v písmu s podsadou nebo polovinu dvoubajtového CID v kompozitním písmu CJK. Norma ISO 32000-1 §9.10 definuje extrakci textu právě jako tento problém dekódování — mapování každého kódu zpět na Unicode pomocí jakýchkoli informací, které slovník písem poskytuje — a norma výslovně uvádí, že vyhovující soubor nemusí k tomuto úkonu poskytovat dostatek informací

Tento poslední odstavec vysvětluje každé hlášení o chybě typu „proč kopírování a vložení z tohoto PDF vytváří nesmysly“, které jste kdy viděli. Nástroj, který vloží písmo s podsadou bez tabulky /ToUnicode, vytvořil soubor, který se vykresluje perfektně, ale extrahuje se jako nesmysl, protože mapování kódu na glyf sice existuje, ale mapování kódu na Unicode nebylo nikdy dodáno. Jakékoli solidní API pro extrakci je proto řetězcem záložních řešení s maximálním úsilím a zásadní otázkou je, jak hluboko tento řetězec sahá

Extrakce textu v pořadí čtení pomocí ExtractLoadedPageText

Pro indexování vyhledávání, porovnávání klíčových slov nebo předávání textu do analytické linky je volání ExtractLoadedPageText přesně tím, co potřebujete. Signatura metody je function ExtractLoadedPageText(PageIndex: Integer; out AText: UnicodeString): boolean — indexy stránek začínají od nuly, výsledek se vrací jako nativní delphijský UnicodeString a funkce vrací False, pokud stránka nemá čitelný stream obsahu, namísto vyvolání výjimky

var
  Pdf: THotPDF;
  PageCount, I: Integer;
  PageText, AllText: UnicodeString;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('invoice.pdf');
    AllText := '';
    for I := 0 to PageCount - 1 do
      if Pdf.ExtractLoadedPageText(I, PageText) then
        AllText := AllText + PageText + #13#10;
    // AllText now holds the reading-flow text of the document
  finally
    Pdf.Free;
  end;
end;

Zlomy řádků ve výstupu pocházejí ze záměrně jednoduché heuristiky: když se vertikální počátek glyfu posune o více než polovinu aktuální velikosti písma — což je znakem kroku Td nebo T* ve streamu obsahu — vloží se nový řádek. Znaky, které dekodér nedokáže rozluštit, se změní na mezery namísto toho, aby zmizely, takže hranice slov zůstanou zachovány, i když jednotlivé glyfy ne. O co se tento režim nepokouší, je shlukování podle pořadí čtení nebo detekce více sloupců: dvousloupcová stránka vystupuje proloženě v pořadí streamu obsahu, což je obvykle, ale ne vždy, vizuální pořadí

Kdy byste měli raději použít extrakci se zachováním rozvržení?

Metoda ExtractLoadedPageTextLayout je správnou volbou, kdykoli má pozice svůj význam: tabulky, formuláře, výpisy kódu, cokoli, co hodláte porovnávat (diff), prohledávat (grep) nebo analyzovat po sloupcích. Namísto zploštění glyfů do streamu je seskupuje do základních linek (baselines), třídí každou základní linku podle osy X a reprodukuje horizontální a vertikální prázdné znaky na mřížce znaků s pevnou šířkou, jejíž velikost je odvozena od mediánu šířky glyfů a velikosti písma. Široké mezery mezi úseky na stejné základní lince se mění na mezery; velké mezery mezi základními linkami se stávají prázdnými řádky. Výsledek se čte tak, jak stránka vypadá

var
  Grid: UnicodeString;
begin
  if Pdf.ExtractLoadedPageTextLayout(0, Grid) then
    TFile.WriteAllText('page1.txt', Grid, TEncoding.UTF8);
  // Columns, indentation and table alignment survive as
  // spaces and blank lines on a character grid
end;

Oba režimy sdílejí každý bajt dekódovacího mechanismu a liší se pouze v tom, jak uspořádají dekódované glyfy, takže tato volba nestojí nic z hlediska přesnosti. Zvolte ExtractLoadedPageText, když záleží pouze na slovech, a ExtractLoadedPageTextLayout, když záleží na jejich uspořádání. Detekce dvousloupcového pořadí čtení zůstává mimo rozsah pro obě metody — mřížkové vykreslení dvousloupcové stránky vám ukáže oba sloupce věrně vedle sebe, což je pro porovnávání naprosto správné, ale pro plynulé čtení textu nikoli

Jak HotPDF dekóduje kódy znaků na Unicode?

Komponenta HotPDF Component řeší každý kód znaku prostřednictvím prioritního řetězce záložních možností: nejprve z integrované tabulky /ToUnicode CMap písma, poté z položky /Encoding (stream nebo pojmenovaná CMap), následně — u kompozitních písem — ze standardních souborů Adobe CMap pro sady znaků jako Adobe-GB1, Adobe-CNS1, Adobe-Japan1 a Adobe-KR a nakonec z integrovaných tabulek WinAnsi a MacRoman pro jednoduchá písma. Strategie, která nedokáže poskytnout odpověď, tiše přejde k další v pořadí namísto vyvolání výjimky, a kód, který vyčerpá celý řetězec, se vyhodnotí jako 0, takže volající může počítat neúspěchy namísto hádání

Tabulka /ToUnicode CMap (ISO 32000-1 §9.10.3) je na prvním místě, protože se jedná o mapování, které tvůrce zapsal specificky pro účely extrakce. Cesta ke standardním souborům Adobe CMap je důležitá pro dokumenty v jazycích CJK, které používají předdefinované tabulky CMap jako UniGB-UTF16-H namísto toho, aby cokoli vkládaly: HotPDF dodává soubory těchto sad ve svém adresáři resources\CMap, za běhu je vyhledává relativně vůči spustitelnému souboru a každou analyzovanou mapu cachuje pro celý proces — což stojí za zmínku, protože ta největší z nich, mapa Adobe-GB1, představuje zhruba 2 MB zdrojového textu, který nechcete analyzovat znovu pro každou stránku. Pokud adresář chybí, dekodér jednoduše diskem podporované tabulky CMap přeskočí a pracuje s vloženými tabulkami a vestavěnými kódováními. To je zrcadlový obraz problému vykreslování na straně čtení popsaného v tvarování textu složitých písem v HotPDF, kde se se stejným rozdílem mezi kódem a glyfem setkáváme při zápisu

Dvě úskalí syntaxe CMap, která stojí za to znát

Soubory CMap vypadají na první pohled snadno analyzovatelné, ale není tomu tak, a dva detaily mohou za většinu selhání při prvních pokusech o vytvoření parseru. Prvním z nich je, že počet záznamů se uvádí před klíčovým slovem sekce: sekce má tvar 2 beginbfchar, nikoli beginbfchar 2. Parser, který očekává počet až po klíčovém slově, zkonzumuje číslo jako zatoulaný token a poté v každé sekci najde nula položek. Robustní přístup — ten, který zvolil čteč HotPDF — spočívá v úplném ignorování počtu a provádění smyčky až do odpovídajícího klíčového slova endbfchar / endbfrange, což přináší bonus v podobě tolerance k reálným souborům, jejichž počty jsou jednoduše chybné

Druhým úskalím je, že cíle bfchar a bfrange jsou řetězce UTF-16BE strings, nikoli celá čísla. Cíl <D83DDE00> znamená U+1F600 — náhradní pár (surrogate pair), který musí být znovu spojen do jednoho znakového bodu — a čtení těchto čtyř bajtů jako celého čísla v big-endian formátu dává bezvýznamnou hodnotu pro každý znak mimo základní vícejazyčnou rovinu (Basic Multilingual Plane). Emoji v PDF již nejsou žádnou exotikou, so dekodér, který vynechá rekombinaci náhradních párů, selže na souborech, které vaši uživatelé skutečně mají. HotPDF nejprve analyzuje hexadecimální literál na surové bajty a poté rekombinuje znakové jednotky UTF-16BE, což pokrývá i víceznakové cíle, které produkují mapování ligatur

Sestoupení na úroveň glyfů pomocí ExtractLoadedPageGlyphs

Obě volání pro text jsou postavena na metodě ExtractLoadedPageGlyphs a podkladové pole THPDFGlyphArray je k dispozici i pro váš kód. Každý záznam THPDFGlyphRecord nese vyřešený znakový bod Unicode spolu se surovým kódem znaku, bajtovou šířkou kódu (1, 2 nebo 4, určenou podle codespacerange tabulky CMap), klíčem a velikostí aktivního prostředku písma, počátkem souřadnic X a Y v uživatelském prostoru a horizontálním krokem. To stačí k sestavení detekce hranic slov, pozicovaného zvýrazňování nebo vlastního algoritmu rozvržení bez nutnosti přímé manipulace se streamem obsahu

var
  Glyphs: THPDFGlyphArray;
  I, Unresolved: Integer;
begin
  if Pdf.ExtractLoadedPageGlyphs(0, Glyphs) then
  begin
    Unresolved := 0;
    for I := 0 to High(Glyphs) do
      if Glyphs[I].Unicode = 0 then
        Inc(Unresolved);
    if Unresolved > 0 then
      ShowMessageFmt('%d of %d glyphs have no Unicode mapping',
        [Unresolved, Length(Glyphs)]);
  end;
end;

Počítání záznamů s Unicode = 0, jak je ukázáno výše, představuje korektní způsob, jak změřit kvalitu extrakce v daném dokumentu předtím, než textu důvěřujete v dalších fázích zpracování. Záznamy glyfů také ukotvují každý znak k zdrojovému operandu ve streamu obsahu, což umožňuje vyhledávání a nahrazování textu v načteném dokumentu v knihovně HotPDF na tomtéž základu

Která PDF neposkytnou svůj text?

Některé soubory porazí jakýkoli extraktor a je lepší je detekovat, než odesílat jejich výstup. Naskenované dokumenty jsou nejjednodušším případem: stránka, která je jedním velkým obrázkem, neobsahuje vůbec žádné operátory textu, takže extrakce správně vrátí prázdný řetězec — řešením je OCR a extrakce obrázků stránek z načteného PDF je prvním krokem této linky. Písma s podsadou bez tabulky /ToUnicode jsou složitějším případem: pokud cesta /Encoding i standardní tabulky CMap skončí prázdné, tyto glyfy se vyhodnotí jako 0 a ve voláních pro text se zobrazí jako mezery. Šifrované dokumenty se extrahují normálně za předpokladu, že je načtete s heslem pomocí přetížené verze LoadFromFile, takže streamy se dešifrují dříve, než je interpret vůbec uvidí

Jeden užší limit je třeba uvést na rovinu: dekódovací řetězec čte CMap a streamy obsahu přes cestu Flate v HotPDF, takže písmo, jehož stream ToUnicode používá neobvyklý filtr, degraduje na další strategii namísto selhání stránky. V praxi FlateDecode pokrývá téměř vše vyrobené v posledních dvou desetiletích a degradace je z designu tichá — získáte nejlepší text, jaký soubor dovoluje, namísto výjimky. Stejné mechanismy objektů na straně čtení, které zde řeší slovníky písem, pohánějí také úpravy metadat v načtených dokumentech, takže linka pro příjem dokumentů může v jednom průchodu extrahovat, kontrolovat i anotovat

Extrakce textu, vykreslování se zachováním rozvržení, přístup na úrovni glyfů a funkce vyhledávání a nahrazování postavené na nich jsou součástí standardní komponenty HotPDF Component pro Delphi a C++Builder — žádné externí DLL, žádné textové služby OS, pouze Object Pascal, kterým můžete krokovat, když ve vaší frontě přistane podivný soubor