驗證表單欄位值,並在攤平流程中保留互動狀態
真實的表單流程很少直接攤平原始輸入值。你會先驗證每個值,再決定哪些欄位要變成永久內容、哪些要保持可編輯。NextPDF core 為此提供兩個建構區塊:HasFormFields 欄位編寫 trait,會在你建立欄位時把欄位值寫入文件;以及 flattenForms(),會把每個欄位烘焙成靜態頁面圖形並移除互動式表單。
這則範例會把這兩個區塊串起來,並加上一個 core 刻意留給你處理的應用層驗證步驟。你將會:
- 在任何欄位寫入前,先用各欄位的規則驗證一份值對映,讓無效值永遠不會進到文件裡。
- 建構單一份已驗證資料集,再輸出兩次——一次攤平(鎖定的唯讀副本)、一次保留互動(可編輯副本)——讓相同的欄位狀態貫穿兩份輸出。
先決條件:已安裝可運作的 NextPDF core(composer require nextpdf/core),並已讀過 建立並預填 PDF 表單 與 攤平表單欄位,本範例正是把這兩篇的欄位編寫與攤平機制結合起來。
範圍界線。 core 的
flattenForms()是文件層級操作:它只會攤平所有欄位,或完全不攤平。core 表單 API 沒有公開的逐欄位攤平開關,也沒有內建的值驗證器。所以「只攤平部分欄位、其餘保持可編輯」必須在應用層完成:先驗證一次,再把同一份已驗證資料集繪製成兩份文件。本範例記錄的就是這個模式;它並不會憑空發明逐欄位的 core 方法。
composer require nextpdf/core不需要額外的擴充套件。表單編寫 trait 與攤平器都隨 core 一併提供。
概念總覽
標題為「概念總覽」的區段AcroForm 欄位會把目前值存在其欄位字典的 V 項目中。flattenForms() 會讀取每個欄位的 V 值,並把它繪製進所屬頁面的內容串流——文字欄位變成 BT ... Tj ... ET 文字,核取方塊與選項按鈕變成繪製出的路徑,選擇欄位則繪出其選取項目——接著移除 /AcroForm 目錄項目。結果是一份非互動式表單:也就是欄位的靜態呈現,能在任何閱讀器中一致顯示,且不需要任何填表功能(ISO 32000-2 12.7)。
有兩項事實決定了正式環境的做法:
-
core 不會驗證欄位值。 每個編寫方法(
textField()、comboBox()、checkBox()等等)都會把你傳入的值原封不動寫進V。電子郵件格式、是否屬於允許選項,以及必填欄位是否存在,全都是應用程式層級的考量。請在編寫前先驗證,一旦違規就快速失敗,而不要輸出一份把錯誤值烘焙進去的文件。 -
攤平是不可逆且作用於整份文件。 一旦你呼叫
flattenForms()並save(),欄位就會變成靜態圖形。想同時保留一份可編輯副本,你不是去「反攤平」——而是不呼叫flattenForms(),把已驗證資料集再繪製一次。兩份副本都從同一組已驗證值開始,因此鎖定副本與可編輯副本具有相同的欄位狀態。
可重現性設定檔為 structural:每份文件都帶有一個 trailer /ID 陣列,後處理會先將它正規化,再比較兩次執行的輸出。
API 介面
標題為「API 介面」的區段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', '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', '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()之後呼叫它,對已寫出的位元組沒有任何影響。 - 欄位名稱必須唯一。 共用同一名稱的兩個欄位,在符合規範的閱讀器中會變成一個共用同一值的邏輯欄位。當名稱由資料驅動產生時,請驗證其唯一性。
- 攤平時的核取方塊真值判定。 當核取方塊的值為
Yes、On、1或true時,攤平後的核取方塊會繪出勾選標記;空值或Off值則只繪出方框。請傳入真正的布林值給checkBox(),讓繪出的狀態與你的已驗證值相符。 - 簽章欄位永遠不會被攤平。
/Sig欄位的表面是由其簽章酬載產生的外觀,而非可重新繪製的值,所以攤平器會跳過它。請在簽署前攤平,絕不要在簽署後攤平。 - 可編輯副本依然可編輯。 在可編輯副本中保留狀態,意味著收件者可以更改它。請把鎖定副本當作具權威性的正式記錄,把可編輯副本當作工作草稿。
驗證成本與欄位數成線性關係,且每份資料集只執行一次。每次繪製會為每個欄位編寫一個 widget 註解(外加一份外觀),攤平步驟則為每個欄位附加一段有界的內容區塊。把資料集繪製兩次大致會讓每份文件的成本加倍,但對於數百個欄位的表單,這仍遠低於 1500 ms / 64 MB 的預算。如果你只需要一份輸出,就繪製一次並略過第二趟。
安全性注意事項
標題為「安全性注意事項」的區段- 在邊界驗證不受信任的輸入。 電子郵件格式、是否屬於允許選項,以及必填欄位是否存在,都在應用程式碼中強制執行,因為 core 不會替你強制執行。凡是衍生自不受信任輸入的值,在進入欄位前都請先跳脫或正規化,因為 core 會把它原封不動寫進文件。
- 攤平不是存取控制。 攤平後的值在一般閱讀器中無法編輯,但它仍可見於頁面內容,並能用任何文字工具擷取出來。請勿把攤平當作遮蔽,也別當作敏感值的防護。
- 可編輯副本帶有相同的資料。 只把它發送給獲准檢視與更改這些值的對象。當內容屬敏感時,請對任一份副本搭配 以權限加密,並留意該頁說明的閱讀器配合但書:權限位元並不會強制讀取限制。
- 失敗時拒絕輸出。 正式環境範例在驗證或輸出失敗時會以非零碼結束,而不是寫出一份不完整或無效的文件。絕對不要吞掉這些例外。
符合性
標題為「符合性」的區段| 陳述 | 規格 | 條款 | 參考 ID |
|---|---|---|---|
| 攤平後的表單是欄位的非互動式(靜態)呈現。 | ISO 32000-2 | 12.7 |
NextPDF 會產出所引用條款描述的靜態結構;它並不主張全面符合 ISO 32000-2。本範例中的驗證規則是應用程式政策,而非該標準的符合性要求。
另請參閱
標題為「另請參閱」的區段- 建立並預填 PDF 表單 — 編寫欄位並設定其初始值。
- 攤平表單欄位 — 本範例所依據的整份文件攤平。
- 以 NextPDF 例外階層處理錯誤 — 以適當粒度捕捉失敗。
- 以權限加密 — 當表單資料屬敏感時加上機密性保護。
- Form 模組 — 表單欄位參考文件。