Odborný článok

Extrahovanie obrázkov z načítaného PDF v Delphi: HotPDF

Na disku máte PDF, zákazník ho naskenoval z balíka faktúr a vašou úlohou je vytiahnuť obrázky strán späť ako bitmapy pre OCR. Načítate súbor, nájdete obrazové XObjecty a potom narazíte na časť, pred ktorou vás nik neupozorní: bajty v tých streamoch nie sú pixely. Sú to dátové toky JPEG, alebo vlnkovo komprimované bloky JPEG 2000, alebo faxový beh Group 4, alebo indexovaný raster za paletou a filtrom Flate. Objekt obrázka pozná svoju šírku a výšku, ale skutočné vzorky sú uzavreté vo filtri, ktorý si zvolil producent. Získať použiteľný TBitmap znamená tento filter zrušiť a PDF ponúka približne osem rôznych spôsobov, ako môžu byť tieto bajty zabalené

Práve túto medzeru ExtractLoadedImage vypĺňa v HotPDF, natívnom VCL PDF komponente pre Delphi a C++Builder. Vypíše obrazové XObjecty v načítanom dokumente, oznámi, čo každý z nich predstavuje, a tie podporované dekóduje späť na 24-bitovú bitmapu. Zaujímavé na tom nie je API, ktoré tvoria tri metódy. Dôležité je, prečo vôbec musí existovať samostatná dekódovacia cesta a čo všetko dokáže alebo nedokáže premeniť späť na pixely

Prečo sa načítané obrázky už nedekódujú

Loader HotPDF je postavený na vernosti pri priepustnom spracovaní. Keď zavoláte LoadFromFile, obrazové streamy sa zachovajú presne tak, ako sa nachádzajú v zdrojovom súbore: pôvodný filter, pôvodné komprimované bajty, pôvodný slovník. Je to zámer. Zmyslom načítania dokumentu je zvyčajne kopírovať strany, spájať súbory, pridávať pečiatky, meniť oprávnenia a znovu ich zapisovať, a pri tom všetkom je najlacnejšie a najbezpečnejšie nechať každý obrazový stream nedotknutý. Dekódovať každý obrázok do rastra už pri načítaní by míňalo pamäť a CPU na prácu, ktorú väčšina volajúcich nikdy nepotrebuje, a opätovné kódovanie pri ukladaní by zhoršilo obrázky, ktoré sa mali len doslova skopírovať

Dôsledok je, že načítaný graf objektov nenesie žiadne pixely. Obrazový XObject, ktorého /Filter je /DCTDecode, obsahuje bajty JPEG; HotPDF nad ním nespustil JPEG dekodér, pretože v ceste kopírovania a prepisu to nikde nebolo potrebné. Keď teda skutočne chcete pixely, extrakčné API musí vykonať dekódovanie samo, od nuly, pre konkrétny filter, ktorý daný obrázok používa. Z toho istého dôvodu sú kodeky na strane kódovania nezávislé od loadera: článok o pridávaní obrázkov JPEG 2000 do PDF v Delphi opisuje, ako sa engine JPX zapája do tvorby dokumentu, a tento engine jednoducho nebol pripojený k čítacej ceste, kým ho extrakčné API nepotrebovalo

API s tromi metódami

Povrch je malý. GetLoadedImageCount vracia, koľko obrazových XObjectov načítaný dokument obsahuje. GetLoadedImageInfo vyplní popisný záznam jedného z nich podľa indexu. ExtractLoadedImage vráti dekódovanú bitmapu alebo nil, keď tento obrázok nevie dekódovať. Enumerácia je indexová a pre dané načítanie stabilná: interne prejde tabuľku nepriamych objektov a zozbiera každý stream, ktorého /Subtype sa vyhodnotí na /Image, takže index, ktorý odovzdáte do GetLoadedImageInfo, je ten istý index, ktorý odovzdáte do ExtractLoadedImage

var
  Pdf: THotPDF;
  Info: THPDFLoadedImageInfo;
  Bmp: TBitmap;
  I, Count: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('scanned-invoices.pdf', '') <= 0 then
      Exit;
    Count := Pdf.GetLoadedImageCount;
    for I := 0 to Count - 1 do
    begin
      if not Pdf.GetLoadedImageInfo(I, Info) then
        Continue;
      if not Info.Decodable then
        Continue;                       // filter or colour space not supported
      Bmp := Pdf.ExtractLoadedImage(I);
      if Bmp <> nil then
      try
        Bmp.SaveToFile(Format('img_%d.bmp', [I]));
      finally
        Bmp.Free;                       // caller owns the bitmap
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

Tu záleží na dvoch detailoch kontraktu. Po prvé, vrátený TBitmap musíte uvoľniť vy; dokument ho neukladá do cache ani nevlastní. Po druhé, pred volaním skontrolujte Decodable a po volaní porovnajte výsledok s nil. Metóda pri nepodporovanom filtri nevyhadzuje výnimku, vracia nil, a tiché nil v dávkovom cykle je presne ten typ chyby, ktorý bez povšimnutia zhltne jednu stranu z tisícstranovej úlohy

Najprv si prečítajte deskriptor, až potom dekódujte

THPDFLoadedImageInfo vám povie, čo je obrázok zač, bez toho, aby ste sa zaviazali k plnému dekódovaniu. Jeho polia sa berú priamo zo slovníka obrázka: Width a Height vo vzorkách, BitsPerComponent, ColorComponents a ColorSpace opisujúce interpretáciu po dekódovaní (1 pre gray, 3 pre RGB, 4 pre CMYK), Filter ako pomenovanú kompresiu, IsImageMask pre stencil masky, ObjectNumber pre podkladový nepriamy objekt a Decodable

Posledný príznak je ten úprimný. Decodable je True len vtedy, keď bežiaci build dokáže túto konkrétnu kombináciu filtra a farebného priestoru skutočne previesť na bitmapu. Kóduje skutočnú maticu podpory, nie želanie: obrázok, ktorého Filter aktuálny build nerozumie, nahlási Decodable = False, a podľa toho sa môžete rozhodnúť, či budete logovať, preskočíte ho alebo si surový stream vytiahnete sami. Berte to ako precondition, nie ako nápovedu

// Triage every image before committing to a decode.
var
  Pdf: THotPDF;
  Info: THPDFLoadedImageInfo;
  I: Integer;
begin
  // ... Pdf loaded ...
  for I := 0 to Pdf.GetLoadedImageCount - 1 do
  begin
    if not Pdf.GetLoadedImageInfo(I, Info) then
      Continue;
    if Info.Decodable then
      // ExtractLoadedImage(I) will return a TBitmap
    else
      // unsupported filter/colour space: log the object and skip
      Writeln(Format('Image %d obj %d: %dx%d %s/%s not decodable',
        [I, Info.ObjectNumber, Info.Width, Info.Height,
         String(Info.Filter), String(Info.ColorSpace)]));
  end;
end;

Jeden implementačný detail často zaskočí ľudí, ktorí si deskriptorové záznamy skladajú ručne. THPDFLoadedImageInfo obsahuje dve polia typu AnsiString, Filter a ColorSpace. Ide o spravované typy s referenčným počítaním, takže reflexne vynulovať záznam pomocou FillChar(Info, SizeOf(Info), 0) je tu nesprávne: prepíše referenciu na reťazec bez jej dekrementácie, čo vedie k úniku alebo poškodeniu. HotPDF inicializuje záznam pole po poli presne z tohto dôvodu a ak tento vzor niekedy skopírujete do vlastného kódu, urobte to rovnako

Jeden dispatcher, osem filtrových ciest

Dôvod, prečo táto funkcia prišla v sérii vydaní namiesto jedného, je ten, že PDF nemá jeden obrazový formát. Má filtre a §8.9.5 normy ISO 32000-1 dovoľuje obrazovému XObjectu pomenovať ktorýkoľvek z nich v /Filter, pričom interpretáciu vzoriek samostatne riadia /ColorSpace, /BitsPerComponent a voliteľné pole /Decode. ExtractLoadedImage prečíta názov filtra a smeruje volanie do vyhradeného dekodéra pre každý prípad. Podporovaná množina, budovaná naprieč verziami v2.229 až v2.231, dnes pokrýva osem odlišných ciest

  • Surové rastre (FlateDecode, LZWDecode alebo bez filtra) v 8-bitovom DeviceRGB alebo DeviceGray. Bajty sa dekomprimujú na zabalený raster a jedinou transformáciou je výmena kanálov, o ktorej bude reč nižšie
  • DCTDecode (JPEG). Dátový tok sa odovzdá do TJPEGImage z VCL, ktorý vyrieši geometriu aj farby, a výsledok sa priradí do 24-bitovej bitmapy
  • JPXDecode (JPEG 2000). Dekóduje sa cez backend OpenJPEG, teda ten istý engine opísaný v článku o JPEG 2000, pričom zložky s vyššou bitovou hĺbkou sa prevzorkujú nadol na 8 bitov
  • Indexovaná farba. Paleta sa číta z poľa [/Indexed base hival lookup] a každá vzorka sa cez lookup table rozbalí na skutočnú farbu
  • DeviceCMYK. Štvor-kanálové vzorky sa konvertujú na RGB štandardným vzorcom atrament na bielom podklade
  • DeviceGray a Indexed pod 8 bitov pri 1, 2 alebo 4 bitoch na komponent, rozbalené vzorku po vzorke a škálované do rozsahu 0 až 255
  • CCITTFaxDecode, teda faxové filtre Group 3 a Group 4, dekódované vyhradeným backendom T.4/T.6
  • JBIG2Decode, vysoko kompresný dvojúrovňový filter, dekódovaný cez registrovaný backend JBIG2, ktorý článok o natívnej kompresii JBIG2 pokrýva zo strany kódovania

Všetko končí na tom istom mieste: 24-bitová bitmapa BGR, pretože presne to VCL TBitmap natívne ukladá a práve to očakáva každý následný spotrebiteľ

Transformácie, ktoré potichu menia pixely

Dve z týchto ciest zahŕňajú transformáciu, ktorú je prekvapivo ľahké nenápadne pokaziť, a stojí za to ju pochopiť, aj keď sa dekodéra nikdy nedotknete. Prvou je výmena poradia farieb. Raster PDF DeviceRGB ukladá vzorky v poradí red-green-blue, od horného riadku nadol. 24-bitový scanline vo VCL ich ukladá v poradí blue-green-red. Dekódovanie obyčajného RGB obrázka teda nie je memcpy; prvý a tretí bajt každého pixelu sa pri zápise do scanline vymenia. Ak to otočíte zle, červená a modrá si vymenia miesta, čo na testovacom obrázku v odtieňoch sivej vyzerá v poriadku a na farebnom katastrofálne. Poradie riadkov sa naopak mapuje priamo: raster PDF zhora nadol sa zhoduje s ScanLine[0] vo VCL ako s horným vizuálnym riadkom, takže netreba žiadne vertikálne prevrátenie

Druhou cestou je CMYK. Obrázky PDF DeviceCMYK nesú štyri atramenty a prevod na RGB je výpočet po kanáloch, nie lookup: každý výstupný kanál je (255 - ink) * (255 - K) / 255. Je to aproximácia zariadenia, nie farebne riadená konverzia cez ICC profil, takže výsledok je dosť správny na zobrazenie a opätovnú rasterizáciu, ale nie je to správna cesta, ak potrebujete tlačovo presné farby. Ak váš workflow vyžaduje vernosť, berte extrahovanú bitmapu len ako náhľad a ponechajte pôvodný CMYK stream pre farebne riadené spracovanie

Cesta Indexed v sebe skrýva vlastnú pascu pri parsovaní. Paleta vo farebnom priestore /Indexed môže byť uložená ako doslovný reťazec alebo ako hexadecimálny reťazec a HotPDF ukladá hodnotu hex reťazca ako hex text, nie ako dekódované bajty. Ak je teda paleta hex reťazec, lookup table sa musí najprv previesť z hex na bajty; doslovný reťazec už sú surové bajty. Ak túto vetvu vynecháte, štvorfarebný indexovaný obrázok vyjde ako nezmysel, pretože každá položka palety sa číta z nesprávnej hranice bajtov

Reťazce filtrov: posledný filter patrí samotnému obrázku

Jeden názov /Filter je jednoduchý prípad. PDF však dovoľuje aj reťazec filtrov, kde stream prechádza viacerými filtrami za sebou, uvedenými v poradí v poli /Filter, napríklad [/ASCII85Decode /FlateDecode] alebo [/ASCIIHexDecode /DCTDecode] (ISO 32000-1 §7.4). Semantika je presná: pri kódovaní sa filtre aplikujú zľava doprava, takže pri dekódovaní ich rušíte sprava doľava, a posledný filter v poli je ten, ktorý skutočne určuje formát obrázka. Predchádzajúce filtre sú iba transportné obaly okolo neho

Extraktor si s tým poradí odlupovaním. Skôr než sa spustí akýkoľvek dekodér obrázkov, aplikujú sa všetky filtre v reťazci okrem posledného, aby vznikol vstup, ktorý očakáva finálny filter, a až potom sa podľa tohto posledného filtra dispatchuje. Takže [/ASCII85Decode /DCTDecode] najprv zo streamu odstráni ASCII85 a potom výsledok odošle do vetvy JPEG; [/FlateDecode] obalený okolo surového rastra sa najprv nafúkne a potom prejde rastrovou vetvou. Práve to umožňuje, aby osem dekodérov zostalo jednoduchých. Žiadny z nich nemusí vedieť o transportných obaloch ASCII85 alebo hex, pretože keď dekodér dostane bajty, obaly už sú preč. Zároveň to znamená, že reťazec s nepodporovaným finálnym filtrom zlyhá čisto v kroku dispatch namiesto toho, aby zlyhal niekde v polovici

Kde sa extrakcia končí a čo potom

Buďte k sebe úprimní, pokiaľ ide o hranice. Obrázok, ktorého finálny filter neleží v podporovanej množine, vráti nil, a rovnako aj taký, ktorého farebný priestor daný build nevie interpretovať. Soft masky a alfa sa do bitmapy nerekonštruujú; dostanete základný obrázok, nie zložený výsledok. Bitové hĺbky nad 8 z JPEG 2000 sa prevzorkujú nadol, čo je zámerne stratové a nesprávne, ak znovu archivujete namiesto zobrazovania. A maska obrázka, jednobitová šablóna bez vlastnej farby, je síce deskriptorom opísaná, ale nie je to to isté ako obrazový obrázok; ak ju dekódujete v očakávaní fotografie, budete prekvapení

Keď extrakcia nestačí, surový stream je stále priamo tam v načítanom grafe objektov, so všetkými filtrami, a môžete ho vytiahnuť bajt po bajte a odovzdať vlastnému špecializovanému kodeku. Toto je záložný plán, ktorý priepustný dizajn zachováva zámerne: pôvodné bajty sa nikdy neodhadzujú, takže v najhoršom prípade ich dekódujete sami namiesto toho, aby boli dáta preč. Pre väčšinu reálnych úloh však podporovaných osem filtrov pokrýva to, čo skenery, kancelárske balíky a reportovacie enginy skutočne vytvárajú, a cyklus nad GetLoadedImageCount s ochranou cez Decodable premení načítané PDF späť na priečinok bitmap v niekoľkých riadkoch

API na extrakciu obrázkov z načítaného dokumentu spolu s plnou sadou dekódovacích filtrov opísaných tu sa dodáva v komponente HotPDF Component pre Delphi a C++Builder