PDF Library for Delphi v3.539.18 i v3.539.20 popravljaju dva načina na koja je spremanje PDF-a koje ne mijenja ništa ipak moglo pokvariti metapodatke dokumenta: kad su /CreationDate i /ModDate referencirali isti string objekt, automatsko ažuriranje ModDatea prepisivalo je oba, a kad je XMP objekt stvoren prije nego što je izvorni /Metadata stream pročitan, zadani paket zamijenio je izvorni. Popravci zamjenjuju reference u rječniku umjesto da mijenjaju dijeljene objekte, i hvataju postojeći paket prije lijene XMP inicijalizacije
Ovaj scenarij najnezanimljivija je operacija koju PDF biblioteka izvodi: učitaj datoteku, spremi je pod novim imenom, ništa ne diraj između. Stranice su se prikazivale jednako prije i poslije. Hashovi content streamova poklapali su se. Datoteka je prošla svaku provjeru koju smo imali, a ipak je bila pogrešna na dva mjesta koja vam nijedan renderer ne bi pokazao. Obje greške sjedile su u read-modify-write putu kroz koji prolazi svako pravo uređivanje, pa je bilo kakvo spremanje bilo dovoljno da ih pokrene, a obje su nađene tek kad je drugi, neovisni parser usporedio ne-vizualnu semantiku tih dviju datoteka
Zašto spremanje PDF-a promijeni njegov CreationDate?
Zato što rječnik informacija dokumenta smije referencirati jedan neizravni string objekt iz dvaju ključeva, a biblioteka je ažurirala objekt, a ne ključ. ISO 32000-1 §7.3.10 dopušta da bilo koja vrijednost rječnika bude neizravna referenca, a ništa u §14.3.3 Tablici 317 ne kaže da vrijednost pod /CreationDate mora biti drugi objekt od vrijednosti pod /ModDate. Proizvođač koji je pri stvaranju dvaput zapisao isti vremenski žig može, posve zakonito, usmjeriti oba ključa na jedan 2728 0 R, što je točno ono što je učinio jedan CJK dizajnerski dokument u našem lokalnom korpusu
Okidač je automatski datum izmjene. Ako UserModDate nije postavljen, SaveToFile prije pisanja poziva SetInfo('ModDate', ...) s trenutačnim vremenom, što dolazi do SetRawInfo. Stari SetRawInfo tražio je objekt pod ključem i, ako bi našao TPDFString, pozvao je SetTo nad njim. To je upis na mjestu u koji god objekt ključ trenutačno razrješava, a kad je taj objekt dijeljen, /CreationDate sada prijavljuje i vrijeme spremanja. Dokument se i dalje otvara, ispisuje i renderira piksel po piksel kao prije, pa vizualni regresijski suite prolazi bez treptaja
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;
Popravak u TPDFDocument.SetRawInfo malen je, a princip iza njega općenit: ažuriranje unosa rječnika zamjenjuje referencu tog unosa, nikad objekt u koji se slučajno razriješio. Novi kod čita postojeći TPDFStringMode kako bi hex string ostao hex, a literal string ostao literal, zatim dodaje svježi string iz FStructure.NewString(Value, StringMode) pod ključ. Dvije druge pojedinosti jednako su važne kao i glavna promjena. Stara grana za unos s vrijednošću streama čistila je stream s SetTo('') prije zamjene, što bi ispraznilo vrijednost za svaki drugi ključ koji još pokazuje na taj stream, pa je to čišćenje uklonjeno. A zamijenjeni objekt ne briše se, jer struktura ga posjeduje i drugim referencama može još trebati
// Prije: mijenjaj na mjestu koji god objekt ključ trenutačno razrješava
if Obj is TPDFString then
TPDFString(Obj).SetTo(Value);
// Poslije: sačuvaj reprezentaciju, zamijeni samo referencu ovog ključa
StringMode := smLiteral;
if Obj is TPDFString then
StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));
Regresijski test u Tests\SharedInfoSemantics.inc gradi to alijasiranje namjerno, a ne oslanjajući se na datoteku iz korpusa: jedan hex string referenciran iz obaju datumskih ključeva, jedan izravni string koji dijele /Title i /Subject, jedan stream koji dijele /Author i /Keywords. Nakon ažuriranja jednog ključa iz svakog para, drugi i dalje mora čitati svoju izvornu vrijednost, a ažurirani string i dalje mora biti hex. Javna referenca za SetInformation sada izriče to jamstvo u jednoj rečenici: ažuriranje Info polja zamjenjuje samo to polje, čak i kad druga polja referenciraju isti objekt
Zašto postojeći XMP paket bude zamijenjen zadanim vrijednostima?
Zbog redoslijeda dvaju redaka. TPDFDocument.GetMetadata ima brzi put: kad je polje XMP već dodijeljeno, vraća XMP.SaveToString umjesto da dekodira /Metadata stream iz kataloga. Nekoliko pozivnih mjesta lijeno se inicijaliziralo s XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata);, što se čita prirodno i pogrešno je: dok GetMetadata izvrši, XMP je dodijeljen, pa je "izvor" koji se učitava serijalizirani zadani paket objekta stvorenog jedan redak ranije. Izvorni paket, sa svojim dc:creator, prilagođenim namespaceovima i bilo kakvom identifikacijom standarda, nikad ne stigne do objekta i bude prepisan pri spremanju. Isti automatski datum izmjene dovoljan je da to pokrene, jer SetInfo inicijalizira XMP prije nego dotakne Info rječnik, kako bi xmp:ModifyDate ostao u koraku s /ModDate. Primijetite iza čega se ta greška skriva: usporedba Info rječnika iz prvog buga prolazi, jer /Author i /Title u /Info nisu dirani. Promijenilo se samo XMP stablo, i to primjećuje samo provjera koja to stablo parsira i usporedi
// Pogrešno: GetMetadata sada serijalizira objekt stvoren u prethodnom retku
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);
// Ispravno: prvo uhvati /Metadata stream, pa stvori i učitaj
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);
Popravak radi dvije stvari. TPDFDocument.EnsureXMP sada hvata Source := GetMetadata prije TPDFlibXMP.Create, a svaka lijena inicijalizacija u dokumentu zamijenjena je pozivom na njega: SetInfo, SetXMPInformation, GetXMPInformation, setteri modova PDF/A, PDF/X, PDF/E, PDF/VT, PDF/VCR i PDF/UA, i put popravka metapodataka. Javne ulazne točke poput SetXMPProperty već su išle kroz EnsureXMP, a GetXMPProperty čita kroz GetDocumentMetadata, pa cijela površina dijeli jedan redoslijed inicijalizacije. Jedan ispravan primjerak slijeda od tri retka vrijedi više od deset primjeraka koji se danas slučajno slažu
Dvije manje zamke nađene na istom putu
XMP serializer na Windowsu koristi platformski XML writer, koji emitira XML deklaraciju koju paket ne smije nositi. Stari kod uklanjao ju je brisanjem znakova dok ne bi stigao do <?xpacket. ISO 16684-1 §7.3.2 čini xpacket omotač opcionalnim, a proizvođač koji zapisuje goli <x:xmpmeta> element unutar je standarda, pa je na takvom paketu petlja izbrisala cijeli, valjani dokument. Serializer sada pronalazi završni ?> deklaracije i uklanja samo njega. Tests\XMPRetentionSemantics.inc pokreće svoju provjeru zadržavanja dvaput, jednom s omotačem i jednom s njim odsječenim, i tvrdi da marker prilagođenog namespacea i izvorni autor prežive SetInfo, GetMetadata, SaveToString i ponovno učitavanje. Druga zamka bio je preprocesorski simbol: sinkronizacija Info u XMP u SetInfo bila je čuvana s NOVCL, koji je postavljen za Free Pascal buildove, ali XMP backend određuje operacijski sustav, a ne framework, jer PDFlibXMP.pas definira NO_XMP samo kad OS_WINDOWS nije prisutan. Windows Lazarus build tako je imao ispravan XMP objekt i SetInfo koji je tiho preskakao njegovo ažuriranje. Zaštita je sada NO_XMP, pa Windows Free Pascal aplikacija dobiva istu sinkronizaciju kao Delphi
Kako sačuvati izvorni ModDate pri pass-through spremanju?
Postavite KeepModDate u TPDFlibSaveOptions i spremite kroz SaveToFileOptions. Ta opcija postavlja UserModDate za vrijeme trajanja poziva, a SaveToFile zatim preskače automatski vremenski žig, što je ujedno i korak koji lijeno inicijalizira XMP objekt. Dokument čije metapodatke nikad niste dirali i za koji nije uključen nijedan compliance mod zadržava i svoj Info rječnik i svoj /Metadata stream onakvima kakvi su učitani. Poziv SetInformation(8, ...) ima isti učinak trajno, jer postavljanje datuma izmjene vlastoručno označava ga kao korisnički kontroliranog
var
Options: TPDFlibSaveOptions;
begin
FillChar(Options, SizeOf(Options), 0);
Options.OptimizeContentStreams := True;
Options.PackObjectStreams := True;
Options.KeepModDate := True; // bez automatskog /ModDate, bez lijene XMP inicijalizacije
if Lib.SaveToFileOptions('design-resaved.pdf', Options) <> 1 then
Log(Format('save failed, LastErrorCode=%d', [Lib.LastErrorCode]));
end;
Budite iskreni oko toga što ovo donosi. KeepModDate pravi je izbor za pass-through korak čiji izlaz treba opisivati istu reviziju kao i njegov ulaz, i pogrešan je izbor za sve što stvarno uređuje sadržaj, jer §14.3.3 očekuje da /ModDate odražava najnoviju izmjenu. On također ne popravlja retroaktivno biblioteku koja mijenja dijeljene objekte; samo izbjegava onaj jedan upis koji je grešku razotkrio. Oba gornja popravka čine obično spremanje sigurnim, a opcija čini namjerni no-op iskrenim
Kako provjeriti da spremanje nije promijenilo ništa osim ModDatea?
Ne pikselima i ne hashovima streamova, jer oba buga ostavljaju svaku stranicu i svaki content stream bajt identičnima. Provjera koja ih je uhvatila je ne-vizualni semantički snimak koji uzima neovisni parser, onaj koji ne dijeli nijednu liniju koda s bibliotekom koja se testira, iz izvorne i iz spremljene datoteke, nakon čega slijedi strukturna usporedba. Snimak pokriva Info rječnik bez /ModDate, stablo outlinea sa svakom oznakom razriješenom u broj stranice, a ne u broj objekta, imenovane destinacije i ciljeve linkova razriješene na isti način, vrijednosti polja obrasca, bajtove privitaka kao hashove, i XMP paket parsiran kao stablo, a ne uspoređen kao tekst. Brojevi objekata namjerno nisu dio toga, jer potpuno prepisivanje prenumerira sve i usporedba vezana na njih prijavljivala bi šum
Izuzeci su jednako važni kao i uključeni dijelovi. /ModDate, xmp:ModifyDate i xmp:MetadataDate očekivano se mijenjaju i izbacuju se prije usporedbe; datoteka čiji izvor uopće nije nosio XMP nije kažnjena zato što je dobila paket. Ono što provjera ne tvrdi jednako je izričito: zadržavanje postojećeg paketa ne govori ništa o tome je li taj paket shema-valjan ili zadovoljava li dokument PDF/UA ili bilo koji dio PDF/A. To su odvojena pitanja s odvojenim alatima, a poistovjećivanje "metapodaci su preživjeli" s "metapodaci su sukladni" razlog je zašto se prvi bug skrivao tako dugo. Na strani biblioteke ta dva regresijska testa sada se vrte u svakom ciljanom prolazu na Delphi Win32 i Win64 te Free Pascal Win32 i Win64, a semantička usporedba uvjet je prolaza za benchmark stvarnog korpusa dokumenata
Ako radite na razini ispod ovih popravaka, mehanika toga kako spremanje prepisuje objekte obrađena je u inkrementalnim ažuriranjima i append-only spremanju, što je jedini mod spremanja u kojem se dijeljeni objekt jednostavno ostavlja ondje gdje je bio, i u razinama izmjena i diffanju revizija, što je drugo mjesto na kojem zastario ili prepisan datum zavarava čitača. Pogled s popravne strane na isti par Info i XMP, gdje se dvije polovice usklađuju, a ne samo čuvaju, nalazi se u pretvorbi u PDF/A i popravku metapodataka
PDF Library for Delphi izvorna je Pascal PDF biblioteka za Delphi, C++Builder i Lazarus, a ovdje opisani read-modify-write put isti je onaj kroz koji prolazi svako uređivanje u vašem procesu, pa gornja jamstva vrijede bez obzira spremate li jednom ili tisuću puta dnevno — podržane kompajlere i platforme potražite na stranici proizvoda PDF Library for Delphi