Technisch artikel

PDFlibPas MovePage: als overgeërfde boxes instanties delen

In PDFlibPas, de Delphi PDF-library, kreeg een pagina die met MovePage was verplaatst vroeger precies dezelfde MediaBox-, CropBox- en Resources-objecten die zijn oude Pages-node bevatte, dus een latere SetPageBox of DrawText op de verplaatste pagina herschreef stilletjes die node en elke zuster die er nog van erfde. Sinds v3.539.36 krijgt de verplaatste pagina zijn eigen kopieën, en een indirecte verwijzing blijft een verwijzing. Dezelfde release dicht twee verwante paden: SetPageBox op een indirecte box die meerdere pagina's delen, en CopyPageRanges die pagina's van het brondocument aan hun Pages-node liet hangen, met de CropBox vast aan de MediaBox

De meldingen die hierheen leiden spreken nooit over objectidentiteit. Ze zeggen dingen als "ik heb pagina 7 bijgesneden en pagina 8 tot 12 is ook bijgesneden", of "ik heb de CropBox versmald en de MediaBox schoof mee", of, het verwarrendste, "ik heb een pagina naar een nieuw document gekopieerd en het originele bestand is veranderd". Niets crasht, niets lekt, en het opgeslagen bestand is prima geldige PDF. Het bevat alleen geometrie die niemand heeft gevraagd

Waarom schaalt SetPageBox op één pagina de zusterpagina's mee?

SetPageBox schoof zusters mee omdat twee entries in de paginaboom naar één array in het geheugen wezen, en SetPageBox bewerkt zijn doelarray ter plekke. Elke pagina of Pages-node met dezelfde instantie zag de bewerking. Drie codepaden in PDFlibPas veroorzaakten dat delen vóór v3.539.36:

  • MovePage materialiseert de overerfbare attributen op de pagina voordat hij hem van zijn parent loskoppelt, en hij hing de eigen objecten van de voorouder vast in plaats van kopieën, dus de verplaatste pagina en zijn voormalige zusters deelden een boxarray en een Resources-dictionary
  • SetPageBox volgde indirecte verwijzingen en bewerkte de gerefereerde array, dus in een bestand waarin meerdere pagina's naar één /MediaBox 11 0 R-object wijzen werden al die pagina's door één aanroep geschaald, of MovePage er nu bij betrokken was of niet
  • CopyPageRanges materialiseert overgeërfde waarden op de bronpagina voordat hij haar kloont naar het doeldocument, en hij hing de Pages-node-instanties aan de bronpagina, plus de MediaBox-instantie zelf als default CropBox
PDFlibPas MovePage-aliasing waarbij een verplaatste pagina en zijn voormalige zuster allebei de eigen MediaBox-arrayinstantie van de voorouder vasthielden, zodat SetPageBox de ene pagina bewerkte en de andere meeschaalde; sinds v3.539.36 hangt materialisatie gedecodeerde kopieën vast en blijven bewerkingen lokaal op de pagina die u aanraakt
Twee paginaboom-entries die naar één array in het geheugen wezen zorgden dat elke bewerking bij elke houder terechtkwam, en de opgeslagen PDF bleef de hele tijd geldig

Het MovePage-geval heeft een korte voorgeschiedenis. Vóór v3.539.27 nam MovePage alleen /Resources mee, dus een pagina die onder een andere parent was verplaatst nam stilletjes de grootte en rotatie van die parent aan. v3.539.27 loste de ontbrekende MediaBox, CropBox en Rotate op, waarop ook CollateDocumentsEx vertrouwt bij het herordenen van pagina's, maar hij hing de waarden van de voorouder vast als gedeelde instanties. Dat is het venster dat v3.539.36 sluit. De SetPageBox- en CopyPageRanges-paden zijn ouder; elke build vóór v3.539.36 heeft ze

Directe waarden, indirecte verwijzingen en pagina-attribuuterfenis

Een correcte kopie van een overgeërfd pagina-attribuut verdubbelt directe waarden en houdt indirecte verwijzingen als verwijzingen, want dat is precies het onderscheid dat ISO 32000-1 zelf trekt. Een direct object zoals [0 0 400 300] binnen een dictionary hoort aan die dictionary alleen. Een indirect object, één keer gedefinieerd als 11 0 obj en aangehaald als 11 0 R, is bij ontwerp gedeeld: ISO 32000-1 §7.3.10 maakt het adresseerbaar vanuit elke hoek van het bestand, en elke 11 0 R betekent hetzelfde object

Pagina-attribuuterfenis, ISO 32000-1 §7.7.3.4, voegt een derde geval toe. Resources, MediaBox, CropBox en Rotate mogen op een Pages-node zitten en gelden voor elke afstammende pagina die geen eigen definitie heeft. De pagina bewaart de waarde niet; hij zoekt de waarde op via /Parent. Die opzoekketen breekt op het moment dat een pagina van parent wisselt, en daarom moeten MovePage en BalancePageTree eerst de effectieve waarden op de pagina zelf wegschrijven. De vraag is alleen hoe ze geschreven worden

Waarom een objectpool de fout verhult

In PDFlibPas is elk geparsed of aangemaakt PDF-object eigendom van de TPDFStructure-pool van het document, en dictionaries en arrays bewaren kale pointers naar hun entries. TPDFDictionary.Add registreert de pointer en niets anders. Eén instantie aan twee parentcontainers toevoegen is daarom legaal op elk niveau dat de runtime kan controleren: geen double free bij het afbreken, geen referentieteller die kan ontsporen, geen exception. Serialisatie is net zo vergevingsgezind, want elke container schrijft de huidige waarde van de gedeelde instantie inline, en vóór elke bewerking is de uitvoer byte voor byte wat een correcte kopie zou opleveren

De aliasing komt pas boven water zodra iemand de gedeelde instantie ter plekke muteert. SetPageBox doet precies dat via een rectangle-wrapper om de bestaande array, en tekenen op een pagina doet hetzelfde met de Resources-dictionary zodra een font of afbeelding wordt geregistreerd. De bewerking landt, stilletjes, in elke andere container die de pointer vasthoudt

Hoe PDFlibPas v3.539.36 kopieert in plaats van deelt

PDFlibPas v3.539.36 pakt het probleem aan beide kanten aan: materialisatie hangt nu kopieën vast, en box-schrijfbewerkingen editen nu alleen een array waarvan de pagina eigenaar is. Elke fix dekt een geval die de ander niet dekt

De materialisatiehelper PLInheritPageAttributes hangt nu Page.Owner.Decode(Value.Output) vast in plaats van Value. Een rondje door de serialisator is een botte maar exacte manier om de PDF-semantiek gratis mee te krijgen. Een directe array of dictionary serialiseert naar zijn letterlijke tekst en decodeert naar een verse, onafhankelijke instantie. Een indirecte verwijzing serialiseert naar 11 0 R en decodeert naar een nieuw referentieobject dat naar hetzelfde object 11 wijst, dus de pagina refereert nog steeds aan het gedeelde object in plaats van een ingelinde kopie te ontvangen, wat het referentiegedrag uit v3.539.27 vasthoudt. De kopie is precies zo diep als de directe structuur: alles wat binnen een gekopieerde dictionary via een verwijzing bereikbaar is, blijft gedeeld, zoals het bestandsformaat bedoeld is. BalancePageTree roept dezelfde helper aan voor elke pagina die hij herparent, dus daar gematerialiseerde pagina's krijgen ook aparte instanties

PDFlibPas-materialisatie-roundtrip waarbij PLInheritPageAttributes Page.Owner.Decode(Value.Output) vasthangt: een directe array serialiseert naar letterlijke tekst en decodeert naar een verse instantie, terwijl een indirecte 11 0 R serialiseert en decodeert naar een nieuwe verwijzing die nog steeds naar het gedeelde object 11 wijst
Serialiseren en herparsen levert de PDF-objectsemantiek gratis op: directe waarden kopiëren, verwijzingen blijven verwijzingen, precies zoals ISO 32000-1 bedoeld

Alleen kopiëren is niet genoeg, want het referentiegeval wijst nog steeds naar een gedeeld object. Als SetPageBox die verwijzing zou volgen en object 11 zou bewerken, zou de verplaatste pagina de oude parent en diens andere kinderen opnieuw doen meeschalen. De boxschrijver past daarom copy-on-write toe: hij edit ter plekke alleen wanneer de eigen entry van de pagina een directe array is, en vervangt een indirecte of ontbrekende box door een nieuwe directe array. Object 11 blijft onaangeroerd voor elke andere pagina die hem aanhaalt

PDFlibPas SetPageBox copy-on-write-beslissing: is de eigen entry van de pagina een directe array, dan wordt die ter plekke ge-edit, en is het een indirecte verwijzing of ontbreekt de box, dan vervangt de schrijver haar door een nieuwe directe array zodat het gedeelde object 11 zijn waarde houdt voor elke andere pagina die hem aanhaalt
Kopiëren bij materialisatie is niet genoeg zolang verwijzingen naar gedeelde objecten wijzen, dus de boxschrijver edit alleen wat de pagina bezit
CodepadVóór v3.539.36Sinds v3.539.36
MovePage-materialisatieDe pagina houdt de eigen directe instanties van de voorouder vastDe pagina houdt gedecodeerde kopieën vast; verwijzingen blijven verwijzingen
SetPageBoxVolgt een verwijzing en edit de gedeelde arrayEdit alleen een directe array op de pagina, schrijft anders een nieuwe
CopyPageRanges-bronpaginaDeelt Pages-node-boxen; de CropBox is de MediaBox-instantieElke gematerialiseerde waarde op de bronpagina is een kopie
Default-boxen bij het klonen van paginabronnenCropBox, BleedBox, TrimBox en ArtBox delen één arrayElke default-box krijgt zijn eigen array

De laatste rij is de latente. Wanneer de library de bronnen van een pagina kloont voor paginavastlegging of samenvoegen, vult hij ontbrekende CropBox-, BleedBox-, TrimBox- en ArtBox-entries aan, en dat waren vroeger dezelfde arrayinstantie. Geen enkele huidige aanroeper liet die alias lang genoeg leven om bewerkt te worden, maar de volgende zou het gedaan hebben. Hoe die default-boxwaarden worden gekozen is een onderwerp op zich, behandeld in de PDFlibPas-gids over TrimBox-, BleedBox- en CropBox-defaults

De MovePage-aliasing reproduceren met een handgebouwde PDF

De snelste manier om een willekeurige PDFlibPas-build te controleren is een klein handgeschreven PDF'tje geladen met LoadFromString, waarin elk objectnummer vooraf bekend is. De helper hieronder schrijft een klassieke cross-reference-tabel met correct berekende byte-offsets, zodat de test niet leunt op het herstelgedrag van de parser voor beschadigde bestanden

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);   // byte-offset vanaf nul van "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      // elke entry is exact 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;

Het testdocument heeft twee tussenliggende Pages-nodes. Node 3 voert een indirecte MediaBox (object 11, 400 bij 300 punten), een directe CropBox en een directe Resources-dictionary, en bezit twee pagina's. Node 4 heeft een Letter-formaat MediaBox en bezit de derde pagina. Pagina 1 naar positie 3 verplaatsen herparent hem onder node 4, en dat is precies de move die materialisatie nodig heeft: zonder die stap zou de pagina een Letter-pagina worden

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);                       // de pagina die we zojuist verplaatsten
    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');

    // Bekijk de oude parent VÓÓR het selecteren van een andere pagina (zie hieronder)
    Check(Pos(AnsiString('/Font'), Lib.GetObjectToString(3)) = 0,
      'font registered in the old Pages node');

    Lib.SelectPage(1);                       // voormalige pagina 2, nog steeds onder 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) neemt boxtype 1 voor de MediaBox en 2 voor de CropBox, en dimensie 2 voor de breedte. Met de default-origine linksonder betekent SetPageBox(1, 0, 200, 200, 200) links 0, boven 200, 200 breed en 200 hoog. Op builds tussen v3.539.27 en v3.539.35 falen de zustercontroles: de CropBox-bewerking landt in de directe array van node 3, en de MediaBox-bewerking herschrijft object 11 via de verwijzing

Verandert CopyPageRanges het brondocument?

Sinds v3.539.36 schrijft CopyPageRanges nog steeds op de bronpagina's, maar elke waarde die hij schrijft is een aparte kopie, dus latere bewerkingen op de bron blijven lokaal op de pagina die u editeert. Het schrijven zelf is intentioneel: de bronpagina heeft expliciete MediaBox, CropBox, Rotate en Resources nodig voordat zijn dictionary naar het doel wordt gekloond, anders verliest de kopie alles wat hij erfde. Hernummeren en de pagina naar het doel kopiëren wordt behandeld in cross-document object deep copy in PDFlibPas; deze bug zat aan de bronkant, waarvan de meeste mensen aannemen dat een kopie haar alleen leest

De uitvoer liet het nooit zien. Gedeeld of gekopieerd, de gematerialiseerde waarden serialiseren identiek, dus beide documenten werden vóór en na de fix byte voor byte hetzelfde opgeslagen. Pas een bewerking van het brondocument na de kopie legde de alias bloot:

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;             // wordt het geselecteerde document
    Check(Lib.CopyPageRanges(SourceID, '1') = 1, 'copy failed');

    Lib.SelectDocument(SourceID);
    Lib.SelectPage(1);
    Lib.SetPageBox(2, 50, 250, 100, 100);    // versmalt alleen de 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);            // de kopie behoudt zijn originele grootte
    Lib.SelectPage(Lib.PageCount);
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'copied page resized');
  finally
    Lib.Free;
  end;
end;

Vóór v3.539.36 erfden beide pagina's hier de directe MediaBox van de rootnode, hing de kopie die instantie vast aan bronpagina 1, en hing haar opnieuw vast als de CropBox van pagina 1. De CropBox versmallen betekende dus de MediaBox versmallen, en de MediaBox herschalen herschaalde pagina 2 via de rootnode. Workflows die pagina's eruit kopiëren en daarna de bron blijven bewerken, zoals duplexscans samenvoegen tot één PDF voordat de originelen worden bijgesneden, zijn waar dit naar boven kwam

Waarom is instantie-aliasing zo moeilijk te testen?

Instantie-aliasing is moeilijk te testen omdat het waarneembare effect drie stappen in een specifieke volgorde vergt: de alias aanmaken, de ene kant muteren, en dan de andere kant inspecteren voordat er iets anders aanzit. De meeste tests doen alleen de eerste stap en vergelijken opgeslagen uitvoer, en die is identiek of de alias nu bestaat of niet

De volgordevalkuil in PDFlibPas is SelectPage. Een pagina selecteren past het huidige font opnieuw toe via SelectFont, wat dat font registreert in de resources van de pagina. Een pagina zonder eigen /Resources valt terug op de dictionary van zijn parent, dus alleen al zo'n pagina selecteren voegt op legitieme wijze /Font toe aan de Pages-node. In de MovePage-test hierboven voegt het selecteren van de voormalige pagina 2 de Helvetica-entry toe aan node 3, wat correct gedrag is en geen lek. Daarom draait de GetObjectToString(3)-controle vóór SelectPage(1); verwissel de twee en de test faalt op een opgeloste build

Die regel markeert ook wat v3.539.36 bewust met rust laat. Een resource schrijven naar een pagina die zijn Resources-dictionary erft, schrijft in de dictionary van de voorouder, en elke zuster ziet de nieuwe entry. Dat is overerving zoals gespecificeerd, geen instantiedeling, en het is onschadelijk want een font- of afbeeldingsnaam aan een gedeelde dictionary toevoegen verandert niet hoe andere pagina's renderen. Als een pagina moet stoppen met erven, geef haar dan eerst een eigen Resources-dictionary

Checklist voor PDF-objectmodelcode

De lessen generaliseren naar elk PDF-objectmodel gebouwd op een pool en pointercontainers, in Delphi of elders:

  • Materialiseer overgeërfde attributen volgens ISO 32000-1 §7.7.3.4 met een deep copy van directe waarden, en houd indirecte verwijzingen als nieuwe verwijzingen naar hetzelfde object
  • Voeg een bestaande instantie nooit met Add toe aan een tweede container tenzij het delen bedoeld en gedocumenteerd is; eigendom van een pool betekent dat de runtime nooit klaagt
  • Edit ter plekke alleen wat de huidige node als direct object bezit; vervang indirecte of overgeërfde waarden door een vers direct object (copy-on-write)
  • Default-waarden afgeleid van een andere entry, zoals een CropBox uit een MediaBox, hebben hun eigen instantie nodig
  • Test aliasing met mutate-daarna-inspecteer-reeksen op de andere houder, en controleer de volgorde van aanroepen die ertussen terecht mogen schrijven
  • Opgeslagen uitvoer vergelijken bewijst hier niets: gedeelde en gekopieerde waarden serialiseren identiek tot de eerste bewerking
  • Op PDFlibPas: upgrade naar v3.539.36 of later als u MovePage, CollateDocumentsEx, BalancePageTree of CopyPageRanges aanroept en daarna pagina-boxen editeert of op pagina's tekent

PDFlibPas stelt paginaboom-editing, cross-document kopiëren en pagina-boxbediening beschikbaar via één TPDFlib-klasse voor Delphi, C++Builder en Free Pascal. Zie de productpagina van de PDFlibPas Delphi PDF-library voor edities, platforms en de volledige API-referentie