Máte PDF na disku, zákazník ho naskenoval z hromady faktur a vaším úkolem je dostat obrázky stránek zpět ven jako bitmapy pro průchod OCR. Soubor načtete, najdete image XObjecty a pak narazíte na část, na kterou vás nikdo neupozorní: bajty v těchto streamech nejsou pixely. Je to JPEG datový tok, nebo vlnkově komprimovaný blok JPEG 2000, nebo faxový záznam Group 4, nebo indexovaný raster ukrytý za paletou a za filtrem Flate. Objekt obrázku zná svou šířku a výšku, ale skutečné vzorky jsou uzavřené uvnitř filtru, který si producent zvolil. Získat použitelný TBitmap znamená ten filtr zase rozbalit a PDF vám dává zhruba osm různých způsobů, jak mohou být bajty uzavřené
Právě tuhle mezeru vyplňuje ExtractLoadedImage v HotPDF, nativní VCL PDF komponentě pro Delphi a C++Builder. Projde image XObjecty v dokumentu, který jste načetli, oznámí, co který z nich je, a ty, které umí, dekóduje zpět do 24bitové bitmapy. Zajímavá není velikost API, které má tři metody. Zajímavé je, proč musí samostatná dekódovací cesta existovat vůbec a co z ní jde a nejde vrátit zpět do pixelů
Proč načtené obrázky ještě nejsou dekódované
Načítací engine HotPDF stojí na bezeztrátovém průchodu. Když zavoláte LoadFromFile, image streamy zůstanou přesně tak, jak jsou v původním souboru: původní filtr, původní komprimované bajty, původní slovník. To je záměr. Smyslem načtení dokumentu bývá kopírovat stránky, slučovat soubory, razítkovat je, upravovat jejich oprávnění a zapisovat je zpět, a pro to všechno je nejlevnější a nejbezpečnější nechat každý image stream nedotčený. Dekódovat při načtení každý obrázek do rastru by žralo paměť i CPU na práci, kterou většina volajících nikdy nepotřebuje, a znovukódování při ukládání by zhoršilo obrázky, které měly být zkopírovány doslova
Důsledek je ten, že načtený graf objektů nenese žádné pixely. Image XObject, jehož /Filter je /DCTDecode, obsahuje JPEG bajty; HotPDF proti němu nikdy nespustil JPEG dekodér, protože nic v cestě kopírování a přepisování to nepotřebovalo. Takže když skutečně chcete pixely, musí je dekódovací API udělat samo, od nuly, pro kterýkoli filtr daný obrázek používá. Ze stejného důvodu jsou kodéry na straně zápisu nezávislé na loaderu: článek o přidávání obrázků JPEG 2000 do PDF v Delphi popisuje, jak se engine JPX připojuje na stranu tvorby, a tenhle engine prostě nebyl do čtecí cesty zapojen, dokud ho nepotřebovalo dekódovací API
Třímetodové API
Rozhraní je malé. GetLoadedImageCount vrací, kolik image XObjectů načtený dokument obsahuje. GetLoadedImageInfo naplní popisný záznam pro jeden z nich podle indexu. ExtractLoadedImage vrátí dekódovanou bitmapu, nebo nil, když ten obrázek dekódovat neumí. Výčet je indexový a pro jedno načtení stabilní: uvnitř prochází tabulku nepřímých objektů a sbírá každý stream, jehož /Subtype se vyhodnotí na /Image, takže index, který předáte do GetLoadedImageInfo, je stejný index, který předá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;
Dvě smluvní detaily jsou zde důležité. Za prvé, vrácený TBitmap je na vás a musíte ho uvolnit; dokument ho necachuje ani nevlastní. Za druhé, před voláním zkontrolujte Decodable a pak výsledek porovnejte s nil. Metoda při nepodporovaném filtru nevyhazuje výjimku, ale vrací nil, a tiché nil v dávkové smyčce je přesně ten typ věci, který bez povšimnutí pohltí stránku z tisícistránkové úlohy
Čtení popisu před dekódováním
THPDFLoadedImageInfo vám řekne, co obrázek je, aniž by se zavazoval k plnému dekódování. Jeho pole jdou přímo z obrazového slovníku: Width a Height ve vzorcích, BitsPerComponent, ColorComponents a ColorSpace popisující interpretaci po dekódování (1 pro šedou, 3 pro RGB, 4 pro CMYK), Filter jako pojmenovaná komprese, IsImageMask pro stencil masky, ObjectNumber pro podkladový nepřímý objekt a Decodable
To poslední příznak je ten poctivý. Decodable je True jen tehdy, když běžící sestavení dokáže tuto konkrétní kombinaci filtru a barevného prostoru opravdu převést na bitmapu. Kóduje skutečnou matici podpory, ne přání: obrázek, jehož Filter aktuální sestavení nezná, hlásí Decodable = False, a můžete na tom větvit logování, přeskočení nebo návrat k ručnímu vytažení syrového streamu. Berte to jako předpoklad, ne jako nápovědu
// 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 lidem, kteří si sestavují popisné záznamy ručně, často škodí. THPDFLoadedImageInfo obsahuje dvě pole AnsiString, Filter a ColorSpace. Jsou to spravované typy s referenčním počítáním, takže reflex vyplnit záznam nulami pomocí FillChar(Info, SizeOf(Info), 0) je tady špatně: přepíše řetězcovou referenci bez jejího snížení, což způsobí únik nebo poškození. HotPDF ten záznam inicializuje pole po poli právě z tohoto důvodu a pokud si tenhle postup někdy zkopírujete do vlastního kódu, udělejte to stejně
Jeden rozcestník, osm cest filtrů
Důvod, proč tahle funkce nepřišla v jedné, ale v několika verzích, je ten, že PDF nemá formát obrázku. Má filtry a §8.9.5 ISO 32000-1 dovoluje image XObjectu pojmenovat v /Filter kterýkoli z nich, zatímco interpretaci vzorků řídí zvlášť /ColorSpace, /BitsPerComponent a volitelné pole /Decode. ExtractLoadedImage čte jméno filtru a směruje ho na dedikovaný dekodér pro každý případ. Podporovaná sada, budovaná napříč verzemi v2.229 až v2.231, dnes pokrývá osm samostatných cest
- Syrové rastry (FlateDecode, LZWDecode nebo bez filtru) v 8bitovém DeviceRGB nebo DeviceGray. Bajty se rozbalí do zabaleného rastru a jedinou transformací je záměna kanálů, kterou popisujeme níže
- DCTDecode (JPEG). Datový tok se předá VCL
TJPEGImage, která vyřeší geometrii a barvy, a výsledek se zapíše do 24bitové bitmapy - JPXDecode (JPEG 2000). Dekóduje se přes backend OpenJPEG, stejný engine popsaný v článku o JPEG 2000, přičemž komponenty s vyšší bitovou hloubkou se převzorkují dolů na 8 bitů
- Indexed colour. Paleta se čte z pole
[/Indexed base hival lookup]a každý vzorek se rozšíří přes vyhledávací tabulku na skutečnou barvu - DeviceCMYK. Čtyřkanálové vzorky se převádějí na RGB standardním vzorcem inkoust na bílém
- Pod 8bitový DeviceGray a Indexed s 1, 2 nebo 4 bity na komponentu, rozbalený vzorek po vzorku a škálovaný do rozsahu 0–255
- CCITTFaxDecode, faxové filtry Group 3 a Group 4, dekódované přes dedikovaný backend T.4/T.6
- JBIG2Decode, vysokokomprimovaný bilevel filtr, dekódovaný přes registrovaný backend JBIG2, který z encode strany popisuje článek o nativní kompresi JBIG2
Všechno končí na stejném místě: 24bitová BGR bitmapa, protože přesně to nativně ukládá VCL TBitmap a přesně to čeká každý downstream spotřebitel
Transformace, které tiše mění pixely
V těchto dvou cestách je transformace, kterou je snadné udělat nenápadně špatně, a stojí za to jí rozumět, i když se dekodéru nikdy nedotknete. První je záměna pořadí barev. Raster PDF DeviceRGB ukládá vzorky v pořadí červená-zelená-modrá, a to od horního řádku. 24bitový scanline VCL je ukládá v pořadí modrá-zelená-červená. Dekódování obyčejného RGB obrázku tedy není memcpy; první a třetí bajt každého pixelu se při zápisu do scanline prohodí. Když to otočíte, červená a modrá si vymění místa, což na šedotónovém testu vypadá v pořádku, ale na barevném je to katastrofálně špatně. Pořadí řádků se přitom mapuje přímo: top-down rastry v PDF sedí s vizuálním horním řádkem VCL ScanLine[0], takže není potřeba žádný vertikální flip
Druhá je CMYK. Obrázky PDF DeviceCMYK nesou čtyři inkousty a převod do RGB je výpočet po jednotlivých kanálech, ne lookup: každý výstupní kanál je (255 - ink) * (255 - K) / 255. Je to aproximace zařízení, ne spravovaný převod přes ICC profil, takže výsledek je dostatečně správný pro zobrazení a znovurasterizaci, ale není to správná cesta, pokud potřebujete barvy přesné pro tisk. Když váš workflow vyžaduje věrnost, berte extrahovanou bitmapu jako náhled a původní CMYK stream nechte pro barevně spravovanou pipeline
Cesta Indexed skrývá vlastní past při parsování. Paleta v barevném prostoru /Indexed může být uložená jako literální řetězec nebo jako hexadecimální řetězec a HotPDF ukládá hodnotu hex řetězce jako hex text, ne jako dekódované bajty. Takže když je paleta hex řetězec, musí se lookup tabulka nejdřív dekódovat z hexu na bajty; literální řetězec už jsou surové bajty. Když tu větev minete, čtyřbarevný indexovaný obrázek vyjde jako odpad, protože každá položka palety se čte z nesprávné hranice bajtu
Řetězce filtrů: poslední filtr je vlastně obraz sám
Jedno jméno /Filter je ten snadný případ. PDF ale dovoluje i řetězec filtrů, kdy stream prošel několika kroky za sebou, vypsanými v pořadí v poli /Filter jako [/ASCII85Decode /FlateDecode] nebo [/ASCIIHexDecode /DCTDecode] (ISO 32000-1 §7.4). Sémantika je přesná: filtry se při kódování aplikují zleva doprava, takže při dekódování je vracíte zprava doleva, a poslední filtr v poli je ten, který skutečně definuje formát obrázku. Předchozí filtry jsou jen transportní kódování navinuté kolem něj
Extraktor to řeší slupováním vrstev. Než se spustí jakýkoli image dekodér, aplikuje se každý filtr v řetězci kromě posledního, aby vznikl vstup, který poslední filtr očekává, a teprve potom dojde k rozcestění podle tohoto posledního filtru. Takže [/ASCII85Decode /DCTDecode] nejdřív zneASCII85uje stream a pak výsledek pošle na JPEG cestu; [/FlateDecode] zabalený kolem syrového rastru ho rozbalí a pak spustí rasterovou cestu. Právě to drží těch osm dekodérů jednoduchých. Žádný z nich nemusí nic vědět o ASCII85 nebo hex transportních obalech, protože v okamžiku, kdy dekodér bajty vidí, obaly už jsou pryč. Znamená to také, že řetězec s posledním nepodporovaným filtrem selže čistě až v kroku rozcestění, ne v půlce
Kde extrakce končí a co dělat dál
Buďte k sobě upřímní ohledně hranic. Obrázek, jehož poslední filtr je mimo podporovanou sadu, vrátí nil, a stejně tak ten, jehož barevný prostor sestavení neumí interpretovat. Soft masky a alfa se do bitmapy znovu nevytvářejí; dostanete základní obrázek, ne složený výsledek. Bitové hloubky nad 8 z JPEG 2000 se převzorkují dolů, což je záměrně ztrátové a špatný krok, pokud znovu archivujete, ne zobrazujete. A image maska, jednoubitová šablona bez vlastní barvy, je v popisu uvedená, ale je to něco jiného než obrazový motiv; když ji dekódujete jako fotografii, budete překvapeni
Když extrakce nestačí, syrový stream je pořád přímo v načteném grafu objektů, včetně filtru, a můžete ho vytáhnout bajt po bajtu a předat ho vlastnímu specializovanému kodeku. To je fallback, který si bezeztrátový návrh zachovává záměrně: původní bajty se nikdy nevyhazují, takže nejhorší případ je, že je dekódujete sami, ne že jsou data pryč. Pro většinu skutečných úloh ale podporovaných osm filtrů pokryje to, co skutečně vypouštějí skenery, kancelářské balíky a reportovací enginy, a smyčka přes GetLoadedImageCount s ochranou Decodable vrátí načtené PDF do složky bitmap v několika řádcích
API pro extrakci načtených obrázků spolu s celou sadou dekódovacích filtrů popsaných zde je součástí HotPDF Component pro Delphi a C++Builder