PDF Library for Delphi ve verzích v3.539.18 a v3.539.20 opravila dva způsoby, jak mohlo uložení PDF, které nic nemění, i tak poškodit metadata dokumentu: když /CreationDate a /ModDate odkazovaly na tentýž řetězcový objekt, automatická aktualizace ModDate přepsala obojí, a když se objekt XMP vytvořil dřív, než se přečetl původní stream /Metadata, nahradil původní packet defaultní. Opravy nahrazují odkazy ve slovnících místo mutace sdílených objektů a zachytí existující packet před línou inicializací XMP
Uložení je nejméně zajímavá operace, kterou PDF knihovna dělá: načti soubor, ulož ho pod novým jménem, nic mezi tím se nedotýkej. Stránky se před i po vykreslily identicky. Hashe content streamů seděly. Soubor prošel každou kontrolou, kterou jsme měli, a přesto byl špatně na dvou místech, která vám nikdy neukáže žádný renderer. Obě vady seděly v cestě read-modify-write, kterou prochází každá skutečná editace, takže ke spuštění stačilo úplně jakékoli uložení, a objevily se teprve, když druhý, nezávislý parser porovnal nevizuální sémantiku obou souborů
Proč uložení PDF změní jeho CreationDate?
Protože informační slovník dokumentu smí odkazovat na jeden nepřímý řetězcový objekt ze dvou klíčů a knihovna aktualizovala objekt místo klíče. ISO 32000-1 §7.3.10 dovoluje, aby hodnotou slovníku byla nepřímá reference, a nic v §14.3.3, tabulce 317 neříká, že hodnota pod /CreationDate musí být jiný objekt než hodnota pod /ModDate. Producent, který zapsal tentýž časový údaj dvakrát už při vytvoření, může úplně legálně ukázat oběma klíči na jediné 2728 0 R — a přesně tohle udělal CJK designový dokument v našem lokálním korpusu
Spouštěčem je automatické datum modifikace. Dokud není nastavené UserModDate, volá SaveToFile před zápisem SetInfo('ModDate', ...) s aktuálním časem, což dojde až k SetRawInfo. Staré SetRawInfo vyhledalo objekt pod klíčem a když našlo TPDFString, zavolalo na něm SetTo. To je zápis na místě do toho, co klíč aktuálně řeší, a když je ten objekt sdílený, hlásí /CreationDate teď také čas uložení. Dokument se pořád otevře, vytiskne a vykreslí pixel za pixelem jako dřív, takže vizuální regresní sada projde ani nemrkne
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 princip za ní obecný: aktualizace položky slovníku nahradí reference té položky, nikdy objekt, na který zrovna ukazovala. Nový kód přečte stávající TPDFStringMode, takže hex string zůstane hex a literal string zůstane literal, a pak přidá pod klíč čerstvý řetězec z FStructure.NewString(Value, StringMode). Dva další detaily mají stejnou váhu jako ta hlavní změna. Stará větev pro položku se streamem vynulovala stream přes SetTo('') dřív, než ho nahradila, čímž by vyprázdnila hodnotu každému jinému klíči, který na ten stream ještě ukazuje, takže tohle mazání je pryč. A nahrazený objekt se nemaže, protože ho vlastní struktura a ostatní reference ho mohou ještě potřebovat
// Před: mutuj cokoliv, na co klíč aktuálně ukazuje
if Obj is TPDFString then
TPDFString(Obj).SetTo(Value);
// Po: nech reprezentaci, nahraď jen referenci tohoto klíče
StringMode := smLiteral;
if Obj is TPDFString then
StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));
Regrese v Tests\SharedInfoSemantics.inc si aliasing postaví záměrně, místo aby spoléhala na soubor z korpusu: jeden hex string odkazovaný z obou datových klíčů, jeden přímý řetězec sdílený /Title a /Subject, jeden stream sdílený /Author a /Keywords. Po aktualizaci jednoho klíče z každého páru musí druhý stále číst svou původní hodnotu a aktualizovaný řetězec musí zůstat hex. Veřejná reference pro SetInformation teď uvádí záruku jednou větou: aktualizace pole Info nahradí jen to pole, i když na tentýž objekt odkazují další pole
Proč se existující packet XMP nahradí defaulty?
Kvůli pořadí dvou řádků. TPDFDocument.GetMetadata má zkratku: když je pole XMP už přiřazené, vrátí XMP.SaveToString místo dekódování streamu /Metadata z katalogu. Několik míst volání inicializovalo líně přes XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata);, což se čte přirozeně a je to špatně: v okamžiku, kdy běží GetMetadata, je XMP už přiřazené, takže „zdroj“, který se načítá, je serializovaný defaultní packet objektu vytvořeného o řádku dřív. Původní packet se svým dc:creator, vlastními namespaces a jakoukoli identifikací standardů se k objektu vůbec nedostane a při uložení se přepíše. Ke spuštění stačí i to samé automatické datum modifikace, protože SetInfo inicializuje XMP dřív, než sáhne na Info slovník, aby xmp:ModifyDate držel krok s /ModDate. Všimněte si, za čím se tahle vada kryje: porovnání Info slovníku z první chyby projde, protože /Author a /Title v /Info zůstaly nedotčené. Změnil se jen strom XMP a všimne si ho jen kontrola, která ten strom parsuje a porovnává
// Špatně: GetMetadata teď serializuje objekt vytvořený na předchozím řádku
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);
// Správně: nejdřív zachyť stream /Metadata, pak vytvoř a načti
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);
Oprava dělá dvě věci. TPDFDocument.EnsureXMP teď zachytí Source := GetMetadata před TPDFlibXMP.Create a každá líná inicializace v dokumentu se nahradila voláním ho: SetInfo, SetXMPInformation, GetXMPInformation, settery režimů PDF/A, PDF/X, PDF/E, PDF/VT, PDF/VCR a PDF/UA a cesta opravy metadat. Veřejné vstupní body jako SetXMPProperty už přes EnsureXMP chodily a GetXMPProperty čte přes GetDocumentMetadata, takže celý povrch sdílí jedno pořadí inicializace. Jedna správná kopie třířádkové sekvence stojí víc než deset kopií, které se dnes náhodou shodují
Dvě menší pasti nalezené na stejné cestě
Serializér XMP na Windows používá platformový XML writer, který vysílá XML deklaraci, kterou packet nosit nesmí. Starý kód ji odstranil mazáním znaků, dokud nedošel k <?xpacket. ISO 16684-1 §7.3.2 činí obal xpacket volitelným a producent, který zapíše holý element <x:xmpmeta>, je ve standardu, takže na takovém packetu smyčka smazala celý platný dokument. Serializér teď najde uzavírací ?> deklarace a odstraní jen to. Tests\XMPRetentionSemantics.inc spouští kontrolu retence dvakrát, jednou s obalem a jednou s odříznutým, a ověřuje, že marker vlastního namespace i původní autor přežijí SetInfo, GetMetadata, SaveToString a znovunačtení. Druhou pastí byl preprocesorový symbol: synchronizace Info do XMP v SetInfo se hlídala přes NOVCL, který je nastavený pro buildy Free Pascal, ale backend XMP se řídí operačním systémem, ne frameworkem, protože PDFlibXMP.pas definuje NO_XMP jen tehdy, když chybí OS_WINDOWS. Build pro Windows Lazarus tak měl funkční objekt XMP a SetInfo, který jeho aktualizaci tiše vynechal. Hlídka je teď NO_XMP, takže aplikace pro Windows Free Pascal dostane stejnou synchronizaci jako Delphi
Jak zachováte původní ModDate při průchozím uložení?
Nastavte KeepModDate v TPDFlibSaveOptions a ukládejte přes SaveToFileOptions. Volba nastaví UserModDate na dobu volání a SaveToFile pak vynechá automatický časový údaj, což je zároveň krok, který líně inicializuje objekt XMP. Dokument, jehož metadata jste nikdy neopérovali a pro který nebyl zapnutý žádný režim shody, ponechá Info slovník i stream /Metadata tak, jak přišel. Volání SetInformation(8, ...) má tentýž efekt trvale, protože vlastnoruční nastavení data modifikace ho označí jako řízené uživatelem
var
Options: TPDFlibSaveOptions;
begin
FillChar(Options, SizeOf(Options), 0);
Options.OptimizeContentStreams := True;
Options.PackObjectStreams := True;
Options.KeepModDate := True; // bez automatického /ModDate, bez líné inicializace XMP
if Lib.SaveToFileOptions('design-resaved.pdf', Options) <> 1 then
Log(Format('save failed, LastErrorCode=%d', [Lib.LastErrorCode]));
end;
Buďte upřímní v tom, co vám tohle koupí. KeepModDate je správná volba pro průchozí krok, jehož výstup má popisovat stejnou revizi jako jeho vstup, a špatná volba pro cokoliv, co obsah doopravdy edituje, protože §14.3.3 čeká, že /ModDate odpovídá poslední modifikaci. Také to nijak dodatečně nezachrání knihovnu, která mutuje sdílené objekty; jen se vyhne tomu jednomu zápisu, který vadu prozradil. Obě opravy výše jsou to, co dělá obyčejné uložení bezpečným, a volba je to, co dělá záměrný no-op poctivým
Jak ověříte, že uložení nezměnilo nic kromě ModDate?
Ne pixely a ne hashe streamů, protože obě vady nechávají každou stránku i každý content stream byte za bytem identické. Kontrola, která je chytla, je nevizuální sémantický snímek pořízený nezávislým parserem — takovým, který s testovanou knihovnou nesdílí ani řádek kódu — ze zdrojového souboru a ze souboru uloženého, následovaný strukturálním porovnáním. Snímek pokrývá Info slovník s vynechaným /ModDate, strom outline s každou záložkou vyřešenou na číslo stránky místo čísla objektu, pojmenované destinace a cíle odkazů řešené stejným způsobem, hodnoty polí formuláře, bajty příloh jako hashe a packet XMP parsovaný jako strom místo porovnávání jako text. Čísla objektů nejsou záměrně jeho součástí, protože plný přepis všechno přečísluje a porovnání na nich založené by hlásilo jen šum
Vynechané jsou stejně důležité jako zahrnuté. /ModDate, xmp:ModifyDate a xmp:MetadataDate se mají změnit a před porovnáním se zahodí; soubor, jehož zdroj nenosil žádné XMP, se netrestá za to, že packet získal. Stejně výslovné je i to, co kontrola netvrdí: zachování existujícího packetu neříká nic o tom, zda je schema-valid, nebo zda dokument splňuje PDF/UA či jakoukoli část PDF/A. To jsou samostatné otázky se samostatnými nástroji a zaměňování „metadata přežila“ s „metadata jsou compliant“ je důvod, proč se první chyba kryla tak dlouho. Na straně knihovny běží ty dvě regrese teď v každém cíleném průchodu napříč Delphi Win32 a Win64 a Free Pascal Win32 a Win64 a sémantické porovnání je podmínkou průchodu pro benchmark korpusu reálných dokumentů
Pokud pracujete o úroveň pod těmito opravami, mechaniku, jak uložení přepisuje objekty, pokrývá článek o inkrementálních aktualizacích a append-only ukládání, který je jediným režimem ukládání, kde se sdílený objekt prostě nechá ležet tam, kde byl, a článek o úrovních modifikace a diffu revizí, který je tím druhým místem, kde staré nebo přepsané datum zavede čtenáře. Opravný pohled na tentýž pár Info a XMP, kde se obě poloviny přivádějí do shody místo pouhého zachování, je v článku o konverzi do PDF/A a opravě metadat
PDF Library for Delphi je nativní Pascal PDF knihovna pro Delphi, C++Builder a Lazarus a cesta read-modify-write popsaná tady je ta samá, kterou prochází každá editace ve vašem vlastním procesu, takže záruky výše platí, ať ukládáte jednou nebo tisíckrát denně — podporované kompilátory a platformy najdete na stránce produktu PDF Library for Delphi