An indirect object is identified by its number and generation. An update can free its number, then assign it to a new identity. The old reference must not designate that new value.

Run object-lifecycle.php:

php examples/object-lifecycle.php /tmp/object-lifecycle.pdf

The generated PDF contains three revisions, one page and two private document references. The page remains visible; console output displays both generations.

Write the first revision

The catalog references a string under PrivateOriginal. FileStructure::toRevision() returns bytes and their frozen objects. binding($old)->identifier returns the identifier assigned to the string.

$initial = $file->toRevision();
$identifier = $initial->binding($old)->identifier;

The application still constructs PDF objects. The serialization context assigns their identifiers in this file; no global registry connects different documents.

Release the number

ReleaseIndirectObject requests release of the exact identifier. The writer adds an xref section; it removes no bytes from the first revision. The free generation becomes 1 and the number enters the free list.

$released = (new IncrementalUpdate(
    $initial,
    new FileBody(),
    objectOperations: new ReleaseIndirectObject($identifier),
))->toRevision();

PrivateOriginal keeps its old generation 0 reference. In PDF, a reference to a nonexistent object has value null. Dictionaries can therefore retain an obsolete optional reference. Required graph links, such as necessary page resources, are checked separately.

Native protections reject releasing the root catalog, pages, xref structure or encryption dictionary. Deleting an ObjStm container requires releasing all its still-compressed members first.

Assign the number to a new identity

ReuseObjectNumber receives the free number and a new value. Here the value is a dictionary; it does not inherit the old string's PHP aliases. The new reference uses free generation 1.

objectOperations: new ReuseObjectNumber($identifier->objectNumber, $new)

The example also replaces the catalog under its existing identifier to add PrivateCurrent, a reference to that dictionary. reference($old) keeps the original pair; reference($new) supplies the new pair. Their numbers match and their generations differ.

A second deletion would increment the free generation to 2. Generation 65535 is permanent and cannot be reused. Never-assigned gaps are distinguished from deleted objects: their generation is 0. A free-list number cannot serve as a new ObjStm container or member, even when it came from a generation 0 gap.

Preserve evidence of what was written

Each FileRevision exposes previous, effective objects, retired generations and an immutable FreeListState. Snapshots retain remapped references and dictionaries actually emitted. For a stream, they retain encoded bytes before encryption from the same write call. These snapshots are evidence; a RevisionStreamSnapshot cannot be serialized as a new stream. A new write requires a new explicit stream object.

LinearizedWriter::revision() also supplies this snapshot. An incremental revision can be added; following Annex F, the resulting file ceases to be linearized. Initial bytes, their /L and hint ranges remain unchanged. These mechanisms concern generated files; reading an external PDF's history belongs to the reader module.

Verification and references

The release-and-reuse corpus verifies 90 histories, or 450 PDFs, under PHP 8.3, 8.4 and 8.5. It checks free lists, generations, physical xref sections, old and new references, compressed objects and nine security variations. The oracle uses unmodified qpdf and checks effective values. A pypdf cache limitation for some obsolete generations is identified; new values and streams remain verified without a blanket exception.

Revisions after linearization have a separate corpus of 90 histories, or 270 PDFs, with exact checks of prefixes, references and hints.

Preserved sources: indirect objects and null, page 29, xref, pages 48–50, updates and ObjStm, pages 52–55, Annex F, page 683.

Tutorial path · Incremental revisions

View the PDF result

This file was generated with the code in this documentation. All pages appear below; open the PDF to use its links and bookmarks.

PDF result object-lifecycle: page 1 of 1
Page 1 of 1
Use the browser PDF viewer

This viewer depends on your browser. If the document does not appear, use “Open the PDF” or “Download”.

Complete source code

Code from roukmoute/pdf · View the source at the documented commit ↗

Read the commented script: object-lifecycle.php
<?php

declare(strict_types=1);

use PDF\Document\Fonts\StandardFont;
use PDF\Document\Fonts\Type1Font;
use PDF\Document\ResourceDictionary;
use PDF\Document\Resources\Font;
use PDF\FileStructure;
use PDF\Syntax\ContentStreamsResources\ContentStreams;
use PDF\Syntax\ContentStreamsResources\Operators\BeginTextObject;
use PDF\Syntax\ContentStreamsResources\Operators\EndTextObject;
use PDF\Syntax\ContentStreamsResources\Operators\MoveTextPosition;
use PDF\Syntax\ContentStreamsResources\Operators\SetTextFontAndSize;
use PDF\Syntax\ContentStreamsResources\Operators\ShowText;
use PDF\Syntax\DataStructure\Rectangle;
use PDF\Syntax\DocumentStructure\DocumentCatalog;
use PDF\Syntax\DocumentStructure\PageTree\PageObject;
use PDF\Syntax\DocumentStructure\PageTree\PageTreeNode;
use PDF\Syntax\FileStructure\CrossReferenceFormat;
use PDF\Syntax\FileStructure\FileBody;
use PDF\Syntax\FileStructure\FileHeader;
use PDF\Syntax\FileStructure\IncrementalUpdate;
use PDF\Syntax\FileStructure\ReleaseIndirectObject;
use PDF\Syntax\FileStructure\ReuseObjectNumber;
use PDF\Syntax\Object\DictionaryObject\Entry;
use PDF\Syntax\Object\DictionaryType;
use PDF\Syntax\Object\IndirectObject;
use PDF\Syntax\Object\IndirectReference;
use PDF\Syntax\Object\IntegerType;
use PDF\Syntax\Object\NameType;
use PDF\Syntax\Object\NumericType;
use PDF\Syntax\Object\StreamObject;
use PDF\Syntax\Object\StringObject\LiteralString;

require dirname(__DIR__) . '/vendor/autoload.php';

// ISO 7.5.4: an indirect object has both a number AND a generation.
// The optional object below demonstrates deletion and recycling.
$old = new LiteralString('Valeur indirecte initiale, generation 0.');
$font = new Type1Font(StandardFont::Helvetica);
$resources = new ResourceDictionary(new Font(new DictionaryType(new Entry(new NameType('F1'), new IndirectReference($font)))));
$page = new PageObject(
    new Rectangle(new IntegerType(0), new IntegerType(0), new IntegerType(612), new IntegerType(792)),
    $resources,
    new StreamObject(new ContentStreams(
        new BeginTextObject(),
        new SetTextFontAndSize(new NameType('F1'), new NumericType(18)),
        new MoveTextPosition(new NumericType(50), new NumericType(720)),
        new ShowText(new LiteralString('Trois revisions, un numero et deux generations.')),
        new EndTextObject(),
    )),
);
$catalog = new DocumentCatalog((new PageTreeNode())->addKid($page), new Entry(new NameType('PrivateOriginal'), new IndirectReference($old)));
$initial = (new FileStructure(new FileHeader(), new FileBody($catalog), crossReferenceFormat: CrossReferenceFormat::Stream))->toRevision();
$identifier = $initial->binding($old)->identifier;

// Deletion removes no historical bytes. It records the number in the
// free list with the next generation, here 1. The old generation 0
// reference now represents null, following ISO 7.3.9.
$released = (new IncrementalUpdate($initial, new FileBody(), objectOperations: new ReleaseIndirectObject($identifier)))->toRevision();

// A new identity can reuse this number with its free generation.
// Its type is independent: the old string becomes a dictionary here.
$new = new DictionaryType(new Entry(new NameType('Message'), new LiteralString('Nouvelle identite, generation 1.')));
$updatedCatalog = $released->binding($catalog)->dictionary ?? throw new LogicException('Le catalogue possède un instantané.');
$updatedCatalog = $updatedCatalog->with(new Entry(new NameType('PrivateCurrent'), new IndirectReference($new)));
$reused = (new IncrementalUpdate(
    $released,
    new FileBody(new IndirectObject($updatedCatalog, $initial->binding($catalog)->identifier)),
    objectOperations: new ReuseObjectNumber($identifier->objectNumber, $new),
))->toRevision();

// Both references stay distinct despite sharing a number. The core
// keeps obsolete aliases to write their original generation without
// aliasing them to the new value or resurrecting the old object.
$oldReference = $reused->reference($old);
$currentReference = $reused->reference($new);
$path = $argv[1] ?? __DIR__ . '/object-lifecycle.pdf';
$reused->writeTo($path);
echo $oldReference->identifier, ' R -> null ; ', $currentReference->identifier, ' R -> dictionnaire', PHP_EOL;
echo $path, PHP_EOL;

Guide source at the documented commit ↗