This tutorial uses core 0.10.0. Its complete PHP source and generated PDF are supplied by the core checkout selected for the site; there is no second implementation in the documentation repository.

The example signs a PDF with DocMDP P2, locks the Reference field using FieldMDP, then changes Response from Pending to Reviewed. The second revision also replaces its visible text appearance. Page content, the original appearance, resources and every signed byte remain unchanged.

Run the native example

From a core checkout containing examples/protected-form-fill.php, its locked development dependencies and public signing fixtures, with PHP 8.3+ and OpenSSL:

php examples/protected-form-fill.php protected-form-fill.pdf
qpdf --check protected-form-fill.pdf

The result has one page showing Reviewed inside its original grey frame. The source is displayed below from the selected core package, with that package's exact source link. Its certificate and private key are public test fixtures; the example demonstrates signature mathematics, not certificate trust.

Establish the signed restrictions

$author = new SignatureFieldSigning(
    $backend,
    new PartialFieldName(new TextString('Certification')),
    new SignatureCapacity(8192),
    new SignatureSigningIntent(
        certification: DocMdpPermissions::FormFillingTemplatesAndSigning,
    ),
    lock: new SignatureFieldLock(
        SignatureLockAction::Include,
        new ArrayType(new TextString('Reference')),
    ),
);

The full script binds this policy to the actual catalog and AcroForm before signing. P2 permits the implemented filling operation; FieldMDP still protects Reference. The native SignedRevision retains actual output verification. Keep its revision object in the same runtime. Reconstructing a FileRevision from these bytes or completing an opaque finalize() call cannot recreate that verified authority.

Stage only the classified replacements

$fieldRecord = $signed->revision->binding($field);
$widgetRecord = $signed->revision->binding($widget);
$fieldDictionary = $fieldRecord->dictionary ?? throw new LogicException('The signed field retains its dictionary.');
$widgetDictionary = $widgetRecord->dictionary ?? throw new LogicException('The signed widget retains its dictionary.');
$update = new IncrementalUpdate($signed->revision, new FileBody(
    new IndirectObject(
        $fieldDictionary->with($entry('V', new TextString('Reviewed'))),
        $fieldRecord->identifier,
    ),
    new IndirectObject(
        $widgetDictionary->with($entry('AP', new DictionaryType(
            $entry('N', new IndirectReference($appearance('Reviewed'))),
        ))),
        $widgetRecord->identifier,
    ),
));
$filled = $update->toRevision();

$entry and $appearance are the explicit constructors in the full script. The field and widget keep their object identities. The replacement appearance receives a fresh identity and refers to the unchanged historical font. The guard compares every other frozen object too: listing two replacements does not permit an unrelated content-stream or resource change.

The original signed bytes remain an exact prefix. The new xref section selects the updated value and widget appearance while preserving the previous revision. An incremental update retains old content and is unsuitable for erasing secrets.

Isolate the changed text

The appearance draws its grey frame before the /Tx BMC…EMC region. The region owns a balanced q, BT…ET, Q sequence for Reviewed. The old and new programs preserve every decoded byte outside it and keep the geometry and resource-name bindings. BMC/EMC alone do not save graphics state. Paths, pending clipping and text objects cannot cross the protected boundaries.

The operation does not rebuild button appearances or add states/alternatives without a comparable historical program. It also does not prove that arbitrary glyph programs visually match a value. The protected-revisions guide lists supported fields, markup subtypes, lexical limits and explicit UR3 authority.

Check what the result proves

qpdf checks syntax; rendering shows the replacement text and preserved frame. Independent CMS/ByteRange verification checks the signature over its original bytes. pyHanko's unchanged modification policy reports OTHER for this example because the changed AP belongs to a widget child without /T. This is recorded as an oracle limitation, not presented as a pyHanko DocMDP approval.

Change the Response value and its Tx appearance together to try another fill. Attempting to fill Reference, change page text or mutate the historical font must be refused. P1 does not authorize filling, and adding a signature or DSS to the same staged fill is outside the classifier's separate operation proofs. External-chain import, templates, rich text, new resources, PKI validation and human interpretation remain outside this example.

← 36. Apply redaction · Protected native revisions · 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 protected-form-fill: 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: protected-form-fill.php
<?php

declare(strict_types=1);

use PDF\Document\Fonts\StandardFont;
use PDF\Document\Fonts\Type1Font;
use PDF\Document\Forms\AcroForm;
use PDF\Document\Forms\DefaultAppearance;
use PDF\Document\Forms\FieldTree;
use PDF\Document\Forms\PartialFieldName;
use PDF\Document\Forms\SignatureFieldLock;
use PDF\Document\Forms\SignatureLockAction;
use PDF\Document\Forms\TextField;
use PDF\Document\Forms\WidgetAnnotation;
use PDF\Document\Forms\WidgetAppearance;
use PDF\Document\ResourceDictionary;
use PDF\Document\Signatures\Policies\DocMdpPermissions;
use PDF\Document\Signatures\SignatureCapacity;
use PDF\Document\Signatures\SignatureFieldSigning;
use PDF\Document\Signatures\SignatureSigningIntent;
use PDF\FileStructure;
use PDF\Security\CertificateChain;
use PDF\Security\OpenSslIdentity;
use PDF\Security\OpenSslSignatureBackend;
use PDF\Security\X509Certificate;
use PDF\Serialization\SignatureWriter;
use PDF\Syntax\ContentStreamsResources\Operators\SetTextFontAndSize;
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\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\IntegerType;
use PDF\Syntax\Object\NameType;
use PDF\Syntax\Object\StreamObject;
use PDF\Syntax\Object\StringObject\HexadecimalString;

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

// This public test credential proves the signature mathematics, not PKI trust.
$fixtures = dirname(__DIR__) . '/tests/Fixtures/signing/';
$certificate = file_get_contents($fixtures . 'signer.der');
$key = file_get_contents($fixtures . 'signer-private-key.pem');
if ($certificate === false || $key === false) {
    throw new RuntimeException('The public signing fixtures are unavailable.');
}
$backend = new OpenSslSignatureBackend(new OpenSslIdentity(new CertificateChain(new X509Certificate(new HexadecimalString($certificate))), $key));
$entry = static fn (string $key, \PDF\Syntax\FileStructure\BasicType $value): Entry => new Entry(new NameType($key), $value);
$rectangle = static fn (int $x0, int $y0, int $x1, int $y1): Rectangle => new Rectangle(new IntegerType($x0), new IntegerType($y0), new IntegerType($x1), new IntegerType($y1));
$font = new Type1Font(StandardFont::Helvetica);
$resources = new ResourceDictionary($entry('Font', new DictionaryType($entry('F', new IndirectReference($font)))));

// ISO1 physical p444: only /Tx BMC...EMC changes. The frame stays byte-identical;
// q/Q and complete text/path scopes prevent graphics state leaking out of /Tx.
$appearance = static fn (string $text): \PDF\Document\Forms\FormAppearanceStream => new \PDF\Document\Forms\FormAppearanceStream(
    new \PDF\Syntax\ContentStreamsResources\ContentStreams(
        new \PDF\Syntax\ContentStreamsResources\Operators\SetStrokingGray(new \PDF\Syntax\Object\NumericType(0.6)),
        new \PDF\Syntax\ContentStreamsResources\Operators\SetLineWidth(new IntegerType(1)),
        new \PDF\Syntax\ContentStreamsResources\Operators\AppendRectangle(new \PDF\Syntax\Object\NumericType(0.5), new \PDF\Syntax\Object\NumericType(0.5), new IntegerType(349), new IntegerType(31)),
        new \PDF\Syntax\ContentStreamsResources\Operators\StrokePath(),
        new \PDF\Syntax\ContentStreamsResources\Operators\BeginMarkedContent(new NameType('Tx')),
        new \PDF\Syntax\ContentStreamsResources\Operators\SaveGraphicsState(),
        new \PDF\Syntax\ContentStreamsResources\Operators\BeginTextObject(),
        new SetTextFontAndSize(new NameType('F'), new IntegerType(14)),
        new \PDF\Syntax\ContentStreamsResources\Operators\MoveTextPosition(new IntegerType(8), new IntegerType(10)),
        new \PDF\Syntax\ContentStreamsResources\Operators\ShowText(new \PDF\Syntax\Object\StringObject\LiteralString($text)),
        new \PDF\Syntax\ContentStreamsResources\Operators\EndTextObject(),
        new \PDF\Syntax\ContentStreamsResources\Operators\RestoreGraphicsState(),
        new \PDF\Syntax\ContentStreamsResources\Operators\EndMarkedContent(),
    ),
    $rectangle(0, 0, 350, 32),
    $resources,
);
$field = new TextField(new PartialFieldName(new TextString('Response')), value: new TextString('Pending'));
$locked = new TextField(new PartialFieldName(new TextString('Reference')), value: new TextString('CASE-2026-010'));
$widget = new WidgetAnnotation($rectangle(48, 570, 398, 602), new WidgetAppearance($appearance('Pending')), entries: $entry('F', new IntegerType(4)));
$field->addWidget($widget);
$author = new SignatureFieldSigning(
    $backend,
    new PartialFieldName(new TextString('Certification')),
    new SignatureCapacity(8192),
    new SignatureSigningIntent(certification: DocMdpPermissions::FormFillingTemplatesAndSigning),
    lock: new SignatureFieldLock(SignatureLockAction::Include, new ArrayType(new TextString('Reference')))
);
$form = new AcroForm((new FieldTree(new DefaultAppearance(new SetTextFontAndSize(new NameType('F'), new IntegerType(14))), $resources))
    ->addRoot($author->field)->addRoot($locked)->addRoot($field));
$contents = new StreamObject("BT /F 22 Tf 48 760 Td (Fill a certified document) Tj ET\n"
    . "BT /F 12 Tf 48 720 Td (DocMDP P2 allows filling; FieldMDP locks Reference.) Tj ET\n"
    . "BT /F 12 Tf 48 680 Td (The first revision is signed before Response is changed.) Tj ET\n"
    . "BT /F 12 Tf 48 624 Td (Response - filled in the second revision:) Tj ET\n"
    . "BT /F 11 Tf 48 520 Td (Original page content, resources and signed bytes remain intact.) Tj ET\n"
    . "BT /F 11 Tf 48 490 Td (The appearance uses a fresh Form object; the old one remains.) Tj ET\n"
    . "BT /F 10 Tf 48 80 Td (Public test certificate. No certificate trust or human review claimed.) Tj ET\n");
$page = new PageObject($rectangle(0, 0, 595, 842), $resources, $contents, $entry('Annots', new ArrayType(new IndirectReference($widget))));
$widget->bindToPage($page);
$permissions = $author->certificationPermissions() ?? throw new LogicException('The author signature declares DocMDP certification.');
$catalog = new DocumentCatalog((new PageTreeNode())->addKid($page), $entry('AcroForm', new IndirectReference($form)), $entry('Perms', $permissions));
$author->bindCatalog(new IndirectReference($catalog));
$signed = $author->sign((new SignatureWriter())->prepare(new FileStructure(new FileHeader('1.7'), new FileBody($catalog)), $author->signature));

// The common writer proves the complete emitted delta, including every object
// not listed here. P2 does not authorize changing arbitrary page content.
$fieldRecord = $signed->revision->binding($field);
$widgetRecord = $signed->revision->binding($widget);
$fieldDictionary = $fieldRecord->dictionary ?? throw new LogicException('The signed field retains its dictionary.');
$widgetDictionary = $widgetRecord->dictionary ?? throw new LogicException('The signed widget retains its dictionary.');
$update = new IncrementalUpdate($signed->revision, new FileBody(
    new IndirectObject($fieldDictionary->with($entry('V', new TextString('Reviewed'))), $fieldRecord->identifier),
    new IndirectObject($widgetDictionary->with($entry('AP', new DictionaryType($entry('N', new IndirectReference($appearance('Reviewed')))))), $widgetRecord->identifier),
));
$filled = $update->toRevision();
if (!str_starts_with($filled->bytes, $signed->revision->bytes)) {
    throw new LogicException('The original signed revision must remain an exact prefix.');
}
$path = $argv[1] ?? __DIR__ . '/protected-form-fill.pdf';
$filled->writeTo($path);
echo 'PDF written to ' . $path . PHP_EOL;

Guide source at the documented commit ↗