Перейти к содержимому

Проверка значений полей формы и сведение с сохранением интерактивного состояния

Промышленный конвейер обработки форм редко сводит необработанный ввод. Сначала вы проверяете каждое значение. Затем решаете, какие поля станут постоянными, а какие останутся редактируемыми. Для этой работы NextPDF core предоставляет два базовых механизма: трейт создания полей HasFormFields, который записывает значение поля в документ при создании, и flattenForms(), который запекает каждое поле в статическую графику страницы и удаляет интерактивную форму.

Этот рецепт дополняет эти два механизма шагом проверки на уровне приложения, который core намеренно оставляет вам. Вы:

  • Проверите карту значений по правилам каждого поля до создания любого поля, чтобы недопустимое значение никогда не попало в документ.
  • Сформируете один проверенный набор данных, затем выведете его дважды: один раз в сведённом виде (заблокированная копия только для чтения) и один раз в интерактивном (редактируемая копия), чтобы одно и то же состояние полей переносилось в оба результата.

Необходимые условия: рабочая установка NextPDF core (composer require nextpdf/core), а также знакомство с разделами Создание и предварительное заполнение PDF-формы и Сведение полей формы, механику создания полей и сведения из которых объединяет этот рецепт.

Граница области применения. flattenForms() в core — операция уровня всего документа: она сводит либо все поля, либо ни одного. В API форм core нет публичного переключателя сведения для отдельных полей и нет встроенного средства проверки значений. Поэтому сценарий “свести одни, оставить другие редактируемыми” реализуется на уровне приложения: проверьте один раз, затем отрисуйте один и тот же проверенный набор данных в два документа. Этот рецепт описывает данный шаблон; он не выдумывает метод core для отдельных полей.

Окно терминала
composer require nextpdf/core

Дополнительное расширение не требуется. Трейт создания форм и средство сведения входят в core.

Поле формы Acrobat (AcroForm) хранит текущее значение в элементе V своего словаря поля. flattenForms() считывает значение V каждого поля и отрисовывает его в поток содержимого страницы-владельца: текстовые поля становятся текстом BT ... Tj ... ET, флажки и переключатели — нарисованными контурами, а поля выбора отрисовывают выбранный элемент. Затем она удаляет элемент каталога /AcroForm. Результат — неинтерактивная форма: статическое представление полей, которое отображается одинаково в любой программе просмотра и не требует возможности заполнения формы (ISO 32000-2 12.7).

Промышленный шаблон определяется двумя фактами:

  1. Core не проверяет значения полей. Каждый метод создания (textField(), comboBox(), checkBox() и остальные) записывает любое переданное вами значение прямо в V. Формат адреса электронной почты, принадлежность к допустимым вариантам и наличие обязательных полей — задачи приложения. Проверяйте значения перед созданием и быстро завершайте работу с ошибкой при нарушении, вместо того чтобы выпускать документ с запечённым в него недопустимым значением.

  2. Сведение необратимо и затрагивает весь документ. После вызова flattenForms() и save() поля становятся статической графикой. Чтобы сохранить ещё и редактируемую копию, вы не отменяете сведение, а отрисовываете проверенный набор данных второй раз, не вызывая flattenForms(). Обе копии создаются из одних и тех же проверенных значений, поэтому заблокированная и редактируемая копии несут идентичное состояние полей.

Профиль воспроизводимости — structural: каждый документ содержит в трейлере массив /ID, который финальный проход нормализует перед сравнением двух прогонов.

NextPDF\Core\Document (через NextPDF\Core\Concerns\HasFormFields):

  • textField(string $name, float $x, float $y, float $w, float $h, string $default = '', array $options = []): static — создаёт текстовое поле, значение которого задаётся в default.
  • comboBox(string $name, float $x, float $y, float $w, float $h, array $items, string $selected = ''): static — создаёт раскрывающийся список, выбранный элемент которого задаётся в selected.
  • checkBox(string $name, float $x, float $y, float $size, bool $checked = false): static — создаёт флажок, состояние которого задаётся в checked.
  • flattenForms(): static — запекает значение каждого поля в статическое содержимое страницы и удаляет AcroForm. При отсутствии полей не выполняет никаких действий. Внутренне делегирует работу NextPDF\Form\FormFlattener.

NextPDF\Core\Concerns\HasOutput:

  • save(string $path): void — формирует и записывает PDF. Выбрасывает NextPDF\Exception\InvalidConfigException, если путь вывода является потоковой оболочкой, содержит нулевой байт или указывает на несуществующий родительский каталог.

Средство проверки в примерах ниже — это ваш код приложения, а не символ core. В core нет API проверки значений, поэтому шаг проверки здесь задан явно.

Этот минимальный процесс проверяет карту значений, создаёт на её основе три поля, затем сводит и сохраняет одну заблокированную копию.

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

Промышленный процесс отделяет проверку от отрисовки. Типизированный FieldRuleSet проверяет карту значений один раз и возвращает проверенный набор данных. Один вспомогательный метод renderForm() создаёт поля и запускается дважды: со сведением для заблокированной копии и без него для редактируемой. Обе копии создаются из одних и тех же проверенных значений, поэтому интерактивное состояние переносится в обе. Обвязка считывает пути вывода из NEXTPDF_COOKBOOK_LOCKED_OUTPUT и 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);
}

Ожидаемый вывод:

Wrote locked and editable registration copies

Заблокированная копия открывается без интерактивных полей: значения представлены статической графикой. Редактируемая копия открывается с теми же предварительно заполненными значениями, и каждое поле остаётся редактируемым. Обе отражают единый проверенный набор данных.

  • Проверяйте перед созданием, а не после. Методы создания записывают ваше значение в V без изменений. Ни один обработчик в core не отклоняет некорректное значение на этапе save(), поэтому значение, которое прошло мимо проверки, запекается в сведённую копию без возможности восстановления.
  • flattenForms() работает по принципу “всё или ничего”. Он сводит каждое поле в документе. Чтобы оставить некоторые поля редактируемыми, отрисуйте второй документ без вызова сведения, как это делает промышленный пример, — не ждите переключателя для отдельных полей в API core.
  • Порядок вызовов. Сначала создайте все поля, затем вызовите flattenForms(), а потом save(). Вызов flattenForms() при отсутствии полей безопасен и не выполняет никаких действий; вызов после save() не влияет на уже записанные байты.
  • Имена полей должны быть уникальными. Два поля с одинаковым именем становятся в соответствующих стандарту программах просмотра одним логическим полем с общим значением. Проверяйте имена на уникальность, когда они формируются на основе данных.
  • Истинность флажка при сведении. Сведённый флажок рисует галочку, когда его значение равно Yes, On, 1 или true; пустое значение или Off рисует только рамку. Передавайте в checkBox() настоящее булево значение, чтобы отрисованное состояние совпадало с вашим проверенным значением.
  • Поля подписи никогда не сводятся. Поверхность поля /Sig — это внешний вид, формируемый из данных подписи, а не значение, которое можно повторно отрисовать, поэтому средство сведения его пропускает. Сводите перед подписанием, но никогда после.
  • Редактируемая копия по-прежнему остаётся редактируемой. Сохранение состояния в редактируемой копии означает, что получатель может его изменить. Рассматривайте заблокированную копию как авторитетную запись, а редактируемую копию — как рабочий черновик.

Проверка линейна по числу полей и выполняется один раз на набор данных. Каждая отрисовка создаёт для каждого поля одну аннотацию-виджет и внешний вид. Шаг сведения добавляет для каждого поля ограниченный блок содержимого. Двойная отрисовка набора данных примерно удваивает затраты на документ, но всё равно укладывается в бюджет 1500 мс / 64 МБ для форм из нескольких сотен полей. Если вам нужен только один результат, отрисуйте один раз и пропустите второй проход.

  • Проверяйте недоверенный ввод на границе. Формат адреса электронной почты, принадлежность к допустимым вариантам и наличие обязательных полей обеспечиваются в коде приложения, потому что core этого не делает. Экранируйте или нормализуйте любое значение из недоверенного ввода до того, как оно попадёт в поле: core записывает его в документ без изменений.
  • Сведение — это не управление доступом. Сведённое значение нельзя редактировать в обычной программе просмотра, но оно остаётся видимым в содержимом страницы и извлекаемым любым текстовым инструментом. Не рассматривайте сведение как редактирование с удалением данных или как защиту конфиденциальных значений.
  • Редактируемая копия несёт те же данные. Распространяйте её только среди сторон, которым разрешено видеть и изменять эти значения. Когда содержимое конфиденциально, сочетайте любую из копий с Шифрованием с разрешениями и учитывайте описанную там оговорку о зависимости от программы просмотра: биты разрешений не обеспечивают ограничений на чтение.
  • Завершайте работу с ошибкой при сбое. При сбое проверки или вывода промышленный пример завершается с ненулевым кодом вместо записи частичного или недопустимого документа. Никогда не подавляйте эти исключения.
УтверждениеСпецификацияПунктИдентификатор ссылки (reference_id)
Сведённая форма — это неинтерактивное (статическое) представление полей.ISO 32000-212.7

NextPDF формирует статическую структуру, описанную в цитируемом пункте; он не заявляет о полном соответствии ISO 32000-2. Правила проверки в этом рецепте — политика приложения, а не требование стандарта к соответствию.