Salta ai contenuti

Convalidare i valori dei campi modulo e appiattire preservando lo stato interattivo

Una pipeline di moduli in produzione raramente appiattisce l’input grezzo. Prima si convalida ogni valore, poi si decide quali campi diventano permanenti e quali restano modificabili. NextPDF core mette a disposizione due elementi costitutivi per questo scopo: il trait di creazione HasFormFields, che scrive il valore di un campo nel documento man mano che viene creato, e flattenForms(), che fonde ogni campo nella grafica statica della pagina ed elimina il modulo interattivo.

Questa ricetta collega questi due elementi con un passaggio di convalida a livello applicativo che il core lascia deliberatamente all’utente. Si procederà a:

  • Convalidare una mappa di valori in base a regole definite per ogni singolo campo prima che venga creato qualsiasi campo, in modo che un valore non valido non raggiunga mai il documento.
  • Creare un unico set di dati convalidato, quindi renderizzarlo due volte — una volta appiattito (una copia bloccata, di sola lettura) e una volta interattivo (una copia modificabile) — in modo che lo stesso stato dei campi sia riportato in entrambi gli output.

Prerequisiti: un’installazione funzionante di NextPDF core (composer require nextpdf/core) e la lettura di Creare e precompilare un modulo PDF e Appiattire i campi modulo, di cui questa ricetta combina i meccanismi di creazione e appiattimento dei campi.

Confine dell’ambito. flattenForms() di core è un’operazione sull’intero documento: appiattisce ogni campo o nessuno. L’API dei moduli di core non offre alcun selettore pubblico per appiattire un singolo campo né alcun validatore integrato dei valori. Pertanto «appiattirne alcuni, mantenerne altri modificabili» avviene a livello applicativo: convalidare una volta, quindi renderizzare lo stesso set di dati convalidato in due documenti. Questa ricetta documenta tale pattern; non inventa un metodo di core per singolo campo.

Terminal window
composer require nextpdf/core

Non serve alcuna estensione aggiuntiva. Il trait di creazione dei moduli e il flattener sono inclusi in core.

Un campo AcroForm memorizza il proprio valore corrente nella voce V del proprio dizionario di campo. flattenForms() legge il valore V di ciascun campo e lo renderizza nel flusso di contenuto della pagina a cui appartiene — i campi di testo diventano testo BT ... Tj ... ET, le caselle di controllo e i pulsanti di opzione diventano tracciati disegnati e i campi di scelta mostrano l’elemento selezionato — quindi rimuove la voce di catalogo /AcroForm. Il risultato è un modulo non interattivo: una rappresentazione statica dei campi che viene visualizzata allo stesso modo in qualsiasi reader, senza richiedere capacità di compilazione del modulo (ISO 32000-2 12.7).

Due aspetti definiscono il pattern di produzione:

  1. Core non convalida i valori dei campi. Ogni metodo di creazione (textField(), comboBox(), checkBox() e gli altri) scrive direttamente in V qualsiasi valore venga passato. Il formato dell’email, l’appartenenza alle opzioni consentite e la presenza dei campi obbligatori rientrano tutti nella competenza dell’applicazione. Convalidare prima della creazione e interrompere subito in caso di violazione, anziché generare un documento con un valore errato incorporato.

  2. L’appiattimento è irreversibile e riguarda l’intero documento. Una volta chiamati flattenForms() e save(), i campi diventano grafica statica. Per mantenere anche una copia modificabile, non si annulla l’appiattimento — si renderizza una seconda volta il set di dati convalidato senza chiamare flattenForms(). Entrambe le copie partono dagli stessi valori convalidati, pertanto la copia bloccata e la copia modificabile presentano uno stato dei campi identico.

Il profilo di riproducibilità è structural: ogni documento contiene un array /ID nel trailer, che viene normalizzato in un passaggio successivo prima di confrontare due esecuzioni.

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

  • textField(string $name, float $x, float $y, float $w, float $h, string $default = '', array $options = []): static — crea un campo di testo con il relativo valore in default.
  • comboBox(string $name, float $x, float $y, float $w, float $h, array $items, string $selected = ''): static — crea un menu a discesa con il relativo elemento selezionato in selected.
  • checkBox(string $name, float $x, float $y, float $size, bool $checked = false): static — crea una casella di controllo con il relativo stato in checked.
  • flattenForms(): static — fonde il valore di ogni campo nel contenuto statico della pagina ed elimina l’AcroForm. Non esegue alcuna operazione se non esiste alcun campo. Internamente delega a NextPDF\Form\FormFlattener.

NextPDF\Core\Concerns\HasOutput:

  • save(string $path): void — genera e scrive il PDF. Solleva NextPDF\Exception\InvalidConfigException quando il percorso di output è uno stream wrapper, contiene un byte null oppure indica una directory padre inesistente.

Il validatore negli esempi seguenti è codice applicativo dell’utente, non un simbolo di core. Core non offre alcuna API di convalida dei valori, e proprio questa assenza rende esplicito qui il passaggio di convalida.

Questo flusso minimo convalida una mappa di valori, crea tre campi a partire da quella mappa, quindi appiattisce e salva una copia bloccata.

<?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";

Il flusso di produzione separa la convalida dal rendering. Un FieldRuleSet tipizzato convalida la mappa di valori una sola volta e restituisce un set di dati convalidato. Un unico helper renderForm() crea i campi e viene richiamato due volte — con appiattimento per la copia bloccata, senza appiattimento per la copia modificabile. Entrambe le copie derivano dagli stessi valori convalidati, pertanto lo stato interattivo viene preservato in entrambe. I percorsi di output provengono da NEXTPDF_COOKBOOK_LOCKED_OUTPUT e NEXTPDF_COOKBOOK_EDITABLE_OUTPUT per l’harness.

<?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);
}

Output previsto:

Wrote locked and editable registration copies

La copia bloccata si apre senza campi interattivi — i valori sono grafica statica. La copia modificabile si apre con gli stessi valori precompilati e ogni campo ancora modificabile. Entrambe riflettono lo stesso set di dati convalidato.

  • Convalidare prima di creare, non dopo. I metodi di creazione scrivono il valore in V così com’è. Nessun hook di core rifiuta un valore non valido al momento di save(), pertanto un valore che ha aggirato la convalida viene incorporato nella copia appiattita senza possibilità di correzione successiva.
  • flattenForms() è tutto o niente. Appiattisce ogni campo del documento. Per mantenere modificabili alcuni campi, renderizzare un secondo documento senza la chiamata di appiattimento, come nell’esempio di produzione — non aspettarsi un interruttore per singolo campo nell’API di core.
  • Ordine delle chiamate. Creare prima ogni campo, quindi flattenForms(), infine save(). Chiamare flattenForms() senza campi è un’operazione sicura e senza effetti; chiamarlo dopo save() non ha alcun effetto sui byte già scritti.
  • I nomi dei campi devono essere univoci. Due campi che condividono un nome diventano un unico campo logico con un valore condiviso nei reader conformi. Convalidare l’univocità dei nomi quando sono basati sui dati.
  • Valori considerati veri per la casella di controllo durante l’appiattimento. Una casella di controllo appiattita disegna il segno di spunta quando il suo valore è Yes, On, 1 o true; un valore vuoto o Off disegna solo la casella. Passare un valore booleano effettivo a checkBox() affinché lo stato renderizzato corrisponda al valore convalidato.
  • I campi di firma non vengono mai appiattiti. La superficie di un campo /Sig è l’aspetto prodotto dal relativo payload di firma, non un valore da renderizzare nuovamente, pertanto il flattener lo ignora. Appiattire prima di firmare, mai dopo.
  • La copia modificabile resta modificabile. Preservare lo stato nella copia modificabile significa che il destinatario può modificarlo. Considerare la copia bloccata come il record autorevole e la copia modificabile come una bozza di lavoro.

La convalida è lineare rispetto al numero di campi e viene eseguita una sola volta per set di dati. Ogni rendering crea un’annotazione widget (più un aspetto) per campo, mentre il passaggio di appiattimento aggiunge un blocco di contenuto limitato per campo. Renderizzare due volte lo stesso set di dati raddoppia all’incirca il costo per documento, che resta comunque ampiamente entro un budget di 1500 ms / 64 MB per moduli con qualche centinaio di campi. Se è necessario un solo output, renderizzare una volta e saltare il secondo passaggio.

  • Convalidare l’input non attendibile al confine. Il formato dell’email, l’appartenenza alle opzioni consentite e la presenza dei campi obbligatori vengono controllati nel codice applicativo perché core non li verifica. Eseguire l’escape o normalizzare qualsiasi valore derivato da input non attendibile prima che raggiunga un campo, poiché core lo scrive così com’è nel documento.
  • L’appiattimento non è controllo degli accessi. Un valore appiattito non è modificabile in un reader normale, ma resta visibile nel contenuto della pagina ed estraibile con qualsiasi strumento di testo. Non considerare l’appiattimento come oscuramento (redaction) o come protezione di valori sensibili.
  • La copia modificabile contiene gli stessi dati. Distribuirla solo alle parti autorizzate a vedere e modificare tali valori. Quando il contenuto è sensibile, proteggere entrambe le copie con Cifrare con autorizzazioni, tenendo presente l’avvertenza sulla cooperazione del reader descritta in quella pagina: i bit di autorizzazione non impongono restrizioni di lettura.
  • Fallire in modo sicuro (fail closed). L’esempio di produzione termina con codice diverso da zero in caso di errore di convalida o di output, anziché scrivere un documento parziale o non valido. Non ignorare mai queste eccezioni.
AffermazioneSpecificaClausolareference_id
Un modulo appiattito è una rappresentazione non interattiva (statica) dei campi.ISO 32000-212.7

NextPDF produce la struttura statica descritta dalla clausola citata; non dichiara una conformità generale a ISO 32000-2. Le regole di convalida di questa ricetta sono criteri applicativi, non requisiti di conformità dello standard.