Teknisk artikel

Varför en no-op-sparning kan förstöra Info och XMP-metadata

PDF Library for Delphi v3.539.18 och v3.539.20 åtgärdar två sätt som en PDF-sparning utan ändringar ändå kunde förstöra dokumentmetadata på: när /CreationDate och /ModDate refererade till samma strängobjekt skrev den automatiska ModDate-uppdateringen om båda, och när XMP-objektet skapades innan den ursprungliga /Metadata-strömmen hade lästs ersatte ett standardpaket originalet. Fixerna ersätter ordboksreferenser i stället för att mutera delade objekt, och fångar det befintliga paketet före den lata XMP-initieringen

Det här scenariot är den minst intressanta operation en PDF-lib utför: läs in en fil, spara den under ett nytt namn, rör ingenting däremellan. Sidorna renderades identiskt före och efter. Innehållsströmmarnas hashar matchade. Filen klarade varje kontroll vi hade, och den var ändå fel på två ställen som ingen renderare någonsin skulle visa dig. Båda defekterna satt i read-modify-write-vägen som varje verklig redigering går genom, så vilken sparning som helst räckte för att utlösa dem, och båda hittades bara när en andra, oberoende parser jämförde de två filernas icke-visuella semantik

Varför ändrar en sparning av en PDF dess CreationDate?

Därför att dokumentinformationsordboken får referera till ett enda indirekt strängobjekt från två nycklar, och biblioteket uppdaterade objektet i stället för nyckeln. ISO 32000-1 §7.3.10 tillåter att vilket ordboks värde som helst är en indirekt referens, och ingenting i §14.3.3 tabell 317 säger att värdet under /CreationDate måste vara ett annat objekt än värdet under /ModDate. En producent som skrev samma tidsstämpel två gånger vid skapandet kan, helt lagligt, peka båda nycklarna mot en enda 2728 0 R, vilket är exakt vad ett CJK-designdokument i vår lokala korpus gjorde

Utlösaren är det automatiska ändringsdatumet. Om inte UserModDate är satt anropar SaveToFile SetInfo('ModDate', ...) med aktuell tid före skrivningen, vilket når SetRawInfo. Den gamla SetRawInfo slog upp objektet under nyckeln och anropade SetTo på det om det hittade en TPDFString. Det är en skrivning på plats i vilket objekt nyckeln än löser upp till just nu, och när det objektet är delat rapporterar /CreationDate plötsligt också sparningstiden. Dokumentet öppnas, skrivs ut och renderas fortfarande pixel för pixel som förut, så en visuell regressionssvit passerar utan att blinka

Mutation av delad Info-sträng i PDFlibPas: /CreationDate och /ModDate refererar lagligt till ett enda strängobjekt 2728 0 R, den gamla SetRawInfo anropade SetTo på vad nyckeln än löste upp till och skrev om båda datumen med sparningstiden, och den nya SetRawInfo lägger till en färsk sträng under nyckeln och bevarar hex-strängläget
En uppdatering av en ordbokspost ersätter nu den postens referens i stället för att mutera det delade objektet, så en automatisk ModDate-skrivning kan inte längre ändra CreationDate, och det ersatta objektet behålls för andra referenser
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;

Fixen i TPDFDocument.SetRawInfo är liten och principen bakom den är allmän: att uppdatera en ordbokspost ersätter den postens referens, aldrig objektet den råkade lösa upp till. Den nya koden läser befintlig TPDFStringMode så att en hex-sträng förblir hex och en literal sträng förblir literal, och lägger sedan till en färsk sträng från FStructure.NewString(Value, StringMode) under nyckeln. Två andra detaljer spelar lika stor roll som huvudändringen. Den gamla grenen för en post med strömvärde tömde strömmen med SetTo('') innan den ersatte den, vilket skulle ha tömt värdet för varje annan nyckel som fortfarande pekade på den strömmen, så den tömningen är borta. Och det ersatta objektet raderas inte, eftersom strukturen äger det och andra referenser kan behöva det fortfarande

// Före: mutera vilket objekt nyckeln än löser upp till just nu
if Obj is TPDFString then
  TPDFString(Obj).SetTo(Value);

// Efter: behåll representationen, ersätt bara den här nyckelns referens
StringMode := smLiteral;
if Obj is TPDFString then
  StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));

Regressionstestet i Tests\SharedInfoSemantics.inc bygger aliaseringen medvetet i stället för att förlita sig på en korpusfil: en hex-sträng refererad från båda datumnycklarna, en direkt sträng delad av /Title och /Subject, en ström delad av /Author och /Keywords. Efter att ha uppdaterat en nyckel i varje par måste den andra fortfarande läsa sitt ursprungliga värde och den uppdaterade strängen måste fortfarande vara hex. Den publika referensen för SetInformation uttrycker nu garantin i en mening: att uppdatera ett Info-fält ersätter bara det fältet, även när andra fält refererar till samma objekt

Varför ersätts ett befintligt XMP-paket av standardvärden?

På grund av ordningen på två rader. TPDFDocument.GetMetadata har en snabbväg: när fältet XMP redan är tilldelat returnerar den XMP.SaveToString i stället för att avkoda /Metadata-strömmen från katalogen. Flera anropsställen initierade lat med XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata);, vilket läses naturligt och är fel: när GetMetadata kör är XMP tilldelat, så den "källa" som läses in är det serialiserade standardpaketet från ett objekt som skapades en rad tidigare. Originalpaketet, med sitt dc:creator, egna namnrymder och eventuell standardidentifiering, når aldrig objektet och skrivs över vid sparning. Samma automatiska ändringsdatum räcker för att utlösa det, eftersom SetInfo initierar XMP innan den rör Info-ordboken så att xmp:ModifyDate håller jämna steg med /ModDate. Notera vad den här defekten gömmer sig bakom: jämförelsen av Info-ordboken från den första buggen passerar, eftersom /Author och /Title i /Info är orörda. Bara XMP-trädet ändrades, och bara en kontroll som parsar och jämför det trädet märker det

Lat XMP-initieringsordning i PDFlibPas: att skapa XMP-objektet innan GetMetadata anropas gör att snabbvägen serialiserar ett standardpaket och tappar dc:creator, egna namnrymder och standardidentifiering, medan Source fångas innan TPDFlibXMP.Create läser in den ursprungliga /Metadata-strömmen från katalogen
Vilken sparning som helst utlöste bytet, eftersom SetInfo initierar XMP för att hålla xmp:ModifyDate i takt med /ModDate, så varje lat initiering i dokumentet går nu genom en enda EnsureXMP som fångar det befintliga paketet innan objektet skapas
// Fel: GetMetadata serialiserar nu objektet som skapades på raden ovan
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);

// Rätt: fånga /Metadata-strömmen först, skapa och läs in sedan
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);

Fixen gör två saker. TPDFDocument.EnsureXMP fångar nu Source := GetMetadata före TPDFlibXMP.Create, och varje lat initiering i dokumentet ersattes av ett anrop till den: SetInfo, SetXMPInformation, GetXMPInformation, settrarna för PDF/A-, PDF/X-, PDF/E-, PDF/VT-, PDF/VCR- och PDF/UA-läge, samt metadatareparationsvägen. Publika ingångar som SetXMPProperty gick redan genom EnsureXMP, och GetXMPProperty läser genom GetDocumentMetadata, så hela ytan delar en enda initieringsordning. En enda korrekt kopia av en treradssekvens är värd mer än tio kopior som råkar vara ense i dag

Två mindre fällor på samma väg

XMP-serialiseraren på Windows använder plattformens XML-skrivare, som skickar ut en XML-deklaration som paketet inte får bära. Den gamla koden strippade den genom att radera tecken tills den nådde <?xpacket. ISO 16684-1 §7.3.2 gör xpacket-omslaget valfritt, och en producent som skriver ett bart <x:xmpmeta>-element ligger inom standarden, så på ett sådant paket raderade loopen hela det giltiga dokumentet. Serialiseraren hittar nu deklarationens avslutande ?> och tar bort bara den. Tests\XMPRetentionSemantics.inc kör sin bevarandekontroll två gånger, en gång med omslaget och en gång med det bortskuret, och hävdar att en markör för egen namnrymd och den ursprungliga författaren överlever SetInfo, GetMetadata, SaveToString och en omladdning. Den andra fällan var en förprocessorsymbol: Info-till-XMP-synkroniseringen i SetInfo skyddades av NOVCL, som sätts för Free Pascal-byggen, men XMP-backenden styrs av operativsystemet, inte av ramverket, eftersom PDFlibXMP.pas definierar NO_XMP bara när OS_WINDOWS saknas. Ett Lazarus-bygge för Windows hade därför ett fungerande XMP-objekt och en SetInfo som tyst hoppade över att uppdatera det. Vakten är nu NO_XMP, så en Free Pascal-applikation för Windows får samma synkronisering som Delphi

Hur behåller du den ursprungliga ModDate vid en pass-through-sparning?

Sätt KeepModDate i TPDFlibSaveOptions och spara genom SaveToFileOptions. Optionen sätter UserModDate under anropet, och SaveToFile hoppar då över den automatiska tidsstämpeln, vilket också är steget som lat initierar XMP-objektet. Ett dokument vars metadata du aldrig rört, och för vilket inget compliance-läge aktiverades, behåller både sin Info-ordbok och sin /Metadata-ström som inlästa. Att anropa SetInformation(8, ...) har samma effekt permanent, eftersom ett eget satt ändringsdatum markerar det som användarstyrt

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

Var ärlig med vad det här ger dig. KeepModDate är rätt val för ett pass-through-steg vars utdata ska beskriva samma revision som sin indata, och det är fel val för allt som faktiskt redigerar innehåll, eftersom §14.3.3 förväntar sig att /ModDate speglar den senaste ändringen. Det lagar inte heller i efterhand ett bibliotek som muterar delade objekt; det undviker bara den enda skrivning som avslöjade defekten. Båda fixerna ovan är vad som gör en vanlig sparning säker, och optionen är vad som gör en avsiktlig no-op ärlig

Hur verifierar du att en sparning inte ändrade något annat än ModDate?

Inte med pixlar och inte med strömhashar, för båda defekterna lämnar varje sida och varje innehållsström byteidentisk. Kontrollen som fångade dem är en icke-visuell semantisk ögonblicksbild tagen av en oberoende parser, en som inte delar någon kod med biblioteket under test, från källfilen och från den sparade filen, följd av en strukturell jämförelse. Ögonblicksbilden täcker Info-ordboken med /ModDate undantaget, dispositionsträdet med varje bokmärke upplöst till ett sidnummer i stället för ett objektnummer, namngivna destinationer och länkmål upplösta på samma sätt, formulärfältvärden, bilagebyte som hashar, och XMP-paketet tolkat som ett träd i stället för jämfört som text. Objektnummer är medvetet inte en del av den, eftersom en full omskrivning numrerar om allt och en jämförelse nycklad på dem skulle rapportera brus

Icke-visuell semantisk verifiering av PDFlibPas-sparningar: en oberoende parser utan delad kod tar en ögonblicksbild av Info-ordboken utan /ModDate, dispositionens och destinationernas sidor, formulärvärden, bilagehashar och XMP-trädet, och jämför sedan källan mot den sparade filen medan /ModDate, xmp:ModifyDate och xmp:MetadataDate släpps som väntade ändringar
Pixlar och strömhashar förblir byteidentiska genom båda defekterna, så jämförelsen arbetar på upplöst semantik i stället för objektnummer, och bevarad metadata rapporteras ärligt som bevarad, inte som schemavaliderad eller PDF/UA- och PDF/A-kompatibel

Undantagen är lika viktiga som inkluderingarna. /ModDate, xmp:ModifyDate och xmp:MetadataDate förväntas ändras och släpps före jämförelsen; en fil vars källa inte bar någon XMP alls straffas inte för att den får ett paket. Vad kontrollen inte påstår är lika uttalat: att behålla ett befintligt paket säger ingenting om huruvida paketet är schemavaliderat eller huruvida dokumentet uppfyller PDF/UA eller någon PDF/A-del. Det är separata frågor med separata verktyg, och att blanda ihop "metadatan överlevde" med "metadatan är kompatibel" är hur den första buggen kunde gömma sig så länge som den gjorde. På bibliotekssidan körs de två regressionerna nu vid varje riktad körning över Delphi Win32 och Win64 samt Free Pascal Win32 och Win64, och den semantiska jämförelsen är ett godkännandevillkor för benchmarken på korpusen med riktiga dokument

Om du arbetar på nivån under de här fixerna täcks mekaniken för hur en sparning skriver om objekt i inkrementella uppdateringar och append-only-sparning, vilket är det enda sparläget där ett delat objekt helt enkelt lämnas där det var, och i ändringsnivåer och revisionsdiffning, som är den andra platsen där ett inaktuellt eller omskrivet datum vilseleder en läsare. Reparationssidans vy av samma Info- och XMP-par, där de två halvorna får komma överens i stället för att bara bevaras, finns i konvertering till PDF/A och metadatareparation

PDF Library for Delphi är ett nativt Pascal-PDF-bibliotek för Delphi, C++Builder och Lazarus, och read-modify-write-vägen som beskrivs här är samma väg som varje redigering i din egen process går genom, så garantierna ovan gäller vare sig du sparar en gång eller tusen gånger om dagen — se produktsidan för PDF Library for Delphi för vilka kompilatorer och plattformar som stöds