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:
MovePagematerialises 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 dictionarySetPageBoxfollowed indirect references and edited the referenced array, so a file in which several pages point at one/MediaBox 11 0 Robject had all of those pages resized by one call, whether or notMovePagewas ever involvedCopyPageRangesmaterialises 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
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. Serialisation 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: materialisation now attaches copies, and box writes now edit only an array the page owns. Each fix covers a case the other cannot
The materialisation helper, PLInheritPageAttributes, now attaches Page.Owner.Decode(Value.Output) instead of Value. Round-tripping through the serialiser is a blunt but exact way to get PDF semantics for free. A direct array or dictionary serialises to its literal text and decodes into a fresh, independent instance. An indirect reference serialises 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 behaviour 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 materialised there also get separate instances
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
| Code path | Before v3.539.36 | Since v3.539.36 |
|---|---|---|
MovePage materialisation | Page holds the ancestor's own direct instances | Page holds decoded copies; references stay references |
SetPageBox | Follows a reference and edits the shared array | Edits only a direct array on the page, otherwise writes a new one |
CopyPageRanges source page | Shares Pages node boxes; CropBox is the MediaBox instance | Every materialised value on the source page is a copy |
| Default boxes when cloning page resources | CropBox, BleedBox, TrimBox and ArtBox share one array | Each 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 behaviour 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 materialisation: 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 materialised values serialise 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 behaviour 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 generalise to any PDF object model built on a pool and pointer containers, in Delphi or elsewhere:
- When materialising 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
Addan 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 serialise identically until the first edit
- On PDFlibPas, upgrade to v3.539.36 or later if you call
MovePage,CollateDocumentsEx,BalancePageTreeorCopyPageRangesand 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