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.

Résultat PDF protected-form-fill : page 1 sur 1
Page 1 sur 1
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;

Source de ce guide au commit documenté ↗