Teknisk artikkel

PDFlibPas MovePage: Når arvede bokser deler instanser

I PDFlibPas, Delphi PDF-biblioteket, fikk en side flyttet med MovePage tidligere nøyaktig de samme MediaBox-, CropBox- og Resources-objektene som den gamle Pages-noden holdt, så en senere SetPageBox eller DrawText på den flyttede siden omskrev i stillhet den noden og hver søster som fortsatt arvet fra den. Siden v3.539.36 får den flyttede siden sine egne kopier, og en indirekte referanse forblir en referanse. Samme utgivelse lukker to relaterte stier: SetPageBox på en indirekte boks som flere sider deler, og CopyPageRanges som lot kildedokumentets sider henge igjen på sin Pages-node, med CropBox-en knyttet til MediaBox-en

Rapportene som leder hit, nevner aldri objektidentitet. De sier ting som «jeg beskars side 7 og sidene 8 til 12 ble beskåret også», eller «jeg snevret inn CropBox-en og MediaBox-en flyttet seg med», eller, den mest forvirrende, «jeg kopierte en side inn i et nytt dokument og originalfilen endret seg». Ingenting krasjer, ingenting lekker, og den lagrede filen er helt gyldig PDF. Den inneholder bare geometri ingen ba om

Hvorfor endrer SetPageBox på én side størrelsen på søstrene?

SetPageBox endret søstrenes størrelse fordi to page tree-oppføringer pekte på én array i minnet, og SetPageBox redigerer mål-arrayen sin in place. Enhver side eller Pages-node som holdt samme instans, så redigeringen. Tre kode-stier i PDFlibPas produserte den delingen før v3.539.36:

  • MovePage materialiserer de arvbare attributtene på siden før den kobles fra forelderen, og den festet forfedrens egne objekter i stedet for kopier, så den flyttede siden og de tidligere søstrene delte en boks-array og en Resources-ordbok
  • SetPageBox fulgte indirekte referanser og redigerte den refererte arrayen, så en fil der flere sider peker på ett /MediaBox 11 0 R-objekt, fikk alle de sidene endret av ett kall, uansett om MovePage noensinne var involvert
  • CopyPageRanges materialiserer arvede verdier på kildesiden før den klones inn i måldokumentet, og den festet Pages-node-instansene til kildesiden, pluss MediaBox-instansen selv som standard CropBox
PDFlibPas MovePage-aliasering der en flyttet side og dens tidligere søster begge holdt forfedrens egen MediaBox-array-instans, så SetPageBox redigerte én side og endret den andre; siden v3.539.36 fester materialisering dekodete kopier og redigeringer forblir lokale for siden du rører
To page tree-oppføringer som pekte på én array i minnet, gjorde at hver redigering landet i hver innehaver, og den lagrede PDF-en forble gyldig hele tiden

MovePage-tilfellet har en kort historie. Før v3.539.27 bar MovePage bare /Resources over, så en side flyttet under en annen forelder tok i stillhet den forelderens størrelse og rotasjon. v3.539.27 fikset den manglende MediaBox, CropBox og Rotate, noe som også er det CollateDocumentsEx er avhengig av når den omstokker sider, men den festet forfedrens verdier som delte instanser. Det er vinduet v3.539.36 lukker. SetPageBox- og CopyPageRanges-stiene er eldre; enhver build før v3.539.36 har dem

Direkte verdier, indirekte referanser og sideattributt-arv

En korrekt kopi av en arvet sideattributt dupliserer direkte verdier og beholder indirekte referanser som referanser, for det er skillet ISO 32000-1 selv trekker. Et direkte objekt som [0 0 400 300] skrevet inne i en ordbok, tilhører den ordboken alene. Et indirekte objekt, definert én gang som 11 0 obj og sitert som 11 0 R, er delt ved design: ISO 32000-1 §7.3.10 gjør det adresserbart fra hvor som helst i filen, og hver 11 0 R betyr samme objekt

Sideattributt-arv, ISO 32000-1 §7.7.3.4, legger til et tredje tilfelle. Resources, MediaBox, CropBox og Rotate kan ligge på en Pages-node og gjelde for hver etterkommende side som ikke definerer sin egen. Siden holder ikke verdien; den slår verdien opp gjennom /Parent. Den oppslagskjeden brytes i det øyeblikket en side bytter forelder, og det er derfor MovePage og BalancePageTree først må skrive de effektive verdiene på selve siden. Spørsmålet er bare hvordan skrive dem

Hvorfor en objektpool skjuler feilen

I PDFlibPas eies hvert parsede eller opprettede PDF-objekt av dokumentets TPDFStructure-pool, og ordbøker og arrayer lagrer rene pekere til sine oppføringer. TPDFDictionary.Add registrerer pekeren og ingenting annet. Å legge én instans til to overordnede containere er derfor lovlig på hvert nivå runtime-en kan sjekke: ingen dobbel free ved nedrivning, ingen referanseteller som kan gå galt, ingen unntak. Serialisering er like tilgivende, siden hver container skriver den delte instansens nåværende verdi inline, og før noen redigering er output byte for byte det en korrekt kopi ville produsert

Aliaserings-overflaten kommer bare frem når noen muterer den delte instansen in place. SetPageBox gjør nøyaktig det gjennom en rektangel-wrapper over den eksisterende arrayen, og tegning på en side gjør det mot Resources-ordboken når en font eller et bilde registreres. Redigeringen lander, i stillhet, i hver annen container som holder pekeren

Hvordan PDFlibPas v3.539.36 kopierer i stedet for å dele

PDFlibPas v3.539.36 fikser problemet i begge ender: materialisering fester nå kopier, og boks-skrivinger redigerer nå bare en array siden eier. Hver fiks dekker et tilfelle den andre ikke kan

Materialiseringshjelperen, PLInheritPageAttributes, fester nå Page.Owner.Decode(Value.Output) i stedet for Value. Å round-trippe gjennom serialisatoren er en grov men eksakt måte å få PDF-semantikk gratis på. En direkte array eller ordbok serialiseres til sin bokstavelige tekst og dekodes til en fersk, uavhengig instans. En indirekte referanse serialiseres til 11 0 R og dekodes til et nytt referanseobjekt som peker på samme objekt 11, så siden refererer fortsatt til det delte objektet i stedet for å motta en inlinet kopi, noe som bevarer referanseoppførselen innført i v3.539.27. Kopien er nøyaktig like dyp som den direkte strukturen: alt som nås gjennom en referanse inne i en kopiert ordbok forblir delt, slik filformatet mener. BalancePageTree kaller samme hjelper for hver side den gir ny forelder, så sider materialisert der får også separate instanser

PDFlibPas materialiserings-roundtrip der PLInheritPageAttributes fester Page.Owner.Decode(Value.Output): en direkte array serialiseres til bokstavelig tekst og dekodes til en fersk instans, mens en indirekte 11 0 R serialiseres og dekodes til en ny referanse som fortsatt peker på det delte objektet 11
Serialisering og re-parsing gir PDF-objektsemantikk gratis: direkte verdier kopieres, referanser forblir referanser, nøyaktig som ISO 32000-1 mener

Å kopiere alene er ikke nok, fordi referansetilfellet fortsatt peker på et delt objekt. Fulgte SetPageBox den referansen og redigerte objekt 11, ville den flyttede siden igjen endret den gamle forelderen og dens andre barn. Så boks-skriveren anvender nå copy-on-write: den redigerer in place bare når sidens egen oppføring er en direkte array, og erstatter en indirekte eller manglende boks med en ny direkte array. Objekt 11 blir urørt for hver annen side som siterer det

PDFlibPas SetPageBox copy-on-write-beslutning: når sidens egen oppføring er en direkte array, redigeres den in place, og når den er en indirekte referanse eller mangler, erstatter skriveren den med en ny direkte array, slik at det delte objektet 11 beholder verdien sin for hver annen side som siterer det
Å kopiere ved materialisering er ikke nok mens referanser fortsatt peker på delte objekter, så boks-skriveren redigerer bare det siden eier
Kode-stiFør v3.539.36Siden v3.539.36
MovePage-materialiseringSiden holder forfedrens egne direkte instanserSiden holder dekodete kopier; referanser forblir referanser
SetPageBoxFølger en referanse og redigerer den delte arrayenRedigerer bare en direkte array på siden, ellers skriver en ny
CopyPageRanges kildesideDeler Pages-node-bokser; CropBox er MediaBox-instansenHver materialisert verdi på kildesiden er en kopi
Standardbokser ved kloning av sideressurserCropBox, BleedBox, TrimBox og ArtBox deler én arrayHver standardboks får sin egen array

Siste rad er den latente. Når biblioteket kloner en sides ressurser til side-fangst eller sammenslåing, fyller den inn manglende CropBox-, BleedBox-, TrimBox- og ArtBox-oppføringer, og de pleide å være samme array-instans. Ingen nåværende kaller lot det aliaset overleve lenge nok til å bli redigert, men neste kaller ville ha gjort det. Hvordan de standard boks-verdiene velges, er et eget tema, dekket i PDFlibPas-guiden til TrimBox-, BleedBox- og CropBox-standarder

Reprodusere MovePage-aliaseringen med en håndbygd PDF

Den raskeste måten å sjekke enhver PDFlibPas-build på, er en liten håndskrevet PDF lastet med LoadFromString, der hvert objektnummer er kjent på forhånd. Hjelperen nedenfor skriver en klassisk kryssreferansetabell med korrekt beregnede byte-offsets, så testen ikke er avhengig av parserens gjenopprettelsesatferd for skadede 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-basert byte-offset av "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 oppføring er nøyaktig 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 to mellomliggende Pages-noder. Node 3 bærer en indirekte MediaBox (objekt 11, 400 ganger 300 punkter), en direkte CropBox og en direkte Resources-ordbok, og eier to sider. Node 4 har en Letter-størrelse MediaBox og eier tredje side. Å flytte side 1 til posisjon 3 gir den ny forelder under node 4, som er nøyaktig flyttingen som trenger materialisering: uten den ville siden blitt 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 nettopp flyttet
    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øk den gamle forelderen FØR du velger en annen side (se nedenfor)
    Check(Pos(AnsiString('/Font'), Lib.GetObjectToString(3)) = 0,
      'font registered in the old Pages node');

    Lib.SelectPage(1);                       // tidligere side 2, fortsatt 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) tar boks-type 1 for MediaBox og 2 for CropBox, og dimensjon 2 for bredde. Med standard origo nede til venstre betyr SetPageBox(1, 0, 200, 200, 200) venstre 0, topp 200, 200 bred og 200 høy. På builds mellom v3.539.27 og v3.539.35 feiler søstersjekkene: CropBox-redigeringen lander i node 3s direkte array, og MediaBox-redigeringen omskriver objekt 11 gjennom referansen

Endrer CopyPageRanges kildedokumentet?

Siden v3.539.36 skriver CopyPageRanges fortsatt på kildesidene, men hver verdi den skriver, er en separat kopi, så senere redigeringer på kilden forblir lokale for siden du redigerer. Skrivingen i seg selv er med vilje: kildesiden trenger eksplisitt MediaBox, CropBox, Rotate og Resources før ordboken dens klones inn i målet, ellers ville kopien mistet alt den arvet. Omnummerering og kopiering av siden inn i målet er dekket i kryss-dokument objekt-dypkopi i PDFlibPas; denne bugen satt på kildesiden, som folk flest antar at en kopi bare leser

Output-en viste det aldri. Delt eller kopiert, serialiseres de materialiserte verdiene identisk, så begge dokumentene lagret byte for byte likt før og etter fiksen. Bare en redigering av kildedokumentet etter kopien avdekket 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;             // blir det valgte dokumentet
    Check(Lib.CopyPageRanges(SourceID, '1') = 1, 'copy failed');

    Lib.SelectDocument(SourceID);
    Lib.SelectPage(1);
    Lib.SetPageBox(2, 50, 250, 100, 100);    // snevr inn bare CropBox-en
    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 opprinnelige 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 arvet begge sider her rotnodens direkte MediaBox, kopien festet den instansen til kildeside 1, og festet den igjen som side 1s CropBox. Å snevre inn CropBox-en snevret dermed inn MediaBox-en, og å endre MediaBox-en endret side 2 gjennom rotnoden. Arbeidsflyter som kopierer sider ut og deretter fortsetter å redigere kilden, som å sortere duplex-skann inn i én PDF før originalene beskjæres, er der dette viste seg

Hvorfor er instansaliasering så vanskelig å teste?

Instansaliasering er vanskelig å teste fordi den observerbare effekten trenger tre steg i en bestemt rekkefølge: skap aliaset, muter den ene siden, og undersøk så den andre siden før noe annet rører den. De fleste tester gjør bare det første steget og sammenligner lagret output, som er identisk uansett om aliaset finnes eller ikke

Rekkefølgefellen i PDFlibPas er SelectPage. Å velge en side gjenanvender gjeldende font gjennom SelectFont, som registrerer den fonten i sidens ressurser. En side uten egen /Resources faller tilbake på forelderens ordbok, så bare å velge en slik side legger legitimt til /Font på Pages-noden. I MovePage-testen over legger valg av den tidligere side 2 Helvetica-oppføringen til node 3, noe som er korrekt oppførsel og ikke en lekkasje. Det er derfor GetObjectToString(3)-sjekken kjører før SelectPage(1); bytt de to, og testen feiler på en fikset build

Den regelen markerer også hva v3.539.36 med vilje lar være i fred. Å skrive en ressurs til en side som arver sin Resources-ordbok, skriver inn i forfedrens ordbok, og hver søster ser den nye oppføringen. Det er arv som fungerer som spesifisert, ikke instansdeling, og det er harmløst fordi å legge til et font- eller bildename i en delt ordbok ikke endrer hvordan andre sider rendres. Trenger du at en side skal slutte å arve, gi den sin egen Resources-ordbok først

Sjekkliste for PDF-objektmodell-kode

Lærdommen generaliserer til enhver PDF-objektmodell bygget på en pool og peker-containere, i Delphi eller annet sted:

  • Ved materialisering av arvede attributter etter ISO 32000-1 §7.7.3.4, dypkopier direkte verdier og behold indirekte referanser som nye referanser til samme objekt
  • Aldri Add en eksisterende instans til en andre container med mindre delingen er tilsiktet og dokumentert; eierskap av en pool betyr at runtime-en aldri klager
  • Rediger in place bare det gjeldende noden eier som et direkte objekt; erstatt indirekte eller arvede verdier med et ferskt direkte objekt (copy-on-write)
  • Standardverdier avledet fra en annen oppføring, som en CropBox fra en MediaBox, trenger sin egen instans
  • Test aliasering med muter-og-undersøk-sekvenser på den andre innehaveren, og sjekk rekkefølgen av kall som kan skrive legitimt i mellom
  • Å sammenligne lagret output beviser ingenting her: delte og kopierte verdier serialiseres identisk til den første redigeringen
  • På PDFlibPas, oppgrader til v3.539.36 eller senere hvis du kaller MovePage, CollateDocumentsEx, BalancePageTree eller CopyPageRanges og deretter redigerer sidebokser eller tegner på sider

PDFlibPas eksponerer page tree-redigering, kryss-dokument kopiering og sideboks-kontroll gjennom én TPDFlib-klasse for Delphi, C++Builder og Free Pascal. Se PDFlibPas Delphi PDF library produktsiden for utgaver, plattformer og full API-referanse