Teknisk artikel

PDFlibPas MovePage: når nedarvede boxes deler instanser

I PDFlibPas, PDF-biblioteket til Delphi, fik en side flyttet med MovePage tidligere præcis de samme MediaBox-, CropBox- og Resources-objekter, som dens gamle Pages-node holdt, så en senere SetPageBox eller DrawText på den flyttede side lydløst omskrev den node og hver søskende, der stadig nedarvede fra den. Siden v3.539.36 får den flyttede side sine egne kopier, og en indirekte reference forbliver en reference. Samme release lukker to relaterede veje: SetPageBox på en indirekte box, som flere sider deler, og CopyPageRanges, der lod kildedokumentets sider hægte på deres Pages-node, med CropBox hægtet på MediaBox

Rapporterne, der leder hertil, nævner aldrig objektidentitet. De siger ting som "jeg beskar side 7, og side 8 til 12 blev beskåret med", eller "jeg indsnævrede CropBox, og MediaBox flyttede med", eller, den mest forvirrende, "jeg kopierede en side til et nyt dokument, og den oprindelige fil ændrede sig". Intet crasher, intet lækker, og den gemte fil er fuldstændig gyldig PDF. Den indeholder bare geometri, ingen bad om

Hvorfor resizer SetPageBox på én side sine søskende?

SetPageBox resizede søskende, fordi to sidetræ-poster pegede på ét in-memory array, og SetPageBox redigerer sit target-array på stedet. Enhver side eller Pages-node, der holdt samme instans, så redigeringen. Tre kodeveje i PDFlibPas producerede den deling før v3.539.36:

  • MovePage materialiserer de nedarvelige attributter på siden, inden den kobles fra sin parent, og den hæftede forfaderens egne objekter på i stedet for kopier, så den flyttede side og dens tidligere søskende delte et box-array og en Resources-dictionary
  • SetPageBox fulgte indirekte referencer og redigerede det refererede array, så en fil, hvor flere sider peger på ét /MediaBox 11 0 R-objekt, fik alle de sider resizeret af ét kald, uanset om MovePage nogensinde var involveret
  • CopyPageRanges materialiserer nedarvede værdier på kildesiden, inden den kloner den ind i target-dokumentet, og den hæftede Pages-node-instanserne på kildesiden plus selve MediaBox-instansen som default CropBox
PDFlibPas MovePage-aliasing, hvor en flyttet side og dens tidligere søskende begge holdt forfaderens egen MediaBox-array-instans, så SetPageBox redigerede én side og resizede den anden; siden v3.539.36 hæfter materialiseringen dekodede kopier på, og redigeringer forbliver lokale for den side, du rører
To sidetræ-poster, der pegede på ét in-memory array, fik hver redigering til at lande hos hver enkelt indehaver, og den gemte PDF forblev gyldig hele vejen

Tilfældet MovePage har en kort historie. Før v3.539.27 overførte MovePage kun /Resources, så en side flyttet under en anden parent lydløst tog parentens størrelse og rotation. v3.539.27 rettede den manglende MediaBox, CropBox og Rotate, hvilket også er det, CollateDocumentsEx støtter sig til, når den sorterer sider om, men den hæftede forfaderens værdier på som delte instanser. Det er vinduet, v3.539.36 lukker. SetPageBox- og CopyPageRanges-vejerne er ældre; enhver build før v3.539.36 har dem

Direkte værdier, indirekte referencer og sideattribut-nedarvning

En korrekt kopi af en nedarvet sideattribut duplikerer direkte værdier og beholder indirekte referencer som referencer, for det er netop dén skelnen, ISO 32000-1 selv trækker. Et direkte objekt som [0 0 400 300] skrevet inde i en dictionary tilhører alene den dictionary. Et indirekte objekt, defineret én gang som 11 0 obj og citeret som 11 0 R, er delt by design: ISO 32000-1 §7.3.10 gør det adresserbart fra hvor som helst i filen, og hver 11 0 R betyder samme objekt

Sideattribut-nedarvning, ISO 32000-1 §7.7.3.4, tilføjer et tredje tilfælde. Resources, MediaBox, CropBox og Rotate må ligge på en Pages-node og gælder for hver nedarvende side, der ikke definerer sine egne. Siden holder ikke værdien; den slår værdien op gennem /Parent. Den opslagskæde knækker i det øjeblik en side skifter parents, og det er derfor, MovePage og BalancePageTree først skal skrive de effektive værdier på selve siden. Spørgsmålet er kun, hvordan de skrives

Hvorfor en objektpool skjuler fejlen

I PDFlibPas ejes hvert parseret eller oprettet PDF-objekt af dokumentets TPDFStructure-pool, og dictionaries og arrays gemmer rå pointere til deres poster. TPDFDictionary.Add noterer pointeren og intet andet. At tilføje én instans til to parent-containere er derfor lovligt på ethvert niveau, runtimen kan tjekke: ingen double free ved teardown, ingen referencecounting, der kan gå galt, ingen exception. Serialisering er lige så tilgivende, da hver container skriver den delte instans' aktuelle værdi inline, og før nogen redigering er outputtet byte for byte, hvad en korrekt kopi ville producere

Aliasingen kommer først til syne, når nogen muterer den delte instans på stedet. SetPageBox gør præcis det gennem en rektangel-wrapper over det eksisterende array, og at tegne på en side gør det mod Resources-dictionary'en, når en font eller et billede registreres. Redigeringen lander, lydløst, i hver anden container, der holder pointeren

Hvordan PDFlibPas v3.539.36 kopierer i stedet for at dele

PDFlibPas v3.539.36 retter problemet i begge ender: materialisering hæfter nu kopier på, og box-skrivninger redigerer nu kun et array, siden ejer. Hvert fix dækker et tilfælde, det andet ikke kan

Materialiseringshjælperen PLInheritPageAttributes hæfter nu Page.Owner.Decode(Value.Output) på i stedet for Value. At round-trippe gennem serialiseringen er en knoldsagtig men eksakt måde at få PDF-semantik gratis. Et direkte array eller en dictionary serialiseres til sin bogstavelige tekst og dekodes til en frisk, uafhængig instans. En indirekte reference serialiseres til 11 0 R og dekodes til et nyt referenceobjekt, der peger på samme objekt 11, så siden stadig refererer til det delte objekt i stedet for at modtage en indlinet kopi, hvilket bevarer referenceadfærden fra v3.539.27. Kopien er præcis så dyb som den direkte struktur: alt, der nås gennem en reference inde i en kopieret dictionary, forbliver delt, som filformatet har tænkt det. BalancePageTree kalder samme hjælper for hver side, den re-parenterer, så sider materialiseret der også får separate instanser

PDFlibPas' materialiserings-round-trip, hvor PLInheritPageAttributes hæfter Page.Owner.Decode(Value.Output) på: et direkte array serialiseres til bogstavelig tekst og dekodes til en frisk instans, mens en indirekte 11 0 R serialiseres og dekodes til en ny reference, der stadig peger på det delte objekt 11
At serialisere og gen-parse giver PDF-objektsemantik gratis: direkte værdier kopieres, referencer forbliver referencer, præcis som ISO 32000-1 har tænkt det

At kopiere alene er ikke nok, for referencetilfældet peger stadig på et delt objekt. Fulgte SetPageBox den reference og redigerede objekt 11, ville den flyttede side igen resize den gamle parent og dens andre børn. Box-writeren anvender derfor nu copy-on-write: den redigerer på stedet, kun når sidens egen post er et direkte array, og erstatter en indirekte eller manglende box med et nyt direkte array. Objekt 11 efterlades urørt for enhver anden side, der citerer det

PDFlibPas SetPageBox' copy-on-write-beslutning: er sidens egen post et direkte array, redigeres det på stedet, og er det en indirekte reference eller manglende, erstatter writeren den med et nyt direkte array, så det delte objekt 11 beholder sin værdi for hver anden side, der citerer det
At kopiere ved materialisering er ikke nok, så længe referencer stadig peger på delte objekter, så box-writeren redigerer kun det, siden ejer
KodevejFør v3.539.36Siden v3.539.36
MovePage-materialiseringSiden holder forfaderens egne direkte instanserSiden holder dekodede kopier; referencer forbliver referencer
SetPageBoxFølger en reference og redigerer det delte arrayRedigerer kun et direkte array på siden, ellers skriver et nyt
CopyPageRanges-kildesideDeler Pages-node-boxes; CropBox er MediaBox-instansenHver materialiseret værdi på kildesiden er en kopi
Default-boxes ved kloning af sideressourcerCropBox, BleedBox, TrimBox og ArtBox deler ét arrayHver default-box får sit eget array

Sidste række er den latente. Når biblioteket kloner en sides ressourcer til page capture eller sammenfletning, udfylder det manglende CropBox-, BleedBox-, TrimBox- og ArtBox-poster, og de plejede at være samme array-instans. Ingen nuværende kaldere lod det alias overleve længe nok til at blive redigeret, men den næste ville have gjort det. Hvordan de default-box-værdier vælges, er sit eget emne, dækket i PDFlibPas' guide til TrimBox-, BleedBox- og CropBox-defaults

At reproducere MovePage-aliasing med en håndbygget PDF

Den hurtigste måde at tjekke enhver PDFlibPas-build på er en lille håndskrevet PDF indlæst med LoadFromString, hvor hvert objektnummer kendes på forhånd. Hjælperen nedenfor skriver en klassisk cross-reference-tabel med korrekt beregnede byte-offsets, så testen ikke er afhængig af parserens recovery-adfærd for beskadigede filer

uses
  System.SysUtils, PDFlibrary;

function BuildPdf(const Objects: array of AnsiString): AnsiString;
var
  Offsets: array of Integer;
  I, XRefPos: Integer;
begin
  Result := '%PDF-1.4'#10;
  SetLength(Offsets, Length(Objects));
  for I := 0 to High(Objects) do
  begin
    Offsets[I] := Length(Result);   // 0-baseret byte offset af "N 0 obj"
    Result := Result + AnsiString(IntToStr(I + 1)) + ' 0 obj'#10 +
      Objects[I] + #10'endobj'#10;
  end;
  XRefPos := Length(Result);
  Result := Result + 'xref'#10'0 ' + AnsiString(IntToStr(Length(Objects) + 1)) +
    #10'0000000000 65535 f '#10;
  for I := 0 to High(Offsets) do      // hver post er præcis 20 bytes
    Result := Result + AnsiString(Format('%.10d 00000 n ', [Offsets[I]])) + #10;
  Result := Result + 'trailer'#10'<< /Size ' +
    AnsiString(IntToStr(Length(Objects) + 1)) + ' /Root 1 0 R >>'#10 +
    'startxref'#10 + AnsiString(IntToStr(XRefPos)) + #10'%%EOF'#10;
end;

function StreamObj(const Content: AnsiString): AnsiString;
begin
  Result := '<< /Length ' + AnsiString(IntToStr(Length(Content))) +
    ' >>'#10'stream'#10 + Content + #10'endstream';
end;

Testdokumentet har to mellemliggende Pages-noder. Node 3 bærer en indirekte MediaBox (objekt 11, 400 gange 300 punkter), en direkte CropBox og en direkte Resources-dictionary og ejer to sider. Node 4 har en Letter-størrelses MediaBox og ejer tredje side. At flytte side 1 til position 3 re-parenterer den under node 4, hvilket er præcis det move, der behøver materialisering: uden den ville siden blive til en Letter-side

procedure Check(Condition: Boolean; const Msg: string);
begin
  if not Condition then
    raise Exception.Create(Msg);
end;

procedure CheckMovedPageIsIsolated;
var
  Lib: TPDFlib;
  FontID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Check(Lib.LoadFromString(BuildPdf([
      '<< /Type /Catalog /Pages 2 0 R >>',
      '<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 3 >>',
      '<< /Type /Pages /Parent 2 0 R /Kids [5 0 R 6 0 R] /Count 2 ' +
        '/MediaBox 11 0 R /CropBox [10 20 390 280] /Resources << >> >>',
      '<< /Type /Pages /Parent 2 0 R /Kids [7 0 R] /Count 1 ' +
        '/MediaBox [0 0 612 792] >>',
      '<< /Type /Page /Parent 3 0 R /Contents 8 0 R >>',
      '<< /Type /Page /Parent 3 0 R /Contents 9 0 R >>',
      '<< /Type /Page /Parent 4 0 R /Contents 10 0 R >>',
      StreamObj('1 w'), StreamObj('2 w'), StreamObj('3 w'),
      '[0 0 400 300]']), '') = 1, 'load failed');

    Lib.SelectPage(1);
    Check(Lib.MovePage(3) = 1, 'MovePage failed');
    Lib.SelectPage(3);                       // siden, vi lige flyttede
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'inherited MediaBox lost');

    Lib.SetPageBox(1, 0, 200, 200, 200);     // MediaBox 200 x 200
    Lib.SetPageBox(2, 0, 100, 100, 100);     // CropBox 100 x 100
    FontID := Lib.AddStandardFont(4);        // Helvetica
    Lib.SelectFont(FontID);
    Lib.SetTextSize(12);
    Lib.DrawText(20, 20, 'MOVED');

    // Undersøg den gamle parent FØR du vælger en anden side (se nedenfor)
    Check(Pos(AnsiString('/Font'), Lib.GetObjectToString(3)) = 0,
      'font registered in the old Pages node');

    Lib.SelectPage(1);                       // tidligere side 2, stadig under node 3
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'sibling MediaBox changed');
    Check(Abs(Lib.GetPageBox(2, 2) - 380) < 0.001, 'sibling CropBox changed');
    Check(Pos(AnsiString('400'), Lib.GetObjectToString(11)) > 0,
      'shared object 11 was rewritten');
  finally
    Lib.Free;
  end;
end;

GetPageBox(BoxType, Dimension) tager boxtype 1 for MediaBox og 2 for CropBox, og dimension 2 for bredde. Med default-origo nederst til venstre betyder SetPageBox(1, 0, 200, 200, 200) venstre 0, top 200, 200 bred og 200 høj. På builds mellem v3.539.27 og v3.539.35 fejler søskende-tjekkene: CropBox-redigeringen lander i node 3's direkte array, og MediaBox-redigeringen omskriver objekt 11 gennem referencen

Ændrer CopyPageRanges kildedokumentet?

Siden v3.539.36 skriver CopyPageRanges stadig på kildesiderne, men hver værdi, den skriver, er en separat kopi, så senere redigeringer på kilden forbliver lokale for den side, du redigerer. Skrivningen i sig er bevidst: kildesiden behøver eksplicit MediaBox, CropBox, Rotate og Resources, inden dens dictionary kloner ind i targetet, ellers ville kopien miste alt, hvad den nedarvede. Omnummerering og kopiering af siden ind i targetet er dækket i cross-document object deep copy i PDFlibPas; denne bug sad på kildesiden, som de fleste går ud fra en kopi kun læser

Outputtet viste det aldrig. Delt eller kopieret serialiseres de materialiserede værdier identisk, så begge dokumenter gemte byte for byte ens før og efter fixet. Først en redigering af kildedokumentet efter kopien afslørede aliaset:

procedure CheckSourceSurvivesCopy;
var
  Lib: TPDFlib;
  SourceID, TargetID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Check(Lib.LoadFromString(BuildPdf([
      '<< /Type /Catalog /Pages 2 0 R >>',
      '<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 ' +
        '/MediaBox [0 0 400 300] /Resources << >> >>',
      '<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>',
      '<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>',
      StreamObj('1 w'), StreamObj('2 w')]), '') = 1, 'load failed');
    SourceID := Lib.SelectedDocument;

    TargetID := Lib.NewDocument;             // bliver det valgte dokument
    Check(Lib.CopyPageRanges(SourceID, '1') = 1, 'copy failed');

    Lib.SelectDocument(SourceID);
    Lib.SelectPage(1);
    Lib.SetPageBox(2, 50, 250, 100, 100);    // indsnævrer kun CropBox
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'MediaBox followed CropBox');
    Lib.SetPageBox(1, 0, 200, 200, 200);

    Lib.SelectPage(2);
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'sibling page resized');

    Lib.SelectDocument(TargetID);            // kopien beholder sin oprindelige størrelse
    Lib.SelectPage(Lib.PageCount);
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'copied page resized');
  finally
    Lib.Free;
  end;
end;

Før v3.539.36 nedarvede begge sider her rodnodens direkte MediaBox, kopien hæftede den instans på kildeside 1 og hæftede den igen som side 1's CropBox. At indsnævre CropBox indsnævrede derfor MediaBox, og at resize MediaBox resizede side 2 gennem rodnoden. Workflows, der kopierer sider ud og bliver ved med at redigere kilden, som at sammenflette duplex-scanninger til én PDF, inden originalerne beskæres, er der, hvor det viste sig

Hvorfor er instans-aliasing så svær at teste?

Instans-aliasing er svær at teste, fordi den observerbare effekt behøver tre trin i en bestemt rækkefølge: skab aliaset, mutér den ene side, og inspiciér så den anden side, før noget andet rører den. De fleste tests gør kun det første trin og sammenligner gemt output, som er identisk, om aliaset findes eller ej

Rækkefølgefælden i PDFlibPas er SelectPage. At vælge en side genanvender den aktuelle font gennem SelectFont, som registrerer den font i sidens ressourcer. En side uden egen /Resources opløses til sin parents dictionary, så blot at vælge en sådan side tilføjer legitimt /Font til Pages-noden. I MovePage-testen ovenfor tilføjer valget af tidligere side 2 Helvetica-posten til node 3, hvilket er korrekt adfærd og ikke en lækage. Det er derfor, GetObjectToString(3)-tjekket kører før SelectPage(1); byt de to, og testen fejler på en fast build

Den regel markerer også, hvad v3.539.36 bevidst lader i fred. At skrive en ressource til en side, der nedarver sin Resources-dictionary, skriver ind i forfaderens dictionary, og hver søskende ser den nye post. Det er nedarvning, som specificeret, ikke instansdeling, og det er harmløst, fordi at tilføje et font- eller billednavn til en delt dictionary ikke ændrer, hvordan andre sider renderer. Behøver du en side, der skal holde op med at nedarve, så giv den først sin egen Resources-dictionary

Tjekliste til PDF object model-kode

Lektionerne generaliserer til enhver PDF object model bygget på en pool og pointer-containere, i Delphi eller andre steder:

  • Ved materialisering af nedarvede attributter efter ISO 32000-1 §7.7.3.4: deep-copy direkte værdier, og behold indirekte referencer som nye referencer til samme objekt
  • Tilføj aldrig en eksisterende instans med Add til en anden container, medmindre delingen er tænkt og dokumenteret; ejerskab af en pool betyder, at runtimen aldrig brokker sig
  • Rediger på stedet kun det, den aktuelle node ejer som direkte objekt; erstat indirekte eller nedarvede værdier med et frisk direkte objekt (copy-on-write)
  • Default-værdier afledt af en anden post, som en CropBox fra en MediaBox, behøver deres egen instans
  • Test aliasing med mutate-then-inspect-sekvenser på den anden indehaver, og tjek rækkefølgen af kald, der legitimt kan skrive midt imellem
  • At sammenligne gemt output beviser intet her: delte og kopierede værdier serialiseres identisk indtil den første redigering
  • På PDFlibPas: opgradér til v3.539.36 eller senere, hvis du kalder MovePage, CollateDocumentsEx, BalancePageTree eller CopyPageRanges og derefter redigerer sideboxes eller tegner på sider

PDFlibPas eksponerer sidetræ-redigering, cross-document-kopiering og sidebox-kontrol gennem én TPDFlib-klasse til Delphi, C++Builder og Free Pascal. Se PDFlibPas Delphi PDF library-produktsiden for udgaver, platforme og den fulde API-reference