Technisch artikel

Waarom een no-op save Info en XMP-metadata kan beschadigen

PDF Library for Delphi v3.539.18 en v3.539.20 verhelpen twee manieren waarop een PDF-save die niets wijzigt toch documentmetadata kon beschadigen: wanneer /CreationDate en /ModDate naar hetzelfde stringobject verwezen, herschreef de automatische ModDate-update beide, en wanneer het XMP-object werd aangemaakt voordat de originele /Metadata-stream was gelezen, verving een standaardpacket het origineel. De fixes vervangen dictionaryverwijzingen in plaats van gedeelde objecten te muteren, en leggen het bestaande packet vast vóór de lazy XMP-initialisatie

De setting is de minst interessante operatie die een PDF-library uitvoert: een bestand laden, het onder een nieuwe naam opslaan, tussendoor niets aanraken. Pagina's rendenden voor en na identiek. Hashes van contentstreams kwamen overeen. Het bestand kwam door elke check die we hadden, en toch was het op twee plekken fout die geen enkele renderer je ooit laat zien. Beide defecten zaten in het read-modify-write-pad waar elke echte bewerking doorheen gaat, dus elke save was genoeg om ze te triggeren, en beide kwamen pas aan het licht toen een tweede, onafhankelijke parser de niet-visuele semantiek van de twee bestanden vergeleek

Waarom verandert het opslaan van een PDF de CreationDate?

Omdat de document information dictionary één indirect stringobject vanuit twee keys mag refereren, en de library het object bijwerkte in plaats van de key. ISO 32000-1 §7.3.10 staat toe dat elke dictionarywaarde een indirecte verwijzing is, en niets in §14.3.3 Tabel 317 zegt dat de waarde onder /CreationDate een ander object moet zijn dan de waarde onder /ModDate. Een producer die bij het aanmaken twee keer dezelfde timestamp schreef mag volstrekt legaal beide keys naar één 2728 0 R laten wijzen, en precies dat deed een CJK-ontwerpdossier in ons lokale corpus

De trigger is de automatische wijzigingsdatum. Tenzij UserModDate gezet is, roept SaveToFile vóór het schrijven SetInfo('ModDate', ...) aan met de huidige tijd, en dat komt uit bij SetRawInfo. De oude SetRawInfo zocht het object op onder de key en riep, als hij een TPDFString vond, SetTo erop aan. Dat is een in-place write naar welk object de key op dat moment ook oplost, en wanneer dat object gedeeld is, meldt /CreationDate nu ook de savetijd. Het document opent, print en rendert nog pixel voor pixel als voorheen, dus een visuele regressiesuite slaagt zonder met de ogen te knipperen

Mutatie van een gedeelde Info-string in PDFlibPas: /CreationDate en /ModDate refereren legaal één stringobject 2728 0 R, de oude SetRawInfo riep SetTo aan op wat de key ook oploste en herschreef beide datums met de savetijd, en de nieuwe SetRawInfo voegt een verse string onder de key toe met behoud van de hex-stringmodus
Het bijwerken van een dictionary-entry vervangt nu de verwijzing van die entry in plaats van het gedeelde object te muteren, dus één automatische ModDate-write kan CreationDate niet meer veranderen, en het vervangen object blijft bewaard voor andere verwijzingen
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;

De fix in TPDFDocument.SetRawInfo is klein en het principe erachter is algemeen: het bijwerken van een dictionary-entry vervangt de verwijzing van die entry, nooit het object waar die toevallig naar oploste. De nieuwe code leest de bestaande TPDFStringMode, zodat een hex-string hex blijft en een letterlijke string letterlijk, en voegt daarna een verse string uit FStructure.NewString(Value, StringMode) onder de key toe. Twee andere details zijn net zo belangrijk als de kopwijziging. De oude tak voor een entry met een streamwaarde wiste de stream met SetTo('') voordat hij hem verving, wat de waarde zou hebben geleegd voor elke andere key die nog naar die stream wees, dus dat wissen is weg. En het vervangen object wordt niet verwijderd, want de structuur bezit het en andere verwijzingen kunnen het nog nodig hebben

// Voorheen: muteer het object waar de key op dat moment naar oplost
if Obj is TPDFString then
  TPDFString(Obj).SetTo(Value);

// Nu: behoud de representatie, vervang alleen de verwijzing van deze key
StringMode := smLiteral;
if Obj is TPDFString then
  StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));

De regressietest in Tests\SharedInfoSemantics.inc bouwt de aliasing bewust op in plaats van op een corpusbestand te vertrouwen: één hex-string waarnaar beide datumkeys verwijzen, één directe string gedeeld door /Title en /Subject, één stream gedeeld door /Author en /Keywords. Na het bijwerken van één key van elk paar moet de andere nog zijn oorspronkelijke waarde lezen en moet de bijgewerkte string nog hex zijn. De publieke referentie voor SetInformation formuleert de garantie nu in één zin: het bijwerken van een Info-veld vervangt alleen dat veld, ook wanneer andere velden naar hetzelfde object verwijzen

Waarom wordt een bestaand XMP-packet vervangen door standaardwaarden?

Door de volgorde van twee regels. TPDFDocument.GetMetadata heeft een snelle route: als het veld XMP al is toegewezen, geeft hij XMP.SaveToString terug in plaats van de /Metadata-stream uit de catalog te decoderen. Verschillende aanroepplekken initialiseerden lazy met XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata);, wat natuurlijk leest en fout is: tegen de tijd dat GetMetadata loopt, is XMP toegewezen, dus de "source" die wordt geladen is het geserialiseerde standaardpacket van een object dat één regel eerder is aangemaakt. Het originele packet, met zijn dc:creator, eigen namespaces en eventuele standards-identificatie, bereikt het object nooit en wordt bij het opslaan overschreven. Dezelfde automatische wijzigingsdatum volstaat om het te triggeren, want SetInfo initialiseert XMP voordat het de Info-dictionary aanraakt, zodat xmp:ModifyDate in de pas blijft met /ModDate. Let op waar dit defect achter schuilt: de Info-dictionaryvergelijking uit de eerste bug slaagt, want /Author en /Title in /Info zijn onaangeroerd. Alleen de XMP-tree is veranderd, en alleen een check die die tree parst en vergelijkt merkt dat

Volgorde van lazy XMP-initialisatie in PDFlibPas: het XMP-object aanmaken vóór de aanroep van GetMetadata laat de snelle route een standaardpacket serialiseren en dc:creator, eigen namespaces en standards-identificatie laten vallen, terwijl het vastleggen van Source vóór TPDFlibXMP.Create de originele /Metadata-stream uit de catalog laadt
Elke save triggerde de verwisseling omdat SetInfo XMP initialiseert om xmp:ModifyDate in de pas te houden met /ModDate, dus elke lazy initialisatie in het document loopt nu via één EnsureXMP die het bestaande packet vastlegt voordat het object wordt aangemaakt
// Fout: GetMetadata serialiseert nu het object van de vorige regel
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);

// Goed: leg eerst de /Metadata-stream vast, maak dan het object aan en laad
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);

De fix doet twee dingen. TPDFDocument.EnsureXMP legt nu Source := GetMetadata vast vóór TPDFlibXMP.Create, en elke lazy initialisatie in het document is vervangen door een aanroep daarvan: SetInfo, SetXMPInformation, GetXMPInformation, de setters voor de modi PDF/A, PDF/X, PDF/E, PDF/VT, PDF/VCR en PDF/UA, en het pad voor metadatareparatie. Publieke entrypoints zoals SetXMPProperty gingen al via EnsureXMP, en GetXMPProperty leest via GetDocumentMetadata, dus het hele oppervlak deelt één initialisatievolgorde. Eén correcte kopie van een reeks van drie regels is meer waard dan tien kopieën die het vandaag toevallig met elkaar eens zijn

Twee kleinere valkuilen op hetzelfde pad

De XMP-serializer op Windows gebruikt de XML-writer van het platform, die een XML-declaratie uitschrijft die het packet niet mag bevatten. De oude code stripte die door tekens te verwijderen tot hij <?xpacket bereikte. ISO 16684-1 §7.3.2 maakt de xpacket-wrapper optioneel, en een producer die een kaal <x:xmpmeta>-element schrijft zit binnen de standaard, dus op zo'n packet verwijderde de lus het hele, geldige document. De serializer lokaliseert nu de afsluitende ?> van de declaratie en verwijdert alleen die. Tests\XMPRetentionSemantics.inc draait zijn retentiecheck twee keer, één keer met de wrapper en één keer met de wrapper weggesneden, en stelt vast dat een marker voor een eigen namespace en de oorspronkelijke auteur SetInfo, GetMetadata, SaveToString en een herlaadbeurt overleven. De tweede valkuil was een preprocessorsymbool: de Info-naar-XMP-synchronisatie in SetInfo was bewaakt met NOVCL, dat gezet is voor Free Pascal-builds, maar de XMP-backend wordt bepaald door het besturingssysteem en niet door het framework, want PDFlibXMP.pas definieert NO_XMP alleen wanneer OS_WINDOWS ontbreekt. Een Windows Lazarus-build had dus een werkend XMP-object en een SetInfo die het stilzwijgend oversloeg. De guard is nu NO_XMP, dus een Windows Free Pascal-applicatie krijgt dezelfde synchronisatie als Delphi

Hoe behoud je de originele ModDate bij een pass-through-save?

Zet KeepModDate in TPDFlibSaveOptions en sla op via SaveToFileOptions. De optie zet UserModDate voor de duur van de aanroep, en SaveToFile slaat dan de automatische timestamp over, wat ook de stap is die het XMP-object lazy initialiseert. Een document waarvan u de metadata nooit hebt aangeraakt en waarvoor geen compliance-modus is aangezet, houdt zowel zijn Info-dictionary als zijn /Metadata-stream zoals geladen. SetInformation(8, ...) aanroepen heeft permanent hetzelfde effect, want als u de wijzigingsdatum zelf zet, markeert u hem als door de gebruiker beheerd

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

Wees eerlijk over wat dit oplevert. KeepModDate is de juiste keuze voor een pass-through-stap waarvan de output dezelfde revisie hoort te beschrijven als zijn input, en de verkeerde keuze voor alles wat werkelijk inhoud bewerkt, want §14.3.3 verwacht dat /ModDate de meest recente wijziging weerspiegelt. Het repareert ook niet met terugwerkende kracht een library die gedeelde objecten muteert; het vermijdt alleen de ene write die het defect blootlegde. De twee fixes hierboven zijn wat een gewone save veilig maakt, en de optie is wat een bewuste no-op eerlijk maakt

Hoe verifieer je dat een save niets anders dan de ModDate heeft gewijzigd?

Niet met pixels en niet met streamhashes, want beide defecten laten elke pagina en elke contentstream byte-identiek. De check die ze ving is een niet-visuele semantische snapshot van een onafhankelijke parser, een die geen code deelt met de library onder test, van het bronbestand en van het opgeslagen bestand, gevolgd door een structurele vergelijking. De snapshot dekt de Info-dictionary met /ModDate uitgesloten, de outline-tree met elk bookmark omgezet naar een paginanummer in plaats van een objectnummer, named destinations en linkdoelen op dezelfde manier omgezet, formulierveldwaarden, bijlagebytes als hashes, en het XMP-packet geparst als tree in plaats van vergeleken als tekst. Objectnummers horen er bewust niet bij, want een volledige herschrijving nummert alles opnieuw en een vergelijking die daarop is gebaseerd zou ruis melden

Niet-visuele semantische verificatie voor saves met PDFlibPas: een onafhankelijke parser zonder gedeelde code maakt een snapshot van de Info-dictionary minus /ModDate, outline- en destinationpagina's, formulierwaarden, bijlagehashes en de XMP-tree, en vergelijkt dan bron met opgeslagen bestand waarbij /ModDate, xmp:ModifyDate en xmp:MetadataDate als verwachte wijzigingen vervallen
Pixels en streamhashes blijven door beide defecten heen byte-identiek, dus de vergelijking werkt op opgeloste semantiek in plaats van objectnummers, en behouden metadata wordt eerlijk gerapporteerd als behouden, niet als schema-valide of PDF/UA- en PDF/A-conform

De uitsluitingen zijn net zo belangrijk als de insluitingen. /ModDate, xmp:ModifyDate en xmp:MetadataDate horen te veranderen en worden vóór de vergelijking weggelaten; een bestand waarvan de bron helemaal geen XMP had wordt er niet op afgerekend dat het een packet krijgt. Wat de check niet claimt is even expliciet: het behouden van een bestaand packet zegt niets over de vraag of dat packet schema-valide is of het document voldoet aan PDF/UA of een PDF/A-deel. Dat zijn aparte vragen met aparte tools, en "de metadata heeft het overleefd" verwarren met "de metadata is compliant" is precies hoe de eerste bug zo lang verborgen bleef. Aan de librarykant draaien de twee regressies nu op elke gerichte pass over Delphi Win32 en Win64 en Free Pascal Win32 en Win64, en de semantische vergelijking is een slaagvoorwaarde voor de benchmark op het echte-documentencorpus

Als u op het niveau onder deze fixes werkt, staan de mechanismen van hoe een save objecten herschrijft in incrementele updates en append-only opslaan, de enige opslagmodus waarin een gedeeld object gewoon blijft waar het stond, en in modification levels en revision diffing, de andere plek waar een verouderde of herschreven datum een lezer misleidt. De reparatiekant van hetzelfde Info- en XMP-paar, waar de twee helften met elkaar in overeenstemming worden gebracht in plaats van alleen bewaard, staat in converteren naar PDF/A en metadata repareren

PDF Library for Delphi is een native Pascal PDF-library voor Delphi, C++Builder en Lazarus, en het hier beschreven read-modify-write-pad is hetzelfde pad waar elke bewerking in uw eigen proces doorheen gaat, dus de garanties hierboven gelden of u nu één keer per dag opslaat of duizend keer — zie de productpagina van PDF Library for Delphi voor de ondersteunde compilers en platforms