Teknisk artikkel

Ekstraher bilder fra en lastet PDF i Delphi: HotPDF

Du har en PDF på disken, en kunde har skannet den fra en bunke fakturaer, og oppgaven din er å hente sidebildene tilbake som bitmaps for en OCR-runde. Du laster inn filen, finner image XObject-ene, og oppdager så delen ingen advarer deg om: bytene i disse streamene er ikke piksler. De kan være en JPEG codestream, en bølgekomprimert JPEG 2000-blokk, en Group 4-fakssekvens eller et indeksert raster skjult bak en palett og et Flate-filter. Image-objektet kjenner bredde og høyde, men de faktiske sample-verdiene ligger forseglet inne i filteret produsenten valgte. For å få en brukbar TBitmap må du pakke ut det filteret, og PDF gir deg omtrent åtte ulike måter å forsegle bytene på

Det er dette gapet ExtractLoadedImage fyller i HotPDF, den native VCL PDF-komponenten for Delphi og C++Builder. Den går gjennom image XObject-ene i et dokument du har lastet inn, rapporterer hva hver av dem er, og dekoder de den kan tilbake til en 24-bit bitmap. Det interessante er ikke API-flaten, som består av tre metoder. Det interessante er hvorfor en egen dekoderute må finnes i det hele tatt, og hva den kan og ikke kan gjøre om til piksler

Hvorfor innlastede bilder ikke allerede er dekodet

HotPDFs loader er bygget rundt pass-through-nøyaktighet. Når du kaller LoadFromFile, beholdes bildestrømmene akkurat slik de fremstår i kildefilen: det opprinnelige filteret, de opprinnelige komprimerte bytene, den opprinnelige ordboken. Det er bevisst. Poenget med å laste et dokument er som regel å kopiere sider, flette filer, stemple dem, gi dem nye rettigheter og skrive dem ut igjen, og for alt dette er det billigste og tryggeste å la hver bildestrøm være urørt. Hvis man dekodet hvert bilde til et raster ved innlasting, ville man brukt minne og CPU på arbeid de fleste aldri trenger, og hvis man kodet alt på nytt ved lagring, ville man degradert bilder som burde vært kopiert ordrett

Konsekvensen er at det innlastede objektgrafer ikke inneholder piksler. Et image XObject der /Filter er /DCTDecode holder JPEG-byter; HotPDF kjørte aldri en JPEG-dekoder mot det, fordi ingenting i kopi- og skriv-om-stien trengte det. Så når du faktisk vil ha piksler, må ekstraksjons-API-et gjøre dekodingen selv, fra bunnen av, for hvert filter akkurat det bildet bruker. Det er samme grunn til at kodeksene på skrivsiden er uavhengige av innlasting: artikkelen om å legge til JPEG 2000-bilder i PDF-er i Delphi beskriver hvordan JPX-motoren kobles på opprettelsessiden, og den motoren var ganske enkelt ikke koblet til lesestien før ekstraksjons-API-et trengte den

API-et med tre metoder

Overflaten er liten. GetLoadedImageCount returnerer hvor mange image XObject-er det innlastede dokumentet inneholder. GetLoadedImageInfo fyller en beskrivelsespost for en av dem etter indeks. ExtractLoadedImage returnerer den dekodede bitmappen, eller nil når den ikke kan dekode det bildet. Opptellingen er indeksbasert og stabil for en gitt lasting: internt går den gjennom tabellen over indirekte objekter og samler hver stream der /Subtype løses til /Image, så indeksen du sender til GetLoadedImageInfo er den samme indeksen du sender til 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;

To kontraktdetaljer er viktige her. For det første er den returnerte TBitmap-en din å frigjøre; dokumentet cacher eller eier den ikke. For det andre bør du sjekke Decodable før du kaller, og sjekke resultatet mot nil etterpå. Metoden kaster ikke ved et filter den ikke støtter, den returnerer nil, og et stille nil i en batchløkke er akkurat den typen feil som kan sluke en side i en jobb med tusen sider uten at noen legger merke til det

Les beskrivelsen før du dekoder

THPDFLoadedImageInfo forteller deg hva et bilde er uten å gå hele veien til full dekoding. Feltene kommer rett fra image-dictionaryen: Width og Height i samples, BitsPerComponent, ColorComponents og ColorSpace som beskriver tolkningen etter dekoding (1 for grå, 3 for RGB, 4 for CMYK), Filter som navngitt komprimering, IsImageMask for stencilmasker, ObjectNumber for det underliggende indirekte objektet, og Decodable

Det siste flagget er det ærlige. Decodable er True bare når den kjørende builden faktisk kan gjøre denne spesifikke kombinasjonen av filter og fargerom om til en bitmap. Det koder den reelle støtte-matrisen, ikke et ønske: et bilde hvis Filter den gjeldende builden ikke forstår, rapporterer Decodable = False, og du kan bruke det til å logge, hoppe over eller falle tilbake til å hente ut den rå streamen selv. Behandle det som en forutsetning, ikke som et hint

// 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;

En implementasjonsdetalj biter folk som bygger beskrivelsesposter for hånd. THPDFLoadedImageInfo holder to AnsiString-felt, Filter og ColorSpace. Dette er forvaltede typer med referansetelling, så refleksen om å nullstille en post med FillChar(Info, SizeOf(Info), 0) er feil her: den overskriver strengreferansen uten å redusere referansetellingen, noe som lekker eller korrumperer. HotPDF initialiserer posten felt for felt nettopp av den grunnen, og hvis du noen gang kopierer dette mønsteret i egen kode, bør du gjøre det samme

Én dispatcher, åtte filterstier

Grunnen til at denne funksjonen tok en serie utgivelser i stedet for én, er at PDF ikke har et bildeformat. Det har filtre, og §8.9.5 i ISO 32000-1 lar et image XObject navngi hvilken som helst av dem i /Filter, med sample-verdienes tolkning styrt separat av /ColorSpace, /BitsPerComponent og en valgfri /Decode-array. ExtractLoadedImage leser filternavnet og ruter til en dedikert dekoder for hvert tilfelle. Det støttede settet, bygget opp gjennom v2.229 til v2.231, dekker nå åtte ulike stier

  • Rå rastere (FlateDecode, LZWDecode eller ingen filter) i 8-bit DeviceRGB eller DeviceGray. Bytene pakkes ut til et tett raster, og den eneste transformasjonen er et kanalbytte, omtalt nedenfor
  • DCTDecode (JPEG). Codestreamen sendes til VCLs TJPEGImage, som løser geometri og farge, og resultatet legges inn i en 24-bit bitmap
  • JPXDecode (JPEG 2000). Dekodes via OpenJPEG-backenden, den samme motoren som beskrives i JPEG 2000-artikkelen, med komponenter med høy bitdybde resamplet ned til 8 bits
  • Indeksert farge. Paletten leses fra [/Indexed base hival lookup]-arrayet og hvert sample utvides gjennom oppslagstabellen til ekte farge
  • DeviceCMYK. Firekanals-samples konverteres til RGB med standard blekk-på-hvitt-formel
  • Under-8-bit DeviceGray og Indexed med 1, 2 eller 4 bits per component, pakket ut sample for sample og skalert til 0-255-området
  • CCITTFaxDecode, Group 3- og Group 4-faksfiltrene, dekodet av en dedikert T.4/T.6-backend
  • JBIG2Decode, det høykomprimerte bilevel-filteret, dekodet gjennom den registrerte JBIG2-backenden som artikkelen om innebygd JBIG2-komprimering dekker fra kodesiden

Alt lander på samme sted: en 24-bit BGR bitmap, fordi det er det en VCL TBitmap lagrer naturlig og det alle nedstrømsforbrukere forventer

Transformasjonene som stille endrer piksler

To av disse stiene innebærer en transformasjon som er lett å gjøre litt feil, og verdt å forstå selv om du aldri rører dekoderen selv. Den første er bytte av fargerekkefølge. Et PDF DeviceRGB-raster lagrer samples i rødt-grønt-blått rekkefølge, med øverste rad først. En VCL 24-bit scanline lagrer dem i blått-grønt-rødt rekkefølge. Så dekoding av et rent RGB-bilde er ikke en memcpy; hver piksel får første og tredje byte byttet på vei inn i scanline. Gjør du dette baklengs, bytter rødt og blått plass, og det ser greit ut på et gråtone-testbilde og katastrofalt feil ut på et fargebilde. Radrekkefølgen, for hva den er verdt, går rett gjennom: PDFs topp-ned-rastere passer med VCLs ScanLine[0] som den øverste visuelle raden, så ingen vertikal flip trengs

Den andre er CMYK. PDF DeviceCMYK-bilder bærer fire blekk, og konverteringen til RGB er en beregning per kanal, ikke et oppslag: hver utgangskanal er (255 - ink) * (255 - K) / 255. Dette er en enhetsnær tilnærming, ikke en fargestyrt konvertering via en ICC-profil, så resultatet er godt nok for visning og re-rasterisering, men er ikke riktig vei hvis du trenger utskriftsnøyaktig farge. Hvis arbeidsflyten din krever trofasthet, bør du behandle den ekstraherte bitmappen som en forhåndsvisning og beholde den opprinnelige CMYK-streamen for den fargestyrte pipelinen

Indexed-stien skjuler sin egen parsefelle. Paletten i et /Indexed-fargerom kan lagres som en literal string eller som en heksadesimal streng, og HotPDF lagrer verdien av en hex-streng som selve hex-teksten, ikke de dekodede bytene. Så når paletten er en hex-streng, må oppslagstabellen først gjennom en hex-til-byter-dekoding; en literal string er allerede rå byter. Gå glipp av den grenen, og et firefarget indeksert bilde blir søppel, fordi hver palettoppføring leses med feil bytegrense

Filterkjeder: det siste filteret er bildets eget

Et enkelt /Filter-navn er den lette varianten. PDF tillater også en kjede av filtre, der streamen har vært gjennom flere etter hverandre, listet i rekkefølge i en /Filter-array som [/ASCII85Decode /FlateDecode] eller [/ASCIIHexDecode /DCTDecode] (ISO 32000-1 §7.4). Semantikken er presis: filtrene brukes fra venstre mot høyre ved koding, så ved dekoding gjør du dem om i motsatt rekkefølge, og det siste filteret i arrayet er det som faktisk definerer bildeformatet. De ledende filtrene er bare transportkodinger rundt det

Ekstraktoren håndterer dette ved å skrelle lag. Før noen bildekodere kjører, brukes hvert filter i kjeden unntatt det siste for å produsere inngangen det siste filteret forventer, og først da skjer dispatch på det siste filteret. Så [/ASCII85Decode /DCTDecode] av-ASCII85-er først streamen og sender deretter resultatet til JPEG-stien; [/FlateDecode] rundt et rått raster pakkes ut og kjører deretter rasterstien. Dette er det som lar de åtte dekoderne holde seg enkle. Ingen av dem trenger å vite noe om ASCII85- eller hex-transportinnpakninger, fordi når en dekoder ser bytene, er innpakningene allerede borte. Det betyr også at en kjede der det siste filteret ikke støttes, fortsatt feiler rent i dispatch-steget og ikke halvveis gjennom

Der ekstraksjonen stopper, og hva du gjør da

Vær ærlig med deg selv om grensene. Et bilde hvis siste filter ligger utenfor det støttede settet, returnerer nil, og det samme gjør et bilde hvis fargerom builden ikke kan tolke. Soft masks og alpha rekonstrueres ikke inn i bitmappen; du får basebildet, ikke et sammensatt resultat. Bitdybder over 8 fra JPEG 2000 samples ned, noe som er bevisst tapsfullt og feil valg hvis du re-arkiverer i stedet for å vise. Og en image mask, en én-bit stencil uten egen farge, beskrives av descriptoren, men er noe annet enn et fotografisk bilde; dekoder du den som om den var et fotografi, blir du overrasket

Når ekstraksjon ikke er nok, ligger den rå streamen fortsatt der i den innlastede objektgrafen, filter og alt, og du kan hente den ut byte for byte og gi den til en spesialisert kodek du selv velger. Det er reserveveien pass-through-designet bevarer med vilje: de opprinnelige bytene kastes aldri, så verstefall er at du dekoder dem selv i stedet for at dataene er borte. For de fleste reelle jobber dekker likevel de åtte støttede filtrene det skannere, kontorpakker og rapportmotorer faktisk sender ut, og en løkke over GetLoadedImageCount med en Decodable-guard gjør et innlastet PDF tilbake til en mappe med bitmaps i noen få linjer

API-et for ekstraksjon av innlastede bilder, sammen med hele settet av dekoderfiltre beskrevet her, følger med i HotPDF Component for Delphi og C++Builder