Aller au contenu

Valider les valeurs des champs de formulaire et aplatir en préservant l’état interactif

Dans un vrai pipeline de formulaire, tu aplatis rarement une saisie brute. Tu valides d’abord chaque valeur, puis tu décides quels champs deviennent permanents et lesquels restent modifiables. Pour cela, le cœur de NextPDF te fournit deux briques : le trait d’écriture HasFormFields, qui écrit la valeur d’un champ dans le document lors de sa création, et flattenForms(), qui fige chaque champ sous forme de graphiques de page statiques et supprime le formulaire interactif.

Ce recipe relie ces deux briques à une étape de validation dans la couche applicative, dont le cœur te laisse volontairement la responsabilité. Tu vas :

  • Valider un tableau de valeurs selon des règles propres à chaque champ avant l’écriture du moindre champ, afin qu’une valeur invalide n’atteigne jamais le document.
  • Construire un seul jeu de données validé, puis le générer deux fois — une fois sous forme aplatie (une copie verrouillée, en lecture seule) et une fois sous forme interactive (une copie modifiable) — afin de reporter le même état de champ sur les deux sorties.

Prérequis : une installation fonctionnelle du cœur de NextPDF (composer require nextpdf/core), ainsi qu’une lecture de Construire et préremplir un formulaire PDF et Aplatir les champs de formulaire, dont ce recipe combine les mécaniques d’écriture de champ et d’aplatissement.

Limite de périmètre. Le flattenForms() du cœur est une opération à l’échelle du document : il aplatit soit tous les champs, soit aucun. L’API de formulaire du cœur n’a pas de commutateur public par champ pour l’aplatissement, ni de validateur de valeurs intégré. Donc « aplatir certains champs, en garder d’autres modifiables » relève de la couche applicative : valide une fois, puis rends le même jeu de données validé dans deux documents. Ce recipe documente ce motif ; il n’invente pas de méthode par champ dans le cœur.

Fenêtre de terminal
composer require nextpdf/core

Aucune extension supplémentaire n’est requise. Le trait d’écriture de formulaire et l’aplatisseur sont tous deux livrés dans le cœur.

Un champ AcroForm stocke sa valeur courante dans l’entrée V de son dictionnaire de champ. flattenForms() lit la valeur V de chaque champ et la rend dans le flux de contenu de la page qui le porte — les champs de texte deviennent du texte BT ... Tj ... ET, les cases à cocher et les boutons radio deviennent des tracés dessinés, et les champs de choix restituent leur élément sélectionné — puis supprime l’entrée de catalogue /AcroForm. Le résultat est un formulaire non interactif : une représentation statique des champs qui s’affiche de façon identique dans n’importe quel lecteur, sans qu’aucune capacité de remplissage de formulaire ne soit requise (ISO 32000-2 12.7).

Deux faits façonnent le motif de production :

  1. Le cœur ne valide pas les valeurs des champs. Chaque méthode d’écriture (textField(), comboBox(), checkBox(), et les autres) écrit directement dans V la valeur que tu lui passes. Le format d’e-mail, l’appartenance aux options autorisées et la présence des champs obligatoires relèvent tous de l’application. Valide avant d’écrire, et fais échouer le traitement tôt en cas de violation plutôt que d’émettre un document contenant une valeur incorrecte déjà figée.

  2. L’aplatissement est irréversible et porte sur tout le document. Une fois que tu appelles flattenForms() et save(), les champs sont des graphiques statiques. Pour conserver aussi une copie modifiable, tu ne désaplatis pas — tu rends le jeu de données validé une seconde fois sans appeler flattenForms(). Les deux copies partent des mêmes valeurs validées ; la copie verrouillée et la copie modifiable présentent donc un état de champ identique.

Le profil de reproductibilité est structural : chaque document porte un tableau /ID dans le trailer, qu’une passe ultérieure normalise avant de comparer deux exécutions.

NextPDF\Core\Document (via NextPDF\Core\Concerns\HasFormFields) :

  • textField(string $name, float $x, float $y, float $w, float $h, string $default = '', array $options = []): static — écrit un champ de texte avec sa valeur dans default.
  • comboBox(string $name, float $x, float $y, float $w, float $h, array $items, string $selected = ''): static — écrit une liste déroulante avec son élément sélectionné dans selected.
  • checkBox(string $name, float $x, float $y, float $size, bool $checked = false): static — écrit une case à cocher avec son état dans checked.
  • flattenForms(): static — fige la valeur de chaque champ dans le contenu statique de la page et supprime l’AcroForm. N’a aucun effet quand aucun champ n’existe. Délègue en interne à NextPDF\Form\FormFlattener.

NextPDF\Core\Concerns\HasOutput :

  • save(string $path): void — construit et écrit le PDF. Lève NextPDF\Exception\InvalidConfigException lorsque le chemin de sortie est un wrapper de flux, contient un octet nul ou désigne un répertoire parent inexistant.

Le validateur des exemples ci-dessous est du code applicatif qui t’appartient, pas un symbole du cœur. Le cœur n’a pas d’API de validation de valeurs, et cette absence est précisément la raison pour laquelle l’étape de validation est explicite ici.

Ce flux minimal valide un tableau de valeurs, s’en sert pour écrire trois champs, puis aplatit et enregistre une seule copie verrouillée.

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\Document;
/** @var array<string, string> $input Untrusted value map (e.g. from a request). */
$input = [
'full_name' => 'Ada Lovelace',
'email' => '[email protected]',
'country' => 'Taiwan',
];
$allowedCountries = ['United Kingdom', 'Taiwan', 'Japan'];
// Validate before authoring: core writes whatever you pass, so reject early.
if (trim($input['full_name']) === '') {
throw new InvalidArgumentException('full_name must not be empty.');
}
if (filter_var($input['email'], FILTER_VALIDATE_EMAIL) === false) {
throw new InvalidArgumentException('email is not a valid address.');
}
if (!in_array($input['country'], $allowedCountries, true)) {
throw new InvalidArgumentException('country is not an allowed option.');
}
$doc = Document::createStandalone();
$doc->setTitle('Validated Form (locked copy)');
$doc->addPage();
$doc->textField(name: 'full_name', x: 20, y: 30, w: 90, h: 8, default: $input['full_name']);
$doc->textField(name: 'email', x: 20, y: 45, w: 90, h: 8, default: $input['email']);
$doc->comboBox(
name: 'country',
x: 20, y: 60, w: 90, h: 8,
items: $allowedCountries,
selected: $input['country'],
);
// Whole-document flatten: every field becomes static graphics.
$doc->flattenForms();
$doc->save(__DIR__ . '/registration-locked.pdf');
echo "Wrote registration-locked.pdf\n";

Le flux de production sépare la validation du rendu. Un FieldRuleSet typé valide le tableau de valeurs une seule fois et renvoie un jeu de données validé. Une unique fonction d’aide renderForm() écrit les champs et s’exécute deux fois — avec aplatissement pour la copie verrouillée, sans aplatissement pour la copie modifiable. Les deux copies sont issues des mêmes valeurs validées, donc l’état interactif se reporte de l’une à l’autre. Les chemins de sortie viennent de NEXTPDF_COOKBOOK_LOCKED_OUTPUT et NEXTPDF_COOKBOOK_EDITABLE_OUTPUT pour le harnais.

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\Document;
use NextPDF\Exception\InvalidConfigException;
/**
* A single validated form record. Immutable: once constructed, every value has
* already passed the rule set, so the rendering step cannot re-introduce a bad
* value. This is the "validate once, render many" boundary.
*/
final readonly class ValidatedRegistration
{
/**
* @param non-empty-string $fullName
* @param non-empty-string $email
* @param non-empty-string $country
*/
public function __construct(
public string $fullName,
public string $email,
public string $country,
public bool $newsletter,
) {}
}
/**
* Application-layer field validation. Core performs no value validation, so the
* rules live here. Each method throws on the first violation; the caller maps
* the exception to an HTTP 422 or a user-facing message.
*/
final class FieldRuleSet
{
/** @var list<non-empty-string> */
private const array ALLOWED_COUNTRIES = ['United Kingdom', 'Taiwan', 'Japan', 'Germany'];
/**
* @param array<string, string|bool> $input Untrusted value map.
*
* @throws InvalidArgumentException When any field fails its rule.
*/
public function validate(array $input): ValidatedRegistration
{
$fullName = $this->requireNonEmpty($input, 'full_name');
$email = $this->requireEmail($input, 'email');
$country = $this->requireAllowed($input, 'country', self::ALLOWED_COUNTRIES);
$newsletter = $input['newsletter'] ?? false;
if (!is_bool($newsletter)) {
throw new InvalidArgumentException('newsletter must be a boolean.');
}
return new ValidatedRegistration(
fullName: $fullName,
email: $email,
country: $country,
newsletter: $newsletter,
);
}
/**
* @param array<string, string|bool> $input
*
* @return non-empty-string
*
* @throws InvalidArgumentException
*/
private function requireNonEmpty(array $input, string $key): string
{
$value = $input[$key] ?? '';
if (!is_string($value) || trim($value) === '') {
throw new InvalidArgumentException(sprintf('%s must not be empty.', $key));
}
return $value;
}
/**
* @param array<string, string|bool> $input
*
* @return non-empty-string
*
* @throws InvalidArgumentException
*/
private function requireEmail(array $input, string $key): string
{
$value = $input[$key] ?? '';
if (!is_string($value) || $value === '' || filter_var($value, FILTER_VALIDATE_EMAIL) === false) {
throw new InvalidArgumentException(sprintf('%s is not a valid email address.', $key));
}
return $value;
}
/**
* @param array<string, string|bool> $input
* @param list<non-empty-string> $allowed
*
* @return non-empty-string
*
* @throws InvalidArgumentException
*/
private function requireAllowed(array $input, string $key, array $allowed): string
{
$value = $input[$key] ?? '';
if (!is_string($value) || !in_array($value, $allowed, true)) {
throw new InvalidArgumentException(sprintf('%s is not an allowed option.', $key));
}
return $value;
}
/** @return list<non-empty-string> */
public function allowedCountries(): array
{
return self::ALLOWED_COUNTRIES;
}
}
/**
* Author the same field layout from one validated record. When $flatten is
* true, every field is baked into static page content and the AcroForm is
* dropped; when false, the fields stay interactive and editable. Both paths
* start from identical values, preserving the form's state across copies.
*/
function renderForm(ValidatedRegistration $record, FieldRuleSet $rules, bool $flatten): Document
{
$doc = Document::createStandalone();
$doc->setTitle($flatten ? 'Customer Registration (locked)' : 'Customer Registration (editable)');
$doc->addPage();
$doc->setFont('helvetica', 'B', 18);
$doc->cell(0, 12, 'Customer Registration', newLine: true);
$doc->ln(4);
$leftMargin = 15.0;
$fieldX = 70.0;
$fieldW = 120.0;
$fieldH = 8.0;
$rowSpacing = 12.0;
$y = 40.0;
$doc->setFont('helvetica', '', 10);
$doc->setXY($leftMargin, $y);
$doc->cell(50, $fieldH, 'Full Name:');
$doc->textField(name: 'full_name', x: $fieldX, y: $y, w: $fieldW, h: $fieldH, default: $record->fullName);
$y += $rowSpacing;
$doc->setXY($leftMargin, $y);
$doc->cell(50, $fieldH, 'Email:');
$doc->textField(name: 'email', x: $fieldX, y: $y, w: $fieldW, h: $fieldH, default: $record->email);
$y += $rowSpacing;
$doc->setXY($leftMargin, $y);
$doc->cell(50, $fieldH, 'Country:');
$doc->comboBox(
name: 'country',
x: $fieldX,
y: $y,
w: $fieldW,
h: $fieldH,
items: $rules->allowedCountries(),
selected: $record->country,
);
$y += $rowSpacing;
$doc->setXY($leftMargin, $y);
$doc->cell(50, $fieldH, 'Newsletter:');
$doc->checkBox(name: 'newsletter', x: $fieldX, y: $y, size: 5, checked: $record->newsletter);
if ($flatten) {
// Whole-document flatten: locked, read-only copy.
$doc->flattenForms();
}
return $doc;
}
/** @var array<string, string|bool> $input Untrusted value map (request payload). */
$input = [
'full_name' => 'Ada Lovelace',
'email' => '[email protected]',
'country' => 'Taiwan',
'newsletter' => true,
];
$rules = new FieldRuleSet();
try {
// Validate once. A violation aborts before any document is built.
$record = $rules->validate($input);
// Render the validated dataset twice: locked, then editable.
$locked = renderForm($record, $rules, flatten: true);
$editable = renderForm($record, $rules, flatten: false);
$lockedPath = getenv('NEXTPDF_COOKBOOK_LOCKED_OUTPUT')
?: __DIR__ . '/registration-locked.pdf';
$editablePath = getenv('NEXTPDF_COOKBOOK_EDITABLE_OUTPUT')
?: __DIR__ . '/registration-editable.pdf';
$locked->save($lockedPath);
$editable->save($editablePath);
echo "Wrote locked and editable registration copies\n";
} catch (InvalidArgumentException $e) {
// Validation failure: a bad value never reached the document.
fwrite(STDERR, 'Form validation failed: ' . $e->getMessage() . "\n");
exit(1);
} catch (InvalidConfigException $e) {
// Output failure: bad path, missing directory, or stream wrapper.
fwrite(STDERR, sprintf(
'PDF save failed for key "%s": %s' . "\n",
$e->getConfigKey(),
$e->getMessage(),
));
exit(1);
}

Sortie attendue :

Wrote locked and editable registration copies

La copie verrouillée s’ouvre sans champ interactif — les valeurs sont des graphiques statiques. La copie modifiable s’ouvre avec les mêmes valeurs préremplies, et chaque champ reste modifiable. Les deux reflètent l’unique jeu de données validé.

  • Valide avant d’écrire, pas après. Les méthodes d’écriture inscrivent ta valeur dans V telle quelle. Aucun point d’ancrage du cœur ne rejette une valeur malformée au moment du save(), donc une valeur qui a échappé à la validation est figée dans la copie aplatie sans possibilité de récupération.
  • flattenForms() est tout ou rien. Il aplatit chaque champ du document. Pour garder certains champs modifiables, rends un second document sans l’appel d’aplatissement, comme le fait l’exemple de production — ne compte pas sur un interrupteur par champ dans l’API du cœur.
  • Ordre des appels. Écris d’abord chaque champ, puis flattenForms(), puis save(). Appeler flattenForms() sans aucun champ est sans effet et reste sûr ; l’appeler après save() n’a aucun effet sur les octets déjà écrits.
  • Les noms de champs doivent être uniques. Deux champs qui partagent un nom deviennent un seul champ logique avec une valeur partagée dans les lecteurs conformes. Valide l’unicité des noms lorsqu’ils sont pilotés par les données.
  • État des cases à cocher à l’aplatissement. Une case à cocher aplatie affiche sa coche quand sa valeur est Yes, On, 1, ou true ; une valeur vide ou Off affiche uniquement la case. Passe un vrai booléen à checkBox() pour que l’état rendu corresponde à ta valeur validée.
  • Les champs de signature ne sont jamais aplatis. L’apparence d’un champ /Sig est produite à partir de sa charge utile de signature, pas à partir d’une valeur à rendre de nouveau, donc l’aplatisseur le saute. Aplatis avant de signer, jamais après.
  • La copie modifiable reste modifiable. Préserver l’état dans la copie modifiable signifie que le destinataire peut le changer. Traite la copie verrouillée comme l’enregistrement de référence et la copie modifiable comme un brouillon de travail.

La validation est linéaire par rapport au nombre de champs et s’exécute une fois par jeu de données. Chaque rendu écrit une annotation de widget (plus une apparence) par champ, et l’étape d’aplatissement ajoute un bloc de contenu de taille bornée par champ. Rendre le jeu de données deux fois multiplie à peu près par deux le coût par document, ce qui reste largement dans un budget de 1500 ms / 64 Mo pour des formulaires de quelques centaines de champs. Si tu n’as besoin que d’une seule sortie, rends une fois et saute la seconde passe.

  • Valide l’entrée non fiable à la frontière. Le format d’e-mail, l’appartenance aux options autorisées et la présence des champs obligatoires sont imposés dans le code applicatif parce que le cœur ne les impose pas. Échappe ou normalise toute valeur dérivée d’une entrée non fiable avant qu’elle n’atteigne un champ, puisque le cœur l’écrit telle quelle dans le document.
  • L’aplatissement n’est pas un contrôle d’accès. Une valeur aplatie n’est pas modifiable dans un lecteur normal, mais elle reste visible dans le contenu de la page et extractible avec n’importe quel outil de texte. Ne traite pas l’aplatissement comme un caviardage ni comme une protection des valeurs sensibles.
  • La copie modifiable contient les mêmes données. Ne la distribue qu’aux parties autorisées à voir et à modifier ces valeurs. Lorsque le contenu est sensible, combine l’une ou l’autre copie avec Chiffrer avec des permissions, et tiens compte de la mise en garde sur la coopération du lecteur décrite dans cette page : les bits de permission n’imposent pas de restrictions de lecture.
  • Échoue en mode fermé. L’exemple de production se termine avec un code non nul en cas d’échec de validation ou de sortie, plutôt que d’écrire un document partiel ou invalide. Ne masque jamais ces exceptions.
DéclarationSpécificationClausereference_id
Un formulaire aplati est une représentation non interactive (statique) des champs.ISO 32000-212.7

NextPDF produit la structure statique décrite par la clause citée ; il n’affirme pas une conformité globale à ISO 32000-2. Les règles de validation de ce recipe sont une politique de l’application, pas une exigence de conformité de la norme.