PDF Library for Delphi v3.539.18 a v3.539.20 opravujú dve cesty, ktorými uloženie PDF bez jedinej zmeny dokázalo pokaziť metadáta dokumentu: keď /CreationDate a /ModDate odkazovali na ten istý reťazcový objekt, automatická aktualizácia ModDate prepísala oba, a keď objekt XMP vznikol skôr, než sa prečítal pôvodný stream /Metadata, predvolený paket nahradil pôvodný. Opravy nahrádzajú referencie v slovníku namiesto mutovania zdieľaných objektov a zachytávajú existujúci paket ešte pred lazy inicializáciou XMP
Uloženie je tá najmenej zaujímavá operácia, akú PDF knižnica vykonáva: načítaj súbor, ulož ho pod novým menom, medzi tým sa ničoho nedotkni. Stránky sa pred aj po vykreslili identicky. Hashy content streamov sedeli. Súbor prešiel každou kontrolou, ktorú sme mali, a napriek tomu bol zlý na dvoch miestach, ktoré by vám žiadny renderer nikdy neukázal. Oba defekty sedeli v read-modify-write ceste, cez ktorú prechádza každá skutočná úprava, takže na ich spustenie stačilo akékoľvek uloženie, a oba sa našli len vtedy, keď dva súbory porovnal druhý, nezávislý parser z hľadiska nevizuálnej sémantiky
Prečo uloženie PDF zmení jeho CreationDate?
Pretože slovník s informáciami o dokumente smie z dvoch kľúčov odkazovať na jeden nepriamy reťazcový objekt a knižnica aktualizovala objekt, nie kľúč. ISO 32000-1 §7.3.10 dovoľuje, aby hodnotou v slovníku bola nepriama referencia, a nikde v §14.3.3 tabuľke 317 sa nepíše, že hodnota pod /CreationDate musí byť iný objekt než hodnota pod /ModDate. Producer, ktorý pri vytváraní zapísal ten istý timestamp dvakrát, môže úplne legálne nasmerovať oba kľúče na jediné 2728 0 R, a presne to urobil jeden CJK dizajnový dokument v našom lokálnom korpuse
Spúšťačom je automatický dátum modifikácie. Ak nie je nastavený UserModDate, SaveToFile pred zápisom zavolá SetInfo('ModDate', ...) s aktuálnym časom, čo sa dostane až do SetRawInfo. Starý SetRawInfo vyhľadal objekt pod kľúčom a ak našiel TPDFString, zavolal naň SetTo. To je zápis na mieste do toho objektu, na ktorý kľúč práve ukazuje, a keď je ten objekt zdieľaný, /CreationDate teraz hlási aj čas uloženia. Dokument sa stále otvorí, vytlačí a vykreslí pixel po pixeli ako predtým, takže vizuálna regresná sada prejde bez žmurknutia
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;
Oprava v TPDFDocument.SetRawInfo je malá a princíp za ňou je všeobecný: aktualizácia položky v slovníku nahradí referenciu tej položky, nikdy nie objekt, na ktorý práve ukazovala. Nový kód prečíta existujúci TPDFStringMode, aby hex reťazec zostal hex a literál zostal literálom, a potom pod kľúč pridá čerstvý reťazec z FStructure.NewString(Value, StringMode). Dva ďalšie detaily sú rovnako dôležité ako tá hlavná zmena. Stará vetva pre položku s hodnotou stream pred nahradením vyčistila stream cez SetTo(''), čo by vyprázdnilo hodnotu pre každý ďalší kľúč, ktorý na ten stream ešte ukazuje, takže to čistenie je preč. A nahradený objekt sa nemaže, pretože ho vlastní štruktúra a iné referencie ho môžu ešte potrebovať
// Predtým: mutuj čokoľvek, na čo kľúč práve ukazuje
if Obj is TPDFString then
TPDFString(Obj).SetTo(Value);
// Potom: zachovaj reprezentáciu, nahraď len referenciu tohto kľúča
StringMode := smLiteral;
if Obj is TPDFString then
StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));
Regresný test v Tests\SharedInfoSemantics.inc stavia to aliasovanie zámerne, nie na základe súboru z korpusu: jeden hex reťazec referencovaný z oboch dátumových kľúčov, jeden priamy reťazec zdieľaný medzi /Title a /Subject, jeden stream zdieľaný medzi /Author a /Keywords. Po aktualizácii jedného kľúča z každej dvojice musí ten druhý stále čítať svoju pôvodnú hodnotu a aktualizovaný reťazec musí zostať hex. Verejná referencia pre SetInformation teraz tú záruku hovorí jednou vetou: aktualizácia poľa Info nahradí len to pole, aj keď iné polia odkazujú na ten istý objekt
Prečo existujúci XMP paket nahradia predvolené hodnoty?
Kvôli poradiu dvoch riadkov. TPDFDocument.GetMetadata má rýchlu cestu: keď je pole XMP už priradené, vráti XMP.SaveToString namiesto dekódovania streamu /Metadata z katalógu. Niekoľko miest volania sa inicializovalo lazy zápisom XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata);, čo sa číta prirodzene a je to zle: v čase, keď GetMetadata beží, je XMP priradené, takže "zdrojom", ktorý sa načítava, je serializovaný predvolený paket objektu vytvoreného o riadok skôr. Pôvodný paket s jeho dc:creator, vlastnými namespacami a prípadnou identifikáciou štandardu sa do objektu nikdy nedostane a pri uložení sa prepíše. Na spustenie stačí ten istý automatický dátum modifikácie, pretože SetInfo inicializuje XMP skôr, než sa dotkne slovníka Info, aby xmp:ModifyDate držal krok s /ModDate. Všimnite si, za čím sa tento defekt schováva: porovnanie slovníka Info z prvej chyby prejde, keďže /Author a /Title v /Info sú nedotknuté. Zmenil sa len strom XMP a všimne si to len kontrola, ktorá ten strom parsuje a porovnáva
// Nesprávne: GetMetadata teraz serializuje objekt vytvorený na predchádzajúcom riadku
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);
// Správne: najprv zachyť stream /Metadata, potom vytvor a načítaj
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);
Oprava robí dve veci. TPDFDocument.EnsureXMP teraz zachytí Source := GetMetadata pred TPDFlibXMP.Create a každá lazy inicializácia v dokumente bola nahradená volaním tohto miesta: SetInfo, SetXMPInformation, GetXMPInformation, settery režimov PDF/A, PDF/X, PDF/E, PDF/VT, PDF/VCR a PDF/UA aj cesta opravy metadát. Verejné vstupné body ako SetXMPProperty už cez EnsureXMP išli a GetXMPProperty číta cez GetDocumentMetadata, takže celá plocha zdieľa jedno poradie inicializácie. Jedna správna kópia trojriadkovej sekvencie má väčšiu cenu než desať kópií, ktoré dnes náhodou súhlasia
Dve menšie pasce nájdené na tej istej ceste
XMP serializér na Windows používa platformový XML writer, ktorý vyprodukuje XML deklaráciu, akú paket nesmie niesť. Starý kód ju odstránil tak, že mazal znaky, dokým sa nedostal na <?xpacket. ISO 16684-1 §7.3.2 robí xpacket wrapper voliteľným a producer, ktorý zapíše holý element <x:xmpmeta>, je v rámci štandardu, takže na takom pakete slučka zmazala celý platný dokument. Serializér teraz nájde zatváracie ?> deklarácie a odstráni len to. Tests\XMPRetentionSemantics.inc púšťa svoju retenčnú kontrolu dvakrát, raz s wrapperom a raz s ním odrezaným, a overuje, že marker vlastného namespace a pôvodný autor prežijú SetInfo, GetMetadata, SaveToString aj opätovné načítanie. Druhá pasca bol preprocesorový symbol: synchronizácia Info do XMP v SetInfo bola strážená cez NOVCL, ktorý je nastavený pre buildy na Free Pascal, ale XMP backend sa riadi operačným systémom, nie frameworkom, keďže PDFlibXMP.pas definuje NO_XMP len vtedy, keď chýba OS_WINDOWS. Windows build pod Lazarusom tak mal funkčný objekt XMP a SetInfo, ktorý jeho aktualizáciu ticho preskakoval. Strážou je teraz NO_XMP, takže Windows aplikácia na Free Pascal dostane rovnakú synchronizáciu ako Delphi
Ako si pri pass-through uložení udržíte pôvodný ModDate?
Nastavte KeepModDate v TPDFlibSaveOptions a uložte cez SaveToFileOptions. Tá voľba nastaví UserModDate na čas trvania volania a SaveToFile potom preskočí automatický timestamp, čo je zároveň ten krok, ktorý lazy inicializuje objekt XMP. Dokument, ktorého metadát ste sa nikdy nedotkli a pre ktorý nebol zapnutý žiadny compliance režim, si udrží slovník Info aj stream /Metadata tak, ako boli načítané. Volanie SetInformation(8, ...) má ten istý efekt natrvalo, pretože keď dátum modifikácie nastavíte sami, označí sa ako používateľom riadený
var
Options: TPDFlibSaveOptions;
begin
FillChar(Options, SizeOf(Options), 0);
Options.OptimizeContentStreams := True;
Options.PackObjectStreams := True;
Options.KeepModDate := True; // žiadny automatický /ModDate, žiadny lazy XMP init
if Lib.SaveToFileOptions('design-resaved.pdf', Options) <> 1 then
Log(Format('save failed, LastErrorCode=%d', [Lib.LastErrorCode]));
end;
Buďte poctiví v tom, čo vám to prinesie. KeepModDate je správna voľba pre pass-through krok, ktorého výstup má opisovať tú istú revíziu ako jeho vstup, a nesprávna voľba pre čokoľvek, čo obsah naozaj upravuje, pretože §14.3.3 očakáva, že /ModDate odzrkadlí najnovšiu modifikáciu. Zároveň spätne neopraví knižnicu, ktorá mutuje zdieľané objekty; len sa vyhne tomu jednému zápisu, ktorý defekt odhalil. Obe opravy vyššie sú to, čo robí bežné uloženie bezpečným, a tá voľba je to, čo robí zámerné no-op uloženie poctivým
Ako overíte, že uloženie nezmenilo nič okrem ModDate?
Nie cez pixely a nie cez hashe streamov, pretože oba defekty nechávajú každú stránku a každý content stream bajt po bajte identické. Kontrola, ktorá ich chytila, je nevizuálny sémantický snapshot od nezávislého parsera, teda takého, ktorý nezdieľa kód s testovanou knižnicou, urobený zo zdrojového aj uloženého súboru a nasledovaný štrukturálnym porovnaním. Snapshot pokrýva slovník Info s vylúčeným /ModDate, strom outline s každou záložkou preloženou na číslo stránky namiesto čísla objektu, named destinations a ciele odkazov preložené rovnako, hodnoty polí formulára, bajty príloh ako hashe a XMP paket naparsovaný ako strom, nie porovnávaný ako text. Čísla objektov doň zámerne nepatria, keďže úplný prepis všetko prečísluje a porovnanie kľúčované podľa nich by hlásilo len šum
Vylúčenia sú rovnako dôležité ako to, čo snapshot obsahuje. /ModDate, xmp:ModifyDate a xmp:MetadataDate sa očakávane menia a pred porovnaním sa vypúšťajú; súbor, ktorého zdroj nenesie žiadne XMP, nie je penalizovaný za to, že paket získal. Rovnako explicitné je aj to, čo kontrola netvrdí: zachovanie existujúceho paketu nehovorí nič o tom, či je ten paket schema-valid alebo či dokument vyhovuje PDF/UA alebo niektorej časti PDF/A. To sú oddelené otázky s oddelenými nástrojmi a miešanie "metadáta prežili" s "metadáta sú v súlade" je presne to, vďaka čomu sa prvá chyba skrývala tak dlho. Na strane knižnice obe regresie teraz bežia pri každom targeted prechode na Delphi Win32 a Win64 aj Free Pascal Win32 a Win64 a sémantické porovnanie je podmienkou prejdenia pre benchmark s korpusom reálnych dokumentov
Ak pracujete o úroveň nižšie pod týmito opravami, mechanika toho, ako uloženie prepisuje objekty, je pokrytá v incrementálnych aktualizáciách a append-only ukladaní, čo je jediný režim ukladania, v ktorom zdieľaný objekt jednoducho zostane tam, kde bol, a v modification levels a porovnávaní revízií, čo je to druhé miesto, kde zastaraný alebo prepísaný dátum pomýli čitateľa. Pohľad z opravnej strany na tú istú dvojicu Info a XMP, kde sa obe polovice zosúlaďujú a nie iba zachovávajú, je v konverzii do PDF/A a oprave metadát
PDF Library for Delphi je natívna Pascal PDF knižnica pre Delphi, C++Builder a Lazarus a read-modify-write cesta opísaná vyššie je tá istá, cez ktorú prechádza každá úprava vo vašom procese, takže uvedené záruky platia bez ohľadu na to, či ukladáte raz denne alebo tisíckrát — podporované kompilátory a platformy nájdete na produktovej stránke PDF Library for Delphi