Zum Inhalt springen

Formularfeldwerte validieren und beim Flatten den interaktiven Zustand erhalten

In einer echten Formular-Pipeline werden selten rohe Eingaben geflattet. Zuerst validieren Sie jeden Wert, dann entscheiden Sie, welche Felder dauerhaft übernommen werden und welche bearbeitbar bleiben. NextPDF core stellt dafür zwei Bausteine bereit: den Authoring-Trait HasFormFields, der den Wert eines Felds beim Erstellen in das Dokument schreibt, und flattenForms(), das jedes Feld in statische Seiteninhalte einbettet und das interaktive Formular entfernt.

Dieses Recipe verbindet diese beiden Bausteine mit einem Validierungsschritt auf Anwendungsebene, den core bewusst Ihnen überlässt. Sie werden:

  • eine Wert-Map gegen feldspezifische Regeln validieren, bevor irgendein Feld geschrieben wird, damit kein ungültiger Wert das Dokument erreicht.
  • einen einzigen validierten Datensatz erstellen und ihn anschließend zweimal ausgeben – einmal geflattet (als gesperrte, schreibgeschützte Kopie) und einmal interaktiv (als bearbeitbare Kopie) –, damit derselbe Feldzustand in beide Ausgaben übernommen wird.

Voraussetzungen sind eine funktionierende NextPDF-Core-Installation (composer require nextpdf/core) sowie die Lektüre von PDF-Formular bauen und vorbefüllen und Formularfelder flatten, deren Feld-Authoring- und Flattening-Mechanik dieses Recipe kombiniert.

Geltungsbereichsgrenze. flattenForms() in core ist eine Operation über das gesamte Dokument: Es flattet entweder jedes Feld oder keins. Die Formular-API von core hat keinen öffentlichen feldspezifischen Flatten-Schalter und keinen eingebauten Wert-Validator. Deshalb passiert „manche flatten, andere bearbeitbar lassen“ auf der Anwendungsebene: einmal validieren, dann denselben validierten Datensatz in zwei Dokumente rendern. Dieses Recipe dokumentiert dieses Muster; es erfindet keine feldspezifische core-Methode.

Terminal-Fenster
composer require nextpdf/core

Es ist keine zusätzliche Extension erforderlich. Der Trait für das Formular-Authoring und der Flattener sind beide in core enthalten.

Ein AcroForm-Feld speichert seinen aktuellen Wert im V-Eintrag seines Feld-Dictionarys. flattenForms() liest den V-Wert jedes Felds und rendert ihn in den Content-Stream der zugehörigen Seite – Textfelder werden zu BT ... Tj ... ET-Text, Kontrollkästchen und Optionsfelder werden zu gezeichneten Pfaden, und Auswahlfelder rendern ihren ausgewählten Eintrag –, anschließend entfernt es den Katalogeintrag /AcroForm. Das Ergebnis ist ein nicht-interaktives Formular: eine statische Darstellung der Felder, die in jedem Reader identisch angezeigt wird und kein Ausfüllen eines Formulars erfordert (ISO 32000-2 12.7).

Für das Produktionsmuster sind zwei Punkte maßgeblich:

  1. Core validiert keine Feldwerte. Jede Authoring-Methode (textField(), comboBox(), checkBox() und die übrigen) schreibt jeden Wert, den Sie übergeben, direkt in V. E-Mail-Format, Zugehörigkeit zu den erlaubten Optionen und das Vorhandensein von Pflichtfeldern liegen vollständig in der Verantwortung der Anwendung. Validieren Sie, bevor Sie schreiben, und brechen Sie bei einem Verstoß sofort ab, statt ein Dokument auszugeben, in dem ein fehlerhafter Wert dauerhaft eingebrannt ist.

  2. Flattening ist unumkehrbar und betrifft das gesamte Dokument. Sobald Sie flattenForms() und save() aufrufen, sind die Felder statische Grafik. Um zusätzlich eine bearbeitbare Kopie zu behalten, machen Sie das Flatten nicht rückgängig – Sie rendern den validierten Datensatz ein zweites Mal, ohne flattenForms() aufzurufen. Beide Kopien gehen von denselben validierten Werten aus, sodass die gesperrte Kopie und die bearbeitbare Kopie einen identischen Feldzustand enthalten.

Das Reproduzierbarkeitsprofil ist structural: Jedes Dokument enthält ein Trailer-/ID-Array, das ein Harness normalisiert, bevor zwei Durchläufe verglichen werden.

NextPDF\Core\Document (über NextPDF\Core\Concerns\HasFormFields):

  • textField(string $name, float $x, float $y, float $w, float $h, string $default = '', array $options = []): static — schreibt ein Textfeld, dessen Wert aus default stammt.
  • comboBox(string $name, float $x, float $y, float $w, float $h, array $items, string $selected = ''): static — schreibt ein Dropdown, dessen ausgewählter Eintrag aus selected stammt.
  • checkBox(string $name, float $x, float $y, float $size, bool $checked = false): static — schreibt ein Kontrollkästchen, dessen Zustand aus checked stammt.
  • flattenForms(): static — bettet den Wert jedes Felds in statischen Seiteninhalt ein und entfernt die AcroForm. Ein No-op, wenn keine Felder vorhanden sind. Delegiert intern an NextPDF\Form\FormFlattener.

NextPDF\Core\Concerns\HasOutput:

  • save(string $path): void — baut das PDF und schreibt es. Wirft NextPDF\Exception\InvalidConfigException, wenn der Ausgabepfad ein Stream-Wrapper ist, ein Null-Byte enthält oder ein nicht existierendes übergeordnetes Verzeichnis angibt.

Der Validator in den folgenden Beispielen ist Anwendungscode, der zu Ihrer Anwendung gehört, kein Symbol aus core. Core hat keine API zur Wertvalidierung, und genau deshalb ist der Validierungsschritt hier explizit.

Dieser minimale Ablauf validiert eine Wert-Map, schreibt daraus drei Felder, flattet anschließend eine gesperrte Kopie und speichert sie.

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

Der Produktionsablauf trennt die Validierung vom Rendering. Ein typisiertes FieldRuleSet validiert die Wert-Map einmal und gibt einen validierten Datensatz zurück. Ein einziger renderForm()-Helfer schreibt die Felder und wird zweimal ausgeführt – mit Flattening für die gesperrte Kopie, ohne Flattening für die bearbeitbare Kopie. Beide Kopien stammen aus denselben validierten Werten, sodass der interaktive Zustand über beide hinweg konsistent bleibt. Die Ausgabepfade kommen für den Harness aus NEXTPDF_COOKBOOK_LOCKED_OUTPUT und NEXTPDF_COOKBOOK_EDITABLE_OUTPUT.

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

Erwartete Ausgabe:

Wrote locked and editable registration copies

Die gesperrte Kopie öffnet sich ohne interaktive Felder – die Werte sind statische Grafik. Die bearbeitbare Kopie öffnet sich mit denselben vorbefüllten Werten, und jedes Feld bleibt bearbeitbar. Beide spiegeln denselben validierten Datensatz wider.

  • Validieren Sie, bevor Sie schreiben, nicht danach. Die Authoring-Methoden schreiben Ihren Wert wörtlich in V. Kein core-Hook weist einen fehlerhaften Wert zur save()-Zeit zurück, sodass ein Wert, der die Validierung übersprungen hat, ohne Wiederherstellungspfad in die geflattete Kopie eingebrannt wird.
  • flattenForms() ist Alles-oder-nichts. Es flattet jedes Feld im Dokument. Um manche Felder bearbeitbar zu halten, rendern Sie ein zweites Dokument ohne den Flatten-Aufruf, so wie es das Produktionsbeispiel macht – erwarten Sie keinen feldspezifischen Schalter in der core-API.
  • Aufrufreihenfolge. Schreiben Sie zuerst jedes Feld, dann flattenForms(), dann save(). Ein Aufruf von flattenForms() ohne Felder ist ein sicheres No-op; ein Aufruf nach save() hat keine Auswirkung auf die bereits geschriebenen Bytes.
  • Feldnamen müssen eindeutig sein. Zwei Felder, die sich einen Namen teilen, werden in konformen Readern zu einem logischen Feld mit einem gemeinsamen Wert. Validieren Sie Namen auf Eindeutigkeit, wenn sie datengetrieben sind.
  • Kontrollkästchen-Wahrheitswert beim Flatten. Ein geflattetes Kontrollkästchen zeichnet seinen Haken, wenn sein Wert Yes, On, 1 oder true ist; ein leerer oder Off-Wert zeichnet nur das Kästchen. Übergeben Sie einen echten Boolean an checkBox(), damit der gerenderte Zustand Ihrem validierten Wert entspricht.
  • Signaturfelder werden niemals geflattet. Die Oberfläche eines /Sig-Felds ist die aus seiner Signatur-Payload erzeugte Darstellung, kein neu renderbarer Wert, daher überspringt der Flattener es. Flatten Sie, bevor Sie signieren, niemals danach.
  • Die bearbeitbare Kopie ist weiterhin bearbeitbar. Den Zustand in der bearbeitbaren Kopie zu erhalten bedeutet, dass der Empfänger ihn ändern kann. Behandeln Sie die gesperrte Kopie als maßgeblichen Datensatz und die bearbeitbare Kopie als Arbeitsentwurf.

Die Validierung ist linear zur Anzahl der Felder und läuft einmal pro Datensatz. Jeder Rendering-Durchlauf schreibt pro Feld eine Widget-Annotation (plus eine Darstellung), und der Flatten-Schritt hängt pro Feld einen begrenzten Inhaltsblock an. Wenn Sie den Datensatz zweimal rendern, verdoppeln sich die Kosten pro Dokument ungefähr; für Formulare mit einigen hundert Feldern bleibt das dennoch deutlich innerhalb eines Budgets von 1500 ms / 64 MB. Wenn Sie nur eine Ausgabe benötigen, rendern Sie einmal und überspringen den zweiten Durchlauf.

  • Validieren Sie nicht vertrauenswürdige Eingaben an der Grenze. E-Mail-Format, Zugehörigkeit zu den erlaubten Optionen und das Vorhandensein von Pflichtfeldern werden im Anwendungscode erzwungen, weil core sie nicht erzwingt. Escapen oder normalisieren Sie jeden Wert, der aus nicht vertrauenswürdigen Eingaben stammt, bevor er ein Feld erreicht, da core ihn wörtlich in das Dokument schreibt.
  • Flattening ist keine Zugriffskontrolle. Ein geflatteter Wert ist in einem normalen Reader nicht bearbeitbar, aber er bleibt im Seiteninhalt sichtbar und mit jedem Text-Tool extrahierbar. Behandle Flattening nicht als Schwärzung oder als Schutz sensibler Werte.
  • Flattening ist keine Zugriffskontrolle. Ein geflatteter Wert ist in einem normalen Reader nicht bearbeitbar, aber er bleibt im Seiteninhalt sichtbar und mit jedem Text-Tool extrahierbar. Behandeln Sie Flattening nicht als Schwärzung oder als Schutz sensibler Werte.
  • Die bearbeitbare Kopie trägt dieselben Daten. Verteilen Sie sie nur an Parteien, die diese Werte sehen und ändern dürfen. Wenn der Inhalt sensibel ist, kombinieren Sie eine der beiden Kopien mit Mit Berechtigungen verschlüsseln, und beachten Sie den dort beschriebenen Vorbehalt zur Reader-Kooperation: Berechtigungsbits erzwingen keine Lesebeschränkungen.
  • Fail closed. Das Produktionsbeispiel wird bei einem Validierungs- oder Ausgabefehler mit einem Wert ungleich null beendet, statt ein unvollständiges oder ungültiges Dokument zu schreiben. Verschlucken Sie diese Ausnahmen niemals.
AussageSpecKlauselreference_id
Ein geflattetes Formular ist eine nicht-interaktive (statische) Darstellung der Felder.ISO 32000-212.7

NextPDF erzeugt die statische Struktur, die in der zitierten Klausel beschrieben wird; es behauptet keine pauschale ISO 32000-2-Konformität. Die Validierungsregeln in diesem Recipe sind Anwendungsrichtlinien, keine Konformitätsanforderung des Standards.