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

Как no-op запис на PDF разважда Info и XMP метаданни

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

Записването е най-неинтересната операция, която една PDF библиотека върши: заредите файл, запишете го под ново име, не пипате нищо по средата. Страниците се рендираха идентично преди и след. Хешовете на content stream-овете пасваха. Файлът мина през всяка проверка, която имахме, и все пак беше грешен на две места, които никакъв renderer никога няма да ви покаже. И двата дефекта седяха в пътя read-modify-write, през който минава всяка истинска редакция, така че всеки изобщо запис беше достатъчен да ги задейства, и двата бяха открити чак когато втори, независим парсер сравни невизуалните семантики на двата файла

Защо записването на PDF променя CreationDate?

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

Задействащият механизъм е автоматичната дата на модификация. Освен ако UserModDate не е вдигнат, SaveToFile вика SetInfo('ModDate', ...) с текущото време преди записа, което стига до SetRawInfo. Старият SetRawInfo търсеше обекта под ключа и при TPDFString викаше SetTo върху него. Това е запис на място в какъвто и да е обект, към който ключът сочи в момента, и когато обектът е споделен, /CreationDate вече докладва времето на записа. Документът пак се отваря, печата и рендира пиксел по пиксел както преди, така че визуален регресионен пакет минава без да мигне

Мутация на споделен Info стринг в PDFlibPas: /CreationDate и /ModDate легитимно сочат един стрингов обект 2728 0 R, старият SetRawInfo викаше SetTo върху каквото ключът resolve-ваше и пренаписваше и двете дати с времето на записа, а новият SetRawInfo добавя свеж стринг под ключа, запазвайки hex string режима
Актуализацията на речников запис вече заменя справката на записа, вместо да мутира споделения обект, така че един автоматичен 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 е малка, а принципът зад нея е общ: актуализацията на речников запис заменя справката на записа, никога обекта, към който случайно е resolve-вал. Новият код прочита съществуващия TPDFStringMode, така че hex стринг остава hex, а literal стринг остава literal, после добавя свеж стринг от FStructure.NewString(Value, StringMode) под ключа. Два други детайла са толкова важни, колкото главната промяна. Старият клон за запис със stream стойност чистеше stream-а с SetTo('') преди замяната, което би изпразнило стойността за всеки друг ключ, сочащ същия stream, така че това изчистване е махнато. А изместеният обект не се трие, защото структурата го притежава и други справки може още да го търсят

// Преди: мутира какъвто и да е обект, към който ключът resolve-ва в момента
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 строи alias-ването нарочно, вместо да разчита на файл от корпуса: един hex стринг, сочен от двата ключа за дати, един direct стринг, споделен между /Title и /Subject, един stream, споделен между /Author и /Keywords. След актуализация на по един ключ от всяка двойка другият трябва пак да чете оригиналната си стойност, а актуализираният стринг трябва пак да е hex. Публичната документация на SetInformation вече казва гаранцията с едно изречение: актуализацията на Info поле заменя само това поле, дори и други полета да сочат същия обект

Защо съществуващ XMP пакет се замества от пакет по подразбиране?

Заради подредбата на два реда. TPDFDocument.GetMetadata има бърз път: когато полето XMP вече е зададено, връща XMP.SaveToString вместо да декодира stream-а /Metadata от каталога. Няколко места на извикване се инициализираха lazy с XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata);, което се чете естествено и е грешно: към момента, в който GetMetadata тръгне, XMP вече е зададен, така че „източникът", който се зарежда, е серилизираният пакет по подразбиране на обект, създаден един ред по-рано. Оригиналният пакет — със своя dc:creator, персонализирани namespace-и и всякаква standards идентификация — никога не стига до обекта и се презаписва при запис. Същата автоматична дата на модификация е достатъчна да го задейства, защото SetInfo инициализира XMP, преди да пипне Info речника, за да държи xmp:ModifyDate в крак с /ModDate. Забележете зад какво се крие този дефект: сравнението на Info речника от първия бъг минава, тъй като /Author и /Title в /Info са нетрогнати. Само XMP дървото е променено, и само проверка, която парсва и сравнява това дърво, забелязва

Подредба на lazy XMP инициализацията в PDFlibPas: създаването на XMP обекта преди извикването на GetMetadata кара бързият път да сериализира пакет по подразбиране и да изпусне dc:creator, персонализирани namespace-и и standards идентификация, а прихващането на Source преди TPDFlibXMP.Create зарежда оригиналния stream /Metadata от каталога
Всеки запис задействаше замяната, защото SetInfo инициализира XMP, за да държи xmp:ModifyDate в крак с /ModDate, така че всяка lazy инициализация в документа сега минава през един EnsureXMP, който прихваща съществуващия пакет преди да създаде обекта
// Грешно: GetMetadata вече сериализира обекта, създаден на предишния ред
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);

// Правилно: прихвани stream-а /Metadata първо, после създай и зареди
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);

Поправката върши две неща. TPDFDocument.EnsureXMP сега прихваща Source := GetMetadata преди TPDFlibXMP.Create, а всяка lazy инициализация в документа е заменена с извикване на него: SetInfo, SetXMPInformation, GetXMPInformation, setter-ите за режимите PDF/A, PDF/X, PDF/E, PDF/VT, PDF/VCR и PDF/UA, и пътят за ремонт на метаданните. Публичните входове като SetXMPProperty вече минаваха през EnsureXMP, а GetXMPProperty чете през GetDocumentMetadata, така че цялата повърхност споделя една инициализираща подредба. Едно-единствено верен копие на триредова последователност струва повече от десет копия, които днес случайно съвпадат

Два по-малки капана, намерени на същия път

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

Как запазвате оригиналния ModDate при транзитен запис?

Вдигнете KeepModDate в TPDFlibSaveOptions и записвайте през SaveToFileOptions. Опцията задава UserModDate за времето на извикването, а SaveToFile тогава прескача автоматичния timestamp — същата стъпка, която lazy инициализира XMP обекта. Документ, чийто метаданни никога не сте пипали и за който не е включван compliance режим, пази и Info речника си, и stream-а /Metadata си, както са били заредени. Извикването на SetInformation(8, ...) има същия ефект трайно, защото самостоятелното задаване на датата на модификация я маркира като user-controlled

var
  Options: TPDFlibSaveOptions;
begin
  FillChar(Options, SizeOf(Options), 0);
  Options.OptimizeContentStreams := True;
  Options.PackObjectStreams := True;
  Options.KeepModDate := True;      // без автоматичен /ModDate, без 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?

Не с пиксели и не с хешове на stream-ове, защото и двата дефекта оставят всяка страница и всеки content stream байт-идентични. Проверката, която ги хвана, е невизуален семантичен snapshot, взет от независим парсер — такъв, който не споделя нито ред код с тестваната библиотека — от изходния файл и от записания файл, последван от структурно сравнение. Snapshot-ът покрива Info речника с изключен /ModDate, дървото на outline с всяка отметка resolve-ната до номер на страница, а не на обект, именувани дестинации и линк таргети, resolve-нати по същия начин, стойностите на форм полетата, байтовете на прикачените файлове като хешове, и XMP пакета, парснат като дърво, а не сравняван като текст. Номерата на обектите нарочно не са част от него, тъй като пълно пренаписване преномерира всичко, а сравнение, ключовано по тях, би докладвало шум

Невизуална семантична верификация на записи в PDFlibPas: независим парсер без споделен код прави snapshot на Info речника без /ModDate, страниците на outline и дестинациите, стойностите на формите, хешовете на прикачени файлове и XMP дървото, после сравнява изходния файл със записания, като пуска /ModDate, xmp:ModifyDate и xmp:MetadataDate като очаквани промени
Пикселите и хешовете на stream-овете остават байт-идентични през и двата дефекта, така че сравнението работи върху resolve-нати семантики, а не върху номера на обекти, а оцелелите метаданни се докладват честно като запазени, а не като schema-валидни или PDF/UA и PDF/A съвместими

Изключванията са толкова важни, колкото включванията. /ModDate, xmp:ModifyDate и xmp:MetadataDate се очаква да се променят и се пускат преди сравнението; файл, чийто източник изобщо не е носил XMP, не се наказва за придобит пакет. Това, което проверката не твърди, е също толкова изрично: запазването на съществуващ пакет не казва нищо дали пакетът е schema-валиден или дали документът покрива PDF/UA или която и да е част на PDF/A. Това са отделни въпроси с отделни инструменти, и сливането на „метаданните оцеляха" с „метаданните са съвместими" е начинът, по който първият бъг се крие толкова дълго. От страната на библиотеката двете регресии сега се пускат на всеки таргетиран пас през Delphi Win32 и Win64 и Free Pascal Win32 и Win64, а семантичното сравнение е pass условие за benchmark-а с corpus от реални документи

Ако работите на нивото под тези поправки, механиката на това как записът пренаписва обекти е покрита в инкременталните актуализации и append-only записа — единственият режим на запис, при който споделен обект просто си остава там, където е бил — и в modification levels и revision diff, другото място, където остаряла или пренаписана дата подвежда читателя. Погледът от страна на ремонта върху същата двойка Info и XMP, където двете половини се карат да се съгласят, а не просто да се запазят, е в конвертирането към PDF/A и ремонта на метаданни

PDF Library for Delphi е нативна Pascal PDF библиотека за Delphi, C++Builder и Lazarus, и пътят read-modify-write, описан тук, е същият, през който минава всяка редакция в собствения ви процес, така че гарантиите по-горе важат дали записвате веднъж или хиляда пъти на ден — вижте продуктовата страница на PDF Library for Delphi за поддържаните компилатори и платформи