Потоки объектов PDF 1.5 упаковывают множество мелких косвенных объектов в один Flate-сжатый контейнер, и losLab PDF Library выпускает их при полном сохранении через флаг PackObjectStreams. Выигрыш реален: сотни словарей страниц, шрифтов и аннотаций, каждый из которых стоит десятки несжатых байт, схлопываются в горстку сжатых блобов. Цена в том, что теперь каждому упакованному объекту нужен поток перекрёстных ссылок, чтобы его описать
Вот эта вторая половина — то место, где ломаются писатели. Построение контейнера /ObjStm — это арифметика; научить механизм перекрёстных ссылок указывать в него — это редизайн. Писатель, который производит совершенно валидный контейнер, а затем описывает его членов обычными смещениями типа 1, произвёл файл, который Acrobat откроет ровно настолько, чтобы объявить его повреждённым. Эти две возможности — одна возможность, и эта статья покрывает сторону записи обеих, как определено в ISO 32000-1 §7.5.7 и §7.5.8
Что на самом деле содержит контейнер ObjStm
Поток объектов — это поток, чьи декодированные байты представляют собой две объединённые области, и ISO 32000-1 §7.5.7 даёт словарю ровно три ключа, значимых для построения. /Type /ObjStm идентифицирует его, /N даёт число членов, а /First даёт длину в байтах области заголовка — эквивалентно, смещение, с которого начинается тело. Заголовок — это разделённые пробелами пары номера объекта и смещения; тело — это члены, сериализованные подряд, при этом каждое смещение измеряется от начала тела, а не от начала декодированной полезной нагрузки. Чтение полностью декодированного контейнера делает это очевидным: ниже /First равно 14, потому что три строки заголовка занимают четырнадцать байт, а объект 7 сидит на 55 байт вглубь тела, потому что объект 4 сериализовался в 54 символа плюс разделитель
// Decoded payload of: 12 0 obj << /Type /ObjStm /N 3 /First 14
// /Filter /FlateDecode /Length 118 >> stream
4 0
7 55
9 90
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
<< /Type /ExtGState /CA 1 /ca 1 >>
[ 0 0 595 842 ]
Два правила членства абсолютны, и оба идут прямиком из §7.5.7. Объект-поток никогда не может быть членом, потому что поток несёт сырые байты, которые пришлось бы вложить внутрь другого потока. А член должен быть полным значением объекта, никогда — голой косвенной ссылкой: сжатый объект, который представляет собой просто 5 0 R, создаёт косвенность, которую читатель не может разрешить, заранее не зная, куда она указывает. losLab PDF Library отфильтровывает оба случая на этапе сбора кандидатов, наряду со словарём шифрования и объектом 0, а затем упаковывает всё, что выжило, группами по 200 на контейнер. Этот предел — решение о произвольном доступе, а не ограничение спецификации: читатель, которому нужен один член, вынужден распаковать весь контейнер, так что чрезмерно большие контейнеры делают мелкие поиски дорогими
Почему члены ObjStm обязаны использовать записи перекрёстных ссылок типа 2?
Потому что у упакованного объекта нет файлового смещения для записи. ISO 32000-1 §7.5.8 отвечает на это тремя типами записей в бинарном потоке перекрёстных ссылок: тип 0 для свободных объектов, тип 1 для обычных используемых объектов, хранящихся по байтовому смещению, и тип 2 для сжатых объектов, чьи два поля данных содержат номер объекта-контейнера и индекс члена внутри него. Выразить упакованный объект в классической текстовой таблице xref невозможно, и именно поэтому PDF 1.5 ввёл обе возможности вместе
Порядок, который следует далее, ставит подножку почти каждой первой реализации, включая нашу. Обычные объекты получают записи типа 1. Сами контейнеры /ObjStm получают записи типа 1, потому что контейнер — это совершенно обычный косвенный объект-поток, записанный по реальному смещению. Только члены получают записи типа 2. А сам поток перекрёстных ссылок — это косвенный объект в файле, поэтому ему нужна собственная запись типа 1, указывающая на смещение, по которому он только что был записан — то самое смещение, которое записывает startxref. Ранняя версия нашего писателя исключала номера объектов-контейнеров из цикла записи вместо исключения членов, и результатом стал файл с потоком перекрёстных ссылок и вообще без потоков объектов: структурно связный, семантически пустой, отвергнутый на следующем этапе. Значение /Size скрывает соответствующую ошибку на единицу, поскольку это наибольший номер объекта плюс один, а поток перекрёстных ссылок выделяется как наибольший номер объекта, так что его тоже нужно учесть
Определение размера массива /W: почему четырёх байт недостаточно
Массив /W объявляет байтовую ширину каждого из трёх полей, и losLab PDF Library пишет его как /W [1 Field2 Field3], где поле 1 фиксировано в один байт для кода типа, а поле 3 фиксировано в два байта, что покрывает и номера поколений до 65535, и индексы членов. Поле 2 — то, которое не может быть константой, потому что оно несёт две несвязанные величины: в записи типа 1 это байтовое смещение, ограниченное только размером файла, а в записи типа 2 это номер объекта-контейнера, а в записи типа 0 — следующий свободный объект в цепочке. Фиксированное четырёхбайтовое поле 2 работает нормально, пока файл не пересечёт 4 ГБ, после чего каждое смещение за границей молча усекается, и вся таблица становится мусором. Поэтому писатель сканирует собранную таблицу на предмет наибольшего значения, которое когда-либо примет любой слот поля 2, включая смещение самого потока перекрёстных ссылок, и расширяет поле до восьми байт
// Field 2 must hold the largest byte offset AND the largest
// ObjStm container number AND the largest free-chain target.
MaxField2Value := XRefStart;
for X := 0 to MaxObj do
begin
if XRefTable[X].InUse and (XRefTable[X].ObjStrNum > 0) then
Field2Value := XRefTable[X].ObjStrNum // type-2: container number
else
Field2Value := XRefTable[X].ObjPos; // type-1 offset / type-0 next-free
if Field2Value > MaxField2Value then
MaxField2Value := Field2Value;
end;
Field2 := 4;
while (Field2 < 8) and
(MaxField2Value > ((Int64(1) shl (Field2 * 8)) - 1)) do
Inc(Field2);
Field3 := 2; // generation numbers and member indices both fit
Как только ширины известны, размер полезной нагрузки известен точно, поэтому писатель предварительно выделяет весь буфер целиком и заполняет его по индексу; добавление записей байт за байтом в AnsiString превращает построение таблицы в квадратичный процесс, чего никто не заметит на счёте-фактуре из десяти страниц и что заметит каждый на документе с двумястами тысячами объектов. Ещё две детали удерживают строгих читателей довольными. /Index объявляет, какие диапазоны номеров объектов покрывает таблица, и для полной перезаписи это просто [0 N] без пропусков. И каждый слот, который писатель на самом деле не выпустил, по умолчанию должен быть свободным, а не используемым: объект 0 возглавляет цепочку свободных, каждый свободный слот ссылается на следующий, а слот, некогда хранивший удалённый объект, сохраняет своё поколение, увеличенное на единицу. Сопутствующая заметка о безопасности памяти при парсинге ненадёжных PDF приводит тот же аргумент о границах со стороны чтения
Почему поток перекрёстных ссылок никогда не должен быть зашифрован?
Потому что читателю нужно его распарсить, прежде чем он сможет узнать, как что-либо расшифровать. Поток перекрёстных ссылок — это то, что сообщает читателю, где живёт словарь /Encrypt; если бы его байты сами были зашифрованы, читателю понадобился бы ключ файла, чтобы найти объект, описывающий ключ файла. losLab PDF Library закрепляет это одним предикатом: ShouldCryptStreamData возвращает False всякий раз, когда словарь потока несёт /Type /XRef, так что исключение выполняется независимо от того, каким путём достигается сериализатор
Контейнер /ObjStm получает противоположное обращение, и эта асимметрия намеренна. Контейнер шифруется целиком, ключуется по собственному номеру объекта, точно как любой другой поток. Его члены не шифруются по отдельности — они упакованы в своей расшифрованной открытой форме, и единственный проход по собранному контейнеру покрывает их, включая строки. Двойное шифрование членов даёт файл, который расшифровывается в шифротекст, и поскольку внешний слой успешен, отказ проявляется как ошибка парсинга глубоко в графе объектов, а не как ошибка аутентификации. Один объект тогда полностью остаётся вне схемы: в зашифрованном документе Каталог хранится как прямой объект типа 1 и никогда не упаковывается, потому что упаковка вынудила бы загрузчик распаковывать и расшифровывать поток объектов, чтобы добраться до корня документа, до того как контекст расшифровки, который помогает установить сам корень, будет полностью построен
Включение упаковки из Delphi
Публичный переключатель — это PackObjectStreams, выставленный как поле TPDFlibSaveOptions, как самостоятельный сеттер SetPackObjectStreams, и как свойство объекта документа. По умолчанию он включён и автоматически привязан к версии: писатель упаковывает только когда документ уже PDF 1.5 или новее, и он вызывает внутреннюю защиту минимальной версии, так что упакованный документ повышается до 1.5, а не маркируется неверно. После сохранения GetLastSaveUsedObjectStreams сообщает, действительно ли открылась защита, и это именно то утверждение, которое нужно в регрессионном тесте, а не сравнение размера в байтах
var
Doc: TPDFlib;
Options: TPDFlibSaveOptions;
begin
Doc := TPDFlib.Create;
try
if Doc.LoadFromFile('report.pdf', '') <= 0 then
Exit;
Doc.SetInformation(0, '1.5'); // packing is gated on PDF 1.5+
FillChar(Options, SizeOf(Options), 0);
Options.CompressContent := True;
Options.GarbageCollect := True; // drop orphans before packing
Options.PackObjectStreams := True;
if Doc.SaveToFileOptions('report-packed.pdf', Options) = 1 then
if Doc.GetLastSaveUsedObjectStreams = 1 then
Writeln('Saved with ObjStm containers and an xref stream');
finally
Doc.Free;
end;
end;
Порядок между упаковкой и сборкой мусора имеет значение. Анализ достижимости должен пройти первым, потому что член, выживающий в контейнере, тащит контейнер за собой — если живой объект упакован, номер его контейнера достижим по определению, и зачистка контейнера оставляет член без способа его найти. Запуск сборщика первым также означает, что мёртвые объекты вообще никогда не попадают в контейнер, откуда и берётся накопительный выигрыш в размере. Упаковка дополняет остальные рычаги размера, а не заменяет их; обзор оптимизации размера PDF-файла и субсеттинга шрифтов покрывает рычаги, действующие на полезную нагрузку потоков, тогда как потоки объектов действуют на структуру
Границы, которые стоит знать перед включением
Инкрементальные сохранения никогда не упаковывают. Инкрементальное обновление дописывает новые объекты и новый раздел перекрёстных ссылок, оставляя более ранние редакции физически нетронутыми, так что переупаковка существующих объектов в свежие контейнеры оставила бы сиротами записи типа 1, на которые предыдущая редакция всё ещё ссылается; losLab PDF Library отключает упаковку всякий раз, когда активен режим дописывания, и статья про инкрементальные обновления и потоковое дописывание полностью покрывает этот путь. Документы ниже PDF 1.5 безусловно сохраняют текстовую таблицу перекрёстных ссылок: потребитель версии 1.4 понятия не имеет, что означает /ObjStm, и молчаливое повышение документа только потому, что писатель предпочёл файл поменьше, было бы неверным решением за вызывающую сторону. Один опциональный ключ, который мы намеренно не выпускаем, — это /Extends, который ISO 32000-1 §7.5.7 определяет, чтобы контейнер мог назвать предшественника и читатели могли трактовать цепочку контейнеров как логическую группу. Это по-настоящему опционально, каждый контейнер, который мы пишем, самодостаточен и независимо декодируем, и пропуск этого убирает целый класс ошибок циклов и висячих ссылок из писателя — хотя читатели, разумеется, всё равно должны уважать /Extends, встречая его в файлах от других производителей
Упаковка потоков объектов и вывод потока перекрёстных ссылок поставляются как часть losLab PDF Library для Delphi и C++Builder, наряду со сборщиком мусора и оптимизатором потоков содержимого, с которыми они сочетаются; страница продукта содержит полный справочник опций сохранения