Technisch artikel

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

Een PDF-save die niets verandert kan documentmetadata toch op twee manieren beschadigen: wanneer /CreationDate en /ModDate naar hetzelfde stringobject verwijzen, herschrijft een automatische ModDate-update die in dat object schrijft beide, en wanneer het XMP-object wordt aangemaakt voordat de originele /Metadata-stream is gelezen, vervangt een standaardpacket het origineel. PDF Library for Delphi vervangt dictionary-referenties in plaats van gedeelde objecten te muteren, en legt het bestaande packet vast vóór de lazy XMP-initialisatie

Opslaan is de minst interessante bewerking die een PDF-library uitvoert: laad een bestand, sla het onder een nieuwe naam op, raak niets aan. Pagina's renderen ervoor en erna identiek, en content-stream-hashes komen overeen. Een bestand kan elke zo'n check halen en toch op twee plekken fout zijn die geen renderer je ooit laat zien. Beide vallen zitten in het read-modify-write-pad waar elke echte bewerking doorheen gaat, dus elke opslag überhaupt is genoeg om ze te activeren, en pas een tweede, onafhankelijke parser die de niet-visuele semantiek van beide bestanden vergelijkt brengt ze aan het licht

Waarom verandert het opslaan van een PDF de CreationDate?

Omdat de document information dictionary van één indirect stringobject naar twee sleutels mag verwijzen, en een writer die het object bijwerkt in plaats van de sleutel beide verandert. ISO 32000-1 §7.3.10 laat elke dictionary-waarde een indirecte verwijzing zijn, en niets in §14.3.3 Table 317 zegt dat de waarde onder /CreationDate een ander object moet zijn dan de waarde onder /ModDate. Een producent die dezelfde timestamp twee keer wegschreef bij het aanmaken kan beide sleutels volkomen legaal naar één 2728 0 R laten wijzen, en bestanden in het wild doen dat precies zo

De trekker is de automatische wijzigingsdatum. Tenzij UserModDate is gezet, roept SaveToFile vóór het schrijven SetInfo('ModDate', ...) aan met de huidige tijd, wat bij SetRawInfo uitkomt. Een naïeve SetRawInfo zoekt het object onder de sleutel en roept, als het een TPDFString vindt, SetTo erop aan. Dat is een in-place schrijfactie naar het object waar de sleutel nu naar verwijst, en als dat object gedeeld is, rapporteert /CreationDate ook de opslagtijd. Het document opent, print en rendert nog pixel voor pixel als daarvoor, dus een visuele regressiesuite komt er moeiteloos doorheen

Gedeelde Info-string mutatie in PDFlibPas: /CreationDate en /ModDate verwijzen legaal naar één stringobject 2728 0 R, een in-place SetTo op wat de sleutel ook oplost zou beide data herschrijven met de opslagtijd, dus SetRawInfo voegt een verse string onder de sleutel toe en behoudt de hex-stringmodus
Het bijwerken van een dictionary-entry vervangt de referentie van die entry's sleutel in plaats van het gedeelde object te muteren, dus één automatische ModDate-schrijfactie kan CreationDate niet 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 regel in TPDFDocument.SetRawInfo is klein en het principe erachter is algemeen: het bijwerken van een dictionary-entry vervangt de referentie van die entry, nooit het object waar hij toevallig naar verwees. De code leest de bestaande TPDFStringMode zodat een hex-string hex blijft en een letterlijke string letterlijk blijft, en voegt daarna een verse string uit FStructure.NewString(Value, StringMode) onder de sleutel toe. Twee andere details wegen even zwaar. Een entry met een stream als waarde wordt vervangen zonder de oude stream eerst te legen, want SetTo('') erop zou de waarde voor elke andere sleutel die naar die stream wijst ook leegmaken. En het vervangen object wordt niet verwijderd, want de structuur is zijn eigenaar en andere verwijzingen hebben hem mogelijk nog nodig

// Naïef: muteer het object waar de sleutel nu naar verwijst
if Obj is TPDFString then
  TPDFString(Obj).SetTo(Value);

// Correct: houd de representatie, vervang alleen de referentie van deze sleutel
StringMode := smLiteral;
if Obj is TPDFString then
  StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));

Een test hiervoor bouwt de aliasing bewust op in plaats van te leunen op een bestand dat toevallig objecten deelt: één hex-string waarnaar beide datum-sleutels verwijzen, één directe string gedeeld door /Title en /Subject, één stream gedeeld door /Author en /Keywords. Na het bijwerken van één sleutel van elk paar moet de andere nog zijn oorspronkelijke waarde lezen en moet de bijgewerkte string nog hex zijn. De publieke referentie van SetInformation stelt de garantie 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 snel pad: wanneer het veld XMP al is toegekend, geeft het XMP.SaveToString terug in plaats van de /Metadata-stream uit de catalogus te decoderen. Een lazy initialisatie als XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata); leest natuurlijk en is fout: op het moment dat GetMetadata draait, is XMP toegekend, dus de "bron" die wordt geladen is het geserialiseerde standaardpacket van een object dat één regel eerder is aangemaakt. Het originele packet, met zijn dc:creator, custom namespaces en eventuele standards-identificatie, bereikt het object nooit en wordt bij het opslaan overschreven. Dezelfde automatische wijzigingsdatum is genoeg om dit te activeren, want SetInfo initialiseert XMP voordat het de Info-dictionary aanraakt, zodat xmp:ModifyDate in de pas blijft met /ModDate. Merk op waar deze val achter schermt: de Info-dictionaryvergelijking van de eerste val slaagt, want /Author en /Title in /Info zijn onaangeroerd. Alleen de XMP-boom is veranderd, en alleen een check die die boom parseert en vergelijkt valt het op

Volgorde van lazy XMP-initialisatie in PDFlibPas: het XMP-object aanmaken vóór het aanroepen van GetMetadata laat het snelle pad een standaardpacket serialiseren en dc:creator, custom namespaces en standards-identificatie laten vallen, terwijl Source vastleggen vóór TPDFlibXMP.Create de originele /Metadata-stream uit de catalogus laadt
Elke opslag kan de verwisseling activeren omdat SetInfo XMP initialiseert om xmp:ModifyDate in de pas te houden met /ModDate, dus elke lazy initialisatie in het document loopt door één EnsureXMP die het bestaande packet vastlegt vóórdat 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);

PDF Library for Delphi houdt die volgorde op één plek bij. TPDFDocument.EnsureXMP legt Source := GetMetadata vast vóór TPDFlibXMP.Create, en elke lazy initialisatie in het document loopt erdoorheen: SetInfo, SetXMPInformation, GetXMPInformation, de moduszetters voor PDF/A, PDF/X, PDF/E, PDF/VT, PDF/VCR en PDF/UA, en het metadata-reparatiepad. Publieke ingangspunten als SetXMPProperty lopen ook door EnsureXMP, en GetXMPProperty leest via GetDocumentMetadata, dus het hele oppervlak deelt één initialisatievolgorde. Eén correcte kopie van een drieregelige sequentie is meer waard dan tien kopieën die vandaag toevallig nog overeenkomen

Twee kleinere vallen op hetzelfde pad

De XMP-serializer op Windows gebruikt de platform-XML-writer, die een XML-declaratie uitzendt die het packet niet mag dragen. De voor de hand liggende manier om hem te strippen — tekens wissen tot je <?xpacket tegenkomt — faalt op geldige invoer: ISO 16684-1 §7.3.2 maakt de xpacket-wrapper optioneel, en een producent die een kaal <x:xmpmeta>-element schrijft is binnen de standaard, dus op zo'n packet verwijdert die lus het hele, geldige document. De serializer zoekt de sluitende ?> van de declaratie en verwijdert alleen die. Een retentiecheck die het kopiëren waard is draait twee keer, eens met de wrapper en eens zonder, en stelt vast dat een custom-namespace-marker en de oorspronkelijke auteur SetInfo, GetMetadata, SaveToString en een herlaadbeurt overleven. De tweede val is een preprocessor-symbool. NOVCL staat aan voor Free Pascal-builds, maar de XMP-backend is gemarkeerd op het besturingssysteem, niet op het framework, want PDFlibXMP.pas definieert NO_XMP alleen wanneer OS_WINDOWS ontbreekt. De Info-naar-XMP-synchronisatie in SetInfo met NOVCL bewaken zou een Windows-Lazarus-build opleveren met een werkend XMP-object en een SetInfo die het stilletjes overslaat. De bewaking is 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 uitvoer dezelfde revisie moet beschrijven als zijn invoer, en de verkeerde keuze voor alles dat echt content bewerkt, want §14.3.3 verwacht dat /ModDate de meest recente wijziging weerspiegelt. Het is ook geen vervanging voor correct referentiebeheer, want het vermijdt alleen de ene schrijfactie die de automatische update activeert. Referenties vervangen in plaats van gedeelde objecten te muteren, en het packet vastleggen vóór de initialisatie, dat maakt een gewone opslag veilig; de optie maakt een opzettelijke no-op eerlijk

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

Niet met pixels en niet met stream-hashes, want beide vallen laten elke pagina en elke content-stream byte-identiek. De check die ze vangt, is een niet-visuele semantische momentopname door een onafhankelijke parser — één die geen code deelt met de library onder test — van het bronbestand en van het opgeslagen bestand, gevolgd door een structurele vergelijking. De momentopname dekt de Info-dictionary met /ModDate uitgesloten, de outline-boom met elke bladwijzer opgelost naar een paginanummer in plaats van een objectnummer, named destinations en linkdoelen op dezelfde manier opgelost, formulierveldwaarden, bijlagebytes als hashes, en het XMP-packet als boom geparseerd in plaats van als tekst vergeleken. Objectnummers zijn bewust geen deel ervan, want een volledige herschrijving vernummert alles en een vergelijking daarop gesleuteld zou ruis rapporteren

Niet-visuele semantische verificatie voor PDFlibPas-saves: een onafhankelijke parser zonder gedeelde code neemt een momentopname van de Info-dictionary minus /ModDate, outline- en doelpagina's, formulierwaarden, bijlage-hashes en de XMP-boom, en vergelijkt dan bron met opgeslagen bestand terwijl /ModDate, xmp:ModifyDate en xmp:MetadataDate als verwachte wijzigingen vervallen
Pixels en stream-hashes blijven door beide vallen heen byte-identiek, dus de vergelijking werkt op opgeloste semantiek in plaats van objectnummers, en overlevende metadata wordt eerlijk gerapporteerd als behouden, niet als schema-geldig of PDF/UA- en PDF/A-compliant

De uitsluitingen wegen net zo zwaar als de insluitingen. /ModDate, xmp:ModifyDate en xmp:MetadataDate mogen veranderen en worden vóór de vergelijking weggegooid; een bestand waarvan de bron helemaal geen XMP had, wordt niet bestraft voor het verwerven van een packet. Wat de check niet beweert, is even expliciet: het behouden van een bestaand packet zegt niets over of dat packet schema-geldig is of of het document aan PDF/UA of een PDF/A-deel voldoet. Dat zijn aparte vragen met aparte tools, en "de metadata overleefde" gelijkstellen aan "de metadata is compliant" is hoe een gedeelde-datum-herschrijving lang verborgen kan blijven

Wie onder het niveau van deze regels werkt, vindt de mechaniek van hoe een opslag objecten herschrijft in incrementele updates en append-only opslaan — de ene opslagmodus waarin een gedeeld object simpelweg blijft waar het lag — 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 op elkaar worden afgestemd in plaats van slechts 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