Вы пишете небольшой валидатор PDF. Он открывает файл, переходит в конец, находит маркер startxref, считывает смещение и ожидает найти ключевое слово xref с расположенной под ним таблицей перекрестных ссылок фиксированной ширины. Из этой таблицы он собирает смещения объектов, а затем сканирует файл в обратном направлении в поисках слова trailer для получения ключей /Root и /Size. Код отлично работает на всех созданных вами тестовых файлах. Но затем поступает документ, сгенерированный современной версией Word или библиотекой для работы с PDF 1.5, и валидатор признает его некорректным. Там нет слова xref по указанному смещению, нет словаря trailer, а созданная таблица объектов оказывается пустой. Сам файл корректен, просто логика вашего валидатора безнадежно устарела.
Это наиболее частая причина сбоев низкоуровневых проверок структуры PDF в современных документах. Классическая структура с текстовой таблицей перекрестных ссылок и ключевым словом trailer стала необязательной начиная с версии PDF 1.5 и часто отсутствует. На замену им пришли две технологии: потоки перекрестных ссылок (cross-reference streams) и сжатые потоки объектов (compressed object streams). Обе они описаны в стандарте 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
Поток объектов представляет собой контейнер. Его словарь содержит тип /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 Component берет на себя парсинг потоков перекрестных ссылок и объектов при загрузке документа, избавляя от необходимости ручной реализации этих процедур. При загрузке файла через компонент TPdf все объекты внутри контейнеров /ObjStm автоматически разрешаются, и валидатор видит полностью готовую структуру. Метод ValidatePdfA возвращает запись TPdfAValidationResult, поле Conformance которой содержит значение соответствия (например, 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), которая работает аналогично. Главное правило для разработчиков валидаторов - считывать ключи трейлера из словаря потока в открытом текстовом виде, распаковывать все /ObjStm перед обходом структуры и откладывать декодирование двоичных записей XRef до момента, когда это действительно понадобится.
После развертывания структуры валидатор может выполнять любые рабочие процессы. Для организации пакетных проверок обратитесь к нашему руководству по созданию консольного инструмента preflight-отчетов. Если валидация предшествует разделению крупного документа на части, методы из статьи разделение документов PDF на несколько файлов отлично дополняют описанный здесь подход. Все эти функции входят в состав PDFium Component для Delphi и C++Builder.