Техническа статия

Извличане на изображения от зареден PDF в Delphi: HotPDF

Имате PDF на диска, клиент е сканирал купчина фактури и вашата задача е да извадите изображенията на страниците обратно като bitmap-и за OCR обработка. Зареждате файла, намирате image XObject-ите и после стигате до частта, за която никой не ви предупреждава: байтовете в тези потоци не са пиксели. Те са JPEG codestream, или JPEG 2000 blob, компресиран с вълново преобразуване, или Group 4 fax поток, или индексиран растр зад палитра зад Flate филтър. Обектът на изображението знае ширината и височината си, но действителните семпли са затворени вътре в който и да е филтър, избран от производителя. Да получите използваем TBitmapbitmap означава да отмените този филтър, а PDF ви дава приблизително осем различни начина байтовете да бъдат заключени

Това е празнината, която ExtractLoadedImage запълва в HotPDF, нативния VCL PDF компонент за Delphi и C++Builder. Той изброява image XObject-ите в документ, който вече сте заредили, показва какъв е всеки от тях и декодира тези, които може, обратно в 24-битов bitmap. Интересното не е API повърхността, която е три метода. Интересното е защо изобщо трябва да има отделен decode път и какво може и не може да превърне обратно в пиксели

Защо заредените изображения още не са декодирани

Лоудърът на HotPDF е изграден около вярност при пропускане без промяна. Когато извикате LoadFromFile, image stream-овете се запазват точно както изглеждат в изходния файл: оригиналният филтър, оригиналните компресирани байтове, оригиналният речник. Това е умишлено. Цялата идея при зареждане на документ обикновено е да копирате страници, да обединявате файлове, да ги щамповате, да им променяте правата и да ги записвате отново, а за всичко това най-евтиното и безопасно нещо е да оставите всеки image stream непокътнат. Декодирането на всяко изображение в растер при зареждане би изгорило памет и CPU за работа, от която повечето извиквания никога няма да имат нужда, а повторното кодиране при запис би влошило изображения, които е трябвало да бъдат копирани дословно

Последицата е, че зареденият обектен граф не носи пиксели. Image XObject, чиито /Filter е /DCTDecode съдържа JPEG байтове; HotPDF никога не е пускал JPEG декодер върху него, защото нищо в пътя за копиране и пренаписване не го е изисквало. Така че когато наистина искате пиксели, API-то за извличане трябва да ги декодира само, от нулата, за всеки филтър, който конкретното изображение използва. Същата причина прави кодеците на encode страната независими от лоудъра: статията за добавяне на JPEG 2000 изображения към PDF-и в Delphi описва как JPX engine се включва в страната на създаване, а този engine просто не беше свързан към пътя за четене, докато API-то за извличане не го поиска

API с три метода

Повърхността е малка. GetLoadedImageCount връща колко image XObject-и съдържа зареденият документ. GetLoadedImageInfo попълва запис-описател за един от тях по индекс. ExtractLoadedImage връща декодирания bitmap, или nil когато не може да декодира това изображение. Изброяването е по индекс и стабилно за дадено зареждане: вътрешно то обхожда таблицата на индиректните обекти и събира всеки поток, чието /Subtype се разрешава до /Image, така че индексът, който подавате на GetLoadedImageInfo е същият индекс, който подавате на 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;

Два договорни детайла са важни тук. Първо, върнатият TBitmap е ваш за освобождаване; документът не го кешира и не го притежава. Второ, проверете Decodable преди да извикате метода и проверете резултата срещу nil след това. Методът не хвърля изключение при неподдържан филтър, а връща nil, а тихо nil в пакетен цикъл е точно онзи вид нещо, което поглъща страница от хилядастранична задача, без никой да забележи

Четене на дескриптора преди декодиране

THPDFLoadedImageInfo ви казва какво е изображението, без да ви обвързва с пълно декодиране. Полетата му идват директно от image dictionary-а: Width и Height в samples, BitsPerComponent, ColorComponents и ColorSpace описващи следдекодираното тълкуване (1 за сиво, 3 за RGB, 4 за CMYK), Filter като именуваната компресия, IsImageMask за stencil маски, ObjectNumber за подлежащия indirect object и Decodable

Този последен флаг е честният. Decodable е True само когато текущото build-нато издание наистина може да превърне тази конкретна комбинация от филтър и цветово пространство в bitmap. То кодира реалната матрица на поддръжка, а не пожелание: изображение, чиито Filter текущото build-нато издание не разбира, връща Decodable = False, и можете да разклоните кода по това, за да логнете, пропуснете или да се върнете към извличане на суровия поток сами. Приемайте го като предпоставка, не като подсказка

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

Един детайл в имплементацията хваща хора, които сглобяват запис-описатели на ръка. THPDFLoadedImageInfo съдържа две AnsiString полета, Filter и ColorSpace. Те са управлявани типове с броене на препратки, така че рефлексът да нулирате запис с FillChar(Info, SizeOf(Info), 0) е грешен тук: той презаписва препратката към низа, без да я декрементира, което води до теч или корупция. HotPDF инициализира записа поле по поле точно по тази причина и ако някога копирате този модел в собствения си код, направете същото

Един диспечер, осем филтърни пътя

Причината тази функция да пристигна през серия издания, а не наведнъж, е че PDF няма image format. Той има филтри и §8.9.5 на ISO 32000-1 позволява image XObject да назовава който и да е от тях в /Filter, като интерпретацията на семплите се управлява отделно от /ColorSpace, /BitsPerComponent и незадължителен /Decode масив. ExtractLoadedImage чете името на филтъра и насочва към специализиран декодер за всеки случай. Поддържаният набор, изграден през v2.229 до v2.231, вече покрива осем различни пътя

  • Сурови растери (FlateDecode, LZWDecode или без филтър) в 8-битов DeviceRGB или DeviceGray. Байтовете се разпухват до packed raster, а единствената трансформация е размяна на канали, описана по-долу
  • DCTDecode (JPEG). Кодираният поток се подава на VCL TJPEGImage, който решава геометрията и цветовете, а резултатът се записва в 24-битов bitmap
  • JPXDecode (JPEG 2000). Декодира се чрез OpenJPEG backend-а, същия двигател, описан в статията за JPEG 2000, като компонентите с висока битова дълбочина се ресемплират надолу до 8 бита
  • Индексиран цвят. Палитрата се чете от [/Indexed base hival lookup] масива и всеки sample се разширява чрез lookup table към истински цвят
  • DeviceCMYK. Четириканалните samples се преобразуват в RGB със стандартната формула мастило-върху-бяло
  • DeviceGray и Indexed под 8 бита при 1, 2 или 4 бита на компонент, разопакован sample по sample и мащабиран към диапазона 0-255
  • CCITTFaxDecode, Group 3 и Group 4 fax филтрите, декодирани от специализиран T.4/T.6 backend
  • JBIG2Decode, висококомпресионният bilevel филтър, декодиран през регистрирания JBIG2 backend, който статията за нативната JBIG2 компресия покрива от encode страната

Всичко каца на едно и също място: 24-битов BGR bitmap, защото това е това, което VCL TBitmap съхранява нативно и което всеки следващ потребител очаква

Трансформациите, които тихо сменят пикселите

Два от тези пътища включват трансформация, която е лесно да объркате по фин начин и си заслужава да я разберете, дори никога да не пипате декодера. Първата е смяната на реда на цветовете. PDF DeviceRGB растрът съхранява семплите в red-green-blue ред, ред по ред отгоре надолу. VCL 24-битов scanline ги съхранява в blue-green-red ред. Така че декодирането на обикновено RGB изображение не е memcpy; първият и третият байт на всеки пиксел се разменят на пътя към scanline-а. Ако го объркате, червеното и синьото си разменят местата, което изглежда добре върху сива тестова картинка и катастрофално зле върху цветна. Редът по ред, което си струва да се каже, минава направо: топ-даун raster-и на PDF се подравняват с VCL ScanLine[0] като горния визуален ред, така че не е нужен вертикален flip

Втората е CMYK. PDF DeviceCMYK изображенията носят четири мастила и преобразуването към RGB е изчисление по канал, а не lookup: всеки изходен канал е (255 - ink) * (255 - K) / 255. Това е device approximation, не color-managed conversion през ICC profile, така че резултатът е достатъчно точен за визуализация и повторно растеризиране, но не е правилният път, ако ви трябва цвят с печатна точност. Ако работният ви поток изисква вярност, третирайте извлечения bitmap като preview и пазете оригиналния CMYK поток за color-managed pipeline-а

Indexed пътят крие свой парсинг капан. Палитрата в /Indexed color space може да бъде записана като literal string или като hexadecimal string, а HotPDF съхранява стойността на hex string-а като hex text, а не като декодирани байтове. Така че когато палитрата е hex string, lookup table-ът трябва първо да мине през hex-to-bytes декодиране; literal string вече е сурови байтове. Пропуснете този клон и четирицветно indexed изображение излиза като боклук, защото всеки palette entry се чете от грешна байтова граница

Филтърни вериги: последният филтър е този на изображението

Един /Filter филтър е лесният случай. PDF позволява и верига от филтри, при която потокът е минал последователно през няколко, изброени в ред в /Filter масив като [/ASCII85Decode /FlateDecode] или [/ASCIIHexDecode /DCTDecode] (ISO 32000-1 §7.4). Семантиката е точна: филтрите се прилагат отляво надясно при encode, така че при decode ги отменяте отдясно наляво, а последният филтър в масива е този, който действително определя формата на изображението. Първите филтри са просто транспортни кодировки, увити около него

Extractor-ът се справя с това като обелва слоевете. Преди да се стартира който и да е image decoder, всеки филтър във веригата освен последния се прилага, за да произведе входа, който последният филтър очаква, и чак тогава диспечирането става по този последен филтър. Така [/ASCII85Decode /DCTDecode] първо премахва ASCII85 от потока, после насочва резултата към JPEG пътя; [/FlateDecode] увит около суров растр се разпакова и после минава по raster пътя. Това е, което позволява на осемте декодера да останат прости. Никой от тях не трябва да знае за ASCII85 или hex транспортни обвивки, защото към момента, в който декодер види байтовете, обвивките вече ги няма. Това също означава, че верига, чийто последен филтър е неподдържан, се проваля чисто на стъпката на dispatch-а, а не по средата

Къде спира извличането и какво да правите тогава

Бъдете честни със себе си за границите. Изображение, чийто последен филтър е извън поддържания набор, връща nil, и също такова връща и изображение, чийто color space build-ът не може да интерпретира. Soft mask-ите и alpha не се реконструират в bitmap-а; получавате базовото изображение, не композирания резултат. Битови дълбочини над 8 от JPEG 2000 се ресемплират надолу, което е умишлено загубно и е грешният ход, ако преработвате за архивиране, а не за показване. И image mask, еднобитов stencil без собствен цвят, е описана от дескриптора, но е различно нещо от pictorial image; ако я декодирате очаквайки снимка, ще се изненадате

Когато извличането не е достатъчно, суровият поток все още е точно там в заредения обектен граф, с филтър и всичко останало, и можете да го извадите байт по байт и да го подадете на ваш собствен специализиран кодек. Това е fallback-ът, който pass-through design-ът пази умишлено: оригиналните байтове никога не се изхвърлят, така че най-лошият случай е да ги декодирате сами, а не данните да са изчезнали. За повечето реални задачи обаче поддържаните осем филтъра покриват това, което скенери, офис пакети и report engine-и наистина изкарват, а цикъл над GetLoadedImageCount с Decodable guard превръща зареден PDF обратно в папка bitmap-и за няколко реда

API-то за извличане на заредени изображения, заедно с пълния набор от decode филтри, описани тук, се доставя в HotPDF Component за Delphi и C++Builder