# Design Spec: plusforta/pdf-forms

**Date:** 2026-04-04
**Status:** Draft
**Package:** `plusforta/pdf-forms`
**Namespace:** `PlusForta\PdfForms`

## Goal

A pure PHP library (no external binary dependencies) that fills form fields in existing PDF documents. Replaces the current pdftk-based workflow with a native Composer package.

## Constraints

- PHP 8.1+
- Zero Composer dependencies
- Standalone package, publishable to Packagist
- Primary test case: `reservation_kfde_07_2023.pdf` (kautionsfrei.de rental application with AcroForm text fields in a hierarchical field tree)

## Supported Field Types

Full AcroForm support:

- Text fields (`/Tx`)
- Checkboxes (`/Btn` with `/AS`)
- Radio buttons (`/Btn` with `/Ff` radio flag)
- Dropdowns/combo boxes (`/Ch`)
- Signature fields (`/Sig`) — presence detection, not cryptographic signing

XFA (XML Forms Architecture) is out of scope for the initial implementation, but the architecture includes a seam (`FormReaderInterface`) to add it later.

## Public API

Fluent interface on `PdfForm`:

```php
use PlusForta\PdfForms\PdfForm;

// Fill and save to file
PdfForm::open('template.pdf')
    ->fill([
        'tenant.address' => '123 Main St',
        'tenant.email' => 'john@example.com',
        'date' => '2024-01-15',
    ])
    ->flatten()
    ->save('output.pdf');

// Return as string (e.g. for HTTP response)
$pdfBytes = PdfForm::open('template.pdf')
    ->fill(['tenant.address' => '123 Main St'])
    ->toString();

// Set individual fields
PdfForm::open('template.pdf')
    ->set('tenant.address', '123 Main St')
    ->set('date', '2024-01-15')
    ->save('output.pdf');

// Inspect available fields
$fields = PdfForm::open('template.pdf')->getFields();
// Returns: ['tenant.address' => Field(name: 'tenant.address', type: FieldType::Text, value: ''), ...]

// Load from raw bytes (e.g. S3 or upload)
$pdfBytes = file_get_contents('s3://bucket/template.pdf');
PdfForm::load($pdfBytes)
    ->fill(['date' => '2024-01-15'])
    ->save('output.pdf');

// Checkboxes, radio buttons, dropdowns
PdfForm::open('template.pdf')
    ->fill([
        'agreeToTerms' => true,
        'paymentMethod' => 'credit',
        'country' => 'DE',
    ])
    ->save('output.pdf');
```

Key behaviors:

- `fill()` accepts an associative array, calls `set()` for each entry
- `set()` sets a single field value
- `flatten()` bakes field values into the PDF as static content (no longer editable). **Note:** requires appearance stream generation (Phase 2). In Phase 1, calling `flatten()` throws `\BadMethodCallException` with a descriptive message.
- `save()` writes to a file path
- `toString()` returns the PDF bytes as a string
- `getFields()` returns `Field` value objects keyed by fully-qualified field name
- Referencing a nonexistent field name throws `FieldNotFoundException`
- `PdfForm::load(string $bytes)` accepts raw PDF bytes (e.g. from S3 or an upload) as an alternative to `open()`

## Architecture

```
PdfForm (public API, fluent interface)
  ├── PdfParser        (reads PDF binary structure)
  │     └── PdfObject  (value type for PDF objects)
  ├── FormReaderInterface
  │     └── AcroFormReader  (AcroForm field discovery)
  └── PdfWriter        (incremental update output)
```

### PdfParser

Reads the PDF binary format:

- Parses the cross-reference table (`xref`) and trailer
- Resolves indirect object references
- Produces a map of object number to `PdfObject`
- Handles both classic xref tables and cross-reference streams (compressed xref streams use Flate decoding and binary-packed format — required for PDF 1.5+ files)
- Accepts both a file path (via `PdfForm::open()`) and raw bytes (via `PdfForm::load()`) — internally always works on a byte string

### PdfObject

Passive value object representing a single PDF object. `PdfParser` is responsible for resolving indirect references; `PdfObject` only holds data.

Handles:

- Dictionaries, arrays, strings (literal and hex), names, numbers, booleans, streams
- PDF string encodings: `PDFDocEncoding`, UTF-16BE (with BOM detection), hex strings

### FormReaderInterface

```php
interface FormReaderInterface
{
    /** @return Field[] */
    public function getFields(): array;

    /** @throws FieldNotFoundException */
    public function getField(string $name): Field;

    public function hasField(string $name): bool;
}
```

This interface exists as a seam for future XFA support. The initial implementation is `AcroFormReader`.

### AcroFormReader

Implements `FormReaderInterface`. Traverses the PDF object tree:

1. Finds `/AcroForm` in the document catalog
2. Walks the `/Fields` array
3. Resolves hierarchical field names (dot-separated via `/T` entries)
4. Reads field type (`/FT`), current value (`/V`), options (`/Opt`), flags (`/Ff`)
5. Returns `Field` value objects

### Field

Value object representing a single form field:

- `name` — fully qualified dot-notation name (e.g. `tenant.address`)
- `type` — `FieldType` backed string enum: `text`, `checkbox`, `radio`, `dropdown`, `listbox`, `signature`
- `value` — current value (string, bool, or null)
- `options` — for radio/dropdown: available choices
- `objectNumber` — internal reference for the writer

### PdfWriter

Takes the original PDF bytes and a set of modified objects. Produces a valid **incremental update**:

1. Appends rewritten objects (field values updated)
2. Writes a new cross-reference table
3. Writes a new trailer with `/Prev` pointer to the original xref

This approach never rewrites the original PDF content — it only appends.

### Appearance Streams and Phasing

PDF viewers render what is in the `/AP` (appearance stream), not the `/V` (value) entry directly. This creates a two-phase implementation strategy:

**Phase 1 — `/NeedAppearances` flag:**
When filling fields, the writer sets `/NeedAppearances true` in the `/AcroForm` dictionary and updates each field's `/V`. Conformant PDF viewers (Adobe Reader, most desktop apps, Preview.app) regenerate appearance streams on open. This is reliable for the primary use case (generating PDFs that are opened in a viewer or printed).

Known limitation: some lightweight renderers (Chrome's built-in viewer, some server-side renderers) may show blank fields because they do not honor `/NeedAppearances`.

**Phase 2 — Appearance stream generation:**
Build `/AP` content streams for each modified field. This requires:
- Parsing the field's `/DA` (default appearance) string for font name, size, and color
- Resolving font resources from the field, page, or `/AcroForm /DR` (default resources)
- Emitting text-drawing operators (`BT ... ET`) with correct positioning
- Handling multi-widget fields (a single field value rendered on multiple pages)

Phase 2 unlocks universal viewer support and flattening.

### Flattening (Phase 2)

Requires appearance streams to exist (Phase 2). The process:

1. For each field widget, generate the appearance stream if not already present
2. Merge each widget's appearance stream into the page content stream (via `Do` operator referencing the appearance XObject)
3. Remove the widget annotations from the page's `/Annots` array
4. Remove the `/AcroForm` entry from the document catalog

In Phase 1, calling `flatten()` throws `\BadMethodCallException` with a message explaining that appearance stream generation is required.

### Multi-Widget Fields

Some fields have multiple widget annotations (e.g. `date` in the test PDF appears on two pages). When setting a value, the library updates `/V` at the field level. When generating appearance streams (Phase 2), all child widgets are updated.

## Error Handling

```
PdfFormsException (base)
  ├── FileNotFoundException      — input file doesn't exist
  ├── InvalidPdfException        — can't parse the PDF structure
  ├── NoFormFieldsException      — PDF has no AcroForm dictionary
  ├── FieldNotFoundException     — caller references a nonexistent field
  └── WriteException             — can't write output file
```

No silent failures. Unknown field names always throw.

## Limitations

- **Encrypted/password-protected PDFs** are not supported. The parser will throw `InvalidPdfException` if encryption is detected.
- **Linearized (web-optimized) PDFs** can be read and filled, but the linearization structure is not preserved in the output. The output remains a valid PDF.
- **XFA forms** are not supported in the initial implementation. The `FormReaderInterface` seam exists for future support.
- **Appearance stream generation** is Phase 2. Phase 1 relies on `/NeedAppearances`, which may not render in all viewers.

## Testing Strategy

- **Unit tests** (PHPUnit) for `PdfParser`, `PdfObject`, `AcroFormReader`, and `PdfWriter` individually
- **Integration test** using the real `reservation_kfde_07_2023.pdf`:
  - Fill all fields, save, re-parse output, assert `/V` values were set
  - Fill, save, re-open, read values back (round-trip)
  - Verify `/NeedAppearances` is set in the output
  - (Phase 2) Fill + flatten, verify output has no remaining form fields
- **Test fixtures:** the sample PDF ships in `tests/fixtures/`; synthetic minimal PDFs added later for checkbox/radio/dropdown edge cases

## Project Structure

```
plusforta/pdf-forms/
├── composer.json
├── src/
│   ├── PdfForm.php
│   ├── Parser/
│   │   ├── PdfParser.php
│   │   └── PdfObject.php
│   ├── Form/
│   │   ├── FormReaderInterface.php
│   │   ├── AcroFormReader.php
│   │   ├── Field.php
│   │   └── FieldType.php
│   ├── Writer/
│   │   └── PdfWriter.php
│   └── Exception/
│       ├── PdfFormsException.php
│       ├── FileNotFoundException.php
│       ├── InvalidPdfException.php
│       ├── NoFormFieldsException.php
│       ├── FieldNotFoundException.php
│       └── WriteException.php
├── tests/
│   ├── Unit/
│   │   ├── PdfParserTest.php
│   │   ├── PdfObjectTest.php
│   │   ├── AcroFormReaderTest.php
│   │   └── PdfWriterTest.php
│   ├── Integration/
│   │   └── FillPdfTest.php
│   └── fixtures/
│       └── reservation_kfde_07_2023.pdf
└── phpunit.xml
```
