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 вече докладва времето на записа. Документът пак се отваря, печата и рендира пиксел по пиксел както преди, така че визуален регресионен пакет минава без да мигне
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 дървото е променено, и само проверка, която парсва и сравнява това дърво, забелязва
// Грешно: 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 пакета, парснат като дърво, а не сравняван като текст. Номерата на обектите нарочно не са част от него, тъй като пълно пренаписване преномерира всичко, а сравнение, ключовано по тях, би докладвало шум
Изключванията са толкова важни, колкото включванията. /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 за поддържаните компилатори и платформи