PDF Library for Delphi v3.539.18 і v3.539.20 виправляють два способи, якими збереження PDF без жодних змін усе одно могло зіпсувати метадані документа: коли /CreationDate і /ModDate посилалися на один і той самий рядковий об'єкт, автоматичне оновлення ModDate переписувало обидва, а коли об'єкт XMP створювався до читання оригінального потоку /Metadata, типовий пакет заміняв оригінал. Виправлення замінюють посилання у словнику, а не мутують спільні об'єкти, і зчитують наявний пакет до лінивої ініціалізації XMP
Ця операція — найнудніша з того, що взагалі робить бібліотека PDF: завантажити файл, зберегти його під новим іменем, не торкнутися нічого між цим. Сторінки рендерилися однаково до і після. Хеші content stream збігалися. Файл проходив усі перевірки, які в нас були, і при цьому був неправильний у двох місцях, яких жоден рендерер вам не покаже. Обидва дефекти сиділи на шляху read-modify-write, через який проходить будь-яка справжня правка, тож для їх запуску вистачало будь-якого збереження, і обидва знайшлися лише коли другий, незалежний парсер порівняв невізуальну семантику двох файлів
Чому збереження 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 тепер теж показує час збереження. Документ усе ще відкривається, друкується й рендериться піксель у піксель як раніше, тож набір візуальних регресій проходить без жодного сумніву
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, щоб hex-рядок лишився hex, а літеральний — літеральним, і додає свіжий рядок із 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 будує аліасинг навмисно, а не покладається на файл із корпусу: один hex-рядок, на який посилаються обидва ключі дат, один прямий рядок, спільний для /Title і /Subject, один потік, спільний для /Author і /Keywords. Після оновлення одного ключа з кожної пари інший мусить усе ще читати своє початкове значення, а оновлений рядок мусить лишитися hex. Публічна довідка з 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, і помічає це лише перевірка, яка це дерево розбирає й порівнює
// Неправильно: 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?
Не пікселями і не хешами потоків, бо обидва дефекти лишають кожну сторінку й кожен content stream побайтово ідентичними. Перевірка, яка їх зловила, — це невізуальний семантичний знімок, зроблений незалежним парсером, який не поділяє коду з бібліотекою під тестом, з вихідного файлу й зі збереженого, а за ним структурне порівняння. Знімок охоплює словник Info без /ModDate, дерево закладок із кожним букмарком, розв'язаним у номер сторінки, а не в номер об'єкта, іменовані пункти призначення й цілі посилань, розв'язані так само, значення полів форми, байти вкладень як хеші та пакет XMP, розібраний як дерево, а не порівняний як текст. Номери об'єктів навмисно до знімка не входять, бо повний перезапис перенумеровує все, і порівняння, прив'язане до них, звітувало б шум
Виключення тут важать не менше за включення. /ModDate, xmp:ModifyDate і xmp:MetadataDate мають змінюватися, тож їх відкидають до порівняння; файл, у якому не було XMP взагалі, не отримує штрафу за появу пакета. Те, чого перевірка не заявляє, сформульовано так само явно: збереження наявного пакета не говорить нічого про те, чи цей пакет schema-valid і чи документ відповідає PDF/UA або якійсь частині PDF/A. Це окремі питання з окремими інструментами, і саме змішування «метадані вижили» з «метадані відповідають вимогам» дозволило першому багові ховатися так довго. З боку бібліотеки обидві регресії тепер виконуються на кожному цільовому прогоні для Delphi Win32 і Win64 та Free Pascal Win32 і Win64, а семантичне порівняння є умовою проходження для бенчмарку корпусу реальних документів
Якщо ви працюєте на рівні нижче цих виправлень, механіка того, як збереження переписує об'єкти, описана в інкрементальних оновленнях і дозаписі — це єдиний режим збереження, де спільний об'єкт просто лишається на місці, — і в рівнях модифікації та порівнянні ревізій, які є іншим місцем, де застаріла чи переписана дата вводить читача в оману. Погляд із боку ремонту на ту саму пару Info і XMP, де обидві половини змушують узгоджуватися, а не лише зберігаються, — у конвертації в PDF/A та ремонті метаданих
PDF Library for Delphi — це нативна Pascal-бібліотека PDF для Delphi, C++Builder і Lazarus, і описаний тут шлях read-modify-write — той самий, через який проходить кожна правка у вашому власному процесі, тож наведені гарантії діють незалежно від того, зберігаєте ви файл раз чи тисячу разів на день — дивіть сторінку продукту PDF Library for Delphi щодо підтримуваних компіляторів і платформ