Technical Article

Валідація стиснених PDF: потоки об'єктів та перехресних посилань (XRef)

Уявіть, що ви написали невеликий валідатор. Він відкриває PDF, переходить у кінець файлу, знаходить startxref, зчитує зміщення та очікує знайти ключове слово xref із таблицею перехресних посилань фіксованої ширини під ним. З цієї таблиці він збирає зміщення об'єктів, а потім шукає у зворотному напрямку ключове слово trailer для отримання значень /Root та /Size. Це чудово працює на всіх створених вами тестових файлах. Проте коли надходить файл, створений сучасною версією Word або бібліотекою, що орієнтується на стандарт PDF 1.5, валідатор заявляє, що файл пошкоджений. Там немає ключового слова xref за вказаним зміщенням, немає словника trailer в жодному місці, а створена валідатором таблиця об'єктів виявляється порожньою. Сам файл при цьому є коректним. Просто валідатор аналізує його крізь призму 15-річної давнини.

Це найпоширеніша причина, чому перевірка PDF на рівні байтів, написана для класичної структури документа, ламається на сучасних файлах. Структури, від яких вона залежить (текстова таблиця перехресних посилань та ключове слово trailer), стали необов'язковими, починаючи з версії PDF 1.5, і часто взагалі відсутні. Їм на зміну прийшли дві нові технології: потік перехресних посилань (cross-reference stream) та стиснений потік об'єктів (compressed object stream). Обидві описані в ISO 32000-1, і валідатор, який не вміє з ними працювати, сприйматиме цілком коректний файл як документ із купою відсутніх об'єктів.

Що змінилося в PDF 1.5 щодо кінця файлу

Параграф §7.5.8 стандарту ISO 32000-1 визначає потік перехресних посилань, а параграф §7.5.7 визначає потік об'єктів типу /ObjStm. Разом вони дозволяють розробникам відмовитися від двох структур, на які орієнтується класичний парсер. Файл PDF 1.5 може взагалі не містити текстової таблиці xref. Замість неї об'єкт, на який вказує startxref, є звичайним об'єктом потоку, словник якого містить запис /Type /XRef, і цей потік зберігає дані перехресних посилань у компактній бінарній формі. Ключового слова trailer також немає, оскільки роль трейлера тепер виконує власний словник цього потоку. Ключі, які розшукує класичний парсер (/Root, /Size та /ID), тепер містяться всередині цього словника.

Друга зміна стосується розташування самих об'єктів. Замість запису кожного непрямого об'єкта за власним зміщенням, модуль запису може запакувати багато невеликих об'єктів (словники сторінок, словники анотацій, структурне дерево) в один потік об'єктів та стиснути весь контейнер за допомогою алгоритму Flate. Окремі об'єкти більше не мають власного зміщення у файлі. Вони розташовані всередині стисненого масиву даних. Валідатор, який шукає вихідні байти 1 0 obj, нічого не знайде, оскільки цей текст з'являється лише після розпакування. Для класичного парсера половина документа просто зникає.

Ключі трейлера є звичайним текстом навіть у стиснутому файлі

Доброю новиною є те, що читання трейлера в потоці перехресних посилань не вимагає декомпресії даних. Об'єкт потоку записується у вигляді словника, за яким слідує ключове слово stream, а потім стиснені байти. Сам словник записаний відкритим текстом. Тому коли startxref вказує на потік перехресних посилань, байти відразу після номера об'єкта виглядають як звичайний текстовий словник, і ключі /Root, /Size та /ID знаходяться там у відкритому вигляді ще до початку ключового слова stream та стиснених даних Flate.

Це означає, що валідатор може отримати три найважливіші факти (місце розташування каталогу, задекларовану кількість об'єктів та ідентифікатор файлу) шляхом парсингу лише словника потоку. Йому не потрібно розпаковувати дані перехресних посилань або інтерпретувати їхні бінарні записи. Завдання, з яким не справляється простий парсер — це не читання трейлера, а саме пошук об'єктів. Це дві окремі проблеми, і вирішення першої є обчислювально простим.

Об'єктні потоки: заголовок, потім Flate blob

Потік об'єктів — це контейнер. Його словник містить запис /Type /ObjStm, запис /N, який вказує кількість упакованих об'єктів, та запис /First, який вказує байтове зміщення всередині розпакованих даних, де починається тіло першого об'єкта. Стиснений вміст після розпакування починається з невеликого заголовка з /N пар цілих чисел. Кожна пара є номером об'єкта та зміщенням тіла цього об'єкта відносно точки /First. За заголовком слідують самі тіла об'єктів, записані послідовно.

Розпакування такого потоку є стандартною процедурою після декомпресії байтів. Ви читаєте словник для отримання значень /N та /First, розпаковуєте потік за допомогою декодера Flate, проходите по перших /N парах чисел для визначення зміщення кожного об'єкта і потім витягуєте кожне тіло об'єкта так, ніби це був звичайний непрямий об'єкт. Єдиною системною залежністю є декодер Flate, який уже є у вашому розпорядженні: Delphi постачається з модулем System.ZLib, а Free Pascal — з модулем zstream; обидва обгортають бібліотеку zlib і розпаковують вихідний потік Flate без використання стороннього коду. Процедура, яка додає кожен витягнутий об'єкт до таблиці об'єктів валідатора, дозволяє решті його логіки (яка аналізує /Root та дерево сторінок) працювати абсолютно так само, як і з класичним файлом.

Що вам не потрібно реалізовувати

Обсяг робіт легко переоцінити. Зчитування ключів трейлера зі стисненого файлу не вимагає декодування бінарних записів потоку перехресних посилань. Потік перехресних посилань §7.5.8 використовує три типи записів, і запис типу 2 (який свідчить, що об'єкт знаходиться всередині потоку об'єктів N під індексом i) — це саме те, що ви декодували б для побудови повної карти зміщень. Ця карта потрібна для довільного доступу до об'єктів за номерами. Але вона не потрібна для читання ключів /Root, /Size та /ID, які знаходяться у відкритому словнику, і вона не потрібна для розгортання потоків об'єктів, оскільки кожен /ObjStm декларує свій вміст через параметри /N та /First.

Вам також не потрібно обробляти функції передбачення PNG та TIFF, які потік перехресних посилань може застосовувати через свій параметр /DecodeParms лише для отримання ключів трейлера. Предиктори фільтрують бінарні рядки перехресних посилань для кращого стиснення; вони не мають відношення до словника перед потоком. Мінімальне оновлення для сумісності класичного валідатора з новими форматами PDF є дуже невеликим: коли startxref вказує на потік, а не на ключове слово xref, виконайте парсинг словника потоку для отримання ключів трейлера та розгорніть усі знайдені об'єкти /ObjStm, щоб внести їхній вміст до таблиці об'єктів. Декодування записів типу 2 та предикторів є окремим складнішим завданням, яке можна відкласти до моменту, коли вам дійсно знадобиться довільний доступ до об'єктів.

Чому перевірка відповідності має спочатку розпакувати потоки

Це питання перестає бути теоретичним у момент перевірки профілю відповідності. Валідатор PDF/A або PDF/X аналізує конкретні об'єкти: каталог документа на наявність масиву /OutputIntents, потік /Metadata для пошуку пакета XMP з правильним ідентифікатором, кожен дескриптор шрифту для перевірки вбудованих шрифтів та трейлер для отримання /ID. У стисненому файлі більшість цих об'єктів знаходяться всередині потоків об'єктів. Валідатор, який не розгорнув ці потоки, не зможе прочитати ключі каталогу, знайти метадані та перерахувати шрифти. Він позначить цілком коректний документ як такий, що не містить наміру виводу, метаданих XMP та половини своєї структури, оскільки потрібні дані досі знаходяться всередині нерозпакованого масиву Flate.

Порядок виконання дій має значення. Розгортання має передувати перевіркам, а не виконуватися паралельно, оскільки кожна перевірка передбачає доступ до об'єкта за номером. Якщо підключити перевірку профілю безпосередньо до сканування вихідних байтів, вона успадкує обмеженість класичного парсера і видаватиме помилкові попередження саме на сучасних файлах, які з великою ймовірністю є коректними, оскільки створені новими інструментами, що за замовчуванням використовують потоки перехресних посилань.

Дозвіл PDFium виконувати парсинг замість вас

Компонент PDFium виконує парсинг потоків перехресних посилань та об'єктів під час завантаження документа, що є практичним способом уникнути написання коду розпакування вручну. При завантаженні файлу за допомогою компонента TPdf об'єкти всередині контейнерів /ObjStm вже розв'язані, і функції валідації бачать повністю розгорнутий документ. Метод ValidatePdfA повертає запис TPdfAValidationResult, де поле Conformance містить значення TPdfAConformance (наприклад, pac1b або pacNone), поле Issues містить набір виявлених проблем, а метод IsCompliant повертає true лише у разі відповідності стандарту та відсутності помилок. Оскільки об'єкти були розгорнуті при завантаженні, масив /OutputIntents або вбудований шрифт всередині потоку об'єктів будуть успішно знайдені, а не позначені як відсутні.

uses
  PDFium, FPdfPdfa;

function CheckPdfA(const FileName: string): TPdfAValidationResult;
var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;            // parses xref/object streams on load
    Result := Pdf.ValidatePdfA;    // sees the expanded object table
  finally
    Pdf.Free;
  end;
end;

Це ж стосується і ValidatePdfX, яка повертає структуру TPdfXValidationResult аналогічного вигляду. Перевага використання PDFium полягає в тому, що структурне розпакування виконується один раз і без помилок всередині завантажувача, тому ваш код валідації не бачить різниці між класичним файлом та повністю стисненим. Обидва варіанти надходять до валідатора як готовий набір об'єктів.

var
  Pdf: TPdf;
  R  : TPdfXValidationResult;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'Press_Ready.pdf';
    Pdf.Active := True;
    R := Pdf.ValidatePdfX;
    if R.IsCompliant then
      Writeln('PDF/X conformance: ', Ord(R.Conformance))
    else
      Writeln('Not conformant; issue count = ', SizeOf(R.Issues));
  finally
    Pdf.Free;
  end;
end;

Якщо байти вже завантажені в пам'ять, аналогічна послідовність 'завантаження-валідація' працює через перевантажений метод LoadDocument(const Data: TBytes), який приймає вихідний вміст файлу та виконує парсиринг його потоків перехресних посилань та об'єктів так само, як і при читанні з диска. Висновок для написання власного валідатора стосується саме структури даних, а не конкретного API: читайте ключі трейлера зі словника потоку у текстовому вигляді, розгортайте кожен /ObjStm за допомогою декодера Flate перед аналізом документа і ставтеся до декодування бінарних записів перехресних посилань як до складнішого та необов'язкового завдання.

Коли структура розгорнута, валідатор може виконувати решту операцій робочого процесу. Для побудови консольного інструменту попередньої перевірки, який створює звіти відповідності для цілої папки файлів, див. нашу інструкцію зі створення консольного додатка для пакетних звітів. Коли валідація є першим кроком перед розділенням великого документа на частини, методи з нашого посібника з розділення документів PDF на кілька файлів чудово поєднуються зі схемою 'завантаження та перевірка', показаною тут. Обидва рішення базуються на можливостях завантаження та валідації компонента PDFium для Delphi та C++Builder.