An incremental update appends objects and a new cross-reference section to the file. Previously written bytes remain intact. This example creates a document, replaces its content stream twice and preserves the original font, page and catalog.

Run the example

php examples/incremental-update.php incremental-update.pdf

The complete script produces a page displaying “Revision 2 : contenu mis a jour.” The file also contains bytes from revisions 0 and 1, but the reader uses the latest xref records to resolve the page's stream.

Preview of the latest revision

1. Freeze an initial revision

FileStructure::toRevision() passes the graph to FileWriter and receives a FileRevision. This object contains serialized bytes, the last xref position, /Size, catalog, additional trailer entries and document identities. It does not read an existing PDF file.

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

An object number belongs to that revision. binding() retrieves its identifier from an instance or explicit ObjectIdentifier. reference() returns an ordinary IndirectReference to the effective value, so dictionary type constraints remain available.

2. Select the replacement

A new StreamObject instance represents new content. IndirectObject preserves the old stream's number and generation:

$update = new IncrementalUpdate(
    $initial,
    new FileBody(new IndirectObject($newContents, $identifier)),
);
$next = $update->toRevision();

FileBody selects objects to write. A reference to an object in an earlier revision produces only n g R: it does not traverse or rewrite that graph. A historical instance explicitly placed in FileBody, however, is rewritten under its existing identifier. Changing a builder after the snapshot does not change already frozen bytes.

Historical aliases are retained during replacement. An old PHP reference to the initial stream remains a reference to the same PDF number, even after several updates; reference() now returns the new effective value. Newly referenced objects receive numbers starting at the previous /Size, without reusing old gaps, and begin with generation zero. A replacement preserves the value's PDF family and its /Type if defined: historical references cannot silently turn a stream into a number or a page tree into another dictionary.

IncrementalWriter starts with $initial's bytes. It measures new offsets from the beginning of the complete file. The new /Prev entry targets exactly the previous revision's xref section; startxref targets the newly appended section.

The xref format is inherited from the initial revision. This example uses xref streams; traditional tables are supported too. A new ObjectStream can contain new or replaced objects with generation zero. An old container is not replaced: a new one is constructed. A previously compressed member can also become a standalone object under the same number.

CrossReferenceSection distinguishes the complete initial index from partial incremental sections. Unused new numbers are represented by free entries and linked to the existing free list. CrossReferenceLimits bounds cumulative capacity to 100,000 entries by default; a writer can receive an explicitly different limit.

4. Inherit the trailer

All additional entries from the previous trailer are carried forward, including private extensions. Entries supplied by the new revision replace them individually. In the example, /Info targets a new title while the catalog stays unchanged.

Writers manage /Size, /Root, /Prev and xref structures. Original /ID[0] is preserved. If /ID exists and is not explicitly replaced, its second identifier is renewed using a deterministic fingerprint of revision content. This fingerprint is a file identifier, not a signature. An explicit /ID must preserve the first identifier.

Try it

  • Use CrossReferenceFormat::Table to obtain textual xref sections.
  • Add a third update and count startxref and /Prev occurrences.
  • Replace only /Info to change metadata without rewriting page content.
  • Keep an old stream reference and use it in a new value to observe its number being preserved.

Verification and limitations

qpdf --check incremental-update.pdf
qpdf --show-xref incremental-update.pdf

Supported revisions come from FileRevision objects produced by the library. The optional pdftoolkit/pdf-reader module can already inspect external PDFs, as lesson 12 demonstrates. Importing their objects into the writer, remapping them and converting them into editable revisions remain to be built. Object deletion and reuse, xref-format changes, hybrid references, encrypted files, signatures and linearization require their own mechanisms. An incremental update does not remove old content from the file: it must not be used to erase confidential information.

References: ISO 32000-1:2008, 7.5.4–7.5.8 for xref, trailers, revisions and object containers, and 14.4 for file identifiers.

← 10. JPEG and PNG images · All tutorials

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 incremental-update: 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: incremental-update.php
<?php

declare(strict_types=1);

use PDF\Document\DocumentInformation;
use PDF\Document\Fonts\StandardFont;
use PDF\Document\Fonts\Type1Font;
use PDF\Document\Information\Title;
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\DataStructure\TextString;
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\Object\ArrayType;
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\NameType;
use PDF\Syntax\Object\NumericType;
use PDF\Syntax\Object\StreamObject;
use PDF\Syntax\Object\StringObject\HexadecimalString;
use PDF\Syntax\Object\StringObject\LiteralString;

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

// Tutorial 11: all three stream versions remain in the file's bytes.
// Readers follow the latest xref and display only revision 2 content.
$font = new Type1Font(StandardFont::Helvetica);
$resources = new ResourceDictionary(new Font(new DictionaryType(
    new Entry(new NameType('F1'), new IndirectReference($font)),
)));
$initialContents = new StreamObject(new ContentStreams(
    new BeginTextObject(),
    new SetTextFontAndSize(new NameType('F1'), new NumericType(20)),
    new MoveTextPosition(new NumericType(64), new NumericType(700)),
    new ShowText(new LiteralString('Revision 0 : document initial.')),
    new EndTextObject(),
));
$page = new PageObject(
    new Rectangle(new NumericType(0), new NumericType(0), new NumericType(612), new NumericType(792)),
    $resources,
    $initialContents,
);
$catalog = new DocumentCatalog((new PageTreeNode())->addKid($page));
$information = new DocumentInformation(new Title(new TextString('Document initial')));
$initial = (new FileStructure(
    new FileHeader('1.7'),
    new FileBody($catalog),
    trailerEntries: new DictionaryType(
        new Entry(new NameType('Info'), new IndirectReference($information)),
        new Entry(new NameType('ID'), new ArrayType(
            new HexadecimalString('incremental-example-original'),
            new HexadecimalString('incremental-example-revision-0'),
        )),
    ),
    crossReferenceFormat: CrossReferenceFormat::Stream,
))->toRevision();

// The identifier belongs to the snapshot, not a global counter.
// It is preserved: replacing a live object does not change its generation.
$contentIdentifier = $initial->binding($initialContents)->identifier;
$revision = $initial;
foreach ([1, 2] as $number) {
    $contents = new StreamObject(new ContentStreams(
        new BeginTextObject(),
        new SetTextFontAndSize(new NameType('F1'), new NumericType(22)),
        new MoveTextPosition(new NumericType(64), new NumericType(700)),
        new ShowText(new LiteralString('Revision ' . $number . ' : contenu mis a jour.')),
        new EndTextObject(),
        new BeginTextObject(),
        new SetTextFontAndSize(new NameType('F1'), new NumericType(12)),
        new MoveTextPosition(new NumericType(64), new NumericType(646)),
        new ShowText(new LiteralString('La police, la page et le catalogue restent les objets initiaux.')),
        new EndTextObject(),
        new BeginTextObject(),
        new SetTextFontAndSize(new NameType('F1'), new NumericType(12)),
        new MoveTextPosition(new NumericType(64), new NumericType(614)),
        new ShowText(new LiteralString('Les anciens octets sont conserves ; Prev relie les trois xref.')),
        new EndTextObject(),
    ));
    // FileBody explicitly selects the stream to replace. A historical
    // reference alone would not republish any already recorded object.
    $updatedInformation = new DocumentInformation(new Title(new TextString('Révision incrémentale ' . $number)));
    $revision = (new IncrementalUpdate(
        $revision,
        new FileBody(new IndirectObject($contents, $contentIdentifier)),
        trailerEntries: new DictionaryType(new Entry(new NameType('Info'), new IndirectReference($updatedInformation))),
    ))->toRevision();
}

// The first ID stays identical; the writer renews the second each time.
// Initial-revision bytes always form the PDF's exact prefix.
if (!str_starts_with($revision->bytes, $initial->bytes)) {
    throw new LogicException('The initial revision must remain byte-for-byte intact.');
}
$path = $argv[1] ?? __DIR__ . '/incremental-update.pdf';
$revision->writeTo($path);
echo $path . PHP_EOL;

Guide source at the documented commit ↗