Поток объектов PDF, который распаковывается без ошибки, но всё равно читается как шум, обычно упускает один шаг: обращение дифференцирования (Predictor) по ISO 32000-1. Когда словарь /DecodeParms потока несёт /Predictor 2 или выше, байты, что возвращает FlateDecode, — не исходные данные: это значения, продифференцированные построчно в стиле PNG или горизонтально в стиле TIFF, которым нужен второй проход восстановления прежде, чем какой-либо поиск по словарю обретёт смысл. PDFiumPas, нативная библиотека компонента PDF на VCL для Delphi и C++Builder, добавила этот проход восстановления в v2.16.0, именно потому что потоки объектов PDF 1.5+ разворачивались в дифференцированные байты, которые не мог прочитать ни один парсер словарей
Почему одного FlateDecode недостаточно
Сам FlateDecode — это лишь распаковка DEFLATE (ISO 32000-1 §7.4.4.1): он воспроизводит те байты, что кодировщик передал компрессору, не более того. Predictor находится на слой выше, в словаре /DecodeParms потока, и описывает преобразование, которое кодировщик применил до сжатия, — дифференцирование превращает длинные последовательности похожих структурированных значений, вроде плотно упакованных целых чисел внутри потока перекрёстных ссылок или потока объектов, в длинные последовательности малых чисел, которые DEFLATE сжимает намного лучше. ISO 32000-1 §7.4.4.3 (таблица 8) прямо указывает, что отмена этого преобразования — часть декодирования фильтрованного потока, а не необязательный проход очистки, и всё же легко написать вспомогательную функцию FlateDecode, которая лишь вызывает inflate и на этом останавливается
Симптом характерен, как только знаешь, что искать. Байты, дифференцированные Predictor, — не случайный шум: они по-прежнему несут форму сжатого потока, так что наивный парсер часто проходит мимо нескольких выглядящих корректно токенов, прежде чем наткнуться на последовательность байтов, которая никак не может быть именем PDF, числом или разделителем, и разные строки проваливаются на разных смещениях в зависимости от того, насколько лежащие в основе значения случайно отличались от соседних. Именно эта непоследовательность делает ошибку трудной для фиксации по одному-единственному проваливающемуся файлу: два PDF от одного и того же производителя могут отличаться только тем, какие значения случайно повторяются, так что один разбирается почти случайно, тогда как другой безоговорочно проваливается
Что на самом деле делает параметр Predictor в PDF?
Запись /Predictor в /DecodeParms сообщает соответствующей спецификации программе чтения, какое обращение выполнить, и таблица 8 ISO 32000-1 определяет значения, важные на практике: 1 означает, что предсказание не применялось, 2 выбирает TIFF Predictor 2 (горизонтальное дифференцирование), а любое значение от 10 до 15 выбирает предсказание в стиле PNG. Вместе с ним идут ещё три ключа — /Colors, /BitsPerComponent и /Columns, — и вместе они описывают геометрию строк, относительно которой было вычислено дифференцирование, даже когда поток вообще не содержит данных изображения: поток объектов — не картинка, но писатели PDF переиспользуют тот же построчный механизм предиктора для него, потому что дельта-затем-deflate сжимает плотно упакованные целые числа и смещения объектов плотнее, чем сжатие их в сыром виде
TIFF Predictor 2 — более простая из двух схем: каждый компонент хранится как разность с тем же компонентом предыдущего пикселя в той же строке, и каждая строка сбрасывается на своём левом крае, а не переносит разность из строки выше. Предсказание PNG более капризно, потому что реальный фильтр может меняться от строки к строке: каждая строка начинается с одного байта-тега — 0 для None, 1 для Sub, 2 для Up, 3 для Average, 4 для Paeth, — и именно этот тег, а не объявленное значение /Predictor, решает, как восстанавливать конкретную строку. /Predictor, равный 12, на самом деле лишь подсказка кодировщика, что он предпочитал фильтр Up, где каждый байт восстанавливается прибавлением байта прямо над ним в предыдущей строке, но корректный декодер всё равно должен читать тег на каждой строке, а не предполагать Up повсюду
Почему потоки объектов делают пропущенный Predictor невидимым?
Потоки объектов усугубляют проблему, а не просто повторяют её. ISO 32000-1 §7.5.7 позволяет писателю PDF 1.5+ упаковать несколько косвенных объектов в единый сжатый контейнер, /ObjStm, и обычное дело, когда именно те объекты, что нужнее всего валидатору — каталог, /OutputIntents или поток метаданных XMP /Metadata, — путешествуют через этот контейнер с прикреплённым /Predictor 12, потому что эти объекты достаточно короткие и повторяющиеся, чтобы выиграть от построчного дифференцирования. Когда шаг предиктора отсутствует, разворачивание потока объектов не выбрасывает ошибку: оно производит последовательность байтов, поверхностно выглядящую правдоподобно, но не токенизирующуюся в ожидаемые объекты, так что то, что было упаковано внутри, попросту не обнаруживается. Рендеринг редко это замечает, потому что соответствующий спецификации движок рендеринга уже восстанавливает дифференцированные предиктором данные ещё до перехода к компоновке; код, который это замечает, — как раз тот вид, внутри которого пряталась эта ошибка: валидатор, подписчик или проверщик версии, обходящий сырые байты PDF сам, чтобы ответить на структурный вопрос, без запасного варианта, если его собственный взгляд на поток объектов оказывается неверным
PDFiumPas сталкивался именно с этим отказом до v2.16.0. Потоки объектов, построенные с /Predictor 12, обычный случай для писателей PDF 1.5+, разворачивались через PdfExpandObjectStreams в дифференцированные байты, которые структурный сканер не мог разобрать, так что каталог, /OutputIntents и объекты /Metadata, упакованные внутри, оказывались фактически невидимыми для сканирований на соответствие — без исключения, без предупреждения, просто сканирование, что незаметно вело себя так, будто этих объектов не существует. Более глубокая механика того, как PDFiumPas разрешает поток объектов относительно активной таблицы перекрёстных ссылок, включая гибридные и чисто потоковые случаи xref, описана отдельно в статье о проверке потоков объектов и xref с PDFiumPas; описанный здесь шаг предиктора выполняется после этого разрешения, на байтах, что реально содержит каждый сжатый объект
Обращение строк PNG и TIFF Predictor в Pascal
PDFiumPas обращает дифференцирование в одной процедуре, PdfApplyPredictor, и её геометрическую арифметику стоит знать независимо от того, вызываете ли вы её или переиспользуете эту идею в собственном коде на Delphi. Ширина строки в байтах — ceil(Columns × Colors × BitsPerComponent ÷ 8), а ширина в байтах на пиксель, которую используют оба алгоритма, — ceil(Colors × BitsPerComponent ÷ 8); ошибитесь в округлении любого из них, и восстановление будет читать через границу строки, а не внутри неё. /Predictor ниже 2 остаётся нетронутым, поскольку 1 означает, что кодировщик вообще не применял преобразование; 2 выбирает ветвь TIFF, показанную ниже, а всё от 10 и выше переходит к восстановлению построчных фильтров PNG, где тег в начале каждой строки, а не объявленное значение /Predictor, решает, как эта конкретная строка отменяется
function PdfApplyPredictor(const Src: TBytes;
Predictor, Colors, Bpc, Columns: Integer): TBytes;
var
RowLen, Bpp, R, I: Integer;
begin
Result:= Src;
if Predictor< 2 then
Exit; // 1 = no prediction, nothing to undo
if Colors<= 0 then Colors:= 1;
if Bpc<= 0 then Bpc:= 8;
if Columns<= 0 then Columns:= 1;
if (Colors> 64)or (Bpc> 32)or (Columns> 1 shl 24) then
Exit; // reject hostile row geometries
RowLen:= (Columns* Colors* Bpc+ 7) div 8; // ceil(), per ISO 32000-1 Table 8
Bpp:= (Colors* Bpc+ 7) div 8;
if Predictor= 2 then
begin
if Bpc<> 8 then
Exit; // only the 8-bit layout is reconstructed
Result:= Copy(Src, 0, Length(Src));
R:= 0;
while R+ RowLen<= Length(Result) do
begin
for I:= R+ Bpp to R+ RowLen- 1 do
Result[I]:= Byte(Result[I]+ Result[I- Bpp]);
Inc(R, RowLen);
end;
Exit;
end;
// Predictor >= 10 falls through to PNG row-filter reconstruction below
end;
// Continues inside PdfApplyPredictor once Predictor>= 10 (PNG row filters).
// Rows:= Length(Src) div (RowLen+ 1); each row is a 1-byte filter tag
// followed by RowLen data bytes, decoded left to right.
for R:= 0 to Rows- 1 do
begin
SrcOfs:= R* (RowLen+ 1);
DstOfs:= R* RowLen;
Tag:= Src[SrcOfs];
Inc(SrcOfs);
for I:= 0 to RowLen- 1 do
begin
if I>= Bpp then A:= Result[DstOfs+ I- Bpp] else A:= 0; // byte to the left
if R> 0 then B:= Result[DstOfs+ I- RowLen] else B:= 0; // byte above
case Tag of
1: Result[DstOfs+ I]:= Byte(Src[SrcOfs+ I]+ A); // Sub
2: Result[DstOfs+ I]:= Byte(Src[SrcOfs+ I]+ B); // Up
3: Result[DstOfs+ I]:= Byte(Src[SrcOfs+ I]+ (A+ B) div 2); // Average
// Paeth (tag 4) adds whichever of A, B or the byte above-left sits
// closest to the linear predictor A+ B- C; tag 0 (None) copies the
// filtered byte through unchanged
else Result[DstOfs+ I]:= Src[SrcOfs+ I];
end;
end;
end;
Что изменилось в PDFiumPas в v2.16.0
Исправление, вышедшее в PDFiumPas v2.16.0, находится внутри PdfReadAndDecodeStream — процедуры, что читает сырые байты потока и декодирует их для каждого вызывающего кода, которому нужно проинспектировать структуру PDF на уровне байтов, включая разворачивание потока объектов; она пытается выполнить восстановление только после подтверждения, что /Filter — это чистый FlateDecode, никогда не каскад, потому что цепочку фильтров нельзя безопасно скорректировать предиктором на этом слое. Считывание /Predictor, /Colors, /BitsPerComponent и /Columns обратно из словаря потока тоже не требует общего парсера словарей: PdfDictRefNum находит каждый ключ прямым поиском токена-имени внутри байтового диапазона именно этого словаря, что здесь безопасно именно потому, что эти четыре ключа не могут повторяться или вкладываться внутри одного словаря потока. Тот же поиск токена-имени намного рискованнее, как только его направляют на более крупную или менее ограниченную область файла PDF, — этому посвящена сопутствующая статья о безопасном разборе словарей PDF
// Inside PdfReadAndDecodeStream, right after PdfInflate() has already run:
if PdfFilterIsPureFlate(DictTxt) then
begin
Inflated:= PdfInflate(Raw);
Predictor:= PdfDictRefNum(Data, DS, DE, 'Predictor');
if Predictor>= 2 then
begin
PColors:= PdfDictRefNum(Data, DS, DE, 'Colors');
PBpc:= PdfDictRefNum(Data, DS, DE, 'BitsPerComponent');
PColumns:= PdfDictRefNum(Data, DS, DE, 'Columns');
Result:= PdfApplyPredictor(Inflated, Predictor, PColors, PBpc, PColumns);
end
else
Result:= Inflated;
end;
До v2.16.0 поток объектов, построенный с /Predictor 12, разворачивался в дифференцированные байты без выброса ошибки, так что любой объект каталога, /OutputIntents или /Metadata, упакованный внутри него, пропадал из структурных сканирований PDFiumPas без единого предупреждения. После исправления тот же поток объектов распаковывается, а затем корректно восстанавливается, и упакованные внутри него объекты вновь становятся видимыми для этих сканирований. Вместе с исправлением появились и защитные границы: PdfApplyPredictor теперь безоговорочно отклоняет /Colors выше 64, /BitsPerComponent выше 32 и /Columns выше 2^24, потому что такие сочетания описывают геометрию строк, не нужную ни одному реальному производителю PDF, и существуют в основном для того, чтобы заставить декодер выделить намного больше памяти, чем оправдывают входные байты
var
Pdf: TPdf;
Report: TPdfAValidationResult;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'incoming.pdf';
Pdf.Active := True;
Report := Pdf.ValidatePdfA;
if not Report.IsCompliant then
LogNonCompliance(Report); // caller-supplied handler
finally
Pdf.Free;
end;
end;
Ограничения, о которых стоит знать
У восстановления предиктора в PDFiumPas есть два ограничения, о которых стоит знать прежде, чем на него полагаться. Восстановление TIFF Predictor 2 покрывает только случай 8 бит на компонент; PDF допускает более узкую упаковку, но данные, дифференцированные TIFF на уровне менее байта, проходят без восстановления, а не угадываются, так что поток, объявляющий /Predictor 2 с /BitsPerComponent 1, 2 или 4, сегодня корректно не декодируется через этот путь. У предсказания PNG такого ограничения нет — каждая строка предоставляет собственный тег фильтра, и все пять определённых типов восстанавливаются независимо от того, каким случайно оказалось объявленное значение /Predictor между 10 и 15, что соответствует тому, как построчная фильтрация в стиле PNG реально работает: объявленное значение ближе к подсказке о том, что кодировщик использовал в основном, чем к обещанию относительно каждой строки
Нативный движок рендеринга PDFium уже корректно восстанавливает данные изображений и потока содержимого, дифференцированные предиктором, и именно поэтому файл может идеально отрисовываться в любом обычном просмотрщике, тогда как валидатор, подписчик или проверщик версии на уровне байтов, построенный поверх него, читает те же байты неверно. Описанное здесь декодирование, учитывающее предиктор, поддерживает функции проверки PDF/A, структурного сканирования и подписания PDFiumPas, нативного компонента PDFium на VCL для Delphi и C++Builder