Tehnički članak

Zašto no-op čuvanje PDF-a kvari Info i XMP metapodatke

PDF Library for Delphi v3.539.18 i v3.539.20 ispravljaju dva načina na koja čuvanje PDF-a bez ikakve izmene ipak može da pokvari metapodatke dokumenta: kada /CreationDate i /ModDate referenciraju isti string objekat, automatsko ažuriranje ModDate prepisivalo je oba, a kada je XMP objekat kreiran pre nego što je originalni /Metadata stream pročitan, podrazumevani paket zamenjivao je original. Popravke zamenjuju reference u rečniku umesto da mutiraju deljene objekte, i hvataju postojeći paket pre lenjog XMP inicijalizovanja

To je najnezanimljivija operacija koju PDF biblioteka obavlja: učitaj fajl, sačuvaj ga pod novim imenom, ne diraj ništa između. Stranice su se pre i posle iscrtavale identično. Hash-evi content stream-ova su se poklapali. Fajl je prošao svaku proveru koju smo imali, a i dalje je bio pogrešan na dva mesta koja vam nijedan renderer ne bi pokazao. Oba defekta sedela su u read-modify-write putanji kroz koju prolazi svaka prava izmena, pa je bilo dovoljno bilo kakvo čuvanje da ih aktivira, i oba su nađena tek kada je drugi, nezavisni parser uporedio ne-vizuelnu semantiku ta dva fajla

Zašto čuvanje PDF-a menja njegov CreationDate?

Zato što rečnik informacija o dokumentu sme da referencira jedan indirektni string objekat iz dva ključa, a biblioteka je ažurirala objekat umesto ključ. ISO 32000-1 §7.3.10 dopušta da svaka vrednost u rečniku bude indirektna referenca, i ništa u §14.3.3 tabeli 317 ne kaže da vrednost pod /CreationDate mora biti drugi objekat od vrednosti pod /ModDate. Proizvođač koji je pri kreiranju dva puta upisao isti vremenski žig može, sasvim legalno, da usmeri oba ključa na jedno 2728 0 R, što je upravo ono što je uradio jedan CJK dokument iz našeg lokalnog korpusa

Okidač je automatski datum izmene. Ako UserModDate nije postavljen, SaveToFile poziva SetInfo('ModDate', ...) sa trenutnim vremenom pre upisivanja, što stiže do SetRawInfo. Stari SetRawInfo tražio je objekat pod ključem i, ako je našao TPDFString, pozivao je SetTo nad njim. To je upis na mestu u koji god objekat ključ trenutno razrešava, a kada je taj objekat deljen, /CreationDate sada prijavljuje i vreme čuvanja. Dokument se i dalje otvara, štampa i iscrtava piksel po piksel kao pre, pa vizuelni regression suite prolazi bez treptaja

Mutacija deljenog Info string-a u PDFlibPas-u: /CreationDate i /ModDate legalno referenciraju jedan string objekat 2728 0 R, stari SetRawInfo pozivao je SetTo nad onim na šta je ključ razrešavao i prepisivao oba datuma vremenom čuvanja, a novi SetRawInfo dodaje svež string pod ključ i čuva hex režim string-a
Ažuriranje unosa u rečniku sada zamenjuje referencu tog unosa umesto da mutira deljeni objekat, pa jedan automatski upis ModDate više ne može da promeni CreationDate, a zamenjeni objekat ostaje sačuvan za druge reference
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;

Popravka u TPDFDocument.SetRawInfo je mala a princip iza nje je opšti: ažuriranje unosa u rečniku zamenjuje referencu tog unosa, nikada objekat na koji je slučajno razrešavao. Novi kod čita postojeći TPDFStringMode da hex string ostane hex a literal ostane literal, pa dodaje svež string iz FStructure.NewString(Value, StringMode) pod ključ. Dva druga detalja su jednako važna kao i glavna promena. Stara grana za unos čija je vrednost stream brisala je stream sa SetTo('') pre zamene, što bi ispraznilo vrednost za svaki drugi ključ koji još pokazuje na taj stream, pa je to brisanje uklonjeno. A zamenjeni objekat se ne briše, jer ga struktura poseduje i druge reference mogu da ga još koriste

// Pre: mutiraj koji god objekat ključ trenutno razrešava
if Obj is TPDFString then
  TPDFString(Obj).SetTo(Value);

// Posle: sačuvaj reprezentaciju, zameni samo referencu ovog ključa
StringMode := smLiteral;
if Obj is TPDFString then
  StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));

Regresija u Tests\SharedInfoSemantics.inc gradi alijasiranje namerno, umesto da se oslanja na fajl iz korpusa: jedan hex string referenciran iz oba ključa za datume, jedan direktni string koji dele /Title i /Subject, jedan stream koji dele /Author i /Keywords. Posle ažuriranja jednog ključa iz svakog para, drugi mora i dalje da čita svoju originalnu vrednost a ažurirani string mora i dalje da bude hex. Javna referenca za SetInformation sada iznosi garanciju u jednoj rečenici: ažuriranje Info polja zamenjuje samo to polje, čak i kada druga polja referenciraju isti objekat

Zašto postojeći XMP paket biva zamenjen podrazumevanim?

Zbog redosleda dve linije. TPDFDocument.GetMetadata ima brzu putanju: kada je polje XMP već dodeljeno, vraća XMP.SaveToString umesto da dekodira /Metadata stream iz kataloga. Nekoliko mesta poziva lenjo se inicijalizovalo sa XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata);, što se čita prirodno i pogrešno je: dok GetMetadata izvrši, XMP je dodeljen, pa je „izvor“ koji se učitava serijalizovani podrazumevani paket objekta kreiranog jednu liniju ranije. Originalni paket, sa svojim dc:creator, prilagođenim namespace-ovima i bilo kakvom identifikacijom standarda, nikada ne stiže do objekta i biva prepisan pri čuvanju. Isti automatski datum izmene je dovoljan da to aktivira, jer SetInfo inicijalizuje XMP pre nego što dodirne Info rečnik, da bi xmp:ModifyDate ostao usklađen sa /ModDate. Primetite za čim se ovaj defekt krije: poređenje Info rečnika iz prvog bug-a prolazi, jer /Author i /Title u /Info nisu dirani. Promenilo se samo XMP stablo, i to primećuje jedino provera koja parsira i poredi to stablo

Redosled lenjog XMP inicijalizovanja u PDFlibPas-u: kreiranje XMP objekta pre poziva GetMetadata tera brzu putanju da serijalizuje podrazumevani paket i izgubi dc:creator, prilagođene namespace-ove i identifikaciju standarda, dok hvatanje Source pre TPDFlibXMP.Create učitava originalni /Metadata stream iz kataloga
Svako čuvanje je aktiviralo zamenu jer SetInfo inicijalizuje XMP da bi xmp:ModifyDate ostao usklađen sa /ModDate, pa svaka lenja inicijalizacija u dokumentu sada prolazi kroz jedan EnsureXMP koji hvata postojeći paket pre kreiranja objekta
// Pogrešno: GetMetadata sada serijalizuje objekat kreiran u prethodnoj liniji
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);

// Ispravno: prvo uhvati /Metadata stream, pa kreiraj i učitaj
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);

Popravka radi dve stvari. TPDFDocument.EnsureXMP sada hvata Source := GetMetadata pre TPDFlibXMP.Create, i svaka lenja inicijalizacija u dokumentu zamenjena je pozivom na nju: SetInfo, SetXMPInformation, GetXMPInformation, setter-i režima PDF/A, PDF/X, PDF/E, PDF/VT, PDF/VCR i PDF/UA, i putanja popravke metapodataka. Javne ulazne tačke kao što je SetXMPProperty već su išle kroz EnsureXMP, a GetXMPProperty čita kroz GetDocumentMetadata, pa cela površina deli jedan redosled inicijalizovanja. Jedan ispravan primerak niza od tri linije vredi više od deset primeraka koji se trenutno slučajno slažu

Dve manje zamke nađene na istoj putanji

XMP serijalizator na Windows-u koristi platformski XML writer, koji emituje XML deklaraciju koju paket ne sme da nosi. Stari kod ju je uklanjao brisanjem znakova dok ne stigne do <?xpacket. ISO 16684-1 §7.3.2 čini xpacket omotač opcionim, a proizvođač koji piše goli <x:xmpmeta> element je u okviru standarda, pa je na takvom paketu petlja obrisala ceo, validan dokument. Serijalizator sada pronalazi završno ?> deklaracije i uklanja samo to. Tests\XMPRetentionSemantics.inc pokreće svoju proveru zadržavanja dvaput, jednom sa omotačem i jednom sa odsečenim omotačem, i tvrdi da marker prilagođenog namespace-a i originalni autor preživljavaju SetInfo, GetMetadata, SaveToString i ponovno učitavanje. Druga zamka bio je simbol preprocesora: sinhronizacija Info-u-XMP u SetInfo bila je pod zaštitom NOVCL, koji je postavljen za Free Pascal build-ove, ali XMP backend je ograničen operativnim sistemom, a ne framework-om, jer PDFlibXMP.pas definiše NO_XMP samo kada OS_WINDOWS nije prisutan. Windows Lazarus build je zato imao ispravan XMP objekat i SetInfo koji je tiho preskakao njegovo ažuriranje. Zaštita je sada NO_XMP, pa Windows Free Pascal aplikacija dobija istu sinhronizaciju kao Delphi

Kako da sačuvate originalni ModDate pri pass-through čuvanju?

Postavite KeepModDate u TPDFlibSaveOptions i čuvajte kroz SaveToFileOptions. Opcija postavlja UserModDate za vreme trajanja poziva, a SaveToFile tada preskače automatski vremenski žig, što je i korak koji lenjo inicijalizuje XMP objekat. Dokument čije metapodatke nikada niste dirali i za koji nije uključen nijedan režim usklađenosti zadržava i svoj Info rečnik i svoj /Metadata stream onakvima kakvi su učitani. Poziv SetInformation(8, ...) ima isti efekat trajno, jer kada sami postavite datum izmene on je označen kao korisnički kontrolisan

var
  Options: TPDFlibSaveOptions;
begin
  FillChar(Options, SizeOf(Options), 0);
  Options.OptimizeContentStreams := True;
  Options.PackObjectStreams := True;
  Options.KeepModDate := True;      // bez automatskog /ModDate, bez lenje XMP inicijalizacije
  if Lib.SaveToFileOptions('design-resaved.pdf', Options) <> 1 then
    Log(Format('save failed, LastErrorCode=%d', [Lib.LastErrorCode]));
end;

Budite iskreni o tome šta ovo donosi. KeepModDate je pravi izbor za pass-through korak čiji izlaz treba da opisuje istu reviziju kao ulaz, a pogrešan izbor za sve što stvarno menja sadržaj, jer §14.3.3 očekuje da /ModDate odražava najnoviju izmenu. Takođe ne ispravlja retroaktivno biblioteku koja mutira deljene objekte; samo izbegava jedan upis koji je razotkrio defekt. Obe popravke iznad su ono što obično čuvanje čini bezbednim, a opcija je ono što namerni no-op čini iskrenim

Kako proveriti da čuvanje nije promenilo ništa osim ModDate-a?

Ne pikselima i ne hash-evima stream-ova, jer oba defekta ostavljaju svaku stranicu i svaki content stream bajt identičnim. Provera koja ih je uhvatila je ne-vizuelni semantički snimak koji pravi nezavisni parser, onaj koji ne deli ni jednu liniju koda sa bibliotekom pod testom, iz izvornog i iz sačuvanog fajla, praćen strukturnim poređenjem. Snimak obuhvata Info rečnik bez /ModDate, stablo strukture sa svakom stavkom razrešenom na broj stranice a ne na broj objekta, imenovana odredišta i ciljeve veza razrešene na isti način, vrednosti polja formulara, bajtove priloga kao hash-eve, i XMP paket parsiran kao stablo a ne poređen kao tekst. Brojevi objekata namerno nisu deo toga, jer potpuno prepisivanje prenumeriše sve i poređenje vezano za njih prijavljivalo bi šum

Ne-vizuelna semantička verifikacija za PDFlibPas čuvanja: nezavisni parser bez deljenog koda snima Info rečnik bez /ModDate, stranice strukture i odredišta, vrednosti formulara, hash-eve priloga i XMP stablo, pa poredi izvorni i sačuvani fajl uz izbacivanje /ModDate, xmp:ModifyDate i xmp:MetadataDate kao očekivanih promena
Pikseli i hash-evi stream-ova ostaju bajt identični kroz oba defekta, pa poređenje radi na razrešenoj semantici a ne na brojevima objekata, a preživeli metapodaci se iskreno prijavljuju kao zadržani, a ne kao schema-valid ili PDF/UA i PDF/A usklađeni

Izuzeci su jednako važni kao i uključivanja. /ModDate, xmp:ModifyDate i xmp:MetadataDate treba da se promene i izbacuju se pre poređenja; fajl čiji izvor nije nosio nikakav XMP nije kažnjen zato što je dobio paket. Ono što provera ne tvrdi jednako je eksplicitno: zadržavanje postojećeg paketa ne govori ništa o tome da li je taj paket schema-valid ili da li dokument ispunjava PDF/UA ili bilo koji deo PDF/A. To su odvojena pitanja sa odvojenim alatima, a poistovećivanje „metapodaci su preživeli“ sa „metapodaci su usklađeni“ je način na koji se prvi bug krio tako dugo. Sa strane biblioteke, te dve regresije sada se pokreću pri svakom targetiranom prolazu na Delphi Win32 i Win64 i Free Pascal Win32 i Win64, a semantičko poređenje je uslov prolaza za benchmark korpusa stvarnih dokumenata

Ako radite na nivou ispod ovih popravki, mehanika toga kako čuvanje prepisuje objekte obrađena je u tekstu inkrementalna ažuriranja i append-only čuvanje, što je jedini režim čuvanja u kojem se deljeni objekat jednostavno ostavlja gde je bio, i u tekstu nivoi izmena i diff revizija, što je drugo mesto gde zastareo ili prepisan datum zavara čitaoca. Pogled sa strane popravke na isti par Info i XMP, gde se dve polovine usaglašavaju a ne samo čuvaju, je u tekstu konvertovanje u PDF/A i popravka metapodataka

PDF Library for Delphi je nativna Pascal PDF biblioteka za Delphi, C++Builder i Lazarus, a read-modify-write putanja opisana ovde je ista ona kroz koju prolazi svaka izmena u vašem procesu, pa gorenavedene garancije važe bez obzira da li čuvate jednom ili hiljadu puta dnevno — pogledajte stranicu proizvoda PDF Library for Delphi za podržane kompajlere i platforme