跳到內容

驗證表單欄位值,並在攤平流程中保留互動狀態

真實的表單流程很少直接攤平原始輸入值。你會先驗證每個值,再決定哪些欄位要變成永久內容、哪些要保持可編輯。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。本範例中的驗證規則是應用程式政策,而非該標準的符合性要求。