양식 필드 값 검증, 대화형 상태 보존, 평탄화
한눈에 보기
섹션 제목: “한눈에 보기”실제 양식 파이프라인에서 원시 입력을 그대로 평탄화하는 경우는 드뭅니다. 먼저 각 값을 검증한 다음, 어떤 필드를 고정하고 어떤 필드를 편집 가능한 상태로 남길지 결정합니다. NextPDF core는 이를 위한 두 가지 구성 요소를 제공합니다. 필드를 생성하는 시점에 그 값을 문서에 기록하는 HasFormFields 작성 트레이트와, 모든 필드를 정적 페이지 그래픽으로 고정하고 대화형 양식을 제거하는 flattenForms()입니다.
이 레시피는 core가 의도적으로 애플리케이션에 맡기는 계층 검증 단계와 이 두 구성 요소를 함께 연결합니다. 다음 작업을 수행합니다.
- 필드를 작성하기 전에 값 맵을 필드별 규칙에 따라 검증하여, 유효하지 않은 값이 문서에 도달하지 않도록 합니다.
- 하나의 검증된 데이터셋을 구축한 다음 두 번 생성합니다. 한 번은 평탄화해 잠긴 읽기 전용 사본으로 만들고, 한 번은 대화형인 편집 가능한 사본으로 만들어, 동일한 필드 상태가 두 출력 모두에 반영되도록 합니다.
사전 요구 사항: NextPDF core가 정상적으로 설치되어 있어야 하며(composer require nextpdf/core), 이 레시피가 결합하는 필드 작성 및 평탄화 메커니즘을 다루는 PDF 양식 구축 및 사전 채우기와 양식 필드 평탄화를 알고 있어야 합니다.
범위 경계. core의
flattenForms()는 문서 전체를 대상으로 하는 작업입니다. 모든 필드를 평탄화하거나 하나도 평탄화하지 않습니다. core 양식 API에는 공개된 필드별 평탄화 스위치가 없으며 내장 값 검증기도 없습니다. 따라서 “일부는 평탄화하고 나머지는 편집 가능하게 유지”하는 것은 애플리케이션 계층에서 이루어집니다. 한 번 검증한 다음 동일한 검증된 데이터셋을 두 개의 문서로 렌더링합니다. 이 레시피는 그 패턴을 문서화합니다. 필드별 core 메서드를 새로 만들어내지는 않습니다.
composer require nextpdf/core추가 확장 모듈은 필요하지 않습니다. 양식 작성 트레이트와 평탄화 도구는 모두 core에 포함되어 제공됩니다.
개념 개요
섹션 제목: “개념 개요”AcroForm 필드는 현재 값을 각 필드 딕셔너리의 V 항목에 저장합니다. flattenForms()는 각 필드의 V 값을 읽어 해당 필드가 속한 페이지의 콘텐츠 스트림에 렌더링합니다. 텍스트 필드는 BT ... Tj ... ET 텍스트로 바뀌고, 확인란과 라디오 버튼은 그려진 경로가 되며, 선택 필드는 선택된 항목을 렌더링합니다. 그런 다음 /AcroForm 카탈로그 항목을 제거합니다. 그 결과물은 비대화형 양식, 즉 어떤 리더에서도 동일하게 표시되며 양식 채우기 기능이 필요하지 않은 필드의 정적 표현입니다(ISO 32000-2 12.7).
두 가지 사실이 프로덕션 패턴의 기반이 됩니다.
-
core는 필드 값을 검증하지 않습니다. 각 작성 메서드(
textField(),comboBox(),checkBox()등)는 전달한 값을 그대로V에 기록합니다. 이메일 형식, 허용된 옵션에 포함되는지, 필수 필드의 존재 여부는 모두 애플리케이션에서 처리해야 합니다. 작성하기 전에 검증하고, 잘못된 값이 고정된 문서를 생성하지 말고 규칙 위반이 있으면 빠르게 실패하십시오. -
평탄화는 되돌릴 수 없으며 문서 전체를 대상으로 합니다.
flattenForms()와save()를 호출하고 나면 필드는 정적 그래픽이 됩니다. 편집 가능한 사본도 유지하려면 평탄화를 되돌리는 것이 아니라,flattenForms()를 호출하지 않고 검증된 데이터셋을 다시 한 번 렌더링합니다. 두 사본 모두 동일한 검증된 값에서 시작하므로, 잠긴 사본과 편집 가능한 사본은 동일한 필드 상태를 갖습니다.
재현성 프로파일은 structural입니다. 각 문서에는 두 실행을 비교하기 전에 후처리 단계에서 정규화하는 트레일러 /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를 빌드하고 기록합니다. 출력 경로가 스트림 래퍼이거나, 널 바이트를 포함하거나, 존재하지 않는 상위 디렉터리를 가리키는 경우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에 기록합니다.save()시점에서 잘못된 형식의 값을 거부하는 core 훅은 없으므로, 검증을 건너뛴 값은 되돌릴 방법 없이 평탄화된 사본에 고정됩니다. flattenForms()는 전부 아니면 전무입니다. 문서의 모든 필드를 평탄화합니다. 일부 필드를 편집 가능한 상태로 유지하려면, 프로덕션 샘플처럼 평탄화 호출 없이 두 번째 문서를 렌더링하십시오. core API에 필드별 스위치가 있을 것이라고 기대하지 마십시오.- 호출 순서. 먼저 모든 필드를 작성한 다음
flattenForms(), 그다음save()를 호출하십시오. 필드가 없는 상태에서flattenForms()를 호출해도 안전하게 아무 작업도 하지 않으며,save()이후에 호출하면 이미 기록된 바이트에는 아무런 영향을 주지 않습니다. - 필드 이름은 고유해야 합니다. 이름을 공유하는 두 필드는 규격을 준수하는 리더에서 값을 공유하는 하나의 논리적 필드가 됩니다. 데이터에서 이름을 생성하는 경우 고유성을 검증하십시오.
- 평탄화 시 확인란의 참 여부. 평탄화된 확인란은 값이
Yes,On,1, 또는true일 때 체크 표시를 렌더링하고, 비어 있거나Off인 값일 때는 상자만 그립니다. 렌더링된 상태가 검증된 값과 일치하도록checkBox()에 실제 불리언을 전달하십시오. - 서명 필드는 절대 평탄화되지 않습니다.
/Sig필드의 외관은 서명 페이로드에서 생성된 것이며 다시 렌더링할 수 있는 값이 아니므로, 평탄화 도구는 이를 건너뜁니다. 서명한 후가 아니라 서명하기 전에 평탄화하십시오. - 편집 가능한 사본은 여전히 편집 가능합니다. 편집 가능한 사본에서 상태를 보존한다는 것은 수신자가 그 값을 변경할 수 있다는 의미입니다. 잠긴 사본을 권위 있는 기록으로, 편집 가능한 사본을 작업용 초안으로 취급하십시오.
검증은 필드 수에 선형적으로 비례하며 데이터셋당 한 번 실행됩니다. 각 렌더링은 필드당 하나의 위젯 주석(및 외관)을 작성하며, 평탄화 단계는 필드마다 크기가 제한된 콘텐츠 블록을 추가합니다. 데이터셋을 두 번 렌더링하면 문서당 비용이 대략 두 배가 되지만, 수백 개의 필드로 구성된 양식의 경우에도 여전히 1500 ms / 64 MB 예산 내에 충분히 들어옵니다. 출력이 하나만 필요하다면, 한 번만 렌더링하고 두 번째 패스를 건너뛰십시오.
보안 참고 사항
섹션 제목: “보안 참고 사항”- 신뢰할 수 없는 입력은 경계에서 검증하십시오. 이메일 형식, 허용된 옵션에 포함되는지, 필수 필드의 존재 여부는 core가 강제하지 않으므로 애플리케이션 코드에서 강제해야 합니다. core는 값을 문서에 그대로 기록하므로, 신뢰할 수 없는 입력에서 파생된 값은 필드에 도달하기 전에 이스케이프하거나 정규화하십시오.
- 평탄화는 접근 제어가 아닙니다. 평탄화된 값은 일반 리더에서 편집할 수 없지만, 페이지 콘텐츠에 계속 표시되며 어떤 텍스트 도구로도 추출할 수 있습니다. 평탄화를 삭제 처리(redaction)나 민감한 값의 보호로 취급하지 마십시오.
- 편집 가능한 사본은 동일한 데이터를 지닙니다. 해당 값을 보고 변경해도 되는 당사자에게만 배포하십시오. 콘텐츠가 민감한 경우, 어느 사본이든 권한과 함께 암호화하기를 함께 적용하고, 거기에서 설명하는 리더 협조형 주의 사항에 유의하십시오. 권한 비트는 읽기 제한을 강제하지 않습니다.
- 실패는 닫힌 방식으로 처리하십시오. 프로덕션 샘플은 부분적이거나 유효하지 않은 문서를 기록하기보다는 검증 또는 출력 실패 시 0이 아닌 값으로 종료합니다. 이러한 예외를 절대 묵살하지 마십시오.
규격 준수
섹션 제목: “규격 준수”| 진술 | 사양 | 조항 | reference_id |
|---|---|---|---|
| 평탄화된 양식은 필드의 비대화형(정적) 표현입니다. | ISO 32000-2 | 12.7 |
NextPDF는 인용된 조항에 설명된 정적 구조를 생성하며, ISO 32000-2 전반에 대한 준수를 주장하지는 않습니다. 이 레시피의 검증 규칙은 애플리케이션 정책이며, 표준 준수 요건이 아닙니다.
참고 항목
섹션 제목: “참고 항목”- PDF 양식 구축 및 사전 채우기 — 필드를 작성하고 초깃값을 설정합니다.
- 양식 필드 평탄화 — 이 레시피가 기반으로 삼는 문서 전체 평탄화 흐름입니다.
- NextPDF 예외 계층 구조로 오류 처리하기 — 적절한 경계에서 실패를 포착합니다.
- 권한과 함께 암호화하기 — 양식 데이터가 민감한 경우 기밀성을 추가합니다.
- 양식 모듈 — 양식 필드 레퍼런스입니다.