Articol tehnic

Extrage imagini dintr-un PDF încărcat în Delphi: HotPDF

Ai un PDF pe disc, un client l-a scanat dintr-un teanc de facturi, iar sarcina ta este să scoți imaginile paginilor înapoi ca bitmapuri pentru o trecere OCR. Încarci fișierul, găsești obiectele XObject de imagine, iar apoi descoperi partea despre care nimeni nu te avertizează: octeții din acele fluxuri nu sunt pixeli. Ei sunt un codestream JPEG, sau un blob JPEG 2000 comprimat cu wavelete, sau un flux fax Group 4, sau un raster indexat ascuns în spatele unei palete, ascuns în spatele unui filtru Flate. Obiectul de imagine își cunoaște lățimea și înălțimea, dar eșantioanele reale sunt sigilate în interiorul filtrului ales de producător. Obținerea unui TBitmap înseamnă să anulezi acel filtru, iar PDF îți oferă cam opt moduri diferite în care pot fi sigilați octeții

Aceasta este diferența pe care ExtractLoadedImage o acoperă HotPDF, componenta PDF VCL nativă pentru Delphi și C++Builder. Ea enumeră obiectele XObject de imagine dintr-un document pe care l-ai încărcat, raportează ce este fiecare și decodează cele pe care le poate înapoi într-un bitmap pe 24 de biți. Partea interesantă nu este suprafața API-ului, care are trei metode. Este motivul pentru care trebuie să existe o cale separată de decodare și ce poate și ce nu poate transforma înapoi în pixeli

De ce imaginile încărcate nu sunt deja decodificate

Încărcătorul HotPDF este construit în jurul fidelității pass-through. Când apelezi LoadFromFile, fluxurile de imagine sunt păstrate exact așa cum apar în fișierul sursă: filtrul original, octeții comprimați originali, dicționarul original. Asta este intenționat. Ideea de bază a încărcării unui document este de obicei să copiezi pagini, să îmbini fișiere, să le aplici ștampile, să le schimbi permisiunile și să le scrii înapoi, iar pentru toate acestea cel mai ieftin și mai sigur lucru este să lași fiecare flux de imagine neatins. Decodarea fiecărei imagini într-un raster la încărcare ar consuma memorie și CPU pentru o muncă de care majoritatea apelanților nu au nevoie niciodată, iar re-encodarea la salvare ar degrada imaginile care ar fi trebuit copiate verbatim

Consecința este că graful de obiecte încărcat nu conține niciun pixel. Un XObject de imagine al cărui /Filter este /DCTDecode conține octeți JPEG; HotPDF nu a rulat niciodată un decodor JPEG asupra lui, fiindcă nimic din calea de copiere și rescriere nu avea nevoie de asta. Așa că atunci când chiar vrei pixeli, API-ul de extragere trebuie să facă singur decodarea, de la zero, pentru orice filtru folosește imaginea respectivă. Acesta este același motiv pentru care codec-urile din partea de codare sunt independente de încărcător: articolul despre adăugarea imaginilor JPEG 2000 în PDF-uri în Delphi descrie cum motorul JPX se conectează la partea de creare, iar acel motor pur și simplu nu era legat la calea de citire până când API-ul de extragere a avut nevoie de el

API-ul în trei metode

Suprafața este mică. GetLoadedImageCount returnează câte XObject-uri de imagine conține documentul încărcat. GetLoadedImageInfo completează o înregistrare descriptor pentru unul dintre ele, după index. ExtractLoadedImage returnează bitmapul decodat, sau nil când nu poate decoda acea imagine. Enumerarea este bazată pe index și stabilă pentru o încărcare dată: intern, parcurge tabelul de obiecte indirecte și colectează fiecare flux al cărui /Subtype se rezolvă la /Image, așa că indexul pe care îl transmiți la GetLoadedImageInfo este același index pe care îl transmiți la 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;

Două detalii de contract contează aici. În primul rând, TBitmap returnat îți aparține și trebuie eliberat de tine; documentul nu îl memorează în cache și nu îl deține. În al doilea rând, verifică Decodable înainte să apelezi și compară rezultatul cu nil după. Metoda nu aruncă o excepție pentru un filtru nesuportat, ci returnează nil, iar un nil silențios într-o buclă de loturi este exact genul de lucru care înghite o pagină dintr-un job de o mie de pagini fără să observe nimeni

Citirea descriptorului înainte de decodare

THPDFLoadedImageInfo îți spune ce este o imagine fără să te angajezi la o decodare completă. Câmpurile ei vin direct din dicționarul imaginii: Width și Height în eșantioane, BitsPerComponent, ColorComponents și ColorSpace care descriu interpretarea de după decodare (1 pentru gri, 3 pentru RGB, 4 pentru CMYK), Filter ca compresia denumită, IsImageMask pentru măștile stencil, ObjectNumber pentru obiectul indirect de bază, iar Decodable

Acest ultim indicator este cel sincer. Decodable este True doar când buildul în execuție poate într-adevăr transforma această combinație specifică de filtru și spațiu de culoare într-un bitmap. El codifică matricea reală de suport, nu o dorință: o imagine al cărei Filter pe care buildul curent nu o înțelege raportează Decodable = False, iar pe baza ei poți decide să înregistrezi, să sari peste sau să revii la extragerea manuală a fluxului brut. Trateaz-o ca pe o precondiție, nu ca pe un indiciu

// 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 detaliu de implementare îi încurcă pe cei care construiesc manual înregistrări descriptor. THPDFLoadedImageInfo conține două AnsiString câmpuri, Filter și ColorSpace. Acestea sunt tipuri gestionate cu numărare de referințe, așa că reflexul de a zeroiza o înregistrare cu FillChar(Info, SizeOf(Info), 0) este greșit aici: suprascrie referința șirului fără să o decrementeze, ceea ce provoacă scurgeri sau corupere. HotPDF inițializează înregistrarea câmp cu câmp tocmai din acest motiv, iar dacă vei copia vreodată acest model în propriul cod, fă la fel

Un singur dispatcher, opt căi de filtru

Motivul pentru care această funcționalitate a necesitat o serie de versiuni și nu doar una este că PDF nu are un format de imagine. Are filtre, iar §8.9.5 din ISO 32000-1 permite unui XObject de imagine să numească oricare dintre ele în /Filter, iar interpretarea eșantioanelor fiind guvernată separat de /ColorSpace, /BitsPerComponent și un /Decode array opțional. ExtractLoadedImage citește numele filtrului și direcționează către un decodor dedicat pentru fiecare caz. Setul suportat, construit de la v2.229 până la v2.231, acoperă acum opt căi distincte

  • Rastere brute (FlateDecode, LZWDecode sau fără filtru) în DeviceRGB sau DeviceGray pe 8 biți. Octeții se expandă într-un raster compact, iar singura transformare este inversarea canalelor, explicată mai jos
  • DCTDecode (JPEG). Fluxul de cod este predat TJPEGImage, care rezolvă geometria și culoarea, iar rezultatul este atribuit unui bitmap pe 24 de biți
  • JPXDecode (JPEG 2000). Decodat prin backendul OpenJPEG, același motor descris în articolul despre JPEG 2000, cu componente de adâncime mare a biților eșantionate din nou la 8 biți
  • Culoare indexată. Paleta este citită din [/Indexed base hival lookup] array, iar fiecare eșantion este extins prin tabela de căutare la culoare reală
  • DeviceCMYK. Eșantioanele pe patru canale sunt convertite în RGB cu formula standard cerneală-pe-alb
  • DeviceGray și Indexed sub 8 biți la 1, 2 sau 4 biți per componentă, despachetate eșantion cu eșantion și scalate la intervalul 0–255
  • CCITTFaxDecode, filtrele fax Group 3 și Group 4, decodate de un backend dedicat T.4/T.6
  • JBIG2Decode, filtrul bilevel cu rată mare de compresie, decodat prin backendul JBIG2 înregistrat pe care îl articol nativ despre compresia JBIG2 acoperă partea de codificare

Totul ajunge în același loc: un bitmap BGR pe 24 de biți, pentru că asta este ceea ce un VCL TBitmap stochează nativ și ceea ce așteaptă orice consumator din aval

Transformările care schimbă în tăcere pixelii

Două dintre aceste căi implică o transformare pe care e ușor să o faci subtil greșit și merită înțeleasă chiar dacă nu te atingi niciodată de decodor. Prima este schimbul ordinii culorilor. Un raster PDF DeviceRGB stochează eșantioanele în ordinea roșu-verde-albastru, cu rândul de sus primul. Un scanline VCL pe 24 de biți le stochează în ordinea albastru-verde-roșu. Așa că decodarea unei imagini RGB obișnuite nu este un memcpy; primul și al treilea octet ai fiecărui pixel sunt schimbați când ajung în scanline. Dacă inversezi asta, roșul și albastrul se interschimbă, ceea ce arată bine pe o imagine de test în tonuri de gri și dezastruos pe una color. Ordinea rândurilor, cât valorează, se păstrează direct: rasterele PDF de sus în jos se aliniază cu VCL ScanLine[0] ca rândul vizual de sus, deci nu este nevoie de o inversare verticală

A doua este CMYK. Imaginile PDF DeviceCMYK poartă patru cerneluri, iar conversia la RGB este un calcul pe canal, nu o căutare: fiecare canal de ieșire este (255 - ink) * (255 - K) / 255. Aceasta este o aproximație de dispozitiv, nu o conversie gestionată color printr-un profil ICC, așa că rezultatul este suficient de corect pentru afișare și rerasterizare, dar nu este calea potrivită dacă ai nevoie de culoare exactă pentru tipar. Dacă fluxul tău cere fidelitate, tratează bitmapul extras ca pe o previzualizare și păstrează fluxul CMYK original pentru conducta gestionată color

Calea Indexed ascunde propria capcană de parsare. Paleta dintr-un /Indexed spațiu de culoare poate fi stocată ca un șir literal sau ca un șir hexadecimal, iar HotPDF stochează valoarea unui șir hex ca textul hexadecimal, nu octeții decodați. Așadar, atunci când paleta este un șir hex, tabela de căutare trebuie mai întâi trecută printr-o decodare hex-to-bytes; un șir literal este deja octeți bruti. Ratează această ramură și o imagine indexed cu patru culori iese coruptă, pentru că fiecare intrare din paletă este citită de pe granița greșită a octetului

Lanțuri de filtre: ultimul filtru este al imaginii însăși

Un singur /Filternume este cazul simplu. PDF permite și un lanț de filtre, în care fluxul a fost trecut prin mai multe în secvență, enumerate în ordine într-un /Filter tablou precum [/ASCII85Decode /FlateDecode] sau [/ASCIIHexDecode /DCTDecode] (ISO 32000-1 §7.4). Semantica este precisă: filtrele se aplică de la stânga la dreapta la codificare, astfel că la decodare le desfaci de la dreapta la stânga, iar ultimul filtru din tablou este cel care definește de fapt formatul imaginii. Filtrele de la început sunt doar codificări de transport înfășurate în jurul lui

Extractorul gestionează asta prin dezlipire. Înainte să ruleze orice decodor de imagine, fiecare filtru din lanț, cu excepția ultimului, este aplicat pentru a produce intrarea pe care o așteaptă filtrul final, și abia apoi are loc trimiterea către acel ultim filtru. Așadar, [/ASCII85Decode /DCTDecode] mai întâi scoate ASCII85 din flux, apoi direcționează rezultatul către calea JPEG; [/FlateDecode] înfășurat în jurul unui raster brut îl dezumflă și apoi rulează calea raster. Asta face ca cele opt decodoare să rămână simple. Niciunul nu trebuie să știe despre învelitoarele de transport ASCII85 sau hex, pentru că până când un decodor vede octeții, învelitoarele au dispărut deja. Înseamnă și că un lanț al cărui filtru final nu este suportat eșuează tot curat la pasul de trimitere, nu la jumătate

Unde se oprește extragerea și ce faci atunci

Fii sincer cu tine însuți în privința limitelor. O imagine al cărei filtru final este în afara setului suportat returnează nil, iar la fel și una al cărei spațiu de culoare nu poate fi interpretat de build. Măștile soft și alfa nu sunt reconstruite în bitmap; primești imaginea de bază, nu un rezultat compozit. Adâncimile de biți peste 8 din JPEG 2000 sunt eșantionate din nou în jos, ceea ce este intenționat cu pierderi și alegerea greșită dacă re-arhivezi în loc să afișezi. Iar o mască de imagine, un șablon pe un bit fără culoare proprie, este descrisă de descriptor, dar este altceva decât o imagine picturală; dacă o decodifici așteptându-te la o fotografie, vei fi surprins

Când extragerea nu este suficientă, fluxul brut este tot acolo în graful de obiecte încărcat, cu tot cu filtru, și îl poți scoate octet cu octet și îl poți da unui codec specializat de-al tău. Acesta este planul de rezervă pe care designul passthrough îl păstrează în mod intenționat: octeții originali nu sunt niciodată aruncați, așa că în cel mai rău caz îi decodifici tu, nu dispar datele. Pentru majoritatea sarcinilor reale, însă, cele opt filtre suportate acoperă ceea ce emit de fapt scanerele, suitele Office și motoarele de rapoarte, iar o buclă peste GetLoadedImageCount cu o Decodablecondiție de protecție transformă un PDF încărcat înapoi într-un folder de bitmapuri în câteva linii

API-ul de extragere a imaginilor încărcate, împreună cu setul complet de filtre de decodare descrise aici, este livrat în HotPDF Component pentru Delphi și C++Builder