Вы пишете небольшой валидатор. Он открывает PDF, переходит в конец, находит startxref, читает смещение и ожидает оказаться на ключевом слове xref с таблицей перекрестных ссылок фиксированной ширины под ним. Из этой таблицы он собирает смещения объектов, затем сканирует в обратном направлении в поисках ключевого слова trailer, чтобы узнать /Root и /Size. Он отлично работает на каждом файле, который вы сгенерировали для его тестирования. Затем приходит файл, созданный текущей версией Word или библиотекой, ориентированной на PDF 1.5, и валидатор объявляет его сломанным. По смещению нет ключевого слова xref, нигде нет словаря trailer, а таблица объектов, которую построил валидатор, почти пуста. Файл валиден. Валидатор читает его через призму пятнадцатилетней давности
Это самая частая причина, по которой побайтовая проверка PDF, написанная с учетом классической структуры, не проходит на современных документах. Структура, от которой она зависит — таблица перекрестных ссылок в виде открытого текста и ключевое слово trailer — была сделана необязательной в PDF 1.5 и часто отсутствует. Ее заменили две функции: поток перекрестных ссылок (cross-reference stream) и поток сжатых объектов (compressed object stream). Оба описаны в ISO 32000-1, и валидатор, который о них не знает, видит здоровый файл как кучу отсутствующих объектов
Что PDF 1.5 изменил в конце файла
Стандарт ISO 32000-1 §7.5.8 определяет поток перекрестных ссылок, а §7.5.7 определяет поток объектов типа /ObjStm. Вместе они позволяют программе-писателю (writer) отбросить две структуры, на которые ориентируется классический парсер. Файл PDF 1.5 может заканчиваться без таблицы xref вообще. Вместо этого объект, на который указывает startxref, является обычным потоковым объектом, словарь которого содержит /Type /XRef, и этот поток содержит данные перекрестных ссылок в компактной бинарной форме. Ключевого слова trailer также нет, поскольку трейлер теперь является собственным словарем потока. Ключи, за которыми охотился классический парсер, /Root, /Size и /ID, находятся внутри этого словаря
Второе изменение перемещает сами объекты. Вместо записи каждого косвенного (indirect) объекта с собственным байтовым смещением, программа-писатель может упаковать множество небольших объектов — словари страниц, словари аннотаций, дерево структуры — в единый поток объектов и сжать весь контейнер с помощью Flate. Отдельные объекты больше не имеют байтового смещения в файле. У них есть позиция внутри сжатого блока. Валидатор, сканирующий сырые байты на наличие 1 0 obj, никогда их не находит, потому что этот текст существует только после распаковки (inflation). Для классического парсера половина документа просто исчезла
Ключи трейлера — это открытый текст даже в сжатом файле
Обнадеживает то, что чтение трейлера потока перекрестных ссылок не требует ничего распаковывать. Потоковый объект записывается как словарь, за которым следует ключевое слово stream, а затем сжатые байты. Словарь — это открытый текст (plaintext). Поэтому, когда 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
Вам также не нужно обрабатывать функции предиктора (predictor) 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 заключается в том, что структурная декомпрессия, описанная выше, происходит один раз и корректно внутри загрузчика, поэтому ваш код валидации никогда не видит разницы между классическим файлом и полностью сжатым. Оба поступают к валидатору как разрешенный набор объектов
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 перед тем, как обходить документ, и относитесь к декодированию бинарных записей перекрестных ссылок как к более крупной и необязательной задаче, которой она и является
Как только структура развернута, валидатор может запускать остальную часть рабочего процесса поверх нее. Относительно создания тестовой среды префлайта из командной строки, которая сообщает о соответствии в папке с входными данными, см. наше пошаговое руководство по созданию CLI для пакетного отчета префлайта. Когда валидация является воротами перед разбиением большого документа на части, методы из нашего руководства по разделению документов PDF на несколько файлов естественным образом сочетаются с показанным здесь паттерном «загрузить и проверить». И то и другое опирается на возможности загрузки и валидации компонента PDFium для Delphi и C++Builder