Ir al contenido

Validar valores de campos de formulario y aplanar manteniendo el estado interactivo

En una canalización real de formularios, rara vez se aplana la entrada sin procesar. Primero se valida cada valor y luego se decide qué campos pasan a ser permanentes y cuáles siguen siendo editables. El núcleo de NextPDF proporciona dos componentes básicos para esto: el trait de creación HasFormFields, que escribe el valor de un campo en el documento al crearlo, y flattenForms(), que incorpora cada campo a los gráficos estáticos de la página y descarta el formulario interactivo.

Esta recipe conecta esos dos componentes mediante un paso de validación en la capa de aplicación que el núcleo deja deliberadamente en manos de la aplicación. El flujo consiste en:

  • Validar un mapa de valores frente a reglas por campo antes de crear cualquier campo, para que un valor inválido nunca llegue al documento.
  • Crear un único conjunto de datos validado y emitirlo dos veces: en una copia aplanada (bloqueada y de solo lectura) y en una copia interactiva (editable), para que el mismo estado de campo llegue a ambas salidas.

Requisitos previos: una instalación funcional del núcleo de NextPDF (composer require nextpdf/core), además de haber leído Crear y rellenar previamente un formulario PDF y Aplanar campos de formulario, ya que esta recipe combina sus mecanismos de creación de campos y de aplanado.

Límite de alcance. El flattenForms() del núcleo es una operación que abarca todo el documento: aplana todos los campos o ninguno. La API de formularios del núcleo no ofrece una opción pública para aplanar por campo ni un validador de valores integrado. Por eso, «aplanar algunos y mantener otros editables» se resuelve en la capa de aplicación: validar una vez y luego renderizar el mismo conjunto de datos validado en dos documentos. Esta recipe documenta ese patrón; no inventa un método del núcleo por campo.

Ventana de terminal
composer require nextpdf/core

No se requiere ninguna extensión adicional. Tanto el trait de creación de formularios como el aplanador se incluyen en el núcleo.

Un campo AcroForm almacena su valor actual en la entrada V de su diccionario de campo. flattenForms() lee el valor V de cada campo y lo renderiza en el flujo de contenido de la página a la que pertenece: los campos de texto se convierten en texto BT ... Tj ... ET, las casillas de verificación y los botones de opción se convierten en trazados dibujados, y los campos de selección renderizan el elemento seleccionado; luego elimina la entrada de catálogo /AcroForm. El resultado es un formulario no interactivo: una representación estática de los campos que se muestra de forma idéntica en cualquier lector, sin necesitar la capacidad de rellenar formularios (ISO 32000-2 12.7).

Dos hechos dan forma al patrón de producción:

  1. El núcleo no valida los valores de los campos. Cada método de creación (textField(), comboBox(), checkBox() y los demás) escribe el valor que se le pase directamente en V. El formato del correo electrónico, la pertenencia a las opciones permitidas y la presencia de los campos obligatorios son responsabilidad de la aplicación. Validar antes de crear y fallar rápido cuando una regla se incumple evita emitir un documento con un valor incorrecto ya incorporado.

  2. El aplanado es irreversible y abarca todo el documento. Una vez que se llama a flattenForms() y save(), los campos son gráficos estáticos. Para conservar también una copia editable, no se deshace el aplanado: se renderiza el conjunto de datos validado una segunda vez sin llamar a flattenForms(). Ambas copias parten de los mismos valores validados, de modo que la copia bloqueada y la copia editable conservan un estado de campo idéntico.

El perfil de reproducibilidad es structural: cada documento lleva un arreglo /ID en el tráiler que un paso posterior normaliza antes de comparar dos ejecuciones.

NextPDF\Core\Document (a través de NextPDF\Core\Concerns\HasFormFields):

  • textField(string $name, float $x, float $y, float $w, float $h, string $default = '', array $options = []): static — crea un campo de texto con su valor en default.
  • comboBox(string $name, float $x, float $y, float $w, float $h, array $items, string $selected = ''): static — crea una lista desplegable con su elemento seleccionado en selected.
  • checkBox(string $name, float $x, float $y, float $size, bool $checked = false): static — crea una casilla de verificación con su estado en checked.
  • flattenForms(): static — incorpora el valor de cada campo al contenido estático de la página y descarta el AcroForm. No hace nada cuando no hay campos. Internamente delega en NextPDF\Form\FormFlattener.

NextPDF\Core\Concerns\HasOutput:

  • save(string $path): void — construye y escribe el PDF. Lanza NextPDF\Exception\InvalidConfigException cuando la ruta de salida es un envoltorio de flujo, contiene un byte nulo o apunta a un directorio padre inexistente.

El validador de los ejemplos siguientes es código de aplicación propio, no un símbolo del núcleo. El núcleo no tiene una API de validación de valores, y esa ausencia es justamente la razón por la que el paso de validación es explícito aquí.

Este flujo mínimo valida un mapa de valores, crea tres campos a partir de él y después aplana y guarda una copia bloqueada.

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

El flujo de producción separa la validación del renderizado. Un FieldRuleSet tipado valida el mapa de valores una vez y devuelve un conjunto de datos validado. Un único helper renderForm() crea los campos y se ejecuta dos veces: con aplanado para la copia bloqueada y sin aplanado para la copia editable. Ambas copias provienen de los mismos valores validados, de modo que el estado interactivo se conserva en ambas. Las rutas de salida se toman de NEXTPDF_COOKBOOK_LOCKED_OUTPUT y NEXTPDF_COOKBOOK_EDITABLE_OUTPUT para el arnés.

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

Salida esperada:

Wrote locked and editable registration copies

La copia bloqueada se abre sin campos interactivos: los valores son gráficos estáticos. La copia editable se abre con los mismos valores rellenados previamente y cada campo sigue siendo editable. Ambas reflejan el mismo conjunto de datos validado.

  • Validar antes de crear, no después. Los métodos de creación escriben el valor en V de forma literal. Ningún enganche del núcleo rechaza un valor mal formado en el momento de save(), así que un valor que se saltó la validación queda incorporado en la copia aplanada sin posibilidad de recuperación.
  • flattenForms() es todo o nada. Aplana todos los campos del documento. Para mantener algunos campos editables, renderizar un segundo documento sin la llamada de aplanado, como hace el ejemplo de producción; no esperar un interruptor por campo en la API del núcleo.
  • Orden de las llamadas. Crear primero todos los campos, luego llamar a flattenForms() y después a save(). Llamar a flattenForms() sin campos es una operación nula segura; llamarlo después de save() no tiene efecto sobre los bytes ya escritos.
  • Los nombres de los campos deben ser únicos. Dos campos que comparten un nombre se convierten en un único campo lógico con un valor compartido en los lectores conformes. Validar la unicidad de los nombres cuando estén basados en datos.
  • Interpretación booleana de la casilla de verificación al aplanar. Una casilla de verificación aplanada dibuja su marca cuando su valor es Yes, On, 1 o true; un valor vacío o Off dibuja solo la casilla. Pasar un booleano real a checkBox() permite que el estado renderizado coincida con el valor validado.
  • Los campos de firma nunca se aplanan. La superficie de un campo /Sig es la apariencia producida a partir de su carga útil de firma, no un valor que pueda volver a renderizarse, así que el aplanador lo omite. Aplanar antes de firmar, nunca después.
  • La copia editable sigue siendo editable. Conservar el estado en la copia editable significa que el destinatario puede cambiarlo. Tratar la copia bloqueada como el registro autoritativo y la copia editable como un borrador de trabajo.

La validación es lineal respecto del número de campos y se ejecuta una vez por conjunto de datos. Cada renderizado crea una anotación de widget (más una apariencia) por campo, y el paso de aplanado añade un bloque de contenido acotado por campo. Renderizar el conjunto de datos dos veces aproximadamente duplica el coste por documento, que aun así se mantiene holgadamente dentro de un presupuesto de 1500 ms / 64 MB para formularios de unos pocos cientos de campos. Si solo se necesita una salida, renderizar una vez y omitir la segunda pasada.

  • Validar la entrada no confiable en el límite. El formato del correo electrónico, la pertenencia a las opciones permitidas y la presencia de los campos obligatorios se aplican en el código de la aplicación porque el núcleo no los aplica. Escapar o normalizar cualquier valor derivado de una entrada no confiable antes de que llegue a un campo, ya que el núcleo lo escribe de forma literal en el documento.
  • El aplanado no es un control de acceso. Un valor aplanado no es editable en un lector normal, pero permanece visible en el contenido de la página y se puede extraer con cualquier herramienta de texto. No tratar el aplanado como eliminación segura de contenido ni como protección de valores sensibles.
  • La copia editable lleva los mismos datos. Distribuirla solo a las partes autorizadas a ver y cambiar esos valores. Cuando el contenido sea sensible, combinar cualquiera de las copias con Cifrar con permisos, y tener en cuenta la advertencia de cooperación del lector descrita allí: los bits de permisos no imponen restricciones de lectura.
  • Fallar de forma segura. El ejemplo de producción sale con un código distinto de cero ante un fallo de validación o de salida, en lugar de escribir un documento parcial o inválido. Nunca silenciar estas excepciones.
DeclaraciónEspecificaciónCláusulareference_id
Un formulario aplanado es una representación no interactiva (estática) de los campos.ISO 32000-212.7

NextPDF produce la estructura estática descrita por la cláusula citada; no afirma conformidad general con ISO 32000-2. Las reglas de validación de esta recipe son política de la aplicación, no un requisito de conformidad de la norma.