PDF Library for Delphi v3.539.18 og v3.539.20 fikser to måder, en PDF-gemning uden ændringer stadig kunne ødelægge dokumentmetadata på: da /CreationDate og /ModDate refererede til det samme string-objekt, omskrev den automatiske ModDate-opdatering begge, og da XMP-objektet blev oprettet, før den oprindelige /Metadata-stream blev læst, erstattede en standardpakke originalen. Fixene erstatter dictionary-referencer i stedet for at mutere delte objekter og fanger den eksisterende pakke, før lazy XMP-initialisering
Gemning er den mindst interessante operation, et PDF-bibliotek udfører: load en fil, gem den under et nyt navn, rør intet imellem. Siderne renderedes identisk før og efter. Content stream-hashes matchede. Filen bestod hvert tjek, vi havde, og den var alligevel forkert to steder, som ingen renderer nogensinde ville vise dig. Begge defekter sad i read-modify-write-vejen, som hver eneste reel redigering går igennem, så enhver gemning overhovedet var nok til at udløse dem, og begge blev kun fundet, da en anden, uafhængig parser sammenlignede de to filers non-visuelle semantik
Hvorfor ændrer gemning af en PDF dens CreationDate?
Fordi document information dictionary har lov til at referere til ét indirekte string-objekt fra to nøgler, og biblioteket opdaterede objektet i stedet for nøglen. ISO 32000-1 §7.3.10 tillader enhver dictionary-værdi at være en indirekte reference, og intet i §14.3.3 Tabel 317 siger, at værdien under /CreationDate skal være et andet objekt end værdien under /ModDate. En producent, der skrev det samme timestamp to gange ved oprettelsen, kan fuldt lovligt pege begge nøgler på et enkelt 2728 0 R, hvilket er præcis, hvad et CJK-designsdokument i vores lokale korpus gjorde
Udløseren er den automatiske ændringsdato. Medmindre UserModDate er sat, kalder SaveToFile SetInfo('ModDate', ...) med klokkeslættet lige før skrivningen, hvilket lander i SetRawInfo. Den gamle SetRawInfo slog objektet op under nøglen og kaldte SetTo på den, hvis den fandt en TPDFString. Det er en in-place-skrivning til det objekt, nøglen i øjeblikket resolver til, og når det objekt er delt, rapporterer /CreationDate nu også gemmetidspunktet. Dokumentet åbner, printer og renderer stadig pixel for pixel som før, så en visuel regressionssuite passerer uden at blinke
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;
Fixet i TPDFDocument.SetRawInfo er lille, og princippet bag det er generelt: opdatering af et dictionary-entry erstatter det entrys reference, aldrig det objekt, det tilfældigvis resolvede til. Den nye kode læser den eksisterende TPDFStringMode, så en hex-streng forbliver hex, og en literal streng forbliver literal, og tilføjer derefter en frisk streng fra FStructure.NewString(Value, StringMode) under nøglen. To andre detaljer betyder lige så meget som overskriften. Den gamle gren for et stream-valgt entry ryddede streamen med SetTo(''), før den erstattede den, hvilket ville have tømt værdien for alle andre nøgler, der stadig pegede på den stream, så den rydning er væk. Og det afløste objekt slettes ikke, for strukturen ejer det, og andre referencer kan stadig få brug for det
// Før: mutér det objekt, nøglen i øjeblikket resolver til
if Obj is TPDFString then
TPDFString(Obj).SetTo(Value);
// Efter: behold repræsentationen, erstat kun denne nøgles reference
StringMode := smLiteral;
if Obj is TPDFString then
StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));
Regressionen i Tests\SharedInfoSemantics.inc bygger aliaseringen bevidst i stedet for at læne sig op ad en korpusfil: én hex-streng refereret fra begge datonøgler, én direkte streng delt af /Title og /Subject, én stream delt af /Author og /Keywords. Efter opdatering af én nøgle i hvert par skal den anden stadig læse sin oprindelige værdi, og den opdaterede streng skal stadig være hex. Den offentlige reference for SetInformation slår garantien fast i én sætning: opdatering af et Info-felt erstatter kun det felt, også når andre felter refererer til det samme objekt
Hvorfor bliver en eksisterende XMP-pakke erstattet af standarder?
På grund af rækkefølgen af to linjer. TPDFDocument.GetMetadata har en hurtig genvej: når XMP-feltet allerede er tildelt, returnerer den XMP.SaveToString i stedet for at dekode /Metadata-streamen fra catalog. Flere call sites initialiserede lazily med XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata);, hvilket læser naturligt og er forkert: i det øjeblik GetMetadata kører, er XMP tildelt, så den "kilde", der loades, er den serialiserede standardpakke af et objekt, der blev oprettet én linje tidligere. Den oprindelige pakke, med dens dc:creator, custom namespaces og enhver standards-identifikation, når aldrig objektet og overskrives ved gemning. Den samme automatiske ændringsdato er nok til at udløse det, for SetInfo initialiserer XMP, før den rører Info-dictionary, så xmp:ModifyDate holder trit med /ModDate. Bemærk, hvad denne defekt skjuler sig bag: Info-dictionary-sammenligningen fra den første bug består, da /Author og /Title i /Info er urørte. Kun XMP-træet ændrede sig, og kun et tjek, der parser og sammenligner det træ, bemærker det
// Forkert: GetMetadata serialiserer nu objektet, der blev oprettet på foregående linje
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);
// Rigtigt: fang /Metadata-streamen først, opret og load derefter
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);
Fixet gør to ting. TPDFDocument.EnsureXMP fanger nu Source := GetMetadata, før TPDFlibXMP.Create, og enhver lazy-initialisering i dokumentet blev erstattet af et kald til den: SetInfo, SetXMPInformation, GetXMPInformation, mode-sætterne til PDF/A, PDF/X, PDF/E, PDF/VT, PDF/VCR og PDF/UA og metadata-reparationsvejen. Offentlige indgangspunkter som SetXMPProperty gik allerede gennem EnsureXMP, og GetXMPProperty læser gennem GetDocumentMetadata, så hele fladen deler én initialiseringsrækkefølge. Én korrekt kopi af en tre-linjers sekvens er mere værd end ti kopier, der tilfældigvis er enige i dag
To mindre fælder fundet på samme vej
XMP-serialiseren på Windows bruger platformens XML-writer, som emitterer en XML-deklaration, som pakken ikke må bære. Den gamle kode strippede den ved at slette tegn, indtil den nåede <?xpacket. ISO 16684-1 §7.3.2 gør xpacket-wrapperen valgfri, og en producent, der skriver et bart <x:xmpmeta>-element, er inden for standarden, så på en sådan pakke slettede løkken hele det gyldige dokument. Serialiseren lokaliserer nu afslutnings-?> af deklarationen og fjerner kun den. Tests\XMPRetentionSemantics.inc kører sit retention-tjek to gange, én gang med wrapperen og én gang med den skåret væk, og assert'er, at en custom-namespace-markør og den oprindelige forfatter overlever SetInfo, GetMetadata, SaveToString og en genindlæsning. Den anden fælde var et preprocessor-symbol: Info-til-XMP-synkroniseringen i SetInfo var bevogtet af NOVCL, som er sat for Free Pascal-builds, men XMP-backend'en styres af styresystemet, ikke af frameworket, da PDFlibXMP.pas kun definerer NO_XMP, når OS_WINDOWS er fraværende. En Windows-Lazarus-build havde derfor et fungerende XMP-objekt og en SetInfo, der lydløst sprang opdateringen over. Værnet er nu NO_XMP, så en Windows Free Pascal-applikation får den samme synkronisering som Delphi
Hvordan bevarer du den oprindelige ModDate ved en pass-through-gemning?
Sæt KeepModDate i TPDFlibSaveOptions og gem gennem SaveToFileOptions. Optionen sætter UserModDate for kaldets varighed, og SaveToFile springer derefter det automatiske timestamp over, hvilket også er det trin, der initialiserer XMP-objektet lazily. Et dokument, hvis metadata du aldrig rørte, og for hvilket ingen compliance-mode var slået til, beholder både sit Info-dictionary og sin /Metadata-stream som loadet. At kalde SetInformation(8, ...) har samme effekt permanent, for selv at sætte ændringsdatoen markerer den som brugerstyret
var
Options: TPDFlibSaveOptions;
begin
FillChar(Options, SizeOf(Options), 0);
Options.OptimizeContentStreams := True;
Options.PackObjectStreams := True;
Options.KeepModDate := True; // ingen automatisk /ModDate, ingen lazy XMP-init
if Lib.SaveToFileOptions('design-resaved.pdf', Options) <> 1 then
Log(Format('save failed, LastErrorCode=%d', [Lib.LastErrorCode]));
end;
Vær ærlig om, hvad dette køber dig. KeepModDate er det rigtige valg til et pass-through-trin, hvis output skal beskrive samme revision som sit input, og det forkerte valg til alt, hvad der reelt redigerer indhold, for §14.3.3 forventer, at /ModDate afspejler den seneste ændring. Det retrofikerer heller ikke et bibliotek, der muterer delte objekter; det undgår kun den ene skrivning, der udstillede defekten. Begge fix ovenfor er det, der gør en almindelig gemning sikker, og optionen er det, der gør en bevidst no-op ærlig
Hvordan verificerer du, at en gemning ikke ændrede andet end ModDate?
Ikke med pixels og ikke med stream-hashes, for begge defekter efterlader hver side og hver content stream byte-identisk. Tjekket, der fangede dem, er et non-visuelt semantisk snapshot taget af en uafhængig parser, én der deler ingen kode med det testede bibliotek, fra kildefilen og fra den gemte fil, efterfulgt af en strukturel sammenligning. Snapshottet dækker Info-dictionary med /ModDate ekskluderet, outline-træet med hvert bookmark resolveret til et sidetal snarere end et objektnummer, named destinations og link-targets resolveret på samme måde, formularfeltværdier, attachment-bytes som hashes og XMP-pakken parset som et træ snarere end sammenlignet som tekst. Objektnumre er bevidst ikke en del af det, da en fuld omskrivning omnummererer alt, og en sammenligning nøglet på dem ville rapportere støj
Eksklusionerne er lige så vigtige som inklusionerne. /ModDate, xmp:ModifyDate og xmp:MetadataDate forventes at ændre sig og droppes før sammenligningen; en fil, hvis kilde slet ikke bar XMP, straffes ikke for at få en pakke. Hvad tjekket ikke påstår, er lige så eksplicit: at bevare en eksisterende pakke siger intet om, hvorvidt den pakke er schema-gyldig, eller om dokumentet opfylder PDF/UA eller nogen PDF/A-del. Det er separate spørgsmål med separate værktøjer, og at blande "metadataen overlevede" med "metadataen er kompatibel" er, hvordan den første bug gemte sig så længe, den gjorde. På bibliotekssiden kører de to regressioner nu ved hvert targetet gennemløb på tværs af Delphi Win32 og Win64 og Free Pascal Win32 og Win64, og den semantiske sammenligning er en beståelsesbetingelse for korpus-benchmarken med rigtige dokumenter
Arbejder du på niveauet under disse fix, er mekanikken i, hvordan en gemning omskriver objekter, dækket i incremental updates og append-only-gemning, som er den ene gemningstilstand, hvor et delt objekt simpelthen efterlades, hvor det var, og i modification levels og revision-diffing, som er det andet sted, en forældet eller omskreven dato vildleder en læser. Reparationssiden af samme Info- og XMP-par, hvor de to halvdele fås til at enes snarere end blot bevares, ligger i konvertering til PDF/A og reparation af metadata
PDF Library for Delphi er et native Pascal PDF-bibliotek til Delphi, C++Builder og Lazarus, og read-modify-write-vejen beskrevet her er den samme, som hver redigering i din egen proces går igennem, så garantierne ovenfor gælder, uanset om du gemmer én eller tusind gange om dagen — se PDF Library for Delphi-produktsiden for understøttede compilere og platforme