Ce tutoriel utilise le core 0.10.0. Son code PHP complet et son PDF généré proviennent du checkout core sélectionné pour le site ; le dépôt de documentation ne contient aucune seconde implémentation.
L’exemple signe un PDF avec DocMDP P2, verrouille Reference par FieldMDP, puis
remplace la valeur Response de Pending par Reviewed. La deuxième révision
remplace aussi son apparence textuelle visible. Contenu de page, ancienne
apparence, ressources et chaque octet signé restent inchangés.
Exécuter l’exemple natif
Depuis un checkout core contenant examples/protected-form-fill.php, ses
dépendances de développement verrouillées et fixtures publiques de signature,
avec PHP 8.3+ et OpenSSL :
php examples/protected-form-fill.php protected-form-fill.pdf
qpdf --check protected-form-fill.pdf
Le résultat comporte une page montrant Reviewed dans son cadre gris d’origine.
Le code ci-dessous provient du package core sélectionné, avec son lien de source
exact. Le certificat et la clé privée sont des fixtures publiques de test ;
l’exemple démontre les mathématiques de signature, pas la confiance du certificat.
Établir les restrictions signées
$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')),
),
);
Le script complet lie cette politique au vrai catalogue et à l’AcroForm avant
signature. P2 permet le remplissage implémenté ; FieldMDP protège toujours
Reference. La SignedRevision native conserve la vérification réelle de sortie.
Garder son objet revision dans la même exécution. Reconstruire une FileRevision
depuis les octets ou compléter un finalize() opaque ne recrée pas cette autorité.
Préparer seulement les remplacements classifiés
$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 et $appearance sont les constructeurs explicites du script complet.
Champ et widget gardent leurs identités. L’apparence de remplacement reçoit une
identité neuve et référence la police historique inchangée. Le garde compare
aussi chaque autre objet figé : lister deux remplacements n’autorise aucun
changement étranger d’un flux de contenu ou d’une ressource.
Les octets signés d’origine restent un préfixe exact. La nouvelle section xref sélectionne valeur et apparence actualisées en conservant la révision précédente. Une mise à jour incrémentale garde l’ancien contenu : elle n’efface pas les secrets.
Isoler le texte modifié
L’apparence dessine son cadre gris avant la région /Tx BMC…EMC. Celle-ci possède
une séquence équilibrée q, BT…ET, Q pour Reviewed. Ancien et nouveau
programmes conservent chaque octet décodé hors de cette région, ainsi que géométrie
et liaisons des noms de ressources. BMC/EMC seuls ne sauvegardent pas l’état
graphique. Chemins, clipping en attente et objets texte ne traversent pas les
frontières protégées.
L’opération ne reconstruit pas les apparences de boutons et n’ajoute aucun état ou alternative sans programme historique comparable. Elle ne prouve pas non plus que des glyphes arbitraires représentent visuellement la valeur. Le guide des révisions protégées liste les champs, sous-types d’annotations, limites lexicales et autorité UR3 explicite.
Vérifier ce que prouve le résultat
qpdf contrôle la syntaxe ; le rendu montre le nouveau texte et le cadre conservé.
La vérification CMS/ByteRange indépendante contrôle la signature sur les octets
d’origine. La politique de modification pyHanko inchangée renvoie OTHER pour
cet exemple, car l’AP modifié appartient à un widget enfant sans /T. Ce constat
est une limite d’oracle, pas une approbation DocMDP revendiquée de pyHanko.
Changer ensemble Response et son apparence Tx pour essayer un autre remplissage.
Modifier Reference, le texte de page ou la police historique doit être refusé.
P1 n’autorise pas le remplissage, et ajouter signature ou DSS au même remplissage
sort des preuves d’opération séparées. Import de chaînes externes, modèles, texte
enrichi, nouvelles ressources, validation PKI et interprétation humaine restent
hors de cet exemple.
← 36. Caviardisation · Révisions natives protégées · Tous les tutoriels
Voir le résultat PDF
Ce fichier a été généré avec le code de cette documentation. Toutes ses pages sont reproduites ci-dessous ; ouvrir le PDF pour utiliser ses liens et ses signets.
Utiliser le lecteur PDF du navigateur
Ce lecteur dépend du navigateur. Si le document ne s’affiche pas, utiliser « Ouvrir le PDF » ou « Télécharger ».
Code source complet
Code de roukmoute/pdf · Consulter la source au commit documenté ↗
Lire le script commenté : 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;
