Technical Article

Ekstrakcija besedila, slik in pisav iz PDF v Delphiju s PDFlibPas

Pridobivanje besedila, slik in pisav iz obstoječe datoteke PDF se morda zdi enostavna naloga, dokler skozi ta postopek ne spustite realnega niza podatkov. Če iskalni indeks usmerite na štirideset tisoč strankinih datotek, se težave hitro razvrstijo v nekaj prepoznavnih skupin. Besede se lahko združijo v eno, ker programu nihče ni povedal, kako velika vrzel velja za presledek. Druge strani so vrnjene kot nerazumljiva zmeda znakov, ker delna pisava (subsetted font) ne vsebuje preslikave iz svojih kod glifov v dejanske znake. "Logotip podjetja" pa se lahko izkaže za devet ločenih slikovnih objektov, naloženih drug čez drugega pod mehko masko (soft mask). Nič od tega ni hrošč v knjižnici. To je razlika med preprostim klicem ekstrakcijske funkcije in razumevanjem, kaj lahko funkcija dejansko obnovi iz bajtov na disku in česa ne.

losLab PDF Library, the Pascal edition, ponuja kodi za Delphi in C++Builder več načinov za branje vsakega od teh treh tokov, ravni pa se razlikujejo glede na to, kaj zagotavljajo. Ključno je izbrati raven, ki ustreza nalogi: iskalni indeks, pregledovalnik za redakcijo (redaction) in preflight korak za PDF/A zahtevajo različne informacije z iste strani, zato napačen klic povzroči izgubo časa ali pa ustvari neuporabne rezultate.

Ravni ekstrakcije besedila in kaj posamezna zagotavlja

Funkcija GetPageText sprejme vrednost možnosti od 0 do 8, pri čemer ta številka izbere pogon in ne samega formata. Vrednosti od 0 do 2 izvedejo hitro analizo, ki je primerna za kratek predogled. Vrednosti od 3 do 8 pa potekajo prek pogona, ki prepozna postavitev strani in rekonstruira vrstice ter razmike glede na to, kje glifi dejansko stojijo na strani. Znotraj tega obsega so razlike pomembne: možnosti 4 in 6 razdelita izpis na besede, 5 in 6 izpišeta širine posameznih glifov, 7 pa vrne preprosto besedilo brez pisave, barve in metapodatkov o blokih. Možnost 7 je najboljša za iskalne indekse, saj indeks potrebuje le besede in nič drugega.

Nobena nastavitev ne more rešiti dokumenta, ki teh informacij sploh nikoli ni vseboval. PDF preslika kode znakov v oblike glifov, edina stvar, ki te kode preslika nazaj v berljivo besedilo, pa je tabela ToUnicode CMap znotraj pisave (ISO 32000-1 §9.10). Če delna pisava (subsetted font) nima te tabele, se ekstrakcija ustavi. Ta knjižnica, ukaz za kopiranje in lepljenje v bralniku ali katero koli drugo orodje: vsi so omejeni na ugibanje iz imen glifov ali pa ne vrnejo ničesar. Praktična rešitev je zaznavanje takšnih primerov in ne poskusi reševanja na silo. Označite stran kot nezanesljivo in jo pošljite v OCR, saj je tiho indeksiranje neuporabnih znakov slabše kot priznanje, da strani ni mogoče prebrati.

Za primere, ki jih osnovne možnosti ne pokrivajo (žetonenje po meri, forenzika toka vsebine, besedilni lijak po lastnih pravilih), je na voljo dekodirnik raven nižje. Razred TPDFExtractor se ustvari nad slovarjem virov strani (resources dictionary) in zbirko pisav. Njegova metoda ExtractTextW izvede neposredne tekstovne operacije toka vsebine skozi mehanizem pisav, da pridobi Unicode, dogodek OnFindObject pa vam preda vsak objekt, ko ta potuje mimo. Večina kode nikoli ne potrebuje tako globokega dostopa; tiste aplikacije, ki ga potrebujejo, pa so hvaležne, da je ta plast javna in ne skrita.

Bloki z določenim položajem: enote iskalnih zadetkov in pregleda za redakcijo

Navadno besedilo pove, kaj piše na strani. Prej ali slej pa mora program vedeti tudi, kje to piše, da lahko označi iskalni zadetek, nariše okvir okoli kandidata za redakcijo ali zasidra anotacijo na pravo mesto. Funkcija ExtractPageTextBlocks vrne ročico seznama besedilnih nizov, pri čemer vsak niz vsebuje besedilo, svoj omejitveni okvir ter ime in velikost pisave, v kateri je bil zapisan:

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;

Ena podrobnost na tem področju povzroča več težav pri integraciji kot katera koli druga. Funkcije SetTextExtractionArea, SetTextExtractionWordGap in SetTextExtractionOptions določajo trajna stanja na ravni dokumenta in niso le parametri, ki jih posredujete ob klicu. Če nastavite omejitev območja za eno funkcijo (npr. branje le zgornjega dela glave za klasifikacijo dokumenta), bo to tiho odrezalo vsako naslednjo ekstrakcijo na isti ročici, vključno z ravnmi GetPageText z zaznavo postavitve, ki jih boste uporabili pozneje. Zato ponastavite stanje ekstrakcije med logičnimi nalogami ali pa vsaki nalogi dodelite lastno ročico dokumenta.

Prag za razmik med besedami (word-gap) je ključ za reševanje prve skupine težav, ko se besede združujejo. SetTextExtractionWordGap pove pogonu za postavitev, kolikšen vodoravni prostor glede na razmik med glifi na strani ločuje eno besedo od druge. Gesta tabela zahteva manjši razmik kot ohlapno oblikovana trženjska stran, zato je nastavitev praga glede na razred dokumenta boljša od ene same splošne konstante. Ker ta nastavitev ostane na dokumentu kot preostalo stanje ekstrakcije, jo načrtujte premišljeno in je ne nastavljajte le enkrat z namenom, da jo pozabite.

Slike: izvirni tokovi in ne posnetki zaslona

Napačen način za pridobivanje slik iz PDF je izris strani in njen izrez. To spremeni ločljivost slikovnih pik, uveljavi morebitno rotacijo in zavrže izvirne podatke. Funkcija GetPageImageList namesto tega popiše dejanske slikovne vire, na katere se stran sklicuje, vsak element pa vrne svoje lastnosti in izvirne, nedotaknjene podatke:

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;

Preden karkoli predvidevate o posameznem elementu, preverite GetImageListItemFormatDesc, saj je tisto, na kar se stran sklicuje, redko ena urejena slika na vizualni element. Mehka maska (soft mask) se prikaže kot samostojen vnos. Isti XObject se pogosto ponovi na več straneh, zato pred arhiviranjem izvoza "vseh slik" odstranite dvojnike na podlagi zgoščene vrednosti vsebine (content hash), sicer boste isti logotip zapisali stokrat. Slike JPEG v barvnem prostoru CMYK potrebujejo barvno upravljanje v nadaljnji obdelavi, sicer se v bralnikih, ki kanale interpretirajo neposredno, izrišejo obrnjeno. Če želite popis celotnega dokumenta in ne le ene strani naenkrat, funkcija FindImages skupaj z SetFindImagesMode pregleda celotno datoteko v enem prehodu.

Preden napišete pogoje za prevzem projekta, je z interesenti vredno razjasniti eno mejo: ekstrakcija slik vrne le rastrske vire. Logotip ali grafikon, izrisan z vektorskimi potmi, ni slika v smislu virov dokumenta in se ne bo nikoli pojavil na nobenem seznamu slik, ne glede na to, kako jasno je videti kot slika na zaslonu. Če je zahteva res dostava tega grafikona kot datoteke, je pravilna rešitev izris območja strani v bitno sliko, kar je povsem drugačna operacija z drugačno natančnostjo prikaza. Ti dve vrsti izpisa ne sodita v isto izvozno mapo brez oznake, katera je katera.

Pisave: površina za revizijo in ne funkcija za izvoz

API za pisave odgovarja na vprašanja o pisavah. Ne omogoča pa pridobivanja samih datotek pisav, in ta razlika oblikuje vse, kar lahko zgradite na tem mehanizmu. Ko FindFonts pregleda dokument, seznanjanje poteka po ID-jih pisav, klici lastnosti pa poročajo o pisavi, ki je trenutno izbrana:

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;

Bodite pozorni na meje zanke. Indeksi pisav tečejo od 1 do FontCount, medtem ko so indeksi besedilnih blokov in seznamov slik nekaj odstavkov višje osnovani na ničli. Če zamešate ti dve konvenciji, dobite napako za ena (off-by-one), ki bodisi preskoči prvo pisavo ali pa prekorači meje, kar bo ob običajnem testiranju ostalo neopaženo, saj ima večina dokumentov več pisav in je napačna pisava še vedno videti verjetna. Bodite jasni tudi glede obsega. Ta API nima izvoza pisav na ravni bajtov. Noben klic ne vrne vgrajenega programa pisave kot datoteke TTF or OTF, temveč sta celoten namen le popis in pregled metapodatkov. Ta model še vedno pokriva vse, kar produkcijsko delo zahteva od pisav: zaznavanje delnih pisav po vzorcu imena, revizijo vgrajevanja pred pretvorbo v arhivski format (nevgrajena pisava je nepremostljiva ovira za PDF/A, kot opisuje članek preflight PDF/A in PDF/UA v Delphiju) in diagnostiko kodiranja, ko zanesljivost ekstrakcije pade. Obstaja tudi licenčni razlog, zakaj je meja postavljena ravno tu. Program delne pisave je licencirano gradivo in je brez večine svojih glifov kot namestljiva pisava neuporaben. Obravnavanje pisave kot metapodatkov za revizijo in ne kot sredstva za ekstrakcijo je stališče, ki ga lahko zagovarjate.

Ta zadnji klic se izkaže za izjemno koristnega pri triaži. Izvedite GetFontEncoding na vsaki pisavi in jo primerjajte z zastavico za delno pisavo (subset), pa boste lahko napovedali kakovost ekstrakcije, preden pridobite en sam znak. Stran, katere pisave so vse delne z nestandardnimi kodiranji, je že na prvi pogled kandidat za OCR, relativno preprosto pa omogoča paketnemu cevovodu, da jo pravilno usmeri, ne da bi pred tem izgubljal čas z neuspešno ekstrakcijo.

Ekstrakcija v velikem obsegu brez nalaganja dokumentov

V paketnem cevovodu je nalaganje celotnega dokumenta le zato, da preberete eno stran, potrata I/O operacij, kar se pri velikem številu dokumentov hitro pozna. Različici z enim klicem, ExtractFilePageText in ExtractFilePageTextBlocks, neposredno sprejmeta ime datoteke, geslo in številko strani ter preskočita celotno nalaganje. Za datoteke velikosti več gigabajtov pa je na voljo še hitrejša možnost. Pot za neposreden dostop odpre datoteko prek pretočnega branja xref, tako da DAOpenFileReadOnly, ki mu sledi DAExtractPageText, doseže le tiste objekte, ki jih ta stran dejansko potrebuje. Pri tem si velja zapomniti pomembno spremembo konvencije: funkcije DA naslavljajo strani prek PageRef (ročice za sklic na objekt, ki jo vrne DAFindPage) in nikoli po dejanski številki strani. Če posredujete številko namesto ročice, bo klic deloval nad napačnim objektom brez opozorila o napaki, kar je izjemno težko odkriti. Preostali del orodij za neposreden dostop je predstavljen v članku združevanje, razdeljevanje in neposreden dostop do velikih datotek PDF.

Če obstaja ena navada, ki ločuje kodo za ekstrakcijo, ki uspešno preživi realne podatke, od tiste, ki ne deluje pravilno, je to obravnavanje strani kot nezanesljivega vnosa in ne kot čistega vira podatkov. Besedilo, ki se razlikuje od tistega, kar bralnik izriše, je skoraj vedno težava s kodiranjem – ligatura se združi v en glif ali pa delni pisavi manjkajo vnosi ToUnicode – rešitev pa je merjenje zanesljivosti in usmerjanje slabih strani v OCR, ne pa boj z samimi bajti. API za pisave po načrtu nikoli ne bo ustvaril datotek TTF ali OTF, zato delo s pisavami oblikujte okoli vprašanj za revizijo. Trajno stanje ekstrakcije, predvsem pa pravokotnik območja, sta nastavitvi, ki ju imate v lasti ves čas delovanja ročice dokumenta, in ne le parametra, na katera pozabite po enem klicu. Če osvojite te tri reflekse, bo preostali del API-ja deloval brez težav.

Ocene različic, predstavitveni projekti in celotna referenca API-ja za ekstrakcijo so na voljo na strani izdelka losLab PDF Library za Delphi.