Технічна стаття

Перевірка стиснутих PDF: потоки Object та XRef

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

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

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

ISO 32000-1 §7.5.8 визначає потік перехресних посилань, а §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

Потік об'єктів — це контейнер. Його словник містить /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. У стиснутому файлі більшість із цих об'єктів знаходяться всередині потоків об'єктів. Валідатор, який не розпакував потоки об'єктів, не може побачити ключі каталогу, не може знайти метадані та не може перерахувати шрифти. Він повідомить про ідеально відповідний документ як про такий, що не має наміру виводу (output intent), не має XMP і не має половини своєї структури, оскільки потрібні йому докази все ще знаходяться в блоці Flate, який він так і не розпакував

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

Дозвольте PDFium зробити аналіз за вас

PDFium Component аналізує потоки перехресних посилань та потоки об'єктів у процесі завантаження документа, що є практичним способом уникнути ручної реалізації етапу розпакування та розширення. Коли ви завантажуєте файл за допомогою компонента TPdf, об'єкти, упаковані в контейнери /ObjStm, вже визначені, і точки входу для валідації бачать повністю розпакований документ. ValidatePdfA повертає запис TPdfAValidationResult, поле Conformance якого має значення TPdfAConformance, як-от pac1b або pacNone, поле Issues є набором знайдених конкретних проблем, а метод IsCompliant повертає істину лише тоді, коли було виявлено рівень відповідності, а набір проблем порожній. Оскільки об'єкти були розпаковані під час завантаження, масив /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 полягає в тому, що структурна декомпресія, описана вище, відбувається один раз, правильно, всередині завантажувача, тому ваш код валідації ніколи не бачить різниці між класичним файлом і повністю стиснутим. Обидва надходять до валідатора як визначений набір об'єктів

function PdfXConformanceName(C: TPdfXConformance): string;
begin
  case C of
    pxc1a: Result := 'PDF/X-1a';
    pxc3 : Result := 'PDF/X-3';
    pxc4 : Result := 'PDF/X-4';
  else
    Result := 'none';
  end;
end;

var
  Pdf: TPdf;
  R  : TPdfXValidationResult;
  Issue: TPdfXValidationIssue;
  IssueCount: Integer;
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: ', PdfXConformanceName(R.Conformance))
    else
    begin
      IssueCount := 0;
      for Issue in R.Issues do   // Issues is a set: count its members
        Inc(IssueCount);
      Writeln('Not conformant; issue count = ', IssueCount);
    end;
  finally
    Pdf.Free;
  end;
end;

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

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