Teknisk artikel

Extrahera bilder från en inläst PDF i Delphi: HotPDF

Du har en PDF på disken, en kund har skannat den från en hög fakturor, och din uppgift är att plocka ut sidbilderna igen som bitmaps för en OCR-körning. Du läser in filen, hittar image XObjects, och sedan stöter du på den del ingen varnar dig för: bytena i de där strömmarna är inte pixlar. Det är en JPEG codestream, eller en vågkomprimerad JPEG 2000-klump, eller en Group 4-faxström, eller ett indexerat raster bakom en palett bakom ett Flate-filter. Bildobjektet känner till sin bredd och höjd, men de faktiska bildproverna är förseglade inuti det filter producenten valde. Att få fram en användbar TBitmap innebär att filtret måste bakas tillbaka, och PDF ger dig ungefär åtta olika sätt att försegla bytena på

Detta är glappet som ExtractLoadedImagetäpper igen i HotPDF, den inbyggda VCL-komponenten för PDF i Delphi och C++Builder. Den går igenom image XObjects i ett dokument du har laddat, rapporterar vad varje bild är och avkodar de som går tillbaka till en 24-bitars bitmap. Det intressanta är inte API:t, som bara består av tre metoder. Det är varför en separat avkodningsväg alls måste finnas, och vad den kan och inte kan göra om till pixlar

Varför inlästa bilder inte redan är avkodade

HotPDF:s laddare bygger på genomsläppsfidelity. När du anropar LoadFromFile, behålls bildströmmarna exakt som de ser ut i källfilen: det ursprungliga filtret, de ursprungliga komprimerade bytena, den ursprungliga ordboken. Det är avsiktligt. Hela poängen med att läsa in ett dokument är oftast att kopiera sidor, slå ihop filer, stämpla dem, ändra behörigheter och skriva ut dem igen, och för allt detta är det billigaste och säkraste att lämna varje bildström orörd. Att avkoda varje bild till ett raster vid inläsning skulle slösa minne och CPU på arbete som de flesta anropare aldrig behöver, och att koda om vid sparning skulle försämra bilder som borde ha kopierats ordagrant

Konsekvensen är att den inlästa objektgrafen inte innehåller några pixlar. Ett image XObject vars /Filter är /DCTDecode innehåller JPEG-bytes; HotPDF körde aldrig en JPEG-avkodare mot det, eftersom inget i kopiera-och-skriv-om-vägen behövde det. Så när du faktiskt vill ha pixlar måste extraherings-API:t göra avkodningen själv, från grunden, för det filter just den bilden råkar använda. Det är samma skäl till att kodningssidans codecs är oberoende av laddaren: artikeln om att lägga till JPEG 2000-bilder i PDF:er i Delphi beskriver hur JPX-motorn kopplas in på skapandesidan, och den motorn var helt enkelt inte inkopplad i läsvägen förrän extraherings-API:t behövde den

API:t med tre metoder

Ytan är liten. GetLoadedImageCountreturnerar hur många image XObjects det inlästa dokumentet innehåller. GetLoadedImageInfofyller en beskrivningspost för en av dem via index. ExtractLoadedImagereturnerar den avkodade bitmapen, eller nil när den inte kan avkoda den bilden. Uppräkningen är indexbaserad och stabil för en viss inläsning: internt går den igenom den indirekta objekttabellen och samlar varje ström vars /Subtype löses upp till /Image, så indexet du skickar till GetLoadedImageInfo är samma index som du skickar till 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;

Två kontraktsdetaljer spelar roll här. För det första är den returnerade TBitmap din att frigöra; dokumentet cachelagrar eller äger den inte. För det andra: kontrollera Decodable innan du anropar, och kontrollera resultatet mot nil efteråt. Metoden kastar inte vid ett filter som inte stöds, den returnerar nil, och en tyst nil i en batchloop är precis den sortens sak som sväljer en sida i ett jobb med tusen sidor utan att någon märker det

Att läsa beskrivningen innan du avkodar

THPDFLoadedImageInfo berättar vad en bild är utan att binda sig till en fullständig avkodning. Dess fält kommer direkt från bildordboken: Width och Height i samples, BitsPerComponent, ColorComponents och ColorSpace som beskriver tolkningen efter avkodning (1 för grå, 3 för RGB, 4 för CMYK), Filter som namngiven komprimering, IsImageMask för stencilmasker, ObjectNumber för det underliggande indirekta objektet, och Decodable

Den sista flaggan är den ärliga. Decodable är True bara när den körande builden faktiskt kan omvandla just den här kombinationen av filter och färgrymd till en bitmap. Den kodar den verkliga stödmatrisen, inte en önskelista: en bild vars Filter som den aktuella byggen inte förstår rapporterar Decodable = False, och du kan grena på det för att logga, hoppa över eller falla tillbaka på att extrahera råströmmen själv. Se det som ett krav före körning, inte som en 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 implementationsdetalj biter folk som bygger beskrivningsposter för hand. THPDFLoadedImageInfo innehåller två AnsiString-fält, Filter och ColorSpace. Dessa är hanterade typer med referensräkning, så reflexen att nollställa en post med FillChar(Info, SizeOf(Info), 0) är fel här: det skriver över strängreferensen utan att minska räknaren, vilket läcker eller korrumperar. HotPDF initierar posten fält för fält av just den orsaken, och om du någonsin kopierar det här mönstret i din egen kod, gör likadant

En avsändare, åtta filtervägar

Anledningen till att den här funktionen kom i en serie versioner i stället för på en gång är att PDF inte har ett bildformat. Den har filter, och §8.9.5 i ISO 32000-1 låter ett image XObject namnge vilket som helst av dem i /Filter, med sampletolkningen styrd separat av /ColorSpace, /BitsPerComponent och en valfri /Decode array. ExtractLoadedImage läser filternamnet och styr till en särskild avkodare för varje fall. Den stödda uppsättningen, uppbyggd över v2.229 till v2.231, täcker nu åtta olika vägar

  • Råa rastrar (FlateDecode, LZWDecode eller inget filter) i 8-bitars DeviceRGB eller DeviceGray. Bytena packas upp till ett packat raster, och den enda transformen är ett kanalbyte, som beskrivs nedan
  • DCTDecode (JPEG). Codestreamen lämnas över till VCL:s TJPEGImage, som löser geometri och färg, och resultatet tilldelas en 24-bitars bitmap
  • JPXDecode (JPEG 2000). Avkodas via OpenJPEG-backenden, samma motor som beskrivs i artikeln om JPEG 2000, med komponenter med hög bitdjup nedsamplade till 8 bitar
  • Indexerad färg. Paletten läses från [/Indexed base hival lookup] arrayen och varje sample expanderas via uppslagstabellen till sann färg
  • DeviceCMYK. Fyra kanalprover omvandlas till RGB med standardformeln för bläck mot vitt
  • DeviceGray och Indexed under 8 bitar med 1, 2 eller 4 bitar per komponent, uppackade sample för sample och skalade till intervallet 0–255
  • CCITTFaxDecode, Group 3- och Group 4-faxfiltren, avkodade av en särskild T.4/T.6-backend
  • JBIG2Decode, filtret för hög komprimeringsgrad för binära bilder, avkodat via den registrerade JBIG2-backenden som artikeln om inbyggd JBIG2-komprimering täcker på kodningssidan

Allt landar på samma plats: en 24-bitars BGR-bitmap, eftersom det är vad en VCL TBitmap lagrar nativt och vad varje nedströms konsument förväntar sig

De transformeringar som i tysthet ändrar pixlar

Två av de här vägarna innebär en transformering som är lätt att få subtilt fel, och värd att förstå även om du aldrig själv rör avkodaren. Den första är färgordningsbytet. Ett PDF DeviceRGB-raster lagrar prover i röd-grön-blå ordning, översta raden först. En VCL 24-bitars scanline lagrar dem i blå-grön-röd ordning. Så avkodning av en vanlig RGB-bild är inte en memcpy; varje pixels första och tredje byte byts plats på vägen in i scanlinen. Får du det baklänges byter rött och blått plats, vilket ser bra ut på en gråskalebild och katastrofalt fel på en färgbild. Radordningen, för vad den är värd, går rakt igenom: PDF:s top-down-rastrar ligger i linje med VCL:s ScanLine[0] som den översta visuella raden, så ingen vertikal flip behövs

Den andra är CMYK. PDF DeviceCMYK-bilder bär fyra färgkanaler, och omvandlingen till RGB är en beräkning per kanal, inte en uppslagning: varje utgående kanal är (255 - ink) * (255 - K) / 255. Det här är en enhetsapproximation, inte en färghanterad konvertering via en ICC-profil, så resultatet är tillräckligt korrekt för visning och omrastrering men är inte rätt väg om du behöver tryckkorrekt färg. Om ditt arbetsflöde kräver trohet, behandla den extraherade bitmapen som en förhandsvisning och behåll den ursprungliga CMYK-strömmen för den färghanterade pipelinen

Indexed-vägen döljer en egen tolkningsfälla. Paletten i ett /Indexed färgrymd kan lagras som en litteralsträng eller som en hexadecimalt sträng, och HotPDF lagrar värdet för en hexsträng som hex-text, inte de avkodade bytena. Så när paletten är en hexsträng måste uppslagstabellen först köras genom en hex-till-bytes-avkodning; en literalsträng är redan råa bytes. Missar du den grenen blir en indexerad bild med fyra färger skräp, eftersom varje palettpost läses från fel bytegräns

Filterkedjorna: det sista filtret är bildens eget

En enskild /Filter namngiven filter är det enkla fallet. PDF tillåter också en kedja av filter, där strömmen har passerat flera i följd, listade i ordning i en /Filter array som [/ASCII85Decode /FlateDecode] eller [/ASCIIHexDecode /DCTDecode] (ISO 32000-1 §7.4). Semantiken är exakt: filtren tillämpas från vänster till höger vid kodning, så vid avkodning backar du dem från höger till vänster, och sista filtret i arrayen är det som faktiskt definierar bildformatet. De inledande filtren är bara transportkodningar runt det

Extraheraren hanterar detta genom att skala av lager. Innan någon bildavkodare körs tillämpas varje filter i kedjan utom det sista för att producera den inmatning som det sista filtret förväntar sig, och först därefter sker fördelningen på det sista filtret. Så [/ASCII85Decode /DCTDecode] av-ASCII85:ar först strömmen, och skickar sedan resultatet till JPEG-vägen; [/FlateDecode] runt ett rått raster packas upp och kör sedan rastervägen. Det här är det som gör att de åtta avkodarna kan förbli enkla. Ingen av dem behöver veta något om ASCII85- eller hex-transportomslag, eftersom när en avkodare ser bytena har omslagen redan försvunnit. Det betyder också att en kedja vars sista filter inte stöds ändå faller rent vid dispatch-steget i stället för halvvägs igenom

Var extraheringen slutar, och vad du ska göra då

Var ärlig mot dig själv om gränserna. En bild vars sista filter ligger utanför den stödda uppsättningen returnerar nil, och det gör också en vars färgrymd bygget inte kan tolka. Soft masks och alpha återuppbyggs inte till bitmapen; du får basbilden, inte ett sammansatt resultat. Bitdjup över 8 från JPEG 2000 nedsamplas till 8, vilket är medvetet förlustbringande och fel steg om du arkiverar om snarare än visar. Och en bildmask, en ettbits stencil utan egen färg, beskrivs av beskrivningen men är något annat än en bild; avkodar du den som om den vore ett foto blir du överraskad

När extrahering inte räcker finns råströmmen fortfarande kvar i den inlästa objektgrafen, filter och allt, och du kan plocka ut den byte för byte och ge den till en specialiserad codec du själv väljer. Det är den reservväg som pass-through-designen bevarar med avsikt: de ursprungliga bytena kastas aldrig bort, så värsta fallet är att du avkodar dem själv i stället för att datan är borta. För de flesta verkliga jobb täcker dock de åtta stödda filtren det som skannrar, kontorssviter och rapportmotorer faktiskt skickar ut, och en loop över GetLoadedImageCount med en Decodable skyddsmekanism gör en inläst PDF till en mapp med bitmaps på några rader

API:t för extrahering av inlästa bilder, tillsammans med hela uppsättningen avkodningsfilter som beskrivs här, levereras i HotPDF Component för Delphi och C++Builder