A PDF Library for Delphi v3.539.18 és v3.539.20 két olyan módot javít, ahogyan egy semmit nem változtató PDF-mentés is elronthatja a dokumentum metaadatait: amikor a /CreationDate és a /ModDate ugyanarra a stringobjektumra hivatkozott, az automatikus ModDate-frissítés mindkettőt átírta, és amikor az XMP objektum az eredeti /Metadata stream beolvasása előtt jött létre, egy alapértelmezett csomag lecserélte az eredetit. A javítások szótárhivatkozásokat cserélnek megosztott objektumok módosítása helyett, és a meglévő csomagot a lusta XMP-inicializálás előtt elkapják
A beállítás a legkevésbé érdekes művelet, amit egy PDF-könyvtár végez: betölt egy fájlt, elmenti új néven, közben semmihez nem nyúl. Az oldalak a mentés előtt és után ugyanúgy renderelődtek. A tartalom-streamek hash-ei megegyeztek. A fájl átment minden ellenőrzésen, amink volt, és mégis két helyen hibás volt, amit egyetlen renderelő sem mutatna meg soha. Mindkét hiba abban a read-modify-write útvonalban ült, amin minden valódi szerkesztés keresztülmegy, így bármilyen mentés elég volt a kiváltásukhoz, és mindkettőt csak akkor találtuk meg, amikor egy második, független parser összehasonlította a két fájl nem vizuális szemantikáját
Miért változtatja meg egy PDF mentése a CreationDate-et?
Mert a dokumentum-információs szótár két kulcsból hivatkozhat egyetlen indirekt stringobjektumra, a könyvtár pedig az objektumot frissítette, nem a kulcsot. Az ISO 32000-1 §7.3.10 megengedi, hogy bármely szótárérték indirekt hivatkozás legyen, és a §14.3.3 317. táblázata semmit nem mond arról, hogy a /CreationDate alatti értéknek más objektumnak kell lennie, mint a /ModDate alatti érték. Egy producer, aki létrehozáskor kétszer írta le ugyanazt az időbélyeget, tökéletesen legálisan mutathatja mindkét kulcsot egyetlen 2728 0 R-re, és a helyi korpuszunkban lévő CJK tervezési dokumentum pontosan ezt tette
A kiváltó ok az automatikus módosítási dátum. Ha a UserModDate nincs beállítva, a SaveToFile az írás előtt meghívja a SetInfo('ModDate', ...)-ot az aktuális idővel, ami eljut a SetRawInfo-ig. A régi SetRawInfo megkereste a kulcs alatti objektumot, és ha TPDFString-et talált, meghívta rajta a SetTo-t. Ez helyben írt abba az objektumba, amire a kulcs épp feloldódott, és amikor ez az objektum megosztott volt, a /CreationDate mostantól a mentés idejét jelenti. A dokumentum továbbra is megnyílik, nyomtatódik és pixelre pontosan úgy renderelődik, mint korábban, így egy vizuális regressziós sorozat rezzenés nélkül átmegy
var
Lib: TPDFlib;
Before, After: WideString;
begin
Lib := TPDFlib.Create;
try
Lib.LoadFromFile('design.pdf', '');
Before := Lib.GetInformation(7); // 7 = létrehozási dátum, 8 = módosítási dátum
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;
A javítás a TPDFDocument.SetRawInfo-ban kicsi, az elv mögötte általános: egy szótárbejegyzés frissítése az adott bejegyzés hivatkozását cseréli ki, soha nem azt az objektumot, amire az épp feloldódott. Az új kód beolvassa a meglévő TPDFStringMode-ot, így a hex string hex marad, a literál string literál, majd egy friss stringet ad a kulcs alá a FStructure.NewString(Value, StringMode)-ból. Két másik részlet legalább annyit számít, mint a fő változás. A stream értékű bejegyzés régi ága SetTo('')-val ürítette ki a streamet, mielőtt lecserélte volna, ami minden más kulcs értékét kiürítette volna, ami még mindig arra a streamre mutat, ezért az az ürítés eltűnt. A felváltott objektum pedig nem törlődik, mert a struktúra birtokolja, és más hivatkozásoknak még szükségük lehet rá
// Előtte: módosítsd, amire a kulcs épp feloldódik
if Obj is TPDFString then
TPDFString(Obj).SetTo(Value);
// Utána: tartsd meg a reprezentációt, csak e kulcs hivatkozását cseréld
StringMode := smLiteral;
if Obj is TPDFString then
StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));
A regresszió a Tests\SharedInfoSemantics.inc-ben szándékosan építi fel az álnevezést, nem korpuszfájlra támaszkodik: egy hex string, amire mindkét dátumkulcs hivatkozik, egy direkt string, amit a /Title és a /Subject oszt meg, és egy stream, amit a /Author és a /Keywords oszt meg. Miután mindegyik pár egyik kulcsát frissítettük, a másiknak még mindig az eredeti értékét kell olvasnia, a frissített stringnek pedig még mindig hexnek kell lennie. A SetInformation nyilvános referenciája mostantól egy mondatban kimondja a garanciát: egy Info-mező frissítése csak azt a mezőt cseréli ki, akkor is, ha más mezők ugyanarra az objektumra hivatkoznak
Miért cserélődik le egy meglévő XMP-csomag alapértelmezettre?
Két sor sorrendje miatt. A TPDFDocument.GetMetadata-nek van egy gyors útja: amikor az XMP mező már értéket kapott, visszaadja az XMP.SaveToString-ot ahelyett, hogy a katalógusból dekódolná a /Metadata streamet. Több hívási hely lustán inicializált így: XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata);, ami természetesnek olvasódik és hibás: mire a GetMetadata lefut, az XMP már értéket kapott, így a betöltött „forrás” az egy sorral korábban létrehozott objektum szerializált alapértelmezett csomagja. Az eredeti csomag a dc:creator-jával, egyedi névtereivel és bármilyen szabványazonosításával soha nem ér el az objektumig, és mentéskor felülíródik. Ugyanaz az automatikus módosítási dátum elég a kiváltásához, mert a SetInfo az Info-szótár megérintése előtt inicializálja az XMP-t, hogy az xmp:ModifyDate lépést tartson a /ModDate-dal. Vedd észre, mi mögé bújik ez a hiba: az első hibából származó Info-szótár-összehasonlítás átmegy, mivel a /Info-ban a /Author és a /Title érintetlen. Csak az XMP-fa változott, és csak az veszi észre, aki parse-olja és összehasonlítja azt a fát
// Rossz: a GetMetadata most az előző sorban létrehozott objektumot szerializálja
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);
// Helyes: előbb kapd el a /Metadata streamet, aztán hozd létre és töltsd be
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);
A javítás két dolgot tesz. A TPDFDocument.EnsureXMP mostantól a TPDFlibXMP.Create előtt elkapja a Source := GetMetadata értéket, és a dokumentum minden lusta inicializálása ennek a hívására cserélődött: SetInfo, SetXMPInformation, GetXMPInformation, a PDF/A, PDF/X, PDF/E, PDF/VT, PDF/VCR és PDF/UA mód-beállítók, valamint a metaadat-javító útvonal. A nyilvános belépési pontok, mint a SetXMPProperty, már eddig is a EnsureXMP-n mentek át, a GetXMPProperty pedig a GetDocumentMetadata-n olvas, így a teljes felület egyetlen inicializálási sorrendet oszt. Egyetlen helyes példány egy háromsoros sorozatból többet ér, mint tíz példány, amik ma véletlenül egyetértenek
Két kisebb csapda ugyanezen az útvonalon
Az XMP-szerializáló Windowson a platform XML-íróját használja, ami olyan XML-deklarációt bocsát ki, amit a csomagnak nem szabad hordoznia. A régi kód úgy távolította el, hogy karaktereket törölt, amíg el nem érte a <?xpacket elemet. Az ISO 16684-1 §7.3.2 az xpacket wrappert opcionálissá teszi, és az a producer, aki csupasz <x:xmpmeta> elemet ír, a szabványon belül van, így egy ilyen csomagon a ciklus az egész, érvényes dokumentumot törölte. A szerializáló mostantól megkeresi a deklaráció záró ?> jelét, és csak azt távolítja el. A Tests\XMPRetentionSemantics.inc kétszer futtatja a megőrzési ellenőrzést, egyszer a wrapperrel, egyszer levágva, és állítja, hogy egy egyedi névtér-markőr és az eredeti szerző túléli a SetInfo-t, a GetMetadata-ot, a SaveToString-et és az újratöltést. A második csapda egy preprocesszor-szimbólum volt: az Info–XMP szinkronizációt a SetInfo-ban a NOVCL őrizte, amit a Free Pascal buildek állítanak be, az XMP back-endet viszont az operációs rendszer kapuzza, nem a keretrendszer, mivel a PDFlibXMP.pas csak akkor definiálja a NO_XMP-t, ha az OS_WINDOWS hiányzik. Egy Windowsos Lazarus buildnek így működő XMP-objektuma volt, és egy SetInfo-ja, ami csendben kihagyta a frissítését. Az őr mostantól a NO_XMP, így egy Windowsos Free Pascal alkalmazás ugyanazt a szinkronizációt kapja, mint a Delphi
Hogyan tartod meg az eredeti ModDate-et egy pass-through mentésnél?
Állítsd be a KeepModDate-et a TPDFlibSaveOptions-ban, és ments a SaveToFileOptions-on keresztül. Az opció a hívás idejére beállítja a UserModDate-et, a SaveToFile pedig kihagyja az automatikus időbélyeget, ami egyben az a lépés is, ami lustán inicializálja az XMP-objektumot. Egy dokumentum, aminek a metaadataihoz soha nem nyúltál, és aminek nem kapcsoltál be megfelelőségi módot, úgy tartja meg az Info-szótárát és a /Metadata streamjét, ahogy betöltődött. A SetInformation(8, ...) hívása ugyanezt a hatást éri el véglegesen, mert a módosítási dátum saját kezű beállítása felhasználói kézben lévőként jelöli meg azt
var
Options: TPDFlibSaveOptions;
begin
FillChar(Options, SizeOf(Options), 0);
Options.OptimizeContentStreams := True;
Options.PackObjectStreams := True;
Options.KeepModDate := True; // nincs automatikus /ModDate, nincs lusta XMP-init
if Lib.SaveToFileOptions('design-resaved.pdf', Options) <> 1 then
Log(Format('save failed, LastErrorCode=%d', [Lib.LastErrorCode]));
end;
Légy őszinte azzal kapcsolatban, mit nyersz ezzel. A KeepModDate a helyes választás egy pass-through lépéshez, aminek a kimenete ugyanazt a revíziót írja le, mint a bemenete, és rossz választás mindenhez, ami ténylegesen szerkeszti a tartalmat, mert a §14.3.3 azt várja, hogy a /ModDate a legutóbbi módosítást tükrözze. Az sem javít visszamenőleg egy olyan könyvtárat, ami megosztott objektumokat módosít; csak azt az egy írást kerüli el, ami leleplezte a hibát. A fenti két javítás az, ami biztonságossá teszi a hétköznapi mentést, az opció pedig az, ami őszintévé tesz egy szándékos no-op-ot
Hogyan ellenőrzöd, hogy egy mentés csak a ModDate-et változtatta meg?
Nem pixelekkel és nem stream-hashekkel, mert mindkét hiba minden oldalt és minden tartalom-streamet bájt pontosan azonoson hagy. Az ellenőrzés, ami elkapja őket, egy független parser által készített nem vizuális szemantikai pillanatkép, ami semennyi kódot nem oszt a tesztelt könyvtárral, a forrásfájlból és a mentett fájlból, majd egy strukturális összehasonlítás. A pillanatkép lefedi az Info-szótárat a /ModDate kizárásával, a könyvjelzőfát, ahol minden könyvjelző oldalszámra van feloldva objektumszám helyett, a nevesített célokat és linkcélokat ugyanígy feloldva, az űrlapmezőértékeket, a mellékletek bájtjait hash formában, és az XMP-csomagot faként parse-olva, nem szövegként összehasonlítva. Az objektumszámok szándékosan nem részei, mivel egy teljes újraírás mindent újraszámoz, és a rájuk kulcsolt összehasonlítás csak zajt jelentene
A kizárások legalább olyan fontosak, mint a belefoglalások. A /ModDate, az xmp:ModifyDate és az xmp:MetadataDate várhatóan változik, és az összehasonlítás előtt kiesnek; egy fájlt, aminek a forrása egyáltalán nem hordozott XMP-t, nem büntetünk azért, mert kapott egy csomagot. Az, amit az ellenőrzés nem állít, ugyanilyen explicit: egy meglévő csomag megőrzése semmit nem mond arról, hogy az a csomag séma-érvényes-e, vagy hogy a dokumentum megfelel-e a PDF/UA-nak vagy bármely PDF/A résznek. Ezek külön kérdések, külön eszközökkel, és ha összemosod azt, hogy „a metaadat túlélte” azzal, hogy „a metaadat megfelelő”, pont így maradt rejtve az első hiba olyan sokáig. A könyvtár oldalán a két regresszió mostantól minden célzott futáson lefut Delphi Win32 és Win64, valamint Free Pascal Win32 és Win64 alatt, a szemantikai összehasonlítás pedig egy átengedési feltétel a valódi dokumentumokat tartalmazó korpusz benchmarkon
Ha ezeknél a javításoknál mélyebb szinten dolgozol, annak a mechanikája, hogyan írja újra egy mentés az objektumokat, az inkrementális frissítésről és a hozzáfűző mentésről szóló cikkben van, ami az egyetlen olyan mentési mód, ahol egy megosztott objektum egyszerűen ott marad, ahol volt, és a módosítási szintekről és revízió-diffelésről szóló cikkben, ami a másik hely, ahol egy elavult vagy újraírt dátum félrevezet egy olvasót. Ugyanannak az Info és XMP párnak a javítás oldali nézete, ahol a két felet egyeztetni kell a puszta megőrzés helyett, a PDF/A-vá alakítás és metaadat-javítás cikkben van
A PDF Library for Delphi egy natív Pascal PDF-könyvtár Delphihez, C++Builderhez és Lazarushoz, és az itt leírt read-modify-write útvonal ugyanaz, amin a saját folyamatodban minden szerkesztés keresztülmegy, így a fenti garanciák érvényesek, akár naponta egyszer mentesz, akár ezerszer — a támogatott fordítókat és platformokat a PDF Library for Delphi termékoldala sorolja fel