Technical Article

Extrakcia textu, obrázkov a písiem z PDF v Delphi s PDFlibPas

Získavanie textu, obrázkov a písiem z existujúceho PDF súboru znie ako vyriešený problém, kým cez neho nenecháte prejsť reálnu sadu dokumentov. Nasměrujte vyhľadávací indexer na štyridsaťtisíc zákazníckych súborov a chyby sa rýchlo rozdelia do niekoľkých známych skupín. Slová sa spájajú dohromady, pretože extraktoru nikto nepovedal, aká široká medzera sa už počíta ako medzera medzi slovami. Iné stránky sa vrátia ako nezmyselná spleť znakov, pretože čiastočne vložené písmo (subsetted font) neobsahuje mapovanie kódov glyfov na skutočné znaky. A „firemné logo“ sa nakoniec ukáže ako deväť samostatných obrazových objektov naskladaných za polopriehľadnou maskou (soft mask). Nič z toho nie je chybou v knižnici. Je to rozdiel medzi obyčajným volaním funkcie extrakcie a pochopením toho, čo táto funkcia dokáže a čo nedokáže získať z bajtov na disku.

Knižnica losLab PDF Library v edícii pre Pascal poskytuje kódu v Delphi a C++Builderi viacero spôsobov, ako čítať každý z týchto troch tokov, pričom jednotlivé úrovne sa líšia v tom, čo garantujú. Trik spočíva v prispôsobení úrovne konkrétnej úlohe: vyhľadávací index, revízia anonymizácie textu a predletová kontrola PDF/A vyžadujú od tej istej stránky úplne odlišné veci. Použitie nesprávneho volania vedie k plytvaniu úsilím alebo k výsledkom, ktorým nemôžete dôverovať.

Úrovne extrakcie textu a ich možnosti

Metóda GetPageText prijíma hodnotu nastavení od 0 do 8, pričom toto číslo definuje engine (vykresľovacie jadro) a nie formát. Hodnoty 0 až 2 spúšťajú rýchlu extrakciu, ktorá postačuje na jednoduchý náhľad. Hodnoty 3 až 8 smerujú cez engine zohľadňujúci rozloženie (layout-aware), ktorý rekonštruuje riadky a medzery podľa toho, kde sa glyfy na stránke skutočne nachádzajú. V rámci tohto rozsahu sú dôležité rozdiely: možnosti 4 a 6 rozdeľujú výstup na slová, 5 a 6 vracajú šírky jednotlivých glyfov a možnosť 7 vracia čistý text bez informácií o písme, farbe a metadátach blokov. Možnosť 7 je ideálna pre vyhľadávací index, pretože ten vyžaduje iba slová a nič iné.

Žiadne nastavenie však nedokáže zachrániť dokument, ktorý potrebné informácie vôbec neobsahuje. Formát PDF mapuje znakové kódy na tvary glyfov a jedinou vecou, ktorá mapuje tieto kódy späť na čitateľný text, je tabuľka ToUnicode CMap daného písma (ISO 32000-1 §9.10). Ak sa čiastočne vložené písmo dodá bez nej, akýkoľvek extraktor zlyhá. Táto knižnica, funkcia kopírovania v prehliadači aj konkurenčné nástroje sú vtedy odkázané na odhadovanie podľa názvov glyfov alebo nevrátia nič. Praktickým riešením je detekcia tohto stavu, nie zložité pokusy o nápravu. Označte stránku ako nízko dôveryhodnú a odošlite ju na OCR (rozpoznávanie textu), pretože tiché indexovanie nečitateľného obsahu je horšie ako priznanie, že text nie je možné prečítať.

Pre prípady, ktoré bežné voľby nepokrývajú (ako je vlastná tokenizácia, analýza prúdu obsahu alebo textový lievik vytvorený podľa vlastných pravidiel), je k dispozícii dekodér o úroveň nižšie. Objekt TPDFExtractor sa vytvára nad slovníkom prostriedkov (resources dictionary) a kolekciou písiem stránky. Jeho metóda ExtractTextW spracováva textové operácie prúdu obsahu cez rovnaký mechanizmus písiem a získava Unicode znaky, pričom udalosť OnFindObject vám odovzdáva každý objekt počas jeho spracovania. Väčšina kódu nemusí ísť takto hlboko. Aplikácie, ktoré to vyžadujú, však ocenia, že táto vrstva je verejne prístupná.

Polohované bloky: základ pre výsledky vyhľadávania a revíziu anonymizácie

Čistý text vám povie, čo je na stránke napísané. Skôr či neskôr však aplikácia potrebuje vedieť aj to, kde presne sa daný text nachádza, aby mohla zvýrazniť výsledok vyhľadávania, vykresliť rámček okolo textu určeného na anonymizáciu alebo ukotviť anotáciu na správne miesto. Funkcia ExtractPageTextBlocks vracia handle (odkaz) na zoznam textových behov (runs), pričom každý z nich nesie svoj text, ohraničenie (bounding box) a názov a veľkosť písma, ktorým bol vysádzaný:

var
  Pdf: TPDFlib;
  Blocks, I: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('contract.pdf', '') <> 1 then
      raise Exception.Create('load failed');
    Pdf.SelectPage(1);
    Blocks := Pdf.ExtractPageTextBlocks(0);
    for I := 0 to Pdf.GetTextBlockCount(Blocks) - 1 do
      Writeln(Format('%s  [%s %.1f pt at %.0f,%.0f]',
        [Pdf.GetTextBlockText(Blocks, I),
         Pdf.GetTextBlockFontName(Blocks, I),
         Pdf.GetTextBlockFontSize(Blocks, I),
         Pdf.GetTextBlockBound(Blocks, I, 0),
         Pdf.GetTextBlockBound(Blocks, I, 1)]));
    Pdf.ReleaseTextBlocks(Blocks);
  finally
    Pdf.Free;
  end;
end;

Jeden detail v tejto oblasti spôsobuje pri integrácii viac problémov než čokoľvek iné. Metódy SetTextExtractionArea, SetTextExtractionWordGap a SetTextExtractionOptions predstavujú trvalý stav na úrovni dokumentu, nie argumenty odovzdávané pri jednotlivých volaniach. Ak nakonfigurujete obmedzenie oblasti pre jednu funkciu, napríklad čítanie iba záhlavia na klasifikáciu dokumentu, potichu sa orežú všetky nasledujúce extrakcie vykonané s rovnakým handle, vrátane úrovní GetPageText zohľadňujúcich rozloženie, ktoré použijete neskôr. Buď po dokončení úlohy resetujte stav extrakcie, alebo priraďte každej logickej úlohe vlastný handle dokumentu.

Prahová hodnota medzery medzi slovami (word-gap threshold) rieši prvú spomínanú chybu, kedy sa slová spájajú dohromady. Metóda SetTextExtractionWordGap hovorí layout enginu, aká veľká horizontálna medzera (vzhľadom na šírku rozstupov glyfov stránky) už oddeľuje jedno slovo od druhého. Husto vysádzaná tabuľka vyžaduje menšiu medzeru než vzdušná marketingová prezentácia, preto je lepšie nastaviť tento prah pre každú triedu dokumentov osobitne, než používať jednu globálnu konštantu. Táto hodnota zostáva uložená v dokumentu rovnako ako ostatné stavy extrakcie, preto je potrebné ju nastavovať cielene a nielen jednorazovo.

Obrázky: pôvodné toky, nie snímky obrazovky

Nesprávnym spôsobom, ako získať obrázky z PDF, je vykresliť stránku a následne ju orezať. Tým sa zmení rozlíšenie pixelov, pevne sa aplikuje prípadné otočenie a zahodí sa pôvodný formát obrázka. Metóda GetPageImageList namiesto toho prechádza skutočné obrazové prostriedky, na ktoré stránka odkazuje, pričom každá položka vracia svoje vlastnosti a pôvodné, neupravené dáta:

var
  ImgList, I: Integer;
begin
  Pdf.SelectPage(1);
  ImgList := Pdf.GetPageImageList(0);
  for I := 0 to Pdf.GetImageListCount(ImgList) - 1 do
  begin
    Writeln(Pdf.GetImageListItemFormatDesc(ImgList, I, 0));
    Pdf.SaveImageListItemDataToFile(ImgList, I, 0,
      Format('page1-img%.2d.bin', [I]));
  end;
  Pdf.ReleaseImageList(ImgList);
end;

Skontrolujte popis formátu cez GetImageListItemFormatDesc predtým, ako urobíte akékoľvek závery o položke, pretože to, na čo stránka odkazuje, je málokedy jeden ucelený obrázok. Polopriehľadná maska (soft mask) sa zobrazuje ako samostatná položka. Rovnaký objekt XObject sa často opakuje na mnohých stránkach, preto pred archiváciou exportu všetkých obrázkov vykonajte deduplikáciu pomocou hašu obsahu, inak zapíšete rovnaké logo stokrát. Obrázky JPEG v modeli CMYK vyžadujú následnú správu farieb, inak sa v prehliadačoch, ktoré iba jednoducho načítajú kanály, zobrazia s invertovanými farbami. Ak potrebujete zoznam obrázkov pre celý dokument a nie iba pre jednotlivé stránky, metóda FindImages spolu so SetFindImagesMode prehľadá celý súbor v jednom kroku.

Pred stanovením akceptačných kritérií je potrebné upozorniť na jedno obmedzenie: extrakcia obrázkov vracia iba rastrové prostriedky. Logo alebo graf nakreslený ako vektorové cesty nie je z pohľadu prostriedkov PDF obrázkom a nikdy sa neobjaví v zozname obrázkov, bez ohľadu na to, ako zreteľne vyzerá ako obrázok na obrazovke. Ak je skutočnou požiadavkou uložiť takýto graf ako súbor, správnym prístupom je vykresliť danú oblasť stránky do bitmapy, čo je iná operácia s odlišnou presnosťou. Tieto dva typy výstupov by nemali byť uložené v rovnakom priečinku bez jasného označenia.

Písma: nástroj na audit, nie funkcia na export

Rozhranie API pre písma odpovedá na otázky týkajúce sa písiem. Neposkytuje vám však samotné súbory písiem a tento rozdiel definuje všetko, čo na ňom môžete postaviť. Po tom, čo metóda FindFonts prehľadá dokument, prechádza zoznam písiem podľa ID a volania vlastností vracajú informácie o písme, ktoré je aktuálne vybrané:

var
  I: Integer;
begin
  Pdf.FindFonts;
  for I := 1 to Pdf.FontCount do        // font indexes start at 1, not 0
    if Pdf.SelectFont(Pdf.GetFontID(I)) = 1 then
      Writeln(Format('%s  type=%d  embedded=%d  subset=%d',
        [Pdf.FontName, Pdf.FontType,
         Pdf.GetFontIsEmbedded, Pdf.GetFontIsSubsetted]));
end;

Dávajte pozor na hranice cyklu. Indexy písiem začínajú od 1 po FontCount, zatiaľ čo indexy textových blokov a zoznamov obrázkov o niekoľko odsekov vyššie začínajú od nuly. Ak zameníte tieto dve konvencie, spôsobíte chybu o jednotku (off-by-one), ktorá buď preskočí prvé písmo, alebo prekročí rozsah, čo môže pri bežnom testovaní ujsť pozornosti, pretože väčšina dokumentov obsahuje viacero písiem a aj nesprávne písmo môže vyzerať vierohodne. Majte jasno aj v rozsahu možností. Toto API neponúka export súborov písiem. Žiadne volanie nevracia vložené písmo ako súbor TTF alebo OTF; celým zámerom tohto modelu je iba prechádzanie zoznamu a kontrola metadát. Tento model však plne pokrýva reálne potreby praxe: detekciu čiastočne vložených písiem podľa vzoru názvu, audity vkladania písiem pred archívnou konverziou (nevložené písmo je kritickou prekážkou pre formát PDF/A, ako uvádza článok predletová kontrola PDF/A a PDF/UA v Delphi) a diagnostiku kódovania pri poklese spoľahlivosti extrakcie. Existuje aj licenčný dôvod, prečo je táto hranica nastavená práve takto. Čiastočne vložené písmo je licencovaný materiál a keďže mu chýba väčšina glyfov, je ako inštalovateľné písmo nepoužiteľné. Jeho vnímanie ako auditných metadát namiesto exportovateľného súboru je právne obhájiteľný prístup.

Posledné spomenuté volanie má veľký význam pri triedení dokumentov. Spustite metódu GetFontEncoding pre každé písmo a porovnajte výsledok s príznakom čiastočného vloženia (subset); takto môžete odhadnúť kvalitu extrakcie ešte pred spracovaním prvého znaku. Stránka, ktorej písma sú všetky čiastočne vložené s neštandardným kódovaním, je jasným kandidátom na OCR už pri prvotnej kontrole, čo umožňuje automatizovanému systému správne ju nasmerovať bez plytvania časom na neúspešný pokus o extrakciu textu.

Extrakcia vo veľkom rozsahu bez načítania celých dokumentov

V hromadnom spracovaní je načítanie celého dokumentu iba kvôli prečítaniu jednej stránky plytvaním I/O operáciami, čo sa pri veľkom počte dokumentov rýchlo prejaví. Varianty s jedným volaním, ExtractFilePageText a ExtractFilePageTextBlocks, prijímajú priamo názov súboru, heslo a číslo stránky, čím preskakujú načítanie celého dokumentu. Pre gigabajtové súbory je k dispozícii ešte úspornejšia cesta. Priamy prístup otvára súbor prostredníctvom streamovaného čítania tabuľky xref, takže volania DAOpenFileReadOnly a následne DAExtractPageText spracovávajú iba objekty, ktoré daná stránka skutočne potrebuje. To prichádza so zmenou konvencie, ktorou je dôležité si zapamätať: funkcie s priamym prístupom (DA) adresujú stránky pomocou PageRef (handle odkazu na objekt, ktorý získate cez DAFindPage) a nikdy nie priamym číslom stránky. Ak odovzdáte číslo tam, kde patrí handle, volanie prebehne nad nesprávnym objektom bez vyvolania chyby, čo je mimoriadne ťažko identifikovateľná chyba. Zvyšok nástrojov pre priamy prístup je popísaný v článku spájanie, rozdeľovanie a priamy prístup k veľkým PDF.

Ak existuje jeden návyk, ktorý odlišuje stabilný kód pre extrakciu od kódu, ktorý pri reálnych dokumentoch zlyháva, je to prístup k stránke ako k nedôveryhodnému vstupu a nie ako k čistému zdroju dát. Text, ktorý nezodpovedá tomu, čo zobrazuje prehliadač, je takmer vždy problémom s kódovaním (napríklad zliatím ligatúry do jedného glyfu alebo chýbajúcimi záznamami ToUnicode v čiastočne vloženom písme). Riešením je vyhodnotiť dôveryhodnosť a problematické stránky odkloniť na OCR, namiesto zložitého analyzovania chybného prúdu bajtov. API pre písma zo svojej podstaty nikdy nevygeneruje súbor TTF ani OTF, preto prácu s písmami zamerajte na auditné otázky. A trvalý stav extrakcie, predovšetkým ohraničenie oblasti (area rectangle), je nastavenie, ktoré vlastníte počas celej životnosti handle dokumentu, nie iba parameter, na ktorý zabudnete po jednom volaní. Ak si osvojíte tieto tri zásady, zvyšok API bude fungovať spoľahlivo.

Skúšobné verzie, ukážkové projekty a kompletná referenčná príručka pre extrakčné API sa nachádzajú na produktovej stránke losLab PDF Library pre Delphi.