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

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

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

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

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

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

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

Изменение общей строки Info в PDFlibPas: /CreationDate и /ModDate законно ссылаются на один строковый объект 2728 0 R, старый SetRawInfo вызывал SetTo у того, во что разрешался ключ, и переписывал обе даты временем сохранения, а новый SetRawInfo добавляет по ключу свежую строку, сохраняя шестнадцатеричный режим строки
Обновление записи словаря теперь заменяет ссылку этой записи, а не меняет общий объект, поэтому одна автоматическая запись 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));

Регрессионный тест в Tests\SharedInfoSemantics.inc строит такое псевдонимизирование намеренно, а не полагается на файл из корпуса: одна шестнадцатеричная строка, на которую ссылаются оба ключа дат, одна прямая строка, общая для /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);

Исправление делает две вещи. 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>, находится в рамках стандарта, так что на таком пакете цикл удалял весь валидный документ. Теперь сериализатор находит закрывающее ?> объявления и убирает только его. Tests\XMPRetentionSemantics.inc прогоняет свою проверку сохранности дважды — один раз с обёрткой, один раз с обрезанной, — и утверждает, что маркер собственного пространства имён и исходный автор переживают SetInfo, GetMetadata, SaveToString и повторную загрузку. Второй ловушкой был символ препроцессора: синхронизация Info с XMP в SetInfo была под защитой NOVCL, который выставляется для сборок Free Pascal, но бэкенд XMP включается по операционной системе, а не по фреймворку, так как PDFlibXMP.pas определяет NO_XMP только при отсутствии OS_WINDOWS. Поэтому сборка 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;      // без авто /ModDate и без ленивого XMP
  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. Это отдельные вопросы с отдельными инструментами, и смешение «метаданные уцелели» с «метаданные соответствуют требованиям» — как раз то, что позволило первому багу прятаться так долго. На стороне библиотеки оба регрессионных теста теперь идут на каждом целевом прогоне по Delphi Win32 и Win64 и Free Pascal Win32 и Win64, а семантическое сравнение — условие прохождения для бенчмарка на корпусе реальных документов

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

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