コンテンツにスキップ

フォームフィールド値を検証し、インタラクティブな状態を保持したままフラット化する

実際のフォームパイプラインで、生の入力をそのままフラット化することはほとんどありません。まず各値を 検証 し、次にどのフィールドを固定化し、どのフィールドを編集可能なままにするかを決定します。NextPDF core は、このための 2 つの構成要素を提供します。1 つはフィールドの作成時にその値をドキュメントへ書き込む HasFormFields 作成用トレイト、もう 1 つはすべてのフィールドを静的なページグラフィックに焼き込み、インタラクティブなフォームを削除する flattenForms() です。

このレシピでは、core がアプリケーション層に委ねている検証ステップを通じて、これら 2 つの構成要素を連携させます。このレシピでは、次のことを行います。

  • いずれかのフィールドが作成される前に、値マップをフィールドごとのルールに照らして検証します。これにより、無効な値がドキュメントに到達することはありません。
  • 検証済みの単一のデータセットを構築し、それを 2 回出力します。一方はフラット化(ロックされた読み取り専用のコピー)、もう一方はインタラクティブ(編集可能なコピー)です。これにより、同じフィールドの状態が両方の出力に引き継がれます。

前提条件:動作する NextPDF core のインストール(composer require nextpdf/core)に加えて、PDF フォームの構築と事前入力フォームフィールドのフラット化 を読んでおくこと。このレシピは、これらのフィールド作成とフラット化のメカニズムを組み合わせたものです。

スコープの境界。 core の flattenForms() はドキュメント全体に対する操作であり、 すべてのフィールドをフラット化するか、まったくしないかのいずれかです。core のフォーム API には、公開されたフィールドごとのフラット化スイッチも、組み込みの値バリデーターもありません。そのため、「一部をフラット化し、他は編集可能なままにする」処理はアプリケーション層で行われます。一度検証し、同じ検証済みデータセットを 2 つのドキュメントにレンダリングします。このレシピはそのパターンを説明するものであり、 フィールドごとの core メソッドを新たに作り出すものではありません。

Terminal window
composer require nextpdf/core

追加の拡張機能は必要ありません。フォーム作成用トレイトとフラット化機能は、いずれも core に同梱されています。

AcroForm フィールドは、その現在の値をフィールド辞書の V エントリに格納します。flattenForms() は各フィールドの V 値を読み取り、それを所属するページのコンテンツストリームにレンダリングします。テキストフィールドは BT ... Tj ... ET テキストになり、チェックボックスとラジオボタンは描画されたパスになり、選択フィールドは選択された項目をレンダリングします。その後、/AcroForm カタログエントリを削除します。その結果、フォームは非インタラクティブになります。これはフィールドの静的な表現であり、どのリーダーでも同一に表示され、フォーム入力機能を必要としません(ISO 32000-2 12.7)。

本番環境でのパターンを決める事実は 2 つあります。

  1. core はフィールド値を検証しません。 各作成メソッド(textField()comboBox()checkBox() など)は、渡された値をそのまま V に書き込みます。メールアドレスの形式、許可されたオプションに含まれているかどうか、必須フィールドが存在するかどうかは、いずれもアプリケーション側で対応すべき事項です。作成する前に検証し、不正な値が焼き込まれたドキュメントを出力するのではなく、違反があれば速やかに失敗させます。

  2. フラット化は不可逆であり、ドキュメント全体が対象です。 flattenForms()save() を呼び出すと、フィールドは静的なグラフィックになります。編集可能なコピーも保持するには、フラット化を取り消すのではなく、flattenForms() を呼び出さずに、検証済みデータセットをもう一度レンダリングします。どちらのコピーも同じ検証済みの値から始まるため、ロックされたコピーと編集可能なコピーは同一のフィールド状態を持ちます。

再現性プロファイルは structural です。各ドキュメントはトレーラーの /ID 配列を持ち、2 回の実行を比較する前に後処理パスがこれを正規化します。

NextPDF\Core\DocumentNextPDF\Core\Concerns\HasFormFields 経由):

  • textField(string $name, float $x, float $y, float $w, float $h, string $default = '', array $options = []): staticdefault を値として指定し、テキストフィールドを作成します。
  • comboBox(string $name, float $x, float $y, float $w, float $h, array $items, string $selected = ''): staticselected を選択項目として指定し、ドロップダウンを作成します。
  • checkBox(string $name, float $x, float $y, float $size, bool $checked = false): staticchecked を状態として指定し、チェックボックスを作成します。
  • flattenForms(): static — すべてのフィールドの値を静的なページコンテンツに焼き込み、AcroForm を削除します。フィールドが存在しない場合は何もしません。内部的には NextPDF\Form\FormFlattener に委譲します。

NextPDF\Core\Concerns\HasOutput:

  • save(string $path): void — PDF を構築して書き込みます。出力パスがストリームラッパーである場合、null バイトを含む場合、または存在しない親ディレクトリを指定している場合は、NextPDF\Exception\InvalidConfigException をスローします。

以下のサンプルのバリデーターは、core のシンボルではなく、ユーザー自身が所有するアプリケーションコードです。core には値検証 API がないため、ここでは検証ステップを明示的に記述しています。

この最小構成のフローでは、値マップを検証し、そこから 3 つのフィールドを作成し、その後にフラット化してロックされたコピーを 1 つ保存します。

<?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() ヘルパーがフィールドを作成し、2 回実行されます。ロックされたコピーではフラット化し、編集可能なコピーではフラット化しません。どちらのコピーも同じ検証済みの値から生成されるため、インタラクティブな状態が両者に引き継がれます。ハーネス向けに、出力パスは 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 に書き込みます。save() の時点で不正な値を拒否する core のフックはないため、検証を経ていない値はフラット化されたコピーに焼き込まれ、回復する手段はありません。
  • flattenForms() は全か無かです。 ドキュメント上のすべてのフィールドをフラット化します。一部のフィールドを編集可能なままにするには、本番環境のサンプルのように、フラット化の呼び出しなしで 2 つ目のドキュメントをレンダリングします。core API にフィールドごとのスイッチがあると期待しないでください。
  • 呼び出し順序。 まずすべてのフィールドを作成し、次に flattenForms()、最後に save() を呼び出します。フィールドがない状態で flattenForms() を呼び出しても安全に何もしません。save() の後に呼び出しても、すでに書き込まれたバイトには影響しません。
  • フィールド名は一意でなければなりません。 同じ名前を共有する 2 つのフィールドは、準拠リーダーでは共有された値を持つ 1 つの論理フィールドになります。名前がデータから生成される場合は、一意性を検証してください。
  • フラット化時のチェックボックスの真偽判定。 フラット化されたチェックボックスは、値が YesOn1、または true のときにチェックマークを描画します。空または Off の値の場合は、ボックスのみを描画します。レンダリングされる状態が検証済みの値と一致するように、checkBox() には実際のブール値を渡してください。
  • 署名フィールドは決してフラット化されません。 /Sig フィールドの表示は、その署名ペイロードから生成された外観であり、再レンダリング可能な値ではないため、フラット化機能はこれをスキップします。フラット化は署名の前に行い、署名の後には決して行わないでください。
  • 編集可能なコピーは依然として編集可能です。 編集可能なコピーで状態を保持するということは、受信者がそれを変更できることを意味します。ロックされたコピーを正式な記録として、編集可能なコピーを作業用ドラフトとして扱ってください。

検証はフィールド数に対して線形であり、データセットごとに 1 回実行されます。各レンダリングはフィールドごとに 1 つのウィジェット注釈(および外観)を作成し、フラット化ステップはフィールドごとに有界のコンテンツブロックを追加します。データセットを 2 回レンダリングすると、ドキュメント生成の総コストはおおよそ 2 倍になりますが、数百のフィールドを持つフォームであれば、それでも 1500 ms / 64 MB の予算内に十分収まります。出力が 1 つだけ必要な場合は、1 回だけレンダリングして 2 回目のパスをスキップしてください。

  • 信頼できない入力は境界で検証してください。 core はこのような制約を強制しないため、メールアドレスの形式、許可されたオプションに含まれているかどうか、必須フィールドが存在するかどうかは、アプリケーションコードで強制されます。core は値をそのままドキュメントに書き込むため、信頼できない入力から得られた値は、フィールドに到達する前にエスケープまたは正規化してください。
  • フラット化はアクセス制御ではありません。 フラット化された値は通常のリーダーでは編集できませんが、ページコンテンツ内に表示されたままであり、任意のテキストツールで抽出できます。フラット化を、墨消しや機密値の保護として扱わないでください。
  • 編集可能なコピーは同じデータを保持しています。 これらの値の閲覧と変更が許可された関係者にのみ配布してください。コンテンツが機密である場合は、いずれかのコピーを 権限付き暗号化 と組み合わせ、そこで説明されている、リーダー協調に関する注意点に留意してください。すなわち、権限ビットは読み取り制限を強制しません。
  • フェイルクローズ。 本番環境のサンプルは、検証または出力の失敗時に、部分的または無効なドキュメントを書き込むのではなく、非ゼロのステータスで終了します。これらの例外を決して握りつぶさないでください。
記述仕様箇条reference_id
フラット化されたフォームは、フィールドの非インタラクティブな(静的な)表現ISO 32000-212.7

NextPDF は、引用された箇条で説明されている静的な構造を生成しますが、ISO 32000-2 への全面的な準拠を主張するものではありません。このレシピの検証ルールはアプリケーションのポリシーであり、標準上の準拠要件ではありません。