У вас є файл PDF на диску, який клієнт відсканував із пачки рахунків-фактур, і ваше завдання — вилучити зображення сторінок у вигляді бітових карт для подальшого розпізнавання тексту (OCR). Ви завантажуєте файл, знаходите об'єкти зображень (XObject) і стикаєтеся з тим, про що ніхто не попереджає: байти в цих потоках не є пікселями. Вони являють собою потік даних JPEG, стиснений вейвлет-кодеком JPEG 2000, кодовану послідовність факсу Group 4 або індексований растр під палітрою у фільтрі Flate. Об'єкт зображення містить інформацію про ширину та висоту, але самі пікселі приховані всередині фільтра, обраного програмою генерації. Отримання придатного для роботи TBitmap вимагає зворотного розкодування цього фільтра, а PDF пропонує близько восьми різних способів упакування цих байтів
Саме цей розрив заповнює метод ExtractLoadedImage у HotPDF — нативному VCL-компоненті PDF для Delphi та C++Builder. Він перелічує об'єкти зображень XObject у завантаженому документі, повідомляє тип кожного з них і декодує ті, що підтримуються, назад у 24-бітні бітові карти. Найцікавішим є не сам інтерфейс API, який складається всього з трьох методів, а те, чому взагалі має існувати окремий шлях декодування та які дані він може або не може перетворити назад на пікселі
Чому завантажені зображення ще не декодовані
Завантажувач HotPDF спроектований за принципом збереження вихідних даних. Кови викликаєте метод LoadFromFile, потоки зображень залишаються в тому ж вигляді, в якому вони записані у вихідному файлі: оригінальний фільтр, оригінальні стиснені байти та оригінальний словник. Це зроблено свідомо. Зазвичай мета завантаження документа полягає в копіюванні сторінок, об'єднанні файлів, додаванні штампів, зміні прав доступу та повторному збереженні, і для всіх цих операцій найшвидше та найбезпечніше — залишити потік кожного зображення без змін. Декодування кожного зображення у растр при завантаженні витрачало б пам'ять і процесорний час на роботу, яка більшості користувачів не потрібна, а повторне стиснення при збереженні погіршувало б якість зображень, які мали бути скопійовані один в один
Як наслідок, завантажене дерево об'єктів не містить пікселів. Об'єкт зображення XObject із фільтром /Filter типу /DCTDecode містить байти JPEG; HotPDF ніколи не запускав для нього декодер JPEG, оскільки під час копіювання чи перезапису файлу в цьому не було потреби. Тому, коли вам дійсно потрібні пікселі, API вилучення має виконати декодування самостійно, з нуля, відповідно до фільтра цього конкретного зображення. З цієї ж причини кодеки стиснення на стороні запису не залежать від завантажувача: стаття про додавання зображень JPEG 2000 до PDF у Delphi описує, як двигун JPX підключається при створенні файлу, і цей двигун просто не був задіяний у процесі читання, поки не знадобився інтерфейс API вилучення
API з трьох методів
Інтерфейс є досить компактним. Метод GetLoadedImageCount повертає кількість об'єктів зображень XObject у завантаженому документі. Метод GetLoadedImageInfo заповнює дескриптор для одного з них за його індексом. Метод ExtractLoadedImage повертає розкодовану бітову карту або значення 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 повідомляє про параметри зображення без виконання повного декодування. Її поля завантажуються безпосередньо зі словника зображення: Width та Height у пікселях, BitsPerComponent, ColorComponents та ColorSpace, які описують інтерпретацію після розкодування (1 для градацій сірого, 3 для RGB, 4 для CMYK), Filter як назва стиснення, IsImageMask для трафаретних масок, ObjectNumber для непрямого об'єкта та властивість Decodable
Цей останній прапорець є ключовим. Властивість Decodable набуває значення True тільки тоді, коли поточна версія бібліотеки може реально перетворити цю конкретну комбінацію фільтра та колірного простору на бітову карту. Він відображає реальну матрицю підтримки: зображення, фільтр Filter якого не підтримується поточною збіркою, повертає Decodable = False, і ви можете використовувати це для логування, пропуску або переходу на самостійне вилучення сирого потоку. Сприймайте це як обов'язкову умову, а не просто як рекомендацію
// Перевірка кожного зображення перед декодуванням.
var
Pdf: THotPDF;
Info: THPDFLoadedImageInfo;
I: Integer;
begin
// ... Pdf завантажено ...
for I := 0 to Pdf.GetLoadedImageCount - 1 do
begin
if not Pdf.GetLoadedImageInfo(I, Info) then
Continue;
if Info.Decodable then
// ExtractLoadedImage(I) поверне TBitmap
else
// непідтримуваний фільтр/колірний простір: записуємо об'єкт у лог та пропускаємо
Writeln(Format('Зображення %d об %d: %dx%d %s/%s не підтримується для декодування',
[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 немає єдиного формату зображень. Він використовує фільтри, і параграф §8.9.5 стандарту ISO 32000-1 дозволяє об'єкту зображення XObject вказувати будь-кий із них у полі /Filter, причому інтерпретація пікселів визначається окремо через /ColorSpace, /BitsPerComponent та додатковий масив /Decode. Метод ExtractLoadedImage зчитує назву фільтра та спрямовує обробку до відповідного декодера. Набір підтримуваних фільтрів, сформований у версіях з v2.229 по v2.231, наразі охоплює вісім окремих шляхів
- Сирі растри (FlateDecode, LZWDecode або без фільтра) у 8-бітному колірному просторі DeviceRGB або DeviceGray. Байти розпаковуються у щільний растр, і єдиним перетворенням є перестановка каналів, описана нижче
- DCTDecode (JPEG). Потік даних передається компоненту
TJPEGImageбібліотеки VCL, який визначає розміри та кольори, а результат записується в 24-бітну бітову карту - JPXDecode (JPEG 2000). Розкодовується через бібліотеку OpenJPEG (той самий двигун, що описаний у статті про JPEG 2000), причому компоненти з високою глибиною кольору приводяться до 8 біт
- Індексовані кольори (Indexed). Палітра зчитується з масиву
[/Indexed base hival lookup], а кожне значення кольору розгортається через таблицю пошуку в повноколірне представлення - DeviceCMYK. Чотириканальні значення перетворюються на RGB за стандартною формулою змішування фарб на білому тлі
- Глибина менше 8 біт для DeviceGray та Indexed (1, 2 або 4 біти на компонент). Пікселі розпаковуються один за одним і масштабуються до діапазону 0–255
- CCITTFaxDecode. Фільтри факсу Group 3 та Group 4, які розкодовуються за допомогою окремого модуля обробки форматів T.4/T.6
- JBIG2Decode. Двоколірний фільтр із високим рівнем стиснення, який декодується через інтегрований модуль JBIG2, описаний на стороні запису в статті про нативне стиснення JBIG2
Усе зводиться до одного результату: 24-бітної бітової карти BGR, оскільки саме такий формат використовується в TBitmap бібліотеки VCL за замовчуванням і очікується більшістю компонентів
Перетворення, які непомітно змінюють пікселі
Два з цих шляхів включають перетворення, у яких легко припуститися помилки, тому корисно зрозуміти їхню логіку, навіть якщо ви не працюєте з декодером напряму. Перший — це перестановка порядку кольорів. Растр PDF DeviceRGB зберігає пікселі в порядку червоний-зелений-синій, починаючи з верхнього рядка. 24-бітний рядок сканування (scanline) бібліотеки VCL зберігає їх у порядку синій-зелений-червоний. Тому декодування простого RGB-зображення — це не просте копіювання пам'яті (memcpy); перший і третій байти кожного пікселя мають мінятися місцями при записі в scanline. Якщо помилитися тут, червоний і синій кольори поміняються місцями, що виглядатиме цілком нормально на сірому тестовому зображенні, але катастрофічно неправильно на кольоровому. Порядок рядків при цьому збігається: орієнтовані зверху вниз растри PDF відповідають рядку ScanLine[0] бібліотеки VCL як верхньому візуальному рядку, тому вертикальне відображення не потрібне
Другий — це простір CMYK. Зображення PDF DeviceCMYK містять чотири канали фарб, і перетворення на RGB є обчисленням для кожного каналу, а не пошуком за таблицею: кожен вихідний канал розраховується за формулою (255 - ink) * (255 - K) / 255. Це є апаратно-залежним наближенням, а не повноцінним перетворенням профілів кольорів через ICC, тому отриманий результат підходить для перегляду або рерастеризації, але не підходить для точного друку кольорів. Якщо ваші бізнес-процеси вимагають високої точності передачі кольору, сприймайте отриману бітову карту як попередній перегляд, а оригінальний потік CMYK передавайте до спеціалізованих систем керування кольором
Індексований шлях (Indexed) також приховує свою пастку парсингу. Палітра в колірному просторі /Indexed може бути записана у вигляді текстового літералу або шістнадцяткового рядка, причому HotPDF зберігає значення шістнадцяткового рядка як шістнадцятковий текст, а не розкодовані байти. Тому, коли палітра представлена шістнадцятковим рядком, таблицю пошуку спочатку потрібно перекодувати з hex у байти; звичайний рядок літералу вже є набором сирих байтів. Якщо пропустити це розгалуження, чотириколірне індексоване зображення перетвориться на сміття, оскільки кожен елемент палітри зчитуватиметься з неправильним зміщенням байтів
Ланцюжки фільтрів: останній фільтр належить самому зображенню
Окреме ім'я /Filter — це найпростіший випадок. Проте PDF також дозволяє використовувати ланцюжки фільтрів, коли потік послідовно проходить через кілька перетворень, які перелічені в масиві /Filter, наприклад [/ASCII85Decode /FlateDecode] або [/ASCIIHexDecode /DCTDecode] (ISO 32000-1 §7.4). Логіка тут є строго визначеною: фільтри застосовуються зліва направо при кодуванні, тому при розкодуванні їх потрібно скасовувати справа наліво, і саме останній фільтр у масиві визначає кінцевий формат зображення. Попередні фільтри є просто транспортними обгортками для передачі байтів
Модуль вилучення обробляє такі випадки послідовно. Перед запуском декодера зображення застосовуються всі фільтри в ланцюжку, крім останнього, для отримання вхідних даних, які очікує кінцевий фільтр, і тільки після цього керування передається останньому фільтру. Наприклад, ланцюжок [/ASCII85Decode /DCTDecode] спочатку розкодовує дані з ASCII85, а потім передає результат у шлях обробки JPEG; ланцюжок [/FlateDecode] навколо сирого растра виконує розпакування і запускає растровий шлях. Це дозволяє зробити кожен з восьми декодерів максимально простим. Жодному з них не потрібно знати про кодування ASCII85 або шістнадцяткові транспортні обгортки, оскільки на момент передачі даних декодеру ці обгортки вже зняті. Крім того, це гарантує, що ланцюжок із непідтримуваним кінцевим фільтром просто чисто завершиться помилкою на етапі розподілу, а не зламається десь посередині процесу
Де видобування зупиняється і що робити далі
Варто чітко розуміти межі можливостей цієї технології. Зображення, кінцевий фільтр якого не входить до списку підтримуваних, повертає значення nil; те саме стосується і випадків, коли поточна збірка не може інтерпретувати колірний простір. М'які маски (soft masks) та альфа-канали не додаються до бітової карти — ви отримуєте лише базове зображення без ефектів прозорості. Глибина кольору більше 8 біт у форматі JPEG 2000 зменшується при вилученні, що призводить до втрати якості та є неправильним вибором для архівації даних, хоча цілком підходить для відображення. А маска зображення (image mask) — однобітний трафарет без власного кольору — описується дескриптором, але принципово відрізняється від звичайного зображення: спроба декодувати його як фотографію дасть несподіваний результат
Коли можливостей вбудованого вилучення недостатньо, ви завжди можете звернутися до сирого потоку даних у завантаженому дереві об'єктів (разом з його фільтрами), отримати його байт за байтом і передати власному спеціалізованому кодеку. Це резервний шлях, який зберігається завдяки архітектурі прямого пропуску даних: оригінальні байти ніколи не видаляються, тому у найгіршому випадку ви просто декодуєте їх самостійно, а не втратите дані назавжди. Проте для більшості реальних завдань вісім підтримуваних фільтрів повністю покривають те, що створюють сканери, офісні пакети та генератори звітів, а цикл по GetLoadedImageCount із перевіркою Decodable дозволяє перетворити завантажений PDF на набір бітових карт всього кількома рядками коду
API вилучення зображень разом із повним набором описаних тут фільтрів декодування поставляються в комплекті компонента HotPDF Component для Delphi та C++Builder