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

Почему сохранение PDF без изменений портит Info и XMP

Сохранение PDF, ничего не меняющее, всё равно может испортить метаданные документа двумя способами: когда /CreationDate и /ModDate ссылаются на один и тот же строковый объект, автоматическое обновление ModDate, пишущее в этот объект, переписывает оба, а когда объект XMP создан раньше, чем прочитан исходный поток /Metadata, пакет по умолчанию заменяет оригинал. PDF Library for Delphi заменяет ссылки в словаре вместо изменения общих объектов и захватывает существующий пакет до ленивой инициализации XMP

Сохранение — самая неинтересная операция, какую выполняет библиотека PDF: загрузить файл, записать его под новым именем, ничего между этим не трогать. Страницы до и после отрисовываются одинаково, хеши потоков содержимого совпадают. Файл может пройти все такие проверки и всё равно быть неправильным в двух местах, которых вам не покажет ни один просмотрщик. Обе ловушки сидят в пути чтения-изменения-записи, через который проходит любое реальное редактирование, так что для их срабатывания хватает вообще любого сохранения, и выдаёт их только второй независимый парсер, сравнивающий невизуальную семантику двух файлов

Почему сохранение PDF меняет его CreationDate?

Потому что словарю информации о документе разрешено ссылаться на один косвенный строковый объект из двух ключей, а тот, кто обновляет объект вместо ключа, меняет оба. ISO 32000-1 §7.3.10 разрешает любому значению словаря быть косвенной ссылкой, и ничто в §14.3.3 Table 317 не требует, чтобы значение под /CreationDate было другим объектом, нежели значение под /ModDate. Производитель, записавший одну и ту же метку времени дважды в момент создания, может совершенно законно направить оба ключа на единственный 2728 0 R — и реальные файлы делают именно так

Спусковой крючок — автоматическая дата изменения. Пока не выставлен UserModDate, SaveToFile вызывает перед записью SetInfo('ModDate', ...) с текущим временем, а тот доходит до SetRawInfo. Наивный SetRawInfo находит объект по ключу и, если это TPDFString, вызывает у него SetTo. Это запись на месте в тот объект, в который ключ сейчас разрешается, и когда объект общий, /CreationDate тоже начинает сообщать время сохранения. Документ по-прежнему открывается, печатается и отрисовывается пиксель в пиксель как раньше, так что набор визуальных регрессионных тестов проходит без единого мигания

Изменение общей строки Info в PDFlibPas: /CreationDate и /ModDate законно ссылаются на один строковый объект 2728 0 R, запись SetTo на месте в тот объект, в который разрешается ключ, переписала бы обе даты временем сохранения, поэтому SetRawInfo добавляет по ключу свежую строку, сохраняя шестнадцатеричный режим строки
Обновление записи словаря заменяет ссылку этой записи (entry's reference), а не меняет общий объект, поэтому одна автоматическая запись ModDate не может изменить CreationDate, а вытесненный объект сохраняется для других ссылок
var
  Lib: TPDFlib;
  Before, After: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('design.pdf', '');
    Before := Lib.GetInformation(7);          // 7 = CreationDate, 8 = ModDate
    Lib.SaveToFile('design-resaved.pdf');
    Lib.LoadFromFile('design-resaved.pdf', '');
    After := Lib.GetInformation(7);
    if Before <> After then
      Log('a save that changed nothing rewrote CreationDate');
  finally
    Lib.Free;
  end;
end;

Правило в TPDFDocument.SetRawInfo невелико, а принцип за ним общий: обновление записи словаря заменяет ссылку этой записи и никогда — объект, в который она разрешилась. Код читает существующий TPDFStringMode, чтобы шестнадцатеричная строка осталась шестнадцатеричной, а литеральная — литеральной, и затем добавляет по ключу свежую строку из FStructure.NewString(Value, StringMode). Ещё две детали значат не меньше. Запись со значением-потоком заменяется без предварительной очистки старого потока, потому что SetTo('') обнулил бы значение для всех остальных ключей, всё ещё указывающих на этот поток. А вытесненный объект не удаляется, потому что им владеет структура и он может понадобиться другим ссылкам

// Наивно: меняем тот объект, в который ключ сейчас разрешается
if Obj is TPDFString then
  TPDFString(Obj).SetTo(Value);

// Правильно: сохраняем представление, заменяем только ссылку этого ключа
StringMode := smLiteral;
if Obj is TPDFString then
  StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));

Тест для такого случая должен строить псевдонимизирование намеренно, а не полагаться на файл, случайно разделивший объекты: одна шестнадцатеричная строка, на которую ссылаются оба ключа дат, одна прямая строка, общая для /Title и /Subject, и один поток, общий для /Author и /Keywords. После обновления одного ключа в каждой паре второй должен по-прежнему читать своё исходное значение, а обновлённая строка должна остаться шестнадцатеричной. Публичная справка SetInformation формулирует гарантию одной фразой: обновление поля Info заменяет только это поле, даже когда другие поля ссылаются на тот же объект

Почему существующий пакет XMP заменяется значениями по умолчанию?

Из-за порядка двух строк. У TPDFDocument.GetMetadata есть быстрый путь: когда поле XMP уже присвоено, он возвращает XMP.SaveToString вместо декодирования потока /Metadata из каталога. Ленивая инициализация в виде XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata); читается естественно и неправильна: к моменту вызова GetMetadata поле XMP уже присвоено, так что «источником» для загрузки оказывается сериализованный пакет по умолчанию от объекта, созданного строкой раньше. Исходный пакет с его dc:creator, собственными пространствами имён и любым опознанием стандартов до объекта не доходит и затирается при сохранении. Той же автоматической даты изменения достаточно, чтобы это сработало, потому что SetInfo инициализирует XMP до того, как трогает словарь Info, — чтобы xmp:ModifyDate шёл в ногу с /ModDate. Заметьте, за чем прячется эта ловушка: сравнение словаря Info из первой проходит, поскольку /Author и /Title в /Info не тронуты. Изменилось только дерево XMP, и замечает это лишь проверка, которая это дерево разбирает и сравнивает

Порядок ленивой инициализации XMP в PDFlibPas: создание объекта XMP до вызова GetMetadata заставляет быстрый путь сериализовать пакет по умолчанию и потерять dc:creator, собственные пространства имён и опознание стандартов, тогда как захват Source до TPDFlibXMP.Create загружает исходный поток /Metadata из каталога
Спровоцировать подмену может любое сохранение, потому что SetInfo инициализирует XMP, чтобы держать xmp:ModifyDate в ногу с /ModDate, поэтому вся ленивая инициализация в документе идёт через один EnsureXMP, захватывающий существующий пакет до создания объекта
// Неправильно: GetMetadata сериализует объект, созданный строкой выше
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);

// Правильно: сначала захватываем поток /Metadata, потом создаём и грузим
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);

PDF Library for Delphi держит этот порядок в одном месте. TPDFDocument.EnsureXMP захватывает Source := GetMetadata до TPDFlibXMP.Create, и каждая ленивая инициализация в документе идёт через неё: SetInfo, SetXMPInformation, GetXMPInformation, установщики режимов PDF/A, PDF/X, PDF/E, PDF/VT, PDF/VCR и PDF/UA и путь починки метаданных. Публичные точки входа вроде SetXMPProperty тоже идут через EnsureXMP, а GetXMPProperty читает через GetDocumentMetadata, так что вся поверхность делит один порядок инициализации. Одна правильная копия последовательности из трёх строк ценнее десяти копий, которые сегодня случайно совпадают

Две ловушки поменьше на том же пути

Сериализатор XMP в Windows использует платформенный писатель XML, который выдаёт объявление XML, а пакет его нести не должен. Очевидный способ срезать его — удалять символы до <?xpacket — проваливается на валидном входе: ISO 16684-1 §7.3.2 делает обёртку xpacket необязательной, и производитель, пишущий голый элемент <x:xmpmeta>, находится в рамках стандарта, так что на таком пакете этот цикл удалил бы весь валидный документ. Сериализатор находит закрывающее ?> объявления и убирает только его. Стоящая копирования проверка сохранности прогоняется дважды — один раз с обёрткой, один раз с обрезанной, — и утверждает, что маркер собственного пространства имён и исходный автор переживают SetInfo, GetMetadata, SaveToString и повторную загрузку. Вторая ловушка — символ препроцессора. NOVCL выставляется для сборок Free Pascal, но бэкенд XMP включается по операционной системе, а не по фреймворку, так как PDFlibXMP.pas определяет NO_XMP только при отсутствии OS_WINDOWS. Защищать синхронизацию Info с XMP в SetInfo символом NOVCL значило бы оставить сборку Lazarus под Windows с рабочим объектом XMP и SetInfo, который молча пропускает его обновление. Защита — NO_XMP, так что приложение на Free Pascal под Windows получает ту же синхронизацию, что и Delphi

Как сохранить исходный ModDate при сквозном сохранении?

Выставьте KeepModDate в TPDFlibSaveOptions и сохраняйте через SaveToFileOptions. Эта опция задаёт UserModDate на время вызова, и SaveToFile затем пропускает автоматическую метку времени — тот самый шаг, который ещё и лениво инициализирует объект XMP. Документ, метаданные которого вы не трогали и для которого не включался ни один режим соответствия, сохраняет и свой словарь Info, и свой поток /Metadata такими, какими они были загружены. Вызов SetInformation(8, ...) даёт тот же эффект навсегда, потому что дату изменения, заданную вами самими, библиотека помечает как управляемую пользователем

var
  Options: TPDFlibSaveOptions;
begin
  FillChar(Options, SizeOf(Options), 0);
  Options.OptimizeContentStreams := True;
  Options.PackObjectStreams := True;
  Options.KeepModDate := True;      // no automatic /ModDate, no lazy XMP init
  if Lib.SaveToFileOptions('design-resaved.pdf', Options) <> 1 then
    Log(Format('save failed, LastErrorCode=%d', [Lib.LastErrorCode]));
end;

Будьте честны насчёт того, что это даёт. KeepModDate — верный выбор для сквозного шага, вывод которого должен описывать ту же ревизию, что и вход, и неверный выбор для всего, что действительно правит содержимое, потому что §14.3.3 ожидает, что /ModDate отражает самое последнее изменение. И он не заменяет корректную работу со ссылками, а лишь избегает той единственной записи, которая запускает автоматическое обновление. Замена ссылок вместо изменения общих объектов и захват пакета до инициализации — вот что делает обычное сохранение безопасным, а опция — вот что делает осознанный no-op честным

Как убедиться, что сохранение изменило только ModDate?

Не по пикселям и не по хешам потоков, потому что обе ловушки оставляют каждую страницу и каждый поток содержимого побайтово одинаковыми. Проверка, которая их ловит, — это невизуальный семантический снимок, снятый независимым парсером, не разделяющим кода с тестируемой библиотекой, с исходного файла и с сохранённого, а затем структурное сравнение. Снимок покрывает словарь Info без /ModDate, дерево закладок с каждой закладкой, разрешённой в номер страницы, а не в номер объекта, именованные назначения и цели ссылок, разрешённые так же, значения полей форм, вложения в виде хешей и пакет XMP, разобранный как дерево, а не сравнённый как текст. Номера объектов намеренно в него не входят, поскольку полная перезапись перенумеровывает всё, и сравнение с опорой на них сообщало бы один шум

Невизуальная семантическая проверка сохранений PDFlibPas: независимый парсер без общего кода снимает словарь Info без /ModDate, страницы закладок и назначений, значения форм, хеши вложений и дерево XMP, а затем сравнивает исходный файл с сохранённым, отбрасывая /ModDate, xmp:ModifyDate и xmp:MetadataDate как ожидаемые изменения
Пиксели и хеши потоков остаются побайтово одинаковыми при обеих ловушках, поэтому сравнение работает по разрешённой семантике, а не по номерам объектов, и уцелевшие метаданные честно называются сохранёнными, а не валидными по схеме или соответствующими PDF/UA и PDF/A

Исключения здесь так же важны, как включения. /ModDate, xmp:ModifyDate и xmp:MetadataDate должны меняться, поэтому их отбрасывают перед сравнением; файл, в исходнике которого не было XMP вообще, не получает штраф за появившийся пакет. А чего проверка не утверждает, сказано столь же явно: сохранение существующего пакета ничего не говорит о том, валиден ли этот пакет по схеме и соответствует ли документ PDF/UA или какой-либо части PDF/A. Это отдельные вопросы с отдельными инструментами, и смешение «метаданные уцелели» с «метаданные соответствуют требованиям» — как раз то, что позволяет переписыванию общей даты прятаться очень долго

Если вам интересен уровень ниже этих правил, механика того, как сохранение перезаписывает объекты, разобрана в статье про инкрементальные обновления и сохранение с добавлением — это единственный режим, где общий объект просто остаётся на месте, — и в статье про уровни изменения и сравнение ревизий, где устаревшая или переписанная дата вводит читателя в заблуждение не меньше. А взгляд со стороны починки на ту же пару Info и XMP, где две половины заставляют согласоваться, а не просто сохраняют, — в статье про конвертацию в PDF/A и починку метаданных

PDF Library for Delphi — это нативная библиотека PDF на Pascal для Delphi, C++Builder и Lazarus, и описанный здесь путь чтения-изменения-записи — тот же самый, через который проходит любое редактирование в вашем процессе, так что гарантии выше действуют независимо от того, сохраняете вы раз в день или тысячу раз, — поддерживаемые компиляторы и платформы перечислены на странице продукта PDF Library for Delphi