跳转到内容

验证表单字段值,并在展平时保留交互状态

真实的表单流程很少会直接展平原始输入。你通常会先验证每个值,再决定哪些字段要固化为永久内容、哪些字段继续保持可编辑。NextPDF core 为此提供了两个构建块:用于写入表单字段的 HasFormFields trait,它会在你创建字段时把字段值写入文档;以及 flattenForms(),它会把每个字段渲染为静态页面图形,并移除交互式表单。

这个示例把这两个构建块串起来,并补上 core 有意留给应用层处理的验证步骤。你将会:

  • 在写入任何字段之前,先按各字段规则验证一份值映射,确保无效值永远不会进入文档。
  • 基于同一份已验证数据集输出两次——一次展平(锁定的只读副本),一次保留交互状态(可编辑副本)——让相同的字段状态贯穿两份输出。

前提条件:已安装可运行的 NextPDF core(composer require nextpdf/core),并已读过 创建并预填 PDF 表单展平表单字段,本示例正是把这两篇中的字段写入与展平机制结合起来。

范围边界。 core 的 flattenForms() 是文档级操作:它要么展平所有字段,要么一个都不展平。core 表单 API 没有公开的逐字段展平开关,也没有内置值验证器。所以「展平一部分,其余保持可编辑」是在应用层完成的:验证一次,再把同一份已验证数据集渲染成两份文档。本示例记录的正是这个模式;它并不会凭空创造一个逐字段的 core 方法。

Terminal window
composer require nextpdf/core

不需要额外的扩展包。用于写入表单的 trait 和展平器都随 core 一并提供。

AcroForm 字段把当前值存放在字段字典的 V 项中。flattenForms() 会读取每个字段的 V 值,并把它渲染进所属页面的内容流——文本字段变成 BT ... Tj ... ET 文本,复选框与单选按钮变成绘制出的路径,选择字段则绘制出其选中项——随后移除 /AcroForm 目录项。结果是一份非交互式表单:字段的静态呈现,在任何阅读器中显示都一致,且不依赖填表功能(ISO 32000-2 12.7)。

有两点决定了生产环境中的模式:

  1. core 不会验证字段值。 每个写入方法(textField()comboBox()checkBox() 等等)都会把你传入的值原封不动写进 V。电子邮件格式、是否属于允许选项,以及必填字段是否存在,全都是应用程序层级的考量。请在写入前先验证,一旦违规就尽早失败,而不要输出一份已经把错误值固化进去的文档。

  2. 展平是不可逆且作用于整份文档的。 一旦你调用 flattenForms()save(),字段就成了静态图形。想同时保留一份可编辑副本,你并不是去「反展平」——而是不调用 flattenForms(),把已验证数据集再渲染一次。两份副本都从同一组已验证值生成,因此锁定副本与可编辑副本会带有相同的字段状态。

可复现性配置为 structural:每份文档都带有一个 trailer /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。当输出路径是流包装器、含有 null 字节,或指向不存在的父目录时,会抛出 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_OUTPUTNEXTPDF_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() 是全有或全无。 它会展平文档中的每个字段。想让部分字段保持可编辑,就像生产环境示例那样,在不调用展平的情况下再渲染第二份文档——不要期待 core API 提供逐字段开关。
  • 调用顺序。 先写入每个字段,再 flattenForms(),最后 save()。没有字段时调用 flattenForms() 是安全的无操作;在 save() 之后调用它,对已经写出的字节没有任何影响。
  • 字段名称必须唯一。 两个同名字段在符合规范的阅读器中会变成一个共享同一值的逻辑字段。名称由数据驱动生成时,请验证其唯一性。
  • 展平时的复选框真值判定。 当复选框的值为 YesOn1true 时,展平后的复选框会绘制出勾选标记;空值或 Off 值只会绘制方框。请传入真正的布尔值给 checkBox(),让绘制出的状态与你的已验证值一致。
  • 签名字段永远不会被展平。 /Sig 字段的外观由其签名有效载荷生成,而不是可重新绘制的值,所以展平器会跳过它。请在签署前展平,绝不要在签署后展平。
  • 可编辑副本依然可编辑。 在可编辑副本中保留状态,意味着收件人可以更改它。请把锁定副本作为具权威性的正式记录,把可编辑副本作为工作草稿。

验证开销与字段数呈线性关系,且每份数据集只执行一次。每次渲染会为每个字段写入一个 widget 注解(外加一份外观),展平步骤则会为每个字段附加一段有界的内容块。把数据集渲染两次大致会让每份文档的成本翻倍,但对于数百个字段的表单,这仍远在 1500 ms / 64 MB 的预算之内。如果你只需要一份输出,就渲染一次并跳过第二趟。

  • 在边界验证不受信任的输入。 电子邮件格式、是否属于允许选项,以及必填字段是否存在,都在应用程序代码中强制执行,因为 core 不会强制这些规则。凡是派生自不受信任输入的值,在进入字段前都应先转义或规范化,因为 core 会把它原封不动写进文档。
  • 展平不是访问控制。 展平后的值在普通阅读器中无法编辑,但它仍然出现在页面内容中,也可以被任何文本工具提取出来。请勿把展平当作遮盖,也不要把它当作敏感值保护。
  • 可编辑副本包含相同数据。 只把它发送给获准查看与修改这些值的对象。当内容属于敏感信息时,请为任一副本搭配 以权限加密,并留意那里提到的阅读器配合限制:权限位并不会强制读取限制。
  • 失败即关闭。 生产环境示例在验证或输出失败时会以非零退出码退出,而不是写出一份不完整或无效的文档。绝对不要吞掉这些异常。
陈述规格条款参考 ID
展平后的表单是字段的非交互式(静态)呈现。ISO 32000-212.7

NextPDF 输出的是所引用条款描述的静态结构;它并不声明全面符合 ISO 32000-2。本示例中的验证规则是应用程序政策,而不是该标准的符合性要求。