The complete script
produces one page, a text field Reference and a signature field Approval.
The detached CMS signature is actually calculated and verified. The field's
widget displays an explicit appearance while its FieldMDP lock selects Reference.
php examples/signature-field-policy.php signature-field-policy.pdf
This tutorial uses the API introduced in 0.5.0, PHP 8.3 or later and OpenSSL. The certificate and key are the repository's public test fixtures. They reproduce a mathematical signature; they identify no trusted person.
Describe obligations, then choose how to satisfy them
SignatureSeedValue represents /SV, the field's conditions.
SignatureSeedFlags indicates which are required. Here the four selected bits require:
/Filter /Adobe.PPKLite, the chosen handler's identity;- the first supported
/SubFilter, here/adbe.pkcs7.detached; /DigestMethod [/SHA256]for the RSA key actually used;- a
/Reasonbelonging to/Reasons, hereÉtudié et approuvé.
$seed = new SignatureSeedValue(
new SignatureSeedFlags(
SignatureSeedFlag::Filter,
SignatureSeedFlag::SubFilter,
SignatureSeedFlag::DigestMethod,
SignatureSeedFlag::Reasons,
),
new Entry(new NameType('Filter'), new NameType('Adobe.PPKLite')),
new Entry(new NameType('SubFilter'), new ArrayType(new NameType('adbe.pkcs7.detached'))),
new Entry(new NameType('DigestMethod'), new ArrayType(new NameType('SHA256'))),
new Entry(new NameType('Reasons'), new ArrayType($reason)),
);
SignatureSigningIntent supplies application choices: reason, optional digest,
certification, document lock, appearance and evidence requests. A requested
reason must be the one in the signed dictionary. Conditions are also reread
from the prepared file: changing a construction object after writing does not
change obligations already emitted.
A SubFilter array retains its order: the first supported format is chosen
even if the preference is optional. If none matches and the bit is optional,
another explicitly supported format can be selected. An optional reason list
permits another reason. With the required bit, absence of Reasons or special
value [.] requires omission of Reason.
Build the field and its lock
SignatureFieldSigning composes the /Contents reservation, /Sig field,
its /V value and necessary transformation references. It retains distinct
objects for resources, appearances, widgets and conditions.
$lock = new SignatureFieldLock(
SignatureLockAction::Include,
new ArrayType(new TextString('Reference')),
);
$signing = new SignatureFieldSigning(
$backend,
new PartialFieldName(new TextString('Approval')),
new SignatureCapacity(8192),
new SignatureSigningIntent(reason: $reason),
$seed,
$lock,
);
The lock selects another field in the actual form. The operation copies its
Action and Fields entries into direct FieldMDP parameters. All,
Include and Exclude retain their meaning; selecting a group name also
selects its descendants.
The script supplies a FormAppearanceStream and WidgetAppearance, then
attaches the widget to Approval with addWidget(). The page references the
widget in Annots, and bindToPage() defines its actual P. Visible text does
not replace the CMS in /V/Contents.
SignatureAppearanceSelection gives an application name to a supplied
appearance. The contract checks that it is the selected normal appearance for
every widget of the field, including merged field/widgets and state dictionaries.
It neither draws an appearance nor reproduces a PDF reader's dialog.
The Reference text-field appearance also follows the variable-text procedure
in the AcroForm tutorial: /Tx BMC ... EMC encloses text
and clipping while the frame remains outside. It is distinct from the static
signature appearance. Marking does not make the field editable after signing
when the reader applies its FieldMDP lock.
Bind the catalog, freeze bytes and sign
$signing->bindCatalog(new IndirectReference($catalog));
$prepared = (new SignatureWriter())->prepare($file, $signing->signature);
$signed = $signing->sign($prepared);
$signed->writeTo($path);
bindCatalog() explicitly closes the FieldMDP.Data reference. It designates
this catalog and form; a reference to another document is rejected. prepare()
writes the PDF and fixes /ByteRange before the cryptographic calculation.
Covered bytes include the form, seed, lock, resources and appearances; only
the /Contents reservation is excluded.
The contract traverses AcroForm/Fields/Kids in actually emitted snapshots.
It resolves inherited fields, full names and Parent relationships, distinguishes
separate widgets and rejects cycles or inconsistent ownership. An arbitrary
dictionary named V outside this tree does not become a signature field.
The backend declares digests it can emit. PHP OpenSSL uses SHA-256 for CMS here; its API cannot arbitrarily select SHA-384 or SHA-512. The verifier reads the digest from the CMS result, mathematically verifies the message and identifies the certificate actually selected. It binds those facts to exact input and output bytes. A substituted certificate, modified CMS or facts reused from another document are rejected before finalization.
Only this verification permits filling the reservation. If CMS exceeds its capacity, prepare a new reservation and sign the new bytes. Final file offsets and length remain fixed.
History and external components
A DocMDP certification must be the first signed field, and a document contains
at most one. Its Perms/DocMDP link is optional: the field signature enables
detection; this link adds prevention of changes. certificationPermissions()
supplies that explicit catalog contribution.
Each subsequent signature uses a new incremental revision. The core retains
previous snapshots: removing an old field or releasing its signature object
does not erase historical order. A retired value reference resolves to null
in the current revision.
For the signing operation, an earlier DocMDP or Adobe P1 lock forbids a new
V. An earlier FieldMDP forbids signing a selected field, including descendants.
Adobe permissions remain the historical minimum: a new P3 does not increase
an old P2.
These operation checks do not yet perform a general comparison of every revision change: all field values, removals or moves, page content, shared resources, appearances, annotations and page-template instantiation. Complete DocMDP/FieldMDP/UR analysis remains to be developed. A mathematically valid UR signature also does not prove that its signer is authorized to grant rights.
PolicySignatureBackend, SignatureOutputVerifier and SignatureSigningEvidence
support explicit external integration. finalizeWithPolicy() applies the same
contract to a signature calculated elsewhere. An RFC3161 timestamp or OCSP/CRL
evidence requires a component producing and verifying it within the actual CMS.
Declared capability does not replace that evidence; the native backend rejects
these requests. The application must supply PKI trust policies, and the core
performs no implicit network access.
Evidence and references
The independent policy corpus inspects eight PDFs and verifies nine RSA/DSA
mathematical signatures using qpdf, pypdf, ASN.1 and independent cryptography on
PHP 8.3, 8.4 and 8.5. It covers actually written conditions, certification with
and without Perms, two signed revisions, FieldMDP.Data, an Adobe lock and an
attached appearance. This script's rendering and form are inspected too.
Preserved sources: conditions and certificates, pages 455–459,
transformations and signatures, pages 474–487,
Adobe locks and appearances, pages 43–45.
The DigestMethod DSA/SHA1 clause is applied when that seed entry exists; an
empty seed or Reasons alone creates no additional restriction on Table 257's
general DSA/SHA256 capability. This interpretation is located in both descriptions
without being presented as an official erratum.
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.
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: signature-field-policy.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\FormAppearanceStream;
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\Resources\Font;
use PDF\Document\Signatures\SignatureAppearanceSelection;
use PDF\Document\Signatures\SignatureCapacity;
use PDF\Document\Signatures\SignatureFieldSigning;
use PDF\Document\Signatures\SignatureSigningIntent;
use PDF\Document\Signatures\Policies\SignatureSeedFlag;
use PDF\Document\Signatures\Policies\SignatureSeedFlags;
use PDF\Document\Signatures\Policies\SignatureSeedValue;
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\ContentStreams;
use PDF\Syntax\ContentStreamsResources\Operators\AppendRectangle;
use PDF\Syntax\ContentStreamsResources\Operators\BeginMarkedContent;
use PDF\Syntax\ContentStreamsResources\Operators\BeginTextObject;
use PDF\Syntax\ContentStreamsResources\Operators\ClipPathNonzero;
use PDF\Syntax\ContentStreamsResources\Operators\EndMarkedContent;
use PDF\Syntax\ContentStreamsResources\Operators\EndPath;
use PDF\Syntax\ContentStreamsResources\Operators\EndTextObject;
use PDF\Syntax\ContentStreamsResources\Operators\FillPathNonzero;
use PDF\Syntax\ContentStreamsResources\Operators\MoveTextPosition;
use PDF\Syntax\ContentStreamsResources\Operators\RestoreGraphicsState;
use PDF\Syntax\ContentStreamsResources\Operators\SaveGraphicsState;
use PDF\Syntax\ContentStreamsResources\Operators\SetLineWidth;
use PDF\Syntax\ContentStreamsResources\Operators\SetNonstrokingGray;
use PDF\Syntax\ContentStreamsResources\Operators\SetStrokingGray;
use PDF\Syntax\ContentStreamsResources\Operators\SetTextFontAndSize;
use PDF\Syntax\ContentStreamsResources\Operators\ShowText;
use PDF\Syntax\ContentStreamsResources\Operators\StrokePath;
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\Object\ArrayType;
use PDF\Syntax\Object\DictionaryObject\Entry;
use PDF\Syntax\Object\DictionaryType;
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\HexadecimalString;
use PDF\Syntax\Object\StringObject\LiteralString;
require dirname(__DIR__) . '/vendor/autoload.php';
// 1. The application supplies bytes. This certificate and key are PUBLIC
// and for testing only; they prove no production identity.
$fixtures = dirname(__DIR__) . '/tests/Fixtures/signing/';
$certificateBytes = file_get_contents($fixtures . 'signer.der');
$keyBytes = file_get_contents($fixtures . 'signer-private-key.pem');
if ($certificateBytes === false || $keyBytes === false) {
throw new RuntimeException('The public signing fixtures are unavailable.');
}
$identity = new OpenSslIdentity(new CertificateChain(new X509Certificate(new HexadecimalString($certificateBytes))), $keyBytes);
$backend = new OpenSslSignatureBackend($identity);
// 2. Resources, coordinates and appearances remain explicit. Stream text
// uses ASCII bytes here; Reason/SV metadata use actual TextString values
// and retain their accented characters.
$resources = new ResourceDictionary(new Font(new DictionaryType(
new Entry(new NameType('F1'), new IndirectReference(new Type1Font(StandardFont::Helvetica))),
new Entry(new NameType('Title'), new IndirectReference(new Type1Font(StandardFont::HelveticaBold))),
)));
$rectangle = static fn (int $x, int $y, int $width, int $height): Rectangle => new Rectangle(new NumericType($x), new NumericType($y), new NumericType($x + $width), new NumericType($y + $height));
$text = static fn (string $value, int $x, int $y, int $size, string $font = 'F1'): ContentStreams => new ContentStreams(
new BeginTextObject(),
new SetTextFontAndSize(new NameType($font), new NumericType($size)),
new MoveTextPosition(new NumericType($x), new NumericType($y)),
new ShowText(new LiteralString($value)),
new EndTextObject(),
);
$referenceAppearance = new FormAppearanceStream(new ContentStreams(...[
new SetStrokingGray(new NumericType(0.7)),
new SetLineWidth(new NumericType(1)),
new AppendRectangle(new NumericType(0.5), new NumericType(0.5), new NumericType(498), new NumericType(31)),
new StrokePath(),
// ISO 12.7.3.3, p.444: readers replace only variable text while
// preserving the frame. This appearance stays distinct from the signature.
new BeginMarkedContent(new NameType('Tx')),
new SaveGraphicsState(),
new AppendRectangle(new NumericType(1), new NumericType(1), new NumericType(497), new NumericType(30)),
new ClipPathNonzero(),
new EndPath(),
new SetNonstrokingGray(new NumericType(0)),
...$text('DOSSIER-2026-010', 12, 10, 12)->operators,
new RestoreGraphicsState(),
new EndMarkedContent(),
]), $rectangle(0, 0, 499, 32), $resources);
$signatureAppearance = new FormAppearanceStream(new ContentStreams(
new SetNonstrokingGray(new NumericType(0.95)),
new AppendRectangle(new NumericType(0), new NumericType(0), new NumericType(499), new NumericType(80)),
new FillPathNonzero(),
new SetNonstrokingGray(new NumericType(0)),
...$text('Signature mathematiquement verifiee', 16, 54, 15, 'Title')->operators,
...$text('CMS detache / SHA-256 / certificat public de test', 16, 31, 11)->operators,
...$text('Apparence fournie explicitement par ce script', 16, 13, 9)->operators,
), $rectangle(0, 0, 499, 80), $resources);
// 3. The seed defines obligations. Ff bits require Filter, the first
// supported SubFilter, DigestMethod and the reason actually written.
// Reference: ISO Table234, PDF32000_2008.pdf, pages455-457.
$reason = new TextString('Étudié et approuvé');
$seed = new SignatureSeedValue(
new SignatureSeedFlags(SignatureSeedFlag::Filter, SignatureSeedFlag::SubFilter, SignatureSeedFlag::DigestMethod, SignatureSeedFlag::Reasons),
new Entry(new NameType('Filter'), new NameType('Adobe.PPKLite')),
new Entry(new NameType('SubFilter'), new ArrayType(new NameType('adbe.pkcs7.detached'))),
new Entry(new NameType('DigestMethod'), new ArrayType(new NameType('SHA256'))),
new Entry(new NameType('Reasons'), new ArrayType($reason)),
);
$referenceName = new PartialFieldName(new TextString('Reference'));
$reference = new TextField($referenceName, value: new TextString('DOSSIER-2026-010'));
$lock = new SignatureFieldLock(SignatureLockAction::Include, new ArrayType($referenceName->value));
$signing = new SignatureFieldSigning(
$backend,
new PartialFieldName(new TextString('Approval')),
new SignatureCapacity(8192),
new SignatureSigningIntent(reason: $reason, appearance: new SignatureAppearanceSelection(new TextString('Approbation pédagogique'), new IndirectReference($signatureAppearance))),
$seed,
$lock,
);
// 4. Field Approval's /V designates the reservation. Its /Lock selects
// Reference. SignatureFieldSigning copies Action/Fields into FieldMDP
// without inventing coordinates or changing the target field for the application.
$defaultAppearance = new DefaultAppearance(new SetTextFontAndSize(new NameType('F1'), new NumericType(12)), new SetNonstrokingGray(new NumericType(0)));
$fields = (new FieldTree($defaultAppearance, $resources))->addRoot($reference)->addRoot($signing->field);
$referenceWidget = new WidgetAnnotation($rectangle(48, 610, 499, 32), new WidgetAppearance($referenceAppearance), entries: new Entry(new NameType('F'), new IntegerType(4)));
$signatureWidget = new WidgetAnnotation($rectangle(48, 353, 499, 80), new WidgetAppearance($signatureAppearance), entries: new Entry(new NameType('F'), new IntegerType(4)));
$reference->addWidget($referenceWidget);
$signing->field->addWidget($signatureWidget);
$content = new ContentStreams(...[
...$text('Signer un champ sous conditions', 48, 760, 22, 'Title')->operators,
...$text('Des objets explicites, un contrat applique aux octets ecrits.', 48, 721, 11)->operators,
...$text('1 Le formulaire conserve la reference du dossier', 48, 672, 14, 'Title')->operators,
...$text('Ce champ est selectionne par le verrou FieldMDP de Approval.', 48, 589, 10)->operators,
...$text('2 Le seed impose le format, la raison et le digest', 48, 553, 14, 'Title')->operators,
...$text('Filter : Adobe.PPKLite | SubFilter : adbe.pkcs7.detached', 48, 526, 11)->operators,
...$text('DigestMethod : SHA256 | Reason : Etudie et approuve', 48, 504, 11)->operators,
...$text('3 Le resultat verifie remplit la reservation Contents', 48, 461, 14, 'Title')->operators,
...$text('Le CMS signe la revision entiere, sauf la valeur Contents.', 48, 310, 11)->operators,
...$text('Le champ, son widget et le verrou appartiennent au meme catalogue.', 48, 289, 10)->operators,
new SetNonstrokingGray(new NumericType(0.97)), new AppendRectangle(new NumericType(48), new NumericType(128), new NumericType(499), new NumericType(128)), new FillPathNonzero(),
new SetNonstrokingGray(new NumericType(0)),
...$text('Ce que cette signature confirme', 60, 234, 12, 'Title')->operators,
...$text('Les octets couverts correspondent au certificat et au CMS generes.', 60, 210, 10)->operators,
...$text('Ce certificat et sa cle sont publics et reserves a la demonstration.', 60, 193, 10)->operators,
...$text('Aucune identite PKI, aucun horodatage TSA ni preuve OCSP/CRL.', 60, 176, 10)->operators,
...$text('Le verrou est une declaration PDF ; la comparaison generale des', 60, 159, 10)->operators,
...$text('revisions DocMDP/FieldMDP/UR reste un mecanisme distinct.', 60, 142, 10)->operators,
new SetNonstrokingGray(new NumericType(0.5)),
...$text('PDFToolkit / Tutoriel 26 / PHP 8.3+', 48, 60, 9)->operators,
]);
$page = new PageObject($rectangle(0, 0, 595, 842), $resources, new StreamObject($content), new Entry(new NameType('Annots'), new ArrayType(new IndirectReference($referenceWidget), new IndirectReference($signatureWidget))));
$referenceWidget->bindToPage($page);
$signatureWidget->bindToPage($page);
$catalog = new DocumentCatalog((new PageTreeNode())->addKid($page), new Entry(new NameType('AcroForm'), new IndirectReference(new AcroForm($fields, entries: new Entry(new NameType('SigFlags'), new IntegerType(3))))));
// 5. Data explicitly closes the circular connection to this catalog. prepare()
// writes the file and fixes ByteRange. The contract rereads actually emitted
// snapshots, then verifies the result's mathematical signature, digest and
// certificate before filling Contents. It grants no PKI trust.
$signing->bindCatalog(new IndirectReference($catalog));
$prepared = (new SignatureWriter())->prepare(new FileStructure(new FileHeader('1.7'), new FileBody($catalog)), $signing->signature);
$signed = $signing->sign($prepared);
$path = $argv[1] ?? __DIR__ . '/signature-field-policy.pdf';
$signed->writeTo($path);
echo 'PDF written to ' . $path . PHP_EOL;
