Hai un PDF sul disco, un cliente lo ha scansionato da una pila di fatture, e il tuo compito è recuperare le immagini delle pagine come bitmap per un passaggio OCR. Carichi il file, trovi gli XObject immagine, e poi scopri la parte su cui nessuno ti avvisa: i byte in quegli stream non sono pixel. Sono un codestream JPEG, oppure un blob JPEG 2000 compresso con wavelet, oppure una scansione fax Group 4, oppure un raster indicizzato nascosto dietro una tavolozza e dietro un filtro Flate. L'oggetto immagine conosce larghezza e altezza, ma i campioni effettivi sono sigillati dentro qualunque filtro abbia scelto il produttore. Ottenere un TBitmap utilizzabile significa annullare quel filtro, e PDF ti offre circa otto modi diversi per sigillare i byte
Questo è il vuoto che ExtractLoadedImage colma in HotPDF, il componente PDF VCL nativo per Delphi e C++Builder. Enumera gli XObject immagine in un documento che hai caricato, riporta che cosa sia ciascuno, e decodifica quelli che può in una bitmap a 24 bit. La parte interessante non è l'API in sé, che sono tre metodi. È il motivo per cui deve esistere un percorso di decodifica separato, e che cosa possa e non possa riportare ai pixel
Perché le immagini caricate non sono già decodificate
Il caricatore di HotPDF è costruito attorno alla fedeltà pass-through. Quando chiami LoadFromFile, gli stream immagine vengono mantenuti esattamente come appaiono nel file sorgente: il filtro originale, i byte compressi originali, il dizionario originale. È una scelta deliberata. Lo scopo di caricare un documento di solito è copiare pagine, unire file, applicare timbri, revocare o modificare i permessi e riscriverlo, e per tutto questo la cosa più economica e sicura è lasciare intatto ogni stream immagine. Decodificare ogni immagine in un raster al caricamento brucerebbe memoria e CPU su lavoro che la maggior parte dei chiamanti non deve mai fare, e ricodificare al salvataggio degraderebbe immagini che avrebbero dovuto essere copiate alla lettera
La conseguenza è che il grafo degli oggetti caricato non contiene pixel. Un XObject immagine il cui /Filter è /DCTDecode contiene byte JPEG; HotPDF non ha mai eseguito su di esso un decoder JPEG, perché nulla nel percorso di copia e riscrittura lo richiedeva. Quindi, quando vuoi davvero i pixel, l'API di estrazione deve fare da sola la decodifica, da zero, per qualunque filtro utilizzi quella specifica immagine. Questo è lo stesso motivo per cui i codec del lato di codifica sono indipendenti dal caricatore: l'articolo su aggiunta di immagini JPEG 2000 ai PDF in Delphi descrive come il motore JPX si innesti nel lato di creazione, e quel motore semplicemente non era collegato al percorso di lettura finché l'API di estrazione non ne ha avuto bisogno
L'API a tre metodi
La superficie è ridotta. GetLoadedImageCount restituisce quanti XObject immagine contiene il documento caricato. GetLoadedImageInfo compila un record descrittore per uno di essi in base all'indice. ExtractLoadedImage restituisce la bitmap decodificata, oppure nil quando non riesce a decodificare quell'immagine. L'enumerazione è basata su indice e stabile per un dato caricamento: internamente percorre la tabella degli oggetti indiretti e raccoglie ogni stream il cui /Subtype risolve a /Image, quindi l'indice che passi a GetLoadedImageInfo è lo stesso indice che passi a 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;
Due dettagli del contratto contano qui. Primo, la TBitmap restituita è tua da liberare; il documento non la memorizza in cache né ne è proprietario. Secondo, controlla Decodable prima di chiamare, e confronta il risultato con nil dopo. Il metodo non solleva eccezioni su un filtro non supportato, restituisce nil, e un nil silenzioso in un ciclo batch è esattamente il tipo di cosa che ingoia una pagina di un lavoro da mille pagine senza che nessuno se ne accorga
Leggere il descrittore prima della decodifica
THPDFLoadedImageInfo ti dice che cosa sia un'immagine senza impegnarti in una decodifica completa. I suoi campi arrivano direttamente dal dizionario dell'immagine: Width e Height nei campioni, BitsPerComponent, ColorComponents e ColorSpace che descrivono l'interpretazione dopo la decodifica (1 per grigio, 3 per RGB, 4 per CMYK), Filter come compressione nominata, IsImageMask per le maschere stencil, ObjectNumber per l'oggetto indiretto sottostante, e Decodable
Quest'ultimo flag è quello sincero. Decodable è True solo quando la build in esecuzione può davvero trasformare questa specifica combinazione di filtro e spazio colore in una bitmap. Codifica la matrice di supporto reale, non un desiderio: un'immagine il cui Filter la build corrente non capisce segnala Decodable = False, e puoi usare quel valore per registrare, saltare o ripiegare sull'estrazione diretta dello stream grezzo. Trattalo come una precondizione, non come un suggerimento
// 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;
Un dettaglio di implementazione sorprende chi costruisce a mano i record descrittori. THPDFLoadedImageInfo contiene due AnsiString campi, Filter e ColorSpace. Sono tipi gestiti con reference counting, quindi l'istinto di azzerare un record con FillChar(Info, SizeOf(Info), 0) è sbagliato qui: sovrascrive il riferimento alla stringa senza decrementarne il conteggio, e quindi causa leak o corruzione. HotPDF inizializza il record campo per campo proprio per questo motivo, e se copi questo schema nel tuo codice, fai lo stesso
Un dispatcher, otto percorsi di filtro
Il motivo per cui questa funzionalità ha richiesto una serie di release invece di una sola è che PDF non ha un formato immagine. Ha filtri, e la §8.9.5 dell'ISO 32000-1 consente a un XObject immagine di nominarne uno qualsiasi in /Filter, con l'interpretazione dei campioni governata separatamente da /ColorSpace, /BitsPerComponent e da un array facoltativo /Decode. ExtractLoadedImage legge il nome del filtro e instrada ogni caso a un decoder dedicato. L'insieme supportato, costruito tra v2.229 e v2.231, copre ora otto percorsi distinti
- Raster grezzi (FlateDecode, LZWDecode o nessun filtro) in DeviceRGB o DeviceGray a 8 bit. I byte si espandono in un raster impacchettato, e l'unica trasformazione è uno scambio di canali, trattato più sotto
- DCTDecode (JPEG). Il codestream viene passato al
TJPEGImagedella VCL, che risolve geometria e colore, e il risultato viene assegnato a una bitmap a 24 bit - JPXDecode (JPEG 2000). Decodificato tramite il backend OpenJPEG, lo stesso motore descritto nell'articolo sul JPEG 2000, con i componenti ad alta profondità di bit ricampionati verso il basso a 8 bit
- Colore indicizzato. La tavolozza viene letta dall'array
[/Indexed base hival lookup]e ogni campione viene espanso tramite la tabella di lookup fino al colore reale - DeviceCMYK. I campioni a quattro canali vengono convertiti in RGB con la formula standard per l'inchiostro su bianco
- DeviceGray e Indexed sotto gli 8 bit a 1, 2 o 4 bit per componente, deimpacchettati campione per campione e scalati nell'intervallo 0–255
- CCITTFaxDecode, i filtri fax Group 3 e Group 4, decodificati da un backend T.4/T.6 dedicato
- JBIG2Decode, il filtro bilevel ad alto rapporto di compressione, decodificato tramite il backend JBIG2 registrato che l'articolo nativo sulla compressione JBIG2 copre dal lato di codifica
Tutto finisce nello stesso posto: una bitmap BGR a 24 bit, perché è ciò che una VCL TBitmap memorizza nativamente e ciò che ogni consumer a valle si aspetta
Le trasformazioni che cambiano i pixel senza dirlo
Due di questi percorsi coinvolgono una trasformazione facile da sbagliare in modo sottile, e che vale la pena capire anche se non tocchi mai il decoder. La prima è lo scambio dell'ordine dei colori. Un raster PDF DeviceRGB memorizza i campioni in ordine rosso-verde-blu, con la riga superiore per prima. Una scanline VCL a 24 bit li memorizza in ordine blu-verde-rosso. Quindi decodificare una semplice immagine RGB non è un memcpy; per ogni pixel il primo e il terzo byte vengono scambiati durante il passaggio nella scanline. Se inverti questa parte, rossi e blu si scambiano di posto, il che su un'immagine di test in scala di grigi sembra corretto e su un'immagine a colori è disastrosamente sbagliato. L'ordine delle righe, per quel che vale, si mappa direttamente: i raster top-down del PDF si allineano con la ScanLine[0] di VCL come riga visiva superiore, quindi non serve alcun ribaltamento verticale
La seconda è CMYK. Le immagini PDF DeviceCMYK portano quattro inchiostri, e la conversione in RGB è un calcolo per canale, non una lookup: ogni canale di output è (255 - ink) * (255 - K) / 255. È un'approssimazione device, non una conversione con gestione colore tramite un profilo ICC, quindi il risultato è abbastanza corretto per la visualizzazione e la ri-rasterizzazione, ma non è il percorso giusto se ti serve un colore fedele alla stampa. Se il tuo flusso richiede fedeltà, considera la bitmap estratta come anteprima e conserva lo stream CMYK originale per la pipeline con gestione colore
Il percorso Indexed nasconde a sua volta una trappola di parsing. La tavolozza in uno spazio colore /Indexed può essere memorizzata come stringa letterale o come stringa esadecimale, e HotPDF memorizza il valore di una stringa esadecimale come testo esadecimaletesto, non i byte decodificati. Quindi, quando la tavolozza è una stringa esadecimale, la lookup table deve prima passare attraverso una decodifica da esadecimale a byte; una stringa letterale è già byte grezzi. Saltare questo ramo fa uscire un'immagine indicizzata a quattro colori come spazzatura, perché ogni voce della tavolozza viene letta sul bordo di byte sbagliato
Catene di filtri: l'ultimo filtro è quello dell'immagine
Una singola /Filter name è il caso semplice. PDF consente anche una catena di filtri, in cui lo stream è stato passato attraverso più filtri in sequenza, elencati in ordine in un /Filter array come [/ASCII85Decode /FlateDecode] o [/ASCIIHexDecode /DCTDecode] (ISO 32000-1 §7.4). La semantica è precisa: i filtri si applicano da sinistra a destra in codifica, quindi in decodifica li annulli da destra a sinistra, e l'ultimo filtro dell'array è quello che definisce davvero il formato dell'immagine. I filtri iniziali sono solo codifiche di trasporto avvolte attorno a esso
L'estrattore gestisce la cosa sbucciando gli strati. Prima che qualsiasi decoder immagine venga eseguito, ogni filtro nella catena tranne l'ultimo viene applicato per produrre l'input che il filtro finale si aspetta, e solo allora avviene l'instradamento su quel filtro finale. Così [/ASCII85Decode /DCTDecode] prima rimuove l'ASCII85 dallo stream, poi indirizza il risultato al percorso JPEG; [/FlateDecode] avvolto attorno a un raster grezzo lo espande e poi esegue il percorso raster. Questo è ciò che permette agli otto decoder di restare semplici. Nessuno di loro deve sapere nulla degli involucri di trasporto ASCII85 o hex, perché quando un decoder vede i byte, gli involucri sono già spariti. Significa anche che una catena il cui filtro finale non è supportato fallisce ancora in modo pulito al punto di dispatch, non a metà strada
Dove l'estrazione si ferma, e cosa fare allora
Sii onesto con te stesso sui limiti. Un'immagine il cui filtro finale è fuori dall'insieme supportato restituisce nil, e lo stesso vale per una di cui lo spazio colore la build non sa interpretare. Le soft mask e l'alpha non vengono ricostruiti nella bitmap; ottieni l'immagine di base, non un risultato compositato. Le profondità di bit superiori a 8 del JPEG 2000 vengono ricampionate verso il basso, il che è volutamente lossy e la scelta sbagliata se stai riorganizzando un archivio invece di visualizzarlo. E un image mask, uno stencil a un bit senza colore proprio, è descritto dal descrittore ma è una cosa diversa da un'immagine pittorica; se la decodifichi aspettandoti una foto, resterai sorpreso
Quando l'estrazione non basta, lo stream grezzo è ancora lì nel grafo degli oggetti caricato, filtro incluso, e puoi estrarlo byte per byte e consegnarlo a un codec specializzato tuo. È questo il fallback che il design pass-through preserva di proposito: i byte originali non vengono mai buttati via, quindi nel peggiore dei casi li decodifichi tu invece di perdere i dati. Per la maggior parte dei lavori reali, però, gli otto filtri supportati coprono ciò che scanner, suite da ufficio e motori di report emettono davvero, e un ciclo su GetLoadedImageCount con un Decodable di guardia trasforma un PDF caricato in una cartella di bitmap in poche righe
L'API di estrazione delle immagini caricate, insieme all'insieme completo dei filtri di decodifica descritti qui, è disponibile nel HotPDF Component per Delphi e C++Builder