Teknisk artikel

PDFlibPas MovePage: när ärvda boxar delar instanser

I PDFlibPas, Delphi PDF-biblioteket, fick en sida som flyttats med MovePage förr exakt samma MediaBox-, CropBox- och Resources-objekt som dess gamla Pages-nod höll, så ett senare SetPageBox eller DrawText på den flyttade sidan skrev tyst om den noden och vart syskon som fortfarande ärvde från den. Sedan v3.539.36 får den flyttade sidan sina egna kopior, och en indirekt referens förblir en referens. Samma release stänger två relaterade vägar: SetPageBox på en indirekt box som flera sidor delar, och CopyPageRanges som lämnade sidor i källdokumentet knutna till sin Pages-nod, med CropBox knuten till MediaBox

Rapporterna som leder hit nämner aldrig objektidentitet. De säger saker som "Jag beskar sida 7 och sidorna 8 till 12 beskars också", eller "Jag smalnade av CropBox och MediaBox flyttade med", eller, den mest förvirrande, "Jag kopierade en sida till ett nytt dokument och originalfilen ändrades". Inget kraschar, inget läcker, och den sparade filen är en helt giltig PDF. Den innehåller bara geometri ingen bett om

Varför storleksänder SetPageBox på en sida dess syskon?

SetPageBox storleksände syskon för att två sidträdsposter pekade på en och samma array i minnet, och SetPageBox redigerar sin målarray på plats. Varje sida eller Pages-nod som höll samma instans såg redigeringen. Tre kodvägar i PDFlibPas framställde den delningen före v3.539.36:

  • MovePage materialiserar de ärvbara attributen på sidan innan den kopplas loss från sin förälder, och den fäste anfäderns egna objekt i stället för kopior, så den flyttade sidan och dess tidigare syskon delade en boxarray och en Resources-ordbok
  • SetPageBox följde indirekta referenser och redigerade den refererade arrayen, så en fil där flera sidor pekar på ett /MediaBox 11 0 R-objekt fick alla de sidorna storleksändrade av ett anrop, oavsett om MovePage var inblandat eller inte
  • CopyPageRanges materialiserar ärvda värden på källsidan innan den klonas in i måldokumentet, och den fäste Pages-nodinstanserna på källsidan, plus MediaBox-instansen själv som standard-CropBox
PDFlibPas MovePage-aliasing där en flyttad sida och dess tidigare syskon båda höll anfäderns egen MediaBox-arrayinstans, så att SetPageBox redigerade en sida och storleksände den andra; sedan v3.539.36 fäster materialisering avkodade kopior och redigeringar stannar lokala på sidan du rör
Två sidträdsposter som pekade på en och samma array i minnet gjorde att varje redigering landade i varje innehavare, och den sparade PDF:en förblev giltig hela tiden

Fallet MovePage har en kort historia. Före v3.539.27 förde MovePage bara med sig /Resources, så en sida flyttad under en annan förälder tog tyst den förälderns storlek och rotation. v3.539.27 fixade de saknade MediaBox, CropBox och Rotate, vilket också är vad CollateDocumentsEx förlitar sig på när den ordnar om sidor, men den fäste anfäderns värden som delade instanser. Det är fönstret v3.539.36 stänger. Vägarna SetPageBox och CopyPageRanges är äldre; alla byggen före v3.539.36 har dem

Direkta värden, indirekta referenser och ärftning av sidattribut

En korrekt kopia av ett ärvt sidattribut duplicerar direkta värden och behåller indirekta referenser som referenser, för det är distinktionen ISO 32000-1 själv drar. Ett direkt objekt som [0 0 400 300] skrivet inuti en ordbok tillhör enbart den ordboken. Ett indirekt objekt, definierat en gång som 11 0 obj och citerat som 11 0 R, delas med flit: ISO 32000-1 §7.3.10 gör det adresserbart från var som helst i filen, och vart 11 0 R betyder samma objekt

Ärftning av sidattribut, ISO 32000-1 §7.7.3.4, lägger till ett tredje fall. Resources, MediaBox, CropBox och Rotate kan sitta på en Pages-nod och gälla varje avkommande sida som inte definierar egna. Sidan håller inte värdet; den slår upp värdet via /Parent. Den uppslagskedjan bryts i ögonblick en sida byter förälder, vilket är varför MovePage och BalancePageTree först måste skriva de effektiva värdena på sidan själv. Frågan är bara hur de ska skrivas

Varför en objektpool döljer misstaget

I PDFlibPas ägs vart tolkat eller skapat PDF-objekt av dokumentets TPDFStructure-pool, och ordböcker och arrayer lagrar bara pekare till sina poster. TPDFDictionary.Add registrerar pekaren och inget annat. Att lägga en instans i två förälderbehållare är därför lagligt på varje nivå runtime kan kontrollera: ingen double free vid nedmontering, ingen referensräknare som kan gå fel, ingen exception. Serialisering är lika förlåtande, eftersom varje behållare skriver den delade instansens aktuella värde inline, och före någon redigering är utdatat byte för byte vad en korrekt kopia skulle framställa

Aliasingen syns först när någon muterar den delade instansen på plats. SetPageBox gör exakt det genom ett rektangelomslag över den befintliga arrayen, och att rita på en sida gör det mot Resources-ordboken när en font eller bild registreras. Redigeringen landar, tyst, i vart annat objekt som håller pekaren

Hur PDFlibPas v3.539.36 kopierar i stället för att dela

PDFlibPas v3.539.36 fixar problemet i båda ändar: materialisering fäster nu kopior, och boxskrivningar redigerar nu bara en array sidan äger. Varje fix täcker ett fall den andra inte kan

Materialiseringshjälparen, PLInheritPageAttributes, fäster nu Page.Owner.Decode(Value.Output) i stället för Value. Att köra tur och retur genom serialiseraren är ett trubbigt men exakt sätt att få PDF-semantik gratis. En direkt array eller ordbok serialiseras till sin bokstavliga text och avkodas till en färsk, oberoende instans. En indirekt referens serialiseras till 11 0 R och avkodas till ett nytt referensobjekt som pekar på samma objekt 11, så sidan hänvisar fortfarande till det delade objektet i stället för att få en inlined kopia, vilket bevarar referensbeteendet introducerat i v3.539.27. Kopian är exakt så djup som den direkta strukturen: allt som nås via en referens inuti en kopierad ordbok förblir delat, som filformatet avser. BalancePageTree anropar samma hjälpare för varje sida den ger ny förälder, så sidor materialiserade där får också separata instanser

PDFlibPas materialiserings tur och retur där PLInheritPageAttributes fäster Page.Owner.Decode(Value.Output): en direkt array serialiseras till bokstavlig text och avkodas till en färsk instans, medan en indirekt 11 0 R serialiseras och avkodas till en ny referens som fortfarande pekar på det delade objektet 11
Att serialisera och omtolka ger PDF-objektsemantik gratis: direkta värden kopieras, referenser förblir referenser, exakt som ISO 32000-1 avser

Att kopiera ensamt räcker inte, för referensfallet pekar fortfarande på ett delat objekt. Följde SetPageBox den referensen och redigerade objekt 11 skulle den flyttade sidan åter storleksändra den gamla föräldern och dess andra barn. Därför tillämpar boxskrivaren nu copy-on-write: den redigerar på plats bara när sidans egen post är en direkt array, och ersätter en indirekt eller saknad box med en ny direkt array. Objekt 11 lämnas orört för varje annan sida som citerar det

PDFlibPas SetPageBox copy-on-write-beslut: är sidans egen post en direkt array redigeras den på plats, och är den en indirekt referens eller saknas ersätter skrivaren den med en ny direkt array så att det delade objektet 11 behåller sitt värde för varje annan sida som citerar det
Att kopiera vid materialisering räcker inte medan referenser fortfarande pekar på delade objekt, så boxskrivaren redigerar bara vad sidan äger
KodvägFöre v3.539.36Sedan v3.539.36
MovePage-materialiseringSidan håller anfäderns egna direkta instanserSidan håller avkodade kopior; referenser förblir referenser
SetPageBoxFöljer en referens och redigerar den delade arrayenRedigerar bara en direkt array på sidan, annars skriver en ny
CopyPageRanges-källsidaDelar Pages-nodboxar; CropBox är MediaBox-instansenVarje materialiserat värde på källsidan är en kopia
Standardboxar vid kloning av sidresurserCropBox, BleedBox, TrimBox och ArtBox delar en arrayVarje standardbox får sin egen array

Sista raden är den latenta. När biblioteket klonar en sidas resurser för sidfångst eller sammanslagning fyller den i saknade CropBox-, BleedBox-, TrimBox- och ArtBox-poster, och de brukade vara samma arrayinstans. Ingen nuvarande anropare lät aliase överleva länge nog att redigeras, men nästa anropare skulle ha gjort det. Hur de standardboxvärdena väljs är ett eget ämne, taget upp i PDFlibPas-guiden till TrimBox-, BleedBox- och CropBox-standarder

Återskapa MovePage-aliasingen med en handbyggd PDF

Det snabbaste sättet att kontrollera vilket PDFlibPas-bygge som helst är en liten handskriven PDF laddad med LoadFromString, där vart objektnummer är känt på förhand. Hjälparen nedan skriver en klassisk xref-tabell med korrekt beräknade byteoffset, så testet inte förlitar sig på tolkarens återställningsbeteende för skadade 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-baserad byteoffset för "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      // varje post är exakt 20 byte
    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 två mellanliggande Pages-noder. Nod 3 bär en indirekt MediaBox (objekt 11, 400 gånger 300 punkter), en direkt CropBox och en direkt Resources-ordbok, och äger två sidor. Nod 4 har en MediaBox i Letter-storlek och äger tredje sidan. Att flytta sida 1 till position 3 ger den ny förälder under nod 4, vilket är exakt den flytt som behöver materialisering: utan den skulle sidan bli en Letter-sida

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);                       // sidan vi precis flyttade
    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');

    // Granska den gamla föräldern FÖRE att en annan sida väljs (se nedan)
    Check(Pos(AnsiString('/Font'), Lib.GetObjectToString(3)) = 0,
      'font registered in the old Pages node');

    Lib.SelectPage(1);                       // tidigare sida 2, fortfarande under nod 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) tar boxtyp 1 för MediaBox och 2 för CropBox, och dimension 2 för bredd. Med standardursprunget nere till vänster betyder SetPageBox(1, 0, 200, 200, 200) vänster 0, topp 200, 200 bred och 200 hög. På byggen mellan v3.539.27 och v3.539.35 misslyckas syskonkontrollerna: CropBox-redigeringen landar i nod 3:s direkta array, och MediaBox-redigeringen skriver om objekt 11 via referensen

Ändrar CopyPageRanges källdokumentet?

Sedan v3.539.36 skriver CopyPageRanges fortfarande på källsidorna, men varje värde den skriver är en separat kopia, så senare redigeringar på källan stannar lokala på sidan du redigerar. Själva skrivandet är med flit: källsidan behöver explicit MediaBox, CropBox, Rotate och Resources innan dess ordbok klonas in i målet, annars skulle kopian förlora allt den ärvt. Omnumrering och kopiering av sidan in i målet tas upp i korsdokumenterad djupkopiering av objekt i PDFlibPas; denna bugg satt på källsidan, som de flesta antar att en kopia bara läser

Utdatat visade det aldrig. Delat eller kopierat serialiseras de materialiserade värdena identiskt, så båda dokumenten sparades byte för byte likadant före och efter fixen. Först en redigering av källdokumentet efter kopian avslöjade aliase:

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;             // blir det valda dokumentet
    Check(Lib.CopyPageRanges(SourceID, '1') = 1, 'copy failed');

    Lib.SelectDocument(SourceID);
    Lib.SelectPage(1);
    Lib.SetPageBox(2, 50, 250, 100, 100);    // smalna bara av 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);            // kopian behåller sin ursprungsstorlek
    Lib.SelectPage(Lib.PageCount);
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'copied page resized');
  finally
    Lib.Free;
  end;
end;

Före v3.539.36 ärvde båda sidorna här rotnodens direkta MediaBox, kopian fäste den instansen på källsida 1, och fäste den igen som sida 1:s CropBox. Att smalna av CropBox smalnade alltså av MediaBox, och att storleksändra MediaBox storleksände sida 2 via rotnoden. Arbetsflöden som kopierar ut sidor och sedan fortsätter redigera källan, som att sammanfoga duplex-skanningar till en PDF innan originalen trimmas, är där detta dök upp

Varför är instansaliasing så svår att testa?

Instansaliasing är svår att testa för att den observerbara effekten behöver tre steg i en specifik ordning: skapa aliase, mutera ena sidan, granska sedan andra sidan innan något annat rör den. De flesta tester gör bara första steget och jämför sparad utdata, som är identisk oavsett om aliase finns eller inte

Ordningsträcket i PDFlibPas är SelectPage. Att välja en sida återanvänder aktuell font via SelectFont, som registrerar den fonten i sidans resurser. En sida utan egen /Resources löser upp sig till sin förälders ordbok, så att bara välja en sådan sida lägger legitimt till /Font på Pages-noden. I MovePage-testet ovan lägger val av tidigare sida 2 till Helvetica-posten på nod 3, vilket är korrekt beteende och ingen läcka. Det är därför kontrollen GetObjectToString(3) körs före SelectPage(1); byt ordning på dem och testet faller på ett fixat bygge

Den regeln markerar också vad v3.539.36 med flit lämnar orört. Att skriva en resurs till en sida som ärver sin Resources-ordbok skriver in i anfäderns ordbok, och varje syskon ser den nya posten. Det är ärftning som fungerar som specificerat, inte instansdelning, och det är ofarligt för att lägga till ett font- eller bildnamn i en delad ordbok inte ändrar hur andra sidor renderar. Behöver du att en sida sluta ärva, ge den sin egen Resources-ordbok först

Checklista för PDF-objektmodellkod

Lärdomarna generaliserar till vilken PDF-objektmodell som helst byggd på en pool och pekarebehållare, i Delphi eller någon annanstans:

  • Vid materialisering av ärvda attribut enligt ISO 32000-1 §7.7.3.4, djupkopiera direkta värden och behåll indirekta referenser som nya referenser till samma objekt
  • Anropa aldrig Add med en existerande instans i en andra behållare om inte delningen är avsedd och dokumenterad; ägande av en pool betyder att runtime aldrig kommer klaga
  • Redigera på plats bara vad aktuell nod äger som direkt objekt; ersätt indirekta eller ärvda värden med ett färskt direkt objekt (copy-on-write)
  • Standardvärden härledda från en annan post, som en CropBox från en MediaBox, behöver sin egen instans
  • Testa aliasing med mutera-sedan-granska-sekvenser på den andra innehavaren, och kontrollera ordningen av anrop som kan skriva legitimt emellan
  • Att jämföra sparad utdata bevisar ingenting här: delade och kopierade värden serialiseras identiskt tills första redigeringen
  • På PDFlibPas, uppgradera till v3.539.36 eller senare om du anropar MovePage, CollateDocumentsEx, BalancePageTree eller CopyPageRanges och sedan redigerar sidboxar eller ritar på sidor

PDFlibPas exponerar sidträdredigering, korsdokumentkopiering och sidboxkontroll genom en enda TPDFlib-klass för Delphi, C++Builder och Free Pascal. Se produktsidan för PDFlibPas Delphi PDF library för utgåvor, plattformar och den fullständiga API-referensen