Tehnički članak

Izdvajanje slika iz učitanog PDF-a u Delphiju: HotPDF

Imate PDF na disku, klijent je skenirao gomilu faktura, a vaš posao je da izvučete slike stranica nazad kao bitmape za OCR obradu. Učitate datoteku, pronađete image XObjecte, a zatim naletite na deo na koji vas niko ne upozori: bajtovi u tim strimovima nisu pikseli. To je JPEG codestream, ili JPEG 2000 blob kompresovan waveletom, ili Group 4 fax zapis, ili indeksirani raster iza palete iza Flate filtera. Image objekat zna svoju širinu i visinu, ali stvarni uzorci su zaključani unutar filtera koji je proizvođač izabrao. Doći do upotrebljive TBitmap znači poništiti taj filter, a PDF vam nudi otprilike osam različitih načina da ti bajtovi budu zapečaćeni

Ovo je praznina koju ExtractLoadedImage popunjava u HotPDF-u, nativnoj VCL PDF komponenti za Delphi i C++Builder. Ona nabraja image XObjecte u dokumentu koji ste učitali, prijavljuje šta je svaki od njih i dekodira one koje može nazad u 24-bitnu bitmapu. Zanimljiv deo nije API površina, koja ima tri metode. To je zašto uopšte mora da postoji poseban put dekodiranja i šta on može, a šta ne može da vrati u piksele

Zašto učitane slike nisu već dekodirane

HotPDF-ov loader je zasnovan na prolaznom očuvanju podataka. Kada pozovete LoadFromFile, strimovi slika ostaju tačno onakvi kakvi su u izvornoj datoteci: originalni filter, originalni kompresovani bajtovi, originalni rečnik. To je namerno. Suština učitavanja dokumenta je obično da kopirate stranice, spajate datoteke, pečatirate ih, menjate im dozvole i upisujete ih nazad, a za sve to najjeftinija i najbezbednija stvar je da svaki image stream ostane netaknut. Dekodiranje svake slike u raster pri učitavanju potrošilo bi memoriju i CPU na posao koji većini pozivaoca nikada ne treba, a ponovno kodiranje pri snimanju degradiralo bi slike koje su morale da budu kopirane doslovno

Posledica je da učitani graf objekata ne nosi piksele. Image XObject čiji je /Filter je /DCTDecode sadrži JPEG bajtove; HotPDF nikada nije pokrenuo JPEG dekoder nad njim, jer ništa u putanji kopiranja i ponovnog pisanja to nije tražilo. Zato, kada zaista želite piksele, API za izdvajanje mora sam da uradi dekodiranje, od nule, za onaj filter koji taj konkretni image koristi. Iz istog razloga su kodeci na strani kodiranja nezavisni od loadera: članak o dodavanju JPEG 2000 slika u PDF-ove u Delphiju opisuje kako se JPX engine priključuje na stranu kreiranja, a taj engine jednostavno nije bio povezan sa putanjom čitanja sve dok API za izdvajanje to nije zatražio

Tri-metodski API

Površina je mala. GetLoadedImageCount vraća koliko image XObjecta učitani dokument sadrži. GetLoadedImageInfo popunjava recordski opis jedne od njih po indeksu. ExtractLoadedImage vraća dekodiranu bitmapu, ili nil kada ne može da dekodira tu sliku. Nabrajanje je zasnovano na indeksu i stabilno za jedno učitavanje: interno prolazi kroz tabelu indirektnih objekata i skuplja svaki strim čiji /Subtype se razrešava u /Image, pa je indeks koji prosledite u GetLoadedImageInfo isti indeks koji prosledite u 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;

Dva ugovorna detalja ovde su važna. Prvo, vraćeni TBitmap je vaš da ga oslobodite; dokument ga ne kešira niti poseduje. Drugo, proverite Decodable pre poziva, i proverite rezultat naspram nil posle. Metoda ne baca izuzetak na nepodržanom filteru, već vraća nil, a tiho nil u petlji nad serijom je tačno onakva stvar koja proguta stranicu posla sa hiljadu stranica, a da to niko ne primeti

Čitanje opisa pre dekodiranja

THPDFLoadedImageInfo vam govori šta je slika bez obavezivanja na potpuno dekodiranje. Njena polja dolaze direktno iz rečnika slike: Width i Height u uzorcima, BitsPerComponent, ColorComponents i ColorSpace opisuju interpretaciju posle dekodiranja (1 za sivu, 3 za RGB, 4 za CMYK), Filter kao imenovana kompresija, IsImageMask za stencil maske, ObjectNumber za osnovni indirektni objekat, i Decodable

Taj poslednji indikator je iskren. Decodable je True samo kada pokrenuta verzija zaista može da pretvori baš tu kombinaciju filtera i prostora boje u bitmapu. To kodira stvarnu matricu podrške, a ne želju: slika čiji Filter filter trenutna verzija ne razume prijavljuje Decodable = False, i na osnovu toga možete da granate logovanje, preskakanje ili povratak na ručno izdvajanje sirovog strima. Tretirajte to kao preduslov, ne kao nagoveštaj

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

Jedan detalj implementacije sapliće ljude koji ručno grade recordske opise. THPDFLoadedImageInfo sadrži dva AnsiString string polja, Filter i ColorSpace. To su upravljani tipovi sa brojanjem referenci, pa je refleks da record poništite pomoću FillChar(Info, SizeOf(Info), 0) pogrešan ovde: time prepisujete referencu stringa bez njenog umanjenja, što curi ili kvari podatke. HotPDF upravo zato inicijalizuje record polje po polje, i ako ikad kopirate ovaj obrazac u svoj kod, uradite isto

Jedan dispatcher, osam putanja filtera

Razlog što je ovoj mogućnosti trebalo više izdanja, a ne jedno, jeste to što PDF nema format slike. On ima filtere, a §8.9.5 ISO 32000-1 dopušta image XObjectu da u /Filter navede bilo koji od njih, dok je interpretacija uzoraka zasebno određena pomoću /ColorSpace, /BitsPerComponent i opcionalnog /Decode niza. ExtractLoadedImage čita naziv filtera i usmerava ga na namenski dekoder za svaki slučaj. Podržani skup, građen kroz v2.229 do v2.231, sada pokriva osam različitih putanja

  • Sirovi rasteri (FlateDecode, LZWDecode ili bez filtera) u 8-bitnom DeviceRGB ili DeviceGray. Bajtovi se raspakuju u upakovani raster, a jedina transformacija je zamena kanala, opisana niže
  • DCTDecode (JPEG). Codestream se predaje VCL-ovom TJPEGImage, koji rešava geometriju i boju, a rezultat se upisuje u 24-bitnu bitmapu
  • JPXDecode (JPEG 2000). Dekodira se kroz OpenJPEG backend, isti engine opisan u članku o JPEG 2000, uz resamplovanje komponenti višeg bit-dubinskog opsega na 8 bita
  • Indeksirana boja. Paleta se čita iz niza [/Indexed base hival lookup] i svaki uzorak se proširuje kroz tabelu za pretragu u pravu boju
  • DeviceCMYK. Uzorci sa četiri kanala pretvaraju se u RGB standardnom formulom mastila-na-belom
  • DeviceGray i Indexed ispod 8 bita pri 1, 2 ili 4 bita po komponenti, raspakovani uzorak po uzorak i skalirani u opseg 0–255
  • CCITTFaxDecode, fax filteri Group 3 i Group 4, dekodirani preko namenski registrovanog T.4/T.6 backenda
  • JBIG2Decode, filter sa visokom kompresijom za dvobojne slike, dekodiran kroz registrovani JBIG2 backend koji članak o nativnoj JBIG2 kompresiji pokriva sa strane kodiranja

Sve završava na istom mestu: 24-bitna BGR bitmapa, jer je to ono što VCL TBitmap čuva nativno i što svaki nizvodni korisnik očekuje

Transformacije koje tiho menjaju piksele

Dve od ovih putanja uključuju transformaciju koju je lako neprimetno pogrešno uraditi, i vredi je razumeti čak i ako sami nikada ne dirate dekoder. Prva je zamena redosleda boja. PDF DeviceRGB raster čuva uzorke redosledom crveno-zeleno-plavo, sa gornjim redom prvi. VCL 24-bitni scanline ih čuva redosledom plavo-zeleno-crveno. Dakle, dekodiranje obične RGB slike nije memcpy; prvi i treći bajt svakog piksela se menjaju pri upisu u scanline. Ako to okrenete naopako, crvene i plave se zamene, što na sivom testu izgleda dobro a na kolor slici katastrofalno pogrešno. Redosled redova, koliko vredi, prolazi pravo kroz to: PDF-ovi top-down rasteri poravnavaju se sa VCL-ovim ScanLine[0] kao gornji vizuelni red, pa nema potrebe za vertikalnim obrtanjem

Druga je CMYK. PDF DeviceCMYK slike nose četiri mastila, a pretvaranje u RGB je proračun po kanalu, ne lookup: svaki izlazni kanal je (255 - ink) * (255 - K) / 255. Ovo je aproksimacija uređaja, ne konverzija sa upravljanjem bojama kroz ICC profil, pa je rezultat dovoljno dobar za prikaz i ponovno rasterizovanje, ali nije prava staza ako vam treba boja tačna za štampu. Ako vaš tok rada zahteva vernost, tretirajte izdvojenu bitmapu kao pregled i zadržite originalni CMYK strim za pipeline sa upravljanjem bojama

Putanja Indexed krije i sopstvenu zamku pri parsiranju. Paleta u prostoru boje /Indexed može da se čuva kao literalni string ili kao heksadecimalni string, a HotPDF vrednost heks stringa čuva kao heks tekst, a ne kao dekodirane bajtove. Zato, kada je paleta heks string, lookup tabelu prvo treba provući kroz dekodiranje heksa u bajtove; literalni string je već sirovi bajtni sadržaj. Propuštanje te grane dovodi do toga da indeksirana slika od četiri boje izađe kao šrot, jer se svaki unos palete čita sa pogrešne granice bajta

Lanci filtera: poslednji filter je sopstveni identitet slike

Pojedinačni /Filter naziv je lak slučaj. PDF dozvoljava i lanac filtera, gde je strim prošao kroz više koraka redom, navedenih u nizu /Filter kao što su [/ASCII85Decode /FlateDecode] ili [/ASCIIHexDecode /DCTDecode] (ISO 32000-1 §7.4). Semantika je precizna: filteri se pri kodiranju primenjuju sleva nadesno, pa ih pri dekodiranju poništavate zdesna nalevo, a poslednji filter u nizu je onaj koji zapravo definiše format slike. Početni filteri su samo transportna enkapsulacija oko njega

Ekstraktor to rešava skidanjem slojeva. Pre nego što krene bilo koji dekoder slike, svaki filter u lancu osim poslednjeg primenjuje se da bi se dobio ulaz koji poslednji filter očekuje, i tek tada se vrši usmeravanje na taj poslednji filter. Tako [/ASCII85Decode /DCTDecode] prvo uklanja ASCII85 sa strima, a zatim rezultat usmerava na JPEG putanju; [/FlateDecode] omotan oko sirovog rastera prvo se raspakuje, a zatim ide na raster putanju. To je ono što dozvoljava da osam dekodera ostane jednostavno. Nijedan ne mora da zna za ASCII85 ili heks transport omotače, jer u trenutku kada dekoder vidi bajtove, omotači su već nestali. Takođe znači da lanac čiji je poslednji filter nepodržan i dalje pada čisto na koraku usmeravanja, a ne na pola puta

Gde izdvajanje staje i šta tada uraditi

Budite iskreni prema sebi oko granica. Slika čiji je poslednji filter van podržanog skupa vraća nil, a isto tako i ona čiji prostor boje ova verzija ne može da protumači. Meke maske i alfa ne rekonstruišu se u bitmapu; dobijate osnovnu sliku, ne kompozitni rezultat. Bit-dubine iznad 8 iz JPEG 2000 se resampluju naniže, što je namerno gubitnički postupak i pogrešan potez ako ponovo arhivirate umesto da prikazujete. A image maska, jedinični stencil bez sopstvene boje, opisana je opisom ali je druga stvar od slikovnog image objekta; dekodirajte je očekujući fotografiju i bićete iznenađeni

Kada izdvajanje nije dovoljno, sirovi strim je i dalje tu u učitanom grafu objekata, sa svim filterima, i možete ga izvući bajt po bajt i predati sopstvenom specijalizovanom kodeku. To je fallback koji prolazni dizajn namerno čuva: originalni bajtovi se nikad ne bacaju, pa je najgori slučaj da ih sami dekodirate umesto da su podaci nestali. Za većinu stvarnih poslova, međutim, podržanih osam filtera pokriva ono što skeneri, kancelarijski paketi i engine-i za izveštaje zaista emituju, a petlja preko GetLoadedImageCount sa Decodable gardom vraća učitani PDF nazad u fasciklu bitmapa u nekoliko redova

API za izdvajanje učitanih slika, zajedno sa kompletnim skupom ovde opisanih filtera za dekodiranje, dolazi u HotPDF Component za Delphi i C++Builder