Техническая статья

Ленивые члены ObjStm и полная перезапись PDF в HotPDF

Когда HotPDF Delphi Component загружает файл PDF 1.5 через LoadFromFile, он не разбирает объекты, упакованные внутри контейнеров /Type /ObjStm. Он запоминает, где живёт каждый сжатый член, и разбирает его только когда его кто-то запросит. Этот ленивый инвариант и делает время загрузки пропорциональным тому, что вы действительно трогаете, и он же — причина того, что полная перезапись обязана выполнить лишнюю работу до того, как наружу уйдут байты: раскрыть каждый ещё не разобранный член, потому что перезапись вот-вот выбросит контейнеры, в которых эти члены живут

Симптом, из-за которого появилась эта заметка, легко описать и неприятно отлаживать. Загрузите файл, чьи шрифты, цветовые пространства и дерево структуры сидят в потоках объектов, прогоните его через пару генерации BeginDoc и EndDoc — и результат откроется без нареканий. Число страниц правильное, на выборочно проверенных страницах текст виден. А потом коллега открывает страницу 40, и основной текст рисуется подставленным шрифтом, или команда Extract Text возвращает мусор там, где было подставленное ActualText. Ничего не падало. Писатель просто сериализовал объект, который никогда не загружался, а незагруженный объект сериализуется как ничто

Что LoadFromFile на самом деле хранит для сжатого объекта?

Для каждой записи перекрёстных ссылок типа 2 LoadFromFile хранит небольшую запись в FCompactObjects: номер объекта, индекс содержащего потока в таблице контейнеров, позицию члена внутри этого потока и указатель ParsedObject, который начинается с nil. Сам контейнер находится, расшифровывается, если документ зашифрован, и распаковывается, но тела членов остаются байтами. ISO 32000-1 §7.5.7 определяет раскладку контейнера, которая это позволяет: заголовок из пар «номер объекта — смещение», затем тела членов, склеенные после /First, так что любой отдельный член можно вырезать, не трогая соседей

EnsureCompressedObjectLoaded — единственный путь, превращающий запись в объект. Он находит запись по номеру объекта и, если ParsedObject уже выставлен, возвращает этот закешированный объект и засчитывает попадание в кеш. Иначе он перезагружает контейнер, если тот был вытеснен, вычисляет байтовый диапазон члена по таблице смещений, отдаёт парсеру представление этого среза без копирования и сохраняет результат обратно в запись. С этого момента объект косвенный, несёт свой настоящий номер и зарегистрирован в индексе объектов документа, как любой объект, разобранный из тела файла. Каталог, словарь информации, корень дерева страниц и объекты страниц проходят этот путь при загрузке, потому что навигация в них нуждается. Шрифты, цветовые пространства, словари ExtGState и элементы структуры — нет, и они остаются записями, пока их не тронет отрисовка страницы или перезапись

Как HotPDF Delphi Component хранит сжатый член до его разбора: запись FCompactObjects держит номер объекта, индекс контейнера, индекс члена и пустой указатель ParsedObject, а EnsureCompressedObjectLoaded превращает запись в зарегистрированный объект через попадания в кеш, перезагрузку контейнера, нарезку по таблице смещений и разбор без копирования
LoadFromFile оставляет тела членов /ObjStm байтами и разбирает их только когда читатель попросит, поэтому время загрузки зависит от того, что вы трогаете: каталог и дерево страниц приходят сразу, а шрифты, цветовые пространства и элементы структуры остаются записями

Это можно наблюдать со стороны. GetLoadedObjectStreamCacheInfo сообщает, сколько контейнеров существует, сколько членов проиндексировано и сколько из них разобрано на данный момент:

var
  Pdf: THotPDF;
  Info: THPDFObjectStreamCacheInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('tagged-report.pdf');
    if Pdf.GetLoadedObjectStreamCacheInfo(Info) then
      Writeln(Format('%d containers, %d members indexed, %d parsed so far',
        [Info.ContainerCount, Info.IndexedObjectCount,
         Info.MaterializedObjectCount]));
  finally
    Pdf.Free;
  end;
end;

На файле с насыщенной структурой третье число сразу после загрузки — небольшая доля второго. Этот разрыв и есть весь смысл ленивой загрузки, и он же — ровно то множество объектов, за которыми полной перезаписи придётся вернуться

Почему полная перезапись теряет шрифты, которые инкрементальное сохранение сохраняет?

Полная перезапись выбрасывает контейнеры /ObjStm и /XRef исходного файла и сериализует граф объектов заново, так что любой член, чей ParsedObject всё ещё nil, не имеет представления в выводе. У инкрементального обновления такой проблемы нет никогда, потому что оно дописывает новые объекты после исходных байтов и оставляет старые контейнеры на месте — предыдущая секция перекрёстных ссылок продолжает их адресовать. Разница не в том, как два режима обходятся со шрифтами. Она в том, доживают ли исходные контейнеры до чтения следующим просмотрщиком

Исправление живёт в SaveToStream — сериализаторе, которым управляет EndDoc, независимо от того, задали вы FileName или OutputStream. Прежде чем передать управление любой ветке писателя, он обходит FCompactObjects и вызывает EnsureCompressedObjectLoaded для каждой записи. Если член загрузить не удаётся, сохранение возбуждает исключение, а не продолжает, потому что перезапись, молча теряющая словарь шрифта, хуже той, что останавливается. Раскрытие должно стоять на этом уровне — выше классической, упакованной и линеаризованной ветвей и выше обрезки перезагруженных структурных потоков на линеаризованном маршруте. Более ранняя версия раскрывала члены только внутри SaveLoadedDocument, что покрывало словарь загруженного документа и полностью упускало словарь генерации. Последовательность LoadFromFile, затем BeginDoc, правки страниц и EndDoc шла прямо к писателю со всеми нетронутыми членами неразобранными

Где находится раскрытие при полной перезаписи в HotPDF: SaveToStream обходит каждую запись FCompactObjects через EnsureCompressedObjectLoaded до передачи управления классическому, упакованному или линеаризованному писателю, поэтому и словарь SaveLoadedDocument, и словарь LoadFromFile плюс BeginDoc плюс EndDoc сериализуют полностью разобранные объекты, а не записи с nil
Инкрементальное обновление дописывает после исходных байтов и оставляет старые контейнеры читаемыми, а полная перезапись их выбрасывает: один проход раскрытия выше всех ветвей писателя не даёт незагруженному шрифту или элементу структуры сериализоваться как ничто
// Оба словаря перезаписи теперь раскрывают компактные члены до запуска писателя.
// Путь загруженного документа:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.SaveLoadedDocument('quarterly-rewritten.pdf');

// Путь генерации поверх загруженного файла:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.FileName := 'quarterly-stamped.pdf';
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 9);
Pdf.CurrentPage.TextOut(40, 20, 0, 'Reviewed 2026-09-11');
Pdf.EndDoc;   // SaveToStream сначала материализует каждую запись FCompactObjects

Закешированные члены сохраняют всё, что вы с ними сделали. Объект, который разобрали, отредактировали и пометили грязным до сохранения, возвращается из кеша со своими правками, а удалённый член сохраняет своё состояние удаления при повторных сохранениях. Проход раскрытия идемпотентен по построению: он только заполняет слоты nil

Почему проверка пикселей на трёх страницах не ловит случай ActualText

Дольше всего этот баг прячется именно в элементах структуры. Запись ActualText у последовательности размеченного содержимого, определённая в ISO 32000-1 §14.9.4, заменяет глифы для извлечения текста и доступности, но не влияет на отрисовку. Если элемент структуры живёт в потоке объектов и перезапись его теряет, страница всё равно рисуется правильно, первая, средняя и последняя страницы совпадают с источником пиксель в пиксель, и регрессия проявляется только когда кто-то запускает извлечение текста или программу чтения с экрана. Тест перезаписи, который только рендерит страницы, — не тест перезаписи для размеченного PDF. Сравнивайте ещё и извлечённый текст и дерево структуры

Как пустой пароль пользователя меняет загрузку?

Пустой пароль пользователя всё равно означает, что файл зашифрован, а потоки объектов в таком файле — шифротекст, пока не восстановлен ключ файла. ISO 32000-1 §7.6.3.4 Algorithm 2 выводит этот ключ из пароля, записи /O, /P и первого идентификатора документа, и HotPDF обязан прогнать его по пустой строке, прежде чем проход по записям типа 2 сможет распаковать хоть один контейнер. Поэтому BeginDoc для загруженного зашифрованного документа первым делом вызывает DecryptLoadedDocument с пустым паролем: граф объектов должен быть аутентифицирован и расшифрован до того, как начнётся перезапись, независимо от того, собирается ли вызывающий защищать вывод. Шифрование вывода — отдельное решение, определяемое настройками защиты вызывающего, и BeginDoc восстанавливает эти настройки после прохода расшифровки, чтобы зашифрованный вход не превратился молча в зашифрованный выход

Политика контейнеров читается из словаря /Encrypt до того, как будет испробован какой-либо пароль. Для /V 1 и 2 каждый поток шифруется ключом файла. Для криптофильтров HotPDF разрешает /StmF через /CF: фильтр Identity или /CFM со значением None означает контейнеры с открытым текстом, а V2 и AESV2 — зашифрованные. Ответ ложится в FReloadObjectStreamsEncrypted, и он важен для одного конкретного случая. Когда контейнеры с открытым текстом, а строки нет, члены несут зашифрованные строки, которые нужно расшифровывать по отдельности, поэтому MaterializeMembersOfPlaintextObjectStreams раскрывает каждый компактный член до прохода поэлементной расшифровки. Он ничего не делает, когда политика ещё неизвестна, и ничего не делает, когда шифровались сами контейнеры, потому что члены зашифрованного контейнера уже расшифрованы вместе с ним и их нельзя расшифровывать дважды

Что происходит, когда контейнер не удаётся расшифровать?

Контейнер, у которого не прошла расшифровка, отправляется в карантин, а не становится фатальным. Проход по записям типа 2 записывает в FObjStmQuarantine запись THPDFObjStmQuarantineInfo с номером объекта контейнера, значением THPDFObjStmQuarantineReason, диагностической строкой и списком номеров объектов-членов, которые перекрёстные ссылки направили в этот контейнер. Причина osqrDecryptFailed возбуждается для четырёх разных ситуаций: не удалось разрешить ни один криптофильтр, расшифровка AES-256 или AES-GCM возбудила исключение, расшифровка устаревшими RC4 или AES-128 возбудила исключение, или подходящего ключа файла нет вообще. Независимые контейнеры продолжают загружаться, так что документ с одним повреждённым контейнером всё равно открывается и отрисовывает каждую страницу, которая от него не зависит

Как работает карантин расшифровки в HotPDF на загруженном PDF: контейнер, расшифровка которого возбудила исключение, записывается как THPDFObjStmQuarantineInfo с причиной osqrDecryptFailed и номерами его членов, независимые контейнеры продолжают загружаться, а BeginDoc возбуждает исключение на первой неудачной записи, прежде чем перезапись успеет отчитаться об успехе
Записи карантина переживают откат парсера, и BeginDoc проверяет их по имени, а не по флагу шифрования, поэтому документ с одним повреждённым контейнером всё равно открывается, а путь перезаписи останавливается вместо записи пустых объектов

Список карантина переживает откат парсера. Если основная загрузка перекрёстных ссылок падает и HotPDF восстанавливает таблицу объектов сканированием файла, флаг шифрования с первой попытки может это восстановление не пережить, а записи карантина переживают. Поэтому BeginDoc проверяет список карантина, а не флаг шифрования: на загруженном документе он обходит FObjStmQuarantine и возбуждает исключение на первой записи osqrDecryptFailed, называя контейнер и требуя перезагрузить документ с корректным паролем. Перезапись, которая прошла бы дальше этой точки, записала бы членов, которые контейнер должен был держать, пустыми объектами и отчиталась об успехе. Ту же проверку можно запустить самостоятельно, раньше и по своей политике, через публичные аксессоры:

var
  Info: THPDFObjStmQuarantineInfo;
  I: Integer;
begin
  Pdf.LoadFromFile('vendor-form.pdf');   // пустой пароль пользователя
  for I := 0 to Pdf.GetLoadedQuarantinedObjStmCount - 1 do
    if Pdf.GetLoadedQuarantinedObjStmInfo(I, Info) and
       (Info.Reason = osqrDecryptFailed) then
      raise Exception.CreateFmt(
        'Object stream %d is unreadable (%s); %d members unresolved',
        [Info.ContainerObjNum, String(Info.Diagnostic),
         Length(Info.MemberObjNums)]);
  // отсюда перезапись безопасна
end;

Остальные причины карантина покрывают несвязанные с шифрованием отказы: контейнер, который не является потоком, отсутствующий словарь, неверные /N или /First, размер потока вне допустимого диапазона, отказ распаковки, /First, указывающий за данные, или тело члена, которое декодировалось, но не разобралось. Их стоит логировать на приёме, потому что каждая называет ровно те члены, которых вам будет не хватать дальше

Почему перезаписи нужен исходный числовой токен?

HotPDF хранит каждый числовой объект как Single, а Single не может воспроизвести исходный текст вещественного числа. ISO 32000-1 §7.3.3 позволяет писателю выдать 0.750000, .75 или 0.75 для одного и того же значения, и ни одна из этих записей не переживёт круговой рейс через 24-битную двоичную форму и обобщённый форматтер без изменений. Хуже того, значение вроде 0.7 в Single вообще не представимо; оно разбирается в ближайшее число с плавающей точкой, а переформатирование этого числа может дать 0.69999999 или округлённого соседа — в зависимости от цикла по цифрам. Для цвета заливки или константы прозрачности /CA это разница в единицу отсчёта в 8-битном канале, чего достаточно, чтобы провалить сравнение пикселей с источником, а на границах градиентов — достаточно, чтобы это было видно

THPDFNumericObject.RememberSourceToken решает это для неизменённого случая. Парсер вызывает его с сырым токеном сразу после присваивания Value; метод принимает только токены из цифр, не более одной десятичной точки и необязательного ведущего знака и сохраняет токен вместе со значением, которому он соответствовал, в FSourceValue. Свойство SourceToken возвращает сохранённый текст только пока Value всё ещё равно FSourceValue. Измените число — и токен испарится, так что изменённое значение всегда идёт через существующий путь форматирования и никогда не выдаёт устаревший текст. SaveNumericObject сначала проверяет SourceToken и, если он есть, пишет его дословно, а к веткам целых, ссылок на цветовые пространства и дробных чисел переходит только для чисел, созданных или отредактированных в памяти

Инвариант невелик и стоит того, чтобы сформулировать его прямо: число, которое вы не трогали, записывается теми байтами, которыми было прочитано, а число, которое вы тронули, записывается собственным форматтером HotPDF. Компактные члены получают от этого ту же выгоду, что и объекты из тела файла, поскольку EnsureCompressedObjectLoaded прогоняет по срезу члена тот же парсер. Само форматирование чисел и его независимость от локали процесса разобраны в статье про инвариантное к локали форматирование чисел PDF в HotPDF

Проверка пути перезаписи против потоков объектов

Три проверки ловят каждый описанный выше отказ, и ни одна из них не требует Acrobat. Во-первых, сравните IndexedObjectCount и MaterializedObjectCount после сохранения; при полной перезаписи они должны быть равны, и любой разрыв — это потерянный член. Во-вторых, извлеките текст и перечислите дерево структуры в обоих файлах, а не просто отрисуйте их, чтобы потерянный ActualText или потерянный элемент структуры проявился как расхождение. В-третьих, загрузите вывод свежим экземпляром и убедитесь, что GetLoadedQuarantinedObjStmCount равен нулю, — это ещё и доказывает, что писатель не создал контейнер, который читатель не может открыть. Сочетания криптофильтров, определяющие FReloadObjectStreamsEncrypted, разложены в статье про политики StmF, StrF и EFF. Сторона писателя в этой истории — как выдавать потоки объектов и когда предпочитать инкрементальное обновление перезаписи — в руководстве по потокам объектов и инкрементальным обновлениям

Ленивая загрузка членов, проход раскрытия перед писателем, карантин расшифровки и сохранение исходного токена поставляются в составе HotPDF Delphi Component для Delphi и C++Builder. Страница продукта ссылается на справочник по API, если вы хотите сопоставить GetLoadedObjectStreamCacheInfo и аксессоры карантина со своим конвейером приёма документов