PDF Library for Delphi v3.539.18 și v3.539.20 repară două feluri prin care o salvare PDF care nu schimbă nimic putea totuși strica metadatele documentului: când /CreationDate și /ModDate referau același obiect de tip șir, actualizarea automată a ModDate le rescria pe amândouă, iar când obiectul XMP era creat înainte de a se citi stream-ul /Metadata original, un pachet implicit lua locul celui original. Reparațiile înlocuiesc referințe din dicționar în loc să mute obiecte partajate și capturează pachetul existent înainte de inițializarea leneșă a XMP
Salvarea este cea mai neinteresantă operație pe care o face o librărie PDF: încarci un fișier, îl salvezi sub alt nume, nu atingi nimic între timp. Paginile se randau identic înainte și după. Hash-urile stream-urilor de conținut se potriveau. Fișierul trecea toate verificările pe care le aveam și era totuși greșit în două locuri pe care niciun renderer nu ți le-ar arăta vreodată. Ambele defecte stăteau pe calea citire-modificare-scriere prin care trece fiecare editare reală, așa că orice salvare era de ajuns ca să le declanșeze, și ambele au fost găsite abia când un al doilea parser, independent, a comparat semantica non-vizuală a celor două fișiere
De ce schimbă o salvare PDF CreationDate?
Pentru că dicționarul de informații al documentului are voie să refere un singur obiect indirect de tip șir din două chei, iar librăria actualiza obiectul, nu cheia. ISO 32000-1 §7.3.10 permite oricărei valori de dicționar să fie o referință indirectă, iar nimic din §14.3.3 tabelul 317 nu spune că valoarea de sub /CreationDate trebuie să fie un obiect diferit de valoarea de sub /ModDate. Un producător care a scris aceeași marcă temporală de două ori la creare poate, perfect legal, să îndrepte ambele chei spre un singur 2728 0 R, exact ce făcea un document de design CJK din corpusul nostru local
Declanșatorul este data de modificare automată. Dacă UserModDate nu este setat, SaveToFile apelează SetInfo('ModDate', ...) cu ora curentă înainte de scriere, ceea ce ajunge la SetRawInfo. Vechiul SetRawInfo căuta obiectul de sub cheie și, dacă găsea un TPDFString, apela SetTo pe el. Asta este o scriere în loc în orice obiect la care rezolvă cheia în acel moment, iar când obiectul acela este partajat, /CreationDate raportează acum și ora salvării. Documentul se deschide, se tipărește și se randează în continuare pixel cu pixel ca înainte, așa că o suită de regresie vizuală trece fără să clipească
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;
Reparația din TPDFDocument.SetRawInfo este mică, iar principiul din spatele ei este general: actualizarea unei intrări de dicționar înlocuiește referința acelei intrări, niciodată obiectul la care se nimerea să rezolve. Codul nou citește TPDFStringMode existent, ca un șir hexazecimal să rămână hexazecimal și un șir literal să rămână literal, apoi adaugă un șir nou din FStructure.NewString(Value, StringMode) sub cheie. Alte două detalii contează la fel de mult ca schimbarea principală. Vechea ramură pentru o intrare cu valoare de stream curăța stream-ul cu SetTo('') înainte de a-l înlocui, ceea ce ar fi golit valoarea pentru fiecare altă cheie care mai arăta spre stream-ul acela, așa că golirea aceea a dispărut. Iar obiectul înlocuit nu este șters, pentru că structura îl deține și alte referințe pot avea încă nevoie de el
// Înainte: muta orice obiect la care rezolvă cheia în acel moment
if Obj is TPDFString then
TPDFString(Obj).SetTo(Value);
// După: păstrează reprezentarea, înlocuiește doar referința acestei chei
StringMode := smLiteral;
if Obj is TPDFString then
StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));
Testul de regresie din Tests\SharedInfoSemantics.inc construiește aliasarea în mod deliberat, în loc să se bazeze pe un fișier din corpus: un șir hexazecimal referit din ambele chei de dată, un șir direct partajat de /Title și /Subject, un stream partajat de /Author și /Keywords. După actualizarea unei chei din fiecare pereche, cealaltă trebuie să citească în continuare valoarea ei originală, iar șirul actualizat trebuie să rămână hexazecimal. Referința publică pentru SetInformation enunță acum garanția într-o singură propoziție: actualizarea unui câmp Info înlocuiește doar acel câmp, chiar și când alte câmpuri referă același obiect
De ce este înlocuit un pachet XMP existent cu valorile implicite?
Din cauza ordinii a două linii. TPDFDocument.GetMetadata are o cale rapidă: când câmpul XMP este deja atribuit, întoarce XMP.SaveToString în loc să decodeze stream-ul /Metadata din catalog. Mai multe locuri de apel se inițializau leneș cu XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata);, ceea ce se citește natural și este greșit: până când rulează GetMetadata, XMP este atribuit, așa că „sursa” care se încarcă este pachetul implicit serializat al unui obiect creat cu o linie mai devreme. Pachetul original, cu dc:creator al său, spațiile de nume personalizate și orice identificare de standard, nu ajunge niciodată în obiect și este suprascris la salvare. Aceeași dată de modificare automată este de ajuns ca să îl declanșeze, pentru că SetInfo inițializează XMP înainte de a atinge dicționarul Info, ca xmp:ModifyDate să rămână în pas cu /ModDate. Observați în spatele cui se ascunde defectul acesta: comparația dicționarului Info din primul bug trece, pentru că /Author și /Title din /Info sunt neatinse. Doar arborele XMP s-a schimbat, și doar o verificare care parsează și compară arborele acela observă
// Greșit: GetMetadata serializează acum obiectul creat pe linia anterioară
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);
// Corect: capturați întâi stream-ul /Metadata, apoi creați și încărcați
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);
Reparația face două lucruri. TPDFDocument.EnsureXMP capturează acum Source := GetMetadata înainte de TPDFlibXMP.Create, iar fiecare inițializare leneșă din document a fost înlocuită cu un apel la el: SetInfo, SetXMPInformation, GetXMPInformation, setter-ele de mod PDF/A, PDF/X, PDF/E, PDF/VT, PDF/VCR și PDF/UA, și calea de reparare a metadatelor. Punctele de intrare publice precum SetXMPProperty treceau deja prin EnsureXMP, iar GetXMPProperty citește prin GetDocumentMetadata, așa că toată suprafața împarte o singură ordine de inițializare. O singură copie corectă a unei secvențe de trei linii valorează mai mult decât zece copii care se nimerește să cadă de acord azi
Două capcane mai mici găsite pe aceeași cale
Serializerul XMP de pe Windows folosește writer-ul XML al platformei, care emite o declarație XML pe care pachetul nu trebuie să o cară. Codul vechi o elimina ștergând caractere până ajungea la <?xpacket. ISO 16684-1 §7.3.2 face învelișul xpacket opțional, iar un producător care scrie un element <x:xmpmeta> gol este în cadrul standardului, așa că pe un astfel de pachet bucla ștergea tot documentul, care era valid. Serializerul localizează acum ?>-ul de închidere al declarației și elimină doar atât. Tests\XMPRetentionSemantics.inc rulează verificarea de retenție de două ori, o dată cu înveliș și o dată cu el tăiat, și asertează că un marcaj de spațiu de nume personalizat și autorul original supraviețuiesc după SetInfo, GetMetadata, SaveToString și o reîncărcare. A doua capcană a fost un simbol de preprocesor: sincronizarea Info-către-XMP din SetInfo era păzită de NOVCL, care este setat pentru build-urile Free Pascal, dar backend-ul XMP este condiționat de sistemul de operare, nu de framework, pentru că PDFlibXMP.pas definește NO_XMP doar când OS_WINDOWS lipsește. Un build Lazarus pe Windows avea deci un obiect XMP funcțional și un SetInfo care sărea în tăcere peste actualizarea lui. Paza este acum NO_XMP, așa că o aplicație Free Pascal pe Windows primește aceeași sincronizare ca Delphi
Cum păstrați ModDate original la o salvare de tip pass-through?
Setați KeepModDate în TPDFlibSaveOptions și salvați prin SaveToFileOptions. Opțiunea setează UserModDate pe durata apelului, iar SaveToFile sare apoi peste marca temporală automată, care este și pasul ce inițializează leneș obiectul XMP. Un document ale cărui metadate nu le-ați atins niciodată și pentru care nu s-a activat niciun mod de conformitate își păstrează și dicționarul Info, și stream-ul /Metadata așa cum au fost încărcate. Apelul SetInformation(8, ...) are același efect în mod permanent, pentru că setarea datei de modificare de unul singur o marchează ca fiind controlată de utilizator
var
Options: TPDFlibSaveOptions;
begin
FillChar(Options, SizeOf(Options), 0);
Options.OptimizeContentStreams := True;
Options.PackObjectStreams := True;
Options.KeepModDate := True; // fără /ModDate automat, fără init XMP leneș
if Lib.SaveToFileOptions('design-resaved.pdf', Options) <> 1 then
Log(Format('save failed, LastErrorCode=%d', [Lib.LastErrorCode]));
end;
Fiți onești în privința a ce vă aduce asta. KeepModDate este alegerea corectă pentru un pas de tip pass-through a cărui ieșire trebuie să descrie aceeași revizie ca intrarea sa, și este alegerea greșită pentru orice chiar editează conținut, pentru că §14.3.3 așteaptă ca /ModDate să reflecte cea mai recentă modificare. Nu repară nici retroactiv o librărie care mutează obiecte partajate; doar evită singura scriere care expunea defectul. Ambele reparații de mai sus sunt cele care fac o salvare obișnuită sigură, iar opțiunea este cea care face un no-op deliberat onest
Cum verificați că o salvare nu a schimbat nimic în afară de ModDate?
Nu cu pixeli și nu cu hash-uri de stream, pentru că ambele defecte lasă fiecare pagină și fiecare stream de conținut identice octet cu octet. Verificarea care le-a prins este un instantaneu semantic non-vizual luat de un parser independent, care nu împarte niciun cod cu librăria testată, din fișierul sursă și din fișierul salvat, urmat de o comparație structurală. Instantaneul acoperă dicționarul Info cu /ModDate exclus, arborele de outline cu fiecare marcaj rezolvat la un număr de pagină în loc de un număr de obiect, destinațiile cu nume și țintele de link rezolvate la fel, valorile câmpurilor de formular, octeții de atașament ca hash-uri și pachetul XMP parsat ca arbore, nu comparat ca text. Numerele de obiect nu fac parte deliberat din el, pentru că o rescriere completă renumerotează tot, iar o comparație bazată pe ele ar raporta doar zgomot
Excluderile sunt la fel de importante ca incluziunile. /ModDate, xmp:ModifyDate și xmp:MetadataDate se așteaptă să se schimbe și sunt eliminate înainte de comparație; un fișier a cărui sursă nu purta deloc XMP nu este penalizat că a câștigat un pachet. Ce nu pretinde verificarea este la fel de explicit: păstrarea unui pachet existent nu spune nimic despre dacă pachetul acela este valid de schemă sau dacă documentul respectă PDF/UA ori vreo parte PDF/A. Sunt întrebări separate cu unelte separate, iar amestecarea lui „metadatele au supraviețuit” cu „metadatele sunt conforme” este felul în care primul bug s-a ascuns atât de mult. Pe partea de librărie, cele două regresii rulează acum la fiecare trecere țintită pe Delphi Win32 și Win64 și pe Free Pascal Win32 și Win64, iar comparația semantică este o condiție de promovare pentru benchmark-ul pe corpus de documente reale
Dacă lucrați la nivelul de dedesubtul acestor reparații, mecanica felului în care o salvare rescrie obiectele este acoperită în actualizări incrementale și salvare de tip append-only, singurul mod de salvare în care un obiect partajat este pur și simplu lăsat unde era, și în niveluri de modificare și diff-uri între revizii, celălalt loc unde o dată perimată sau rescrisă înșală un cititor. Vederea dinspre reparații a aceleiași perechi Info și XMP, unde cele două jumătăți sunt făcute să cadă de acord, nu doar păstrate, este în conversia la PDF/A și repararea metadatelor
PDF Library for Delphi este o librărie PDF nativă în Pascal pentru Delphi, C++Builder și Lazarus, iar calea citire-modificare-scriere descrisă aici este aceeași prin care trece fiecare editare din procesul dumneavoastră, așa că garanțiile de mai sus se aplică indiferent dacă salvați o dată pe zi sau o mie de ori — vedeți pagina de produs PDF Library for Delphi pentru compilatoarele și platformele suportate