Инкрементные обновления PDF позволяют приложению Delphi изменять документ путем добавления только измененных объектов, оставляя нетронутым каждый исходный байт. Библиотека losLab PDF Library реализует этот механизм с помощью метода AppendToStream, который записывает только инкрементную секцию, определенную стандартом ISO 32000-1 §7.5.6. В результате добавление одной закладки в файл размером 2 ГБ требует лишь нескольких килобайт вывода вместо полной перезаписи. Этот же механизм позволяет обновлять подписанные документы без нарушения их цифровых подписей
Проблема, которую это решает, вполне реальна. При полном сохранении перезаписывается весь файл: каждый объект сериализуется заново, пересчитывается каждое смещение перекрестной ссылки, а вывод на байтовом уровне никак не связан с исходным файлом. Для счета размером 40 КБ это не проблема. Но для отсканированного архива размером 2 ГБ, в котором вы лишь исправили опечатку в заголовке документа, переписывать два гигабайта ради изменения двадцати байтов нелепо — а если файл содержал цифровую подпись, перезапись просто уничтожит её
Почему сохранение PDF нарушает его цифровую подпись?
Цифровая подпись PDF подписывает не логическое содержимое документа, а диапазоны байтов физического файла. Запись /ByteRange в словаре подписи точно фиксирует, какие именно участки файла охватывает криптографический дайджест. Любая операция сохранения, выполняющая повторную сериализацию этих байтов (даже если создается семантически идентичный документ), изменяет дайджест, и все валидаторы сообщат о нарушении подписи. Это сделано намеренно: подпись подтверждает именно те байты, которые видел подписант, а не абстрактную модель документа
Инкрементные обновления — это лазейка, предусмотренная спецификацией PDF. Поскольку инкрементное сохранение добавляет новые данные после исходного маркера %%EOF и никогда не затрагивает подписанные диапазоны байтов, существующая подпись продолжает проходить проверку по тем байтам, которые она охватывает. Затем валидаторы классифицируют добавленные изменения отдельно (вторая подпись, заполнение формы, аннотация) и определяют, допустимы ли эти изменения. На этом основан любой процесс многократного подписания: каждый следующий подписант добавляет свой инкрементный раздел поверх предыдущего. Если вы создаете конвейеры подписания, сопутствующая статья о подписании и валидации PAdES в Delphi подробно описывает взаимодействие диапазонов байтов подписи и инкрементных разделов
Как работают инкрементные обновления согласно ISO 32000-1 §7.5.6
Стандарт ISO 32000-1 §7.5.6 определяет эту модель тремя правилами. Во-первых, исходное содержимое файла остается полностью нетронутым — ни один байт не сдвигается. Во-вторых, измененные и новые объекты добавляются после последнего маркера %%EOF, каждый с тем же номером объекта, который у него был ранее (измененные объекты просто получают новое определение, которое затеняет старое). В-третьих, добавляется новая секция перекрестных ссылок и трейлер; запись /Prev в трейлере указывает на смещение байтов предыдущей секции перекрестных ссылок, образуя цепочку, по которой программа чтения обходит объекты от новых к старым для нахождения их наиболее актуальных определений
Эта структура дает два полезных свойства. Затраты на обновление пропорциональны объему изменений, а не размеру документа — стоимость добавления складывается из размера измененных объектов и небольших накладных расходов на xref/трейлер. Кроме того, файл становится собственной историей версий: каждая предыдущая редакция физически присутствует в нем, поэтому аудитор может обрезать файл на любом более раннем маркере %%EOF и восстановить именно тот документ, который существовал на тот момент. Для рабочих процессов соблюдения нормативных требований, где необходимо доказать, как выглядел документ до каждого внесения изменений, такой встроенный аудиторский след часто является решающим аргументом в пользу инкрементных сохранений
Запись инкрементного обновления с помощью AppendToStream
losLab PDF Library предоставляет доступ к инкрементному выводу через метод AppendToStream(AppendMode: Integer; OutStream: TStream): Integer, который возвращает 1 при успешном завершении и 0 в случае сбоя. Параметр AppendMode определяет, что именно записывается в целевой поток. Режим 0 записывает файл целиком: сначала в поток копируются байты исходного файла, а затем добавляется инкрементный раздел. Режим 1 записывает только сам инкрементный раздел — дельту — полностью исключая исходные байты. Режим 2 сначала записывает предоставленный вызывающей стороной префикс, зарегистрированный через SetAppendInputFromString, а затем добавляет раздел обновления поверх него
var
Doc: TPDFlib;
Delta: TMemoryStream;
begin
Doc := TPDFlib.Create;
try
if Doc.LoadFromFile('contract.pdf', '') <= 0 then
Exit;
// Небольшое изменение: тип изменений, который не должен
// приводить к перезаписи всего файла
Doc.SetInformation(3, 'Amended 2026-07-04'); // ключ 3 = /Subject
Delta := TMemoryStream.Create;
try
// AppendMode = 1: запись только инкрементного раздела.
// Исходные байты + Дельта = полный, валидный PDF.
if Doc.AppendToStream(1, Delta) = 1 then
Delta.SaveToFile('contract.delta.bin');
finally
Delta.Free;
end;
finally
Doc.Free;
end;
end;
Режим 1 наиболее интересен для проектирования систем. Поскольку дельта является самодостаточной, вы можете передавать её отдельно от оригинала: хранить редакции как отдельные объекты в хранилище, реплицировать на удаленный узел только дельты или восстанавливать любую редакцию, объединяя базовый файл с его цепочкой инкрементов. Правило восстановления представляет собой простое слияние байтов — сначала исходный файл, затем каждая дельта по порядку, поскольку именно такой макет предписывает стандарт §7.5.6 для инкрементно обновленного файла
Как библиотека вычисляет смещения xref без копирования исходного файла?
Записи перекрестных ссылок внутри инкрементного раздела должны содержать абсолютные смещения байтов — позиции, измеряемые от начала всего файла, а не от начала дельты. Это создает проблему для режима 1: модуль записи никогда не выводит исходные байты, но каждое записываемое им смещение должно имитировать их присутствие. losLab PDF Library решает эту задачу с помощью внутреннего адаптера потока TPDFAppendSectionStream, который предоставляет виртуальное пространство координат для сериализатора. Адаптер создается с длиной исходного файла в байтах в качестве базового смещения, сообщает свою позицию и размер как эту базу плюс всё, что было добавлено к этому моменту, и направляет только вновь записанные байты в целевой поток вызывающей стороны
В результате режим 1 никогда не создает физическую копию исходного документа — ни на диске, ни в памяти. Простая реализация (запись всего файла во временный буфер с последующим отсечением хвоста) содержала бы временную копию всего исходного PDF, что для гигабайтных объемов данных сводит на нет все преимущества инкрементных обновлений. Эта техника виртуализации смещений очень близка к сдвигу байтовых ссылок, используемому в других частях библиотеки; в статье о быстром объединении PDF со сдвигом байтовых ссылок показана та же идея применительно к объединению документов, а в руководстве по слиянию и разделению больших PDF с прямым доступом к файлам описана соответствующая архитектура ввода-вывода для файлов, которые не помещаются в оперативной памяти
Потоковое сохранение всего файла с помощью SaveToStream
Инкрементный вывод — это половина возможностей потоковой передачи; другая половина связана с полным сохранением. Метод SaveToStream в losLab PDF Library направляет сериализатор документа непосредственно в целевой поток, вместо того чтобы сначала формировать весь документ в промежуточную строку AnsiString, а затем записывать этот буфер за один вызов. Прежний подход работал, но приводил к тому, что при каждом полном сохранении в памяти временно создавалась вторая копия всего документа, что безвредно при 10 МБ, но ощутимо при 500 МБ и критично для многогигабайтных файлов в 32-битных процессах. Прямая сериализация привязывает пиковое потребление памяти к структуре объектов документа, а не к его сериализованной длине
var
Doc: TPDFlib;
Output: TFileStream;
begin
Doc := TPDFlib.Create;
try
if Doc.LoadFromFile('archive.pdf', '') <= 0 then
Exit;
// ... изменения, оправдывающие полную перезапись ...
Output := TFileStream.Create('archive-rewritten.pdf', fmCreate);
try
if Doc.SaveToStream(Output) = 0 then
Writeln('Save failed, error ', Doc.LastErrorCode);
finally
Output.Free;
end;
finally
Doc.Free;
end;
end;
Урок о режиме общего доступа: почему AppendToFile возвращал 0
Стоит рассказать об одной регрессии в этой области, поскольку её шаблон сбоя носит общий характер. Метод AppendToFile(FileName) добавляет инкрементное обновление непосредственно к существующему PDF-файлу на диске — естественный вызов для процесса локального аудита: загрузить файл, внести изменения, добавить данные по тому же пути. В версии 3.71.2 эта последовательность стала возвращать 0. Первопричина крылась в загрузчике, а не в модуле записи: для поддержки чтения больших документов по требованию метод LoadFromFile сохраняет дескриптор исходного файла открытым на протяжении всей жизни объекта документа, причем этот дескриптор открывался с флагом fmShareDenyWrite. Когда метод AppendToFile пытался снова открыть тот же файл для записи, собственный режим совместного использования загрузчика запрещал это, и вызов API завершался сбоем до того, как записывался хотя бы один байт
Исправление заключалось в ослаблении режима совместного использования загрузчика до fmShareDenyNone, что безопасно благодаря самой природе инкрементного добавления: оно добавляет байты строго после конца файла и никогда не перезаписывает область, с которой работает долгоживущий дескриптор чтения. Общий урок для тех, кто создает обертки над этой библиотекой или строит аналогичные потоковые загрузчики, заключается в том, что «ленивые» читатели, удерживающие дескрипторы, и модули записи в один и тот же файл находятся в постоянном конфликте, и режим совместного использования, выбираемый при открытии, является контрактом API, а не деталью реализации. Если AppendToFile в вашем коде возвращает 0, в первую очередь проверьте, не удерживает ли другой процесс целевой файл с ограничивающим режимом совместного доступа
Реальные затраты: когда инкрементные обновления — неподходящий выбор
Инкрементные обновления меняют размер файла на эффективность записи, и этот обмен не всегда выгоден. Каждая редакция добавляет свои измененные объекты, в то время как замененные определения остаются в файле, поэтому документ, отредактированный сотни раз, накапливает «мертвые» объекты и длинную цепочку /Prev, которую приходится обходить каждой программе чтения. Хуже того, «удаленный» контент никуда не исчезает: текст, удаленный в пятой редакции, по-прежнему физически присутствует в байтах четвертой редакции и может быть восстановлен любым, кто обрежет файл. Поэтому удаление конфиденциальных данных, очистка или любое изъятие важной информации требуют полной перезаписи — инкрементное сохранение удаленной информации представляет собой просто утечку данных в несколько шагов
Полная перезапись также является верным решением, если целью является сжатие (удаление накопленных инкрементов и неиспользуемых объектов), изменение общедокументных свойств вроде шифрования (поскольку повторное шифрование затрагивает каждую строку и поток, лишая изменение инкрементного характера) или подготовка «чистого» итогового файла, история редактирования которого не должна передаваться вместе с ним. Разумное правило: используйте AppendToStream или AppendToFile, пока документ находится в процессе активного изменения (особенно если он уже содержит подписи); используйте полную перезапись SaveToStream на границах жизненного цикла, когда документ покидает вашу систему или его историю необходимо объединить
Инкрементные обновления, вывод дельты с виртуальным смещением и потоковая сериализация входят в стандартный комплект поставки библиотеки losLab PDF Library для Delphi, C# и VB.NET; страница продукта содержит полное описание API для сохранения и добавления данных вместе с функциями подписания и работы с большими файлами, упомянутыми выше