Technisch artikel

Afbeeldingen uit een geladen PDF extraheren in Delphi

Je hebt een PDF op schijf, een klant heeft hem gescand van een stapel facturen, en jouw taak is om de pagina-afbeeldingen als bitmaps terug te halen voor een OCR-pass. Je opent het bestand, je vindt de image XObjects, en dan kom je bij het deel waar niemand je voor waarschuwt: de bytes in die streams zijn geen pixels. Het zijn een JPEG-codestream, een met wavelet gecomprimeerde JPEG 2000-blob, een Group 4-faxrun of een geïndexeerde raster achter een palette achter een Flate-filter. Het image object kent zijn breedte en hoogte, maar de echte samples zitten opgesloten in welk filter de producer ook heeft gekozen. Om een bruikbare TBitmap te krijgen moet je dat filter terugdraaien, en PDF geeft je daarvoor ongeveer acht verschillende manieren

Dat is de kloof die ExtractLoadedImage in HotPDF vult, de native VCL PDF-component voor Delphi en C++Builder. De methode loopt de image XObjects in een document dat je hebt geladen langs, meldt wat elk object is en decodeert de ondersteunde varianten terug naar een 24-bit bitmap. Het interessante deel is niet de API zelf, die uit drie methoden bestaat. Het is waarom er überhaupt een apart decodepad moet bestaan, en wat het wel en niet kan omzetten naar pixels

Waarom geladen afbeeldingen nog niet gedecodeerd zijn

De loader van HotPDF is gebouwd rond pass-through getrouwheid. Wanneer je LoadFromFile aanroept, blijven de image streams exact zoals ze in het bronbestand staan: het oorspronkelijke filter, de oorspronkelijke gecomprimeerde bytes, de oorspronkelijke dictionary. Dat is opzettelijk. Het hele punt van een document laden is meestal pagina's kopiëren, bestanden samenvoegen, stempels toevoegen, permissies aanpassen en het resultaat weer wegschrijven, en voor al die taken is het goedkoopste en veiligste om elke image stream ongemoeid te laten. Elke afbeelding bij het laden naar een raster decoderen zou geheugen en CPU verspillen aan werk dat de meeste aanroepen nooit nodig hebben, en opnieuw coderen bij het opslaan zou afbeeldingen aantasten die gewoon byte voor byte gekopieerd hadden moeten worden

Het gevolg is dat de geladen objectgraph geen pixels bevat. Een image XObject waarvan /Filter /DCTDecode is, bevat JPEG-bytes; HotPDF heeft daar nooit een JPEG-decoder op losgelaten, omdat niets in het kopieer-en-herschrijfpad dat nodig had. Dus wanneer je echt pixels wilt, moet de extractie-API zelf decoderen, vanaf nul, voor welk filter dat specifieke beeld ook gebruikt. Dat is dezelfde reden waarom de encode-codecs losstaan van de loader: het artikel over JPEG 2000-afbeeldingen toevoegen aan PDF's in Delphi laat zien hoe de JPX-engine aan de creatiekant wordt aangesloten, en die engine was simpelweg niet gekoppeld aan het leespad totdat de extractie-API hem nodig had

De driemethoden-API

De surface is klein. GetLoadedImageCount geeft terug hoeveel image XObjects het geladen document bevat. GetLoadedImageInfo vult voor een van die objecten een descriptorrecord op basis van de index. ExtractLoadedImage geeft de gedecodeerde bitmap terug, of nil wanneer dat beeld niet gedecodeerd kan worden. Enumeratie is index-gebaseerd en stabiel voor een gegeven load: intern loopt de methode door de indirect-objecttabel en verzamelt elk stream waarvan /Subtype naar /Image resolveert, dus de index die je aan GetLoadedImageInfo doorgeeft is dezelfde index die je aan ExtractLoadedImage doorgeeft

Twee contractdetails zijn hier belangrijk. Ten eerste is de teruggegeven TBitmap van jou om vrij te geven; het document cachet hem niet en bezit hem niet. Ten tweede, controleer Decodable voordat je aanroept, en controleer de uitkomst op nil daarna. De methode gooit geen exception bij een niet-ondersteund filter, hij geeft nil terug, en een stille nil in een batchlus is precies het soort fout waardoor een pagina uit een job van duizend pagina's verdwijnt zonder dat iemand het merkt

De descriptor lezen voordat je decodeert

THPDFLoadedImageInfo vertelt je wat een afbeelding is zonder meteen een volledige decode af te dwingen. De velden komen rechtstreeks uit de image dictionary: Width en Height in samples, BitsPerComponent, ColorComponents en ColorSpace die de interpretatie na decodering beschrijven, 1 voor grijs, 3 voor RGB, 4 voor CMYK, Filter als de genoemde compressie, IsImageMask voor stencil masks, ObjectNumber voor het onderliggende indirect object, en Decodable

Die laatste vlag is de eerlijke. Decodable is alleen True wanneer de lopende build deze specifieke combinatie van filter en kleurruimte echt naar een bitmap kan omzetten. Hij encodeert de echte supportmatrix, geen wensdenken: een image waarvan de huidige build het Filter niet begrijpt rapporteert Decodable = False, en daarop kun je brancheren om te loggen, over te slaan of terug te vallen op het zelf uitlezen van de ruwe stream. Behandel het als een voorwaarde, niet als een hint

Eén implementatiedetail bijt mensen die descriptorrecords met de hand opbouwen. THPDFLoadedImageInfo bevat twee AnsiString-velden, Filter en ColorSpace. Dit zijn managed types met referentietelling, dus de reflex om een record met FillChar(Info, SizeOf(Info), 0) te wissen is hier fout: daarmee overschrijf je de stringreferentie zonder hem af te tellen, wat lekt of corrupt raakt. HotPDF initialiseert het record veld voor veld om precies die reden, en als je dit patroon ooit in je eigen code kopieert, doe dan hetzelfde

Eén dispatcher, acht filterpaden

Dat deze functie in meerdere releases is uitgerold in plaats van in één, komt doordat PDF geen imageformaat heeft. Het heeft filters, en §8.9.5 van ISO 32000-1 laat een image XObject elk van die filters noemen in /Filter, terwijl de interpretatie van de samples apart wordt geregeld door /ColorSpace, /BitsPerComponent en een optionele /Decode-array. ExtractLoadedImage leest de filternaam en stuurt door naar een speciale decoder per geval. De ondersteunde set, opgebouwd in v2.229 tot en met v2.231, omvat nu acht verschillende paden

  • Ruwe rasters (FlateDecode, LZWDecode of geen filter) in 8-bit DeviceRGB of DeviceGray. De bytes worden uitgepakt tot een packed raster, en de enige transformatie is een kanaalwissel, hieronder beschreven
  • DCTDecode (JPEG). De codestream gaat naar VCL's TJPEGImage, die geometrie en kleur oplost, en het resultaat wordt in een 24-bit bitmap gezet
  • JPXDecode (JPEG 2000). Gedecodeerd via de OpenJPEG-back-end, dezelfde engine die in het JPEG 2000-artikel wordt beschreven, met componenten met een hoge bitdiepte teruggeschaald naar 8 bits
  • Geïndexeerde kleur. De palette wordt gelezen uit de [/Indexed base hival lookup]-array en elk sample wordt via de lookup table uitgebreid naar true color
  • DeviceCMYK. Vierkanaals samples worden met de standaard inkt-op-wit-formule naar RGB omgezet
  • DeviceGray en Indexed onder 8 bit, op 1, 2 of 4 bits per component, sample voor sample uitgepakt en geschaald naar het bereik 0-255
  • CCITTFaxDecode, de Group 3- en Group 4-faxfilters, gedecodeerd door een speciale T.4/T.6-back-end
  • JBIG2Decode, de bilevelfilter met hoge compressie, gedecodeerd via de geregistreerde JBIG2-back-end die het native JBIG2-compressieartikel vanuit de encode-kant behandelt

Alles landt op dezelfde plek: een 24-bit BGR bitmap, omdat dat is wat een VCL TBitmap standaard opslaat en wat elke downstream consumer verwacht

De transformaties die stilletjes pixels veranderen

Twee van deze paden bevatten een transformatie die subtiel fout kan gaan, en die het waard is om te begrijpen, zelfs als je de decoder zelf nooit aanraakt. De eerste is de verwisseling van kleurovolgorde. Een PDF DeviceRGB-raster bewaart samples in rood-groen-blauw-volgorde, met de bovenste rij eerst. Een VCL 24-bit scanline bewaart ze in blauw-groen-rood-volgorde. Dus een gewone RGB-afbeelding decoderen is geen memcpy; de eerste en derde byte van elk pixel worden tijdens het vullen van de scanline omgewisseld. Doe je dat verkeerd, dan wisselen rood en blauw van plaats, wat op een grijswaardentestbeeld prima lijkt en op een kleurbeeld rampzalig fout is. De rijvolgorde loopt voor zover relevant gewoon door: de top-down rasters van PDF sluiten aan op VCL's ScanLine[0] als de bovenste zichtbare rij, dus er is geen verticale flip nodig

De tweede is CMYK. PDF DeviceCMYK-afbeeldingen dragen vier inkten, en de omzetting naar RGB is een berekening per kanaal, geen lookup: elk uitvoerkanaal is (255 - ink) * (255 - K) / 255. Dit is een apparaatbenadering, geen kleurgecontroleerde omzetting via een ICC-profiel, dus het resultaat is goed genoeg voor weergave en opnieuw rasteriseren, maar niet de juiste route als je printnauwkeurige kleur nodig hebt. Als je workflow trouw moet zijn, behandel de geëxtraheerde bitmap dan als een preview en bewaar de originele CMYK-stream voor de kleurgecontroleerde pipeline

Het Indexed-pad verbergt zijn eigen parsevalkuil. De palette in een /Indexed-kleurruimte kan worden opgeslagen als een letterlijke string of als een hexadecimale string, en HotPDF bewaart de waarde van een hex-string als de hex tekst, niet als de gedecodeerde bytes. Dus wanneer de palette een hex-string is, moet de lookup table eerst door een hex-naar-bytes-decoder. Een letterlijke string zijn al ruwe bytes. Sla je die tak over, dan komt een vierkleurige geïndexeerde afbeelding als rommel terug, omdat elke palette-entry vanaf het verkeerde bytegrens wordt gelezen

Filterketens: het laatste filter is de afbeelding zelf

Een enkele /Filter-naam is het eenvoudige geval. PDF staat ook een keten van filters toe, waarbij de stream meerdere keren achter elkaar is verwerkt en in volgorde wordt opgesomd in een /Filter-array zoals [/ASCII85Decode /FlateDecode] of [/ASCIIHexDecode /DCTDecode] (ISO 32000-1 §7.4). De semantiek is precies: de filters worden bij coderen van links naar rechts toegepast, dus bij decoderen draai je ze van rechts naar links terug, en het laatste filter in de array is degene die het imageformaat werkelijk bepaalt. De leidende filters zijn alleen transportcoderingen eromheen

De extractor handelt dit af door lagen weg te pellen. Voor er ook maar één image-decoder draait, wordt elk filter in de keten behalve het laatste toegepast om de input te maken die het finale filter verwacht, en pas daarna gebeurt dispatch op dat laatste filter. Dus [/ASCII85Decode /DCTDecode] haalt eerst de ASCII85-laag van de stream af en stuurt het resultaat dan naar het JPEG-pad; [/FlateDecode] rond een ruwe raster inflates en draait daarna het rasterpad. Daardoor kunnen de acht decoders eenvoudig blijven. Geen van hen hoeft iets te weten over ASCII85- of hex-transportwrappers, omdat tegen de tijd dat een decoder de bytes ziet, die wrappers al weg zijn. Het betekent ook dat een keten met een niet-ondersteund laatste filter nog steeds netjes faalt bij de dispatchstap in plaats van halverwege

Waar extractie stopt, en wat je dan doet

Wees eerlijk over de grenzen. Een afbeelding waarvan het laatste filter buiten de ondersteunde set valt geeft nil terug, en hetzelfde geldt voor een beeld waarvan de kleurruimte door de build niet geïnterpreteerd kan worden. Soft masks en alpha worden niet in de bitmap gereconstrueerd; je krijgt de basisafbeelding, niet een samengestelde uitkomst. Bitdieptes boven 8 uit JPEG 2000 worden teruggeschaald, wat bewust verliesgevend is en de verkeerde keuze als je opnieuw archiveert in plaats van alleen weergeeft. En een image mask, een stencil van één bit zonder eigen kleur, wordt door de descriptor beschreven maar is iets anders dan een picturale afbeelding; als je het als foto probeert te decoderen, kom je bedrogen uit

Wanneer extractie niet genoeg is, staat de ruwe stream nog steeds gewoon in de geladen objectgraph, filter en al, en kun je hem byte voor byte uitlezen en aan een eigen gespecialiseerde codec geven. Dat is de fallback die het pass-through-ontwerp bewust bewaart: de originele bytes worden nooit weggegooid, dus in het slechtste geval decodeer je ze zelf in plaats van dat de data weg is. Voor de meeste echte taken dekken de ondersteunde acht filters echter wat scanners, office suites en rapportengines daadwerkelijk produceren, en een lus over GetLoadedImageCount met een Decodable-guard zet een geladen PDF in een paar regels terug in een map met bitmaps

De API voor extractie van geladen afbeeldingen, samen met de volledige set decodefilters die hier is beschreven, wordt meegeleverd met de HotPDF Component voor Delphi en C++Builder

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