Technical Article

PDFlibPas MovePage: When Inherited Boxes Share Instances

In PDFlibPas, the Delphi PDF library, a page moved with MovePage used to receive the very same MediaBox, CropBox and Resources objects that its old Pages node held, so a later SetPageBox or DrawText on the moved page silently rewrote that node and every sibling still inheriting from it. Since v3.539.36 the moved page gets its own copies, and an indirect reference stays a reference. The same release closes two related paths: SetPageBox on an indirect box that several pages share, and CopyPageRanges leaving pages of the source document tied to their Pages node, with the CropBox tied to the MediaBox

The reports that lead here never mention object identity. They say things like "I cropped page 7 and pages 8 to 12 got cropped too", or "I narrowed the CropBox and the MediaBox moved with it", or, the most confusing one, "I copied a page into a new document and the original file changed". Nothing crashes, nothing leaks, and the saved file is perfectly valid PDF. It just contains geometry nobody asked for

Why does SetPageBox on one page resize its siblings?

SetPageBox resized siblings because two page tree entries pointed at one in-memory array, and SetPageBox edits its target array in place. Any page or Pages node holding the same instance saw the edit. Three code paths in PDFlibPas produced that sharing before v3.539.36:

  • MovePage materializes the inheritable attributes onto the page before detaching it from its parent, and it attached the ancestor's own objects rather than copies, so the moved page and its former siblings shared a box array and a Resources dictionary
  • SetPageBox followed indirect references and edited the referenced array, so a file in which several pages point at one /MediaBox 11 0 R object had all of those pages resized by one call, whether or not MovePage was ever involved
  • CopyPageRanges materializes inherited values on the source page before cloning it into the target document, and it attached the Pages node instances to the source page, plus the MediaBox instance itself as the default CropBox
PDFlibPas MovePage aliasing where a moved page and its former sibling both held the ancestor's own MediaBox array instance, so SetPageBox edited one page and resized the other; since v3.539.36 materialization attaches decoded copies and edits stay local to the page you touch
Two page tree entries pointing at one in-memory array made every edit land in every holder, and the saved PDF stayed valid the whole time

The MovePage case has a short history. Before v3.539.27, MovePage carried only /Resources across, so a page moved under a different parent silently took on that parent's size and rotation. v3.539.27 fixed the missing MediaBox, CropBox and Rotate, which is also what CollateDocumentsEx relies on when it reorders pages, but it attached the ancestor's values as shared instances. That is the window v3.539.36 closes. The SetPageBox and CopyPageRanges paths are older; any build before v3.539.36 has them

Direct values, indirect references and page attribute inheritance

A correct copy of an inherited page attribute duplicates direct values and keeps indirect references as references, because that is the distinction ISO 32000-1 itself draws. A direct object such as [0 0 400 300] written inside a dictionary belongs to that dictionary alone. An indirect object, defined once as 11 0 obj and cited as 11 0 R, is shared by design: ISO 32000-1 §7.3.10 makes it addressable from anywhere in the file, and every 11 0 R means the same object

Page attribute inheritance, ISO 32000-1 §7.7.3.4, adds a third case. Resources, MediaBox, CropBox and Rotate may sit on a Pages node and apply to every descendant page that does not define its own. The page does not hold the value; it looks the value up through /Parent. That lookup chain breaks the moment a page changes parents, which is why MovePage and BalancePageTree must first write the effective values onto the page itself. The question is only how to write them

Why an object pool hides the mistake

In PDFlibPas every parsed or created PDF object is owned by the document's TPDFStructure pool, and dictionaries and arrays store plain pointers to their entries. TPDFDictionary.Add records the pointer and nothing else. Adding one instance to two parent containers is therefore legal at every level the runtime can check: no double free on teardown, no reference count to go wrong, no exception. Serialization is equally forgiving, since each container writes the shared instance's current value inline, and before any edit the output is byte-for-byte what a correct copy would produce

The aliasing only surfaces when someone mutates the shared instance in place. SetPageBox does exactly that through a rectangle wrapper over the existing array, and drawing on a page does it to the Resources dictionary when a font or image is registered. The edit lands, silently, in every other container holding the pointer

How PDFlibPas v3.539.36 copies instead of sharing

PDFlibPas v3.539.36 fixes the problem at both ends: materialization now attaches copies, and box writes now edit only an array the page owns. Each fix covers a case the other cannot

The materialization helper, PLInheritPageAttributes, now attaches Page.Owner.Decode(Value.Output) instead of Value. Round-tripping through the serializer is a blunt but exact way to get PDF semantics for free. A direct array or dictionary serializes to its literal text and decodes into a fresh, independent instance. An indirect reference serializes to 11 0 R and decodes into a new reference object pointing at the same object 11, so the page still refers to the shared object instead of receiving an inlined copy, which preserves the reference behavior introduced in v3.539.27. The copy is exactly as deep as the direct structure: anything reached through a reference inside a copied dictionary remains shared, as the file format intends. BalancePageTree calls the same helper for every page it re-parents, so pages materialized there also get separate instances

PDFlibPas materialization round-trip where PLInheritPageAttributes attaches Page.Owner.Decode(Value.Output): a direct array serializes to literal text and decodes into a fresh instance, while an indirect 11 0 R serializes and decodes into a new reference that still points at the shared object 11
Serializing and re-parsing gets PDF object semantics for free: direct values copy, references stay references, exactly as ISO 32000-1 intends

Copying alone is not enough, because the reference case still points at a shared object. If SetPageBox followed that reference and edited object 11, the moved page would again resize the old parent and its other children. So the box writer now applies copy-on-write: it edits in place only when the page's own entry is a direct array, and replaces an indirect or missing box with a new direct array. Object 11 is left untouched for every other page that cites it

PDFlibPas SetPageBox copy-on-write decision: when the page's own entry is a direct array it is edited in place, and when it is an indirect reference or missing the writer replaces it with a new direct array so the shared object 11 keeps its value for every other page citing it
Copying at materialization is not enough while references still point at shared objects, so the box writer edits only what the page owns
Code pathBefore v3.539.36Since v3.539.36
MovePage materializationPage holds the ancestor's own direct instancesPage holds decoded copies; references stay references
SetPageBoxFollows a reference and edits the shared arrayEdits only a direct array on the page, otherwise writes a new one
CopyPageRanges source pageShares Pages node boxes; CropBox is the MediaBox instanceEvery materialized value on the source page is a copy
Default boxes when cloning page resourcesCropBox, BleedBox, TrimBox and ArtBox share one arrayEach default box gets its own array

The last row is the latent one. When the library clones a page's resources for page capture or merging, it fills in missing CropBox, BleedBox, TrimBox and ArtBox entries, and those used to be the same array instance. No current caller let that alias survive long enough to be edited, but the next caller would have. How those default box values are chosen is its own topic, covered in the PDFlibPas guide to TrimBox, BleedBox and CropBox defaults

Reproducing the MovePage aliasing with a hand-built PDF

The fastest way to check any PDFlibPas build is a small hand-written PDF loaded with LoadFromString, where every object number is known in advance. The helper below writes a classic cross-reference table with correctly computed byte offsets, so the test does not rely on the parser's recovery behavior for damaged files

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-based byte offset of "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      // each entry is exactly 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;

The test document has two intermediate Pages nodes. Node 3 carries an indirect MediaBox (object 11, 400 by 300 points), a direct CropBox and a direct Resources dictionary, and owns two pages. Node 4 has a Letter-size MediaBox and owns the third page. Moving page 1 to position 3 re-parents it under node 4, which is exactly the move that needs materialization: without it, the page would turn into a Letter page

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);                       // the page we just moved
    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');

    // Inspect the old parent BEFORE selecting another page (see below)
    Check(Pos(AnsiString('/Font'), Lib.GetObjectToString(3)) = 0,
      'font registered in the old Pages node');

    Lib.SelectPage(1);                       // former page 2, still 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) takes box type 1 for MediaBox and 2 for CropBox, and dimension 2 for width. With the default bottom-left origin, SetPageBox(1, 0, 200, 200, 200) means left 0, top 200, 200 wide and 200 high. On builds between v3.539.27 and v3.539.35, the sibling checks fail: the CropBox edit lands in node 3's direct array, and the MediaBox edit rewrites object 11 through the reference

Does CopyPageRanges change the source document?

Since v3.539.36, CopyPageRanges still writes onto the source pages, but every value it writes is a separate copy, so later edits on the source stay local to the page you edit. The writing itself is intentional: the source page needs explicit MediaBox, CropBox, Rotate and Resources before its dictionary is cloned into the target, otherwise the copy would lose everything it inherited. Renumbering and copying the page into the target is covered in cross-document object deep copy in PDFlibPas; this bug sat on the source side, which most people assume a copy only reads

The output never showed it. Shared or copied, the materialized values serialize identically, so both documents saved byte-for-byte the same before and after the fix. Only an edit to the source document after the copy revealed the alias:

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

    Lib.SelectDocument(SourceID);
    Lib.SelectPage(1);
    Lib.SetPageBox(2, 50, 250, 100, 100);    // narrow only the 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);            // the copy keeps its original size
    Lib.SelectPage(Lib.PageCount);
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'copied page resized');
  finally
    Lib.Free;
  end;
end;

Before v3.539.36 both pages here inherited the root node's direct MediaBox, the copy attached that instance to source page 1, and attached it again as page 1's CropBox. Narrowing the CropBox therefore narrowed the MediaBox, and resizing the MediaBox resized page 2 through the root node. Workflows that copy pages out and then keep editing the source, such as collating duplex scans into one PDF before trimming the originals, are where this showed up

Why is instance aliasing so hard to test?

Instance aliasing is hard to test because the observable effect needs three steps in a specific order: create the alias, mutate one side, then inspect the other side before anything else touches it. Most tests do only the first step and compare saved output, which is identical whether the alias exists or not

The ordering trap in PDFlibPas is SelectPage. Selecting a page re-applies the current font through SelectFont, which registers that font in the page's resources. A page with no /Resources of its own resolves to its parent's dictionary, so simply selecting such a page legitimately adds /Font to the Pages node. In the MovePage test above, selecting the former page 2 adds the Helvetica entry to node 3, which is correct behavior and not a leak. That is why the GetObjectToString(3) check runs before SelectPage(1); swap the two and the test fails on a fixed build

That rule also marks what v3.539.36 deliberately leaves alone. Writing a resource to a page that inherits its Resources dictionary writes into the ancestor's dictionary, and every sibling sees the new entry. That is inheritance working as specified, not instance sharing, and it is harmless because adding a font or image name to a shared dictionary does not change how other pages render. If you need a page to stop inheriting, give it its own Resources dictionary first

Checklist for PDF object model code

The lessons generalize to any PDF object model built on a pool and pointer containers, in Delphi or elsewhere:

  • When materializing inherited attributes per ISO 32000-1 §7.7.3.4, deep-copy direct values and keep indirect references as new references to the same object
  • Never Add an existing instance to a second container unless the sharing is intended and documented; ownership by a pool means the runtime will never complain
  • Edit in place only what the current node owns as a direct object; replace indirect or inherited values with a fresh direct object (copy-on-write)
  • Default values derived from another entry, such as a CropBox from a MediaBox, need their own instance
  • Test aliasing with mutate-then-inspect sequences on the other holder, and check the order of calls that might write legitimately in between
  • Comparing saved output proves nothing here: shared and copied values serialize identically until the first edit
  • On PDFlibPas, upgrade to v3.539.36 or later if you call MovePage, CollateDocumentsEx, BalancePageTree or CopyPageRanges and then edit page boxes or draw on pages

PDFlibPas exposes page tree editing, cross-document copying and page box control through one TPDFlib class for Delphi, C++Builder and Free Pascal. See the PDFlibPas Delphi PDF library product page for editions, platforms and the full API reference