# plusforta/pdf-forms Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build a pure PHP library that fills AcroForm fields in existing PDFs using incremental updates, with no external binary dependencies.

**Architecture:** Parse the PDF binary structure (xref table, trailer, object tree) to locate AcroForm fields. Modify field `/V` values and set `/NeedAppearances true`. Append changes via incremental update (new objects + new xref + new trailer). Fluent public API on `PdfForm`.

**Tech Stack:** PHP 8.1+, PHPUnit 10, Composer, zlib extension (for FlateDecode streams)

**Spec:** `docs/superpowers/specs/2026-04-04-pdf-forms-design.md`

---

## File Map

| File | Responsibility |
|------|---------------|
| `composer.json` | Package metadata, autoload, dev dependencies |
| `phpunit.xml` | PHPUnit configuration |
| `src/PdfForm.php` | Public fluent API — `open()`, `load()`, `fill()`, `set()`, `flatten()`, `save()`, `toString()`, `getFields()` |
| `src/Parser/PdfParser.php` | Binary PDF parsing — xref table, trailer, object resolution, indirect references |
| `src/Parser/PdfObject.php` | Passive value object for PDF objects (dict, array, string, number, bool, stream, name, null) |
| `src/Form/FormReaderInterface.php` | Interface seam for form readers (`getFields()`, `getField()`, `hasField()`) |
| `src/Form/AcroFormReader.php` | AcroForm field discovery — walks `/AcroForm` → `/Fields` tree, resolves hierarchical names |
| `src/Form/Field.php` | Value object: name, type, value, options, objectNumber, widgetObjectNumbers |
| `src/Form/FieldType.php` | Backed string enum: text, checkbox, radio, dropdown, listbox, signature |
| `src/Writer/PdfWriter.php` | Incremental update writer — modified objects + new xref + new trailer |
| `src/Exception/PdfFormsException.php` | Base exception |
| `src/Exception/FileNotFoundException.php` | Input file missing |
| `src/Exception/InvalidPdfException.php` | Unparseable PDF or encrypted PDF detected |
| `src/Exception/NoFormFieldsException.php` | No `/AcroForm` dictionary |
| `src/Exception/FieldNotFoundException.php` | Caller references nonexistent field |
| `src/Exception/WriteException.php` | Output write failure |
| `tests/Unit/PdfParserTest.php` | Parser unit tests |
| `tests/Unit/PdfObjectTest.php` | PdfObject unit tests |
| `tests/Unit/AcroFormReaderTest.php` | Form reader unit tests |
| `tests/Unit/PdfWriterTest.php` | Writer unit tests |
| `tests/Integration/FillPdfTest.php` | End-to-end tests against real PDF |
| `tests/fixtures/reservation_kfde_07_2023.pdf` | Test fixture (copy from project root) |
| `tests/fixtures/minimal_form.pdf` | Tiny synthetic PDF for unit tests (hand-crafted) |

---

## Reference: Test PDF Structure

The test PDF (`reservation_kfde_07_2023.pdf`) has this internal structure, needed throughout:

- **PDF 1.7**, classic xref table, with one incremental update
- **Trailer:** `/Root 115 0 R`, `/Prev 434650` (points to original xref)
- **AcroForm** (object 116): `/DR<</Font<</OpenSans 102 0 R/OpenSans-Light 103 0 R/OpenSans-Semibold 104 0 R>>>>/Fields 95 0 R`
- **Fields array** (object 95): `[96 0 R 97 0 R 98 0 R]` — three top-level field nodes

**Field hierarchy:**
```
95: Fields = [96, 97, 98]
├── 96: /T(tenant) — non-terminal, Kids [130, 138, 101, 56]
│   ├── 130: Widget /T(address)    → fully qualified: "tenant.address"     /DA(/OpenSans-Light 7 Tf)   Page 1
│   ├── 138: Widget /T(email)      → fully qualified: "tenant.email"       /DA(/OpenSans 8 Tf)         Page 1
│   ├── 101: /T(salutation), Kids [132, 58] — has widgets on both pages
│   │   ├── 132: Widget (no /T)    → fully qualified: "tenant.salutation"  /DA(/OpenSans 8 Tf)         Page 1
│   │   └── 58:  Widget (no /T)    → fully qualified: "tenant.salutation"  /DA(/OpenSans-Semibold 14)  Page 2
│   └── 56:  Widget /T(dateOfBirth) → fully qualified: "tenant.dateOfBirth" /DA(/OpenSans-Semibold 14)  Page 2
├── 97: /T(address) — non-terminal, Kids [99, 100]
│   ├── 99: /T(street), Kids [134, 72]
│   │   ├── 134: Widget (no /T)    → fully qualified: "address.street"     /DA(/OpenSans 8 Tf)         Page 1
│   │   └── 72:  Widget (no /T)    → fully qualified: "address.street"     /DA(/OpenSans-Semibold 14)  Page 2
│   └── 100: /T(zipAndCity), Kids [136, 66]
│       ├── 136: Widget (no /T)    → fully qualified: "address.zipAndCity" /DA(/OpenSans 8 Tf)         Page 1
│       └── 66:  Widget (no /T)    → fully qualified: "address.zipAndCity" /DA(/OpenSans-Semibold 14)  Page 2
└── 98: /T(date), /FT/Tx — terminal with two widgets
    ├── 128: Widget (no /T)        → fully qualified: "date"               /DA(/OpenSans 8 Tf)         Page 1
    └── 61:  Widget (no /T)        → fully qualified: "date"               /DA(/OpenSans 10 Tf)        Page 2
```

**Unique fillable fields (7 total):**
`date`, `tenant.address`, `tenant.email`, `tenant.salutation`, `tenant.dateOfBirth`, `address.street`, `address.zipAndCity`

---

### Task 1: Project Scaffolding

**Files:**
- Create: `composer.json`
- Create: `phpunit.xml`
- Copy: `tests/fixtures/reservation_kfde_07_2023.pdf` (from project root)

- [ ] **Step 1: Create `composer.json`**

```json
{
    "name": "plusforta/pdf-forms",
    "description": "Pure PHP library for filling AcroForm fields in PDF documents",
    "type": "library",
    "license": "MIT",
    "minimum-stability": "stable",
    "require": {
        "php": "^8.1",
        "ext-zlib": "*"
    },
    "require-dev": {
        "phpunit/phpunit": "^10.0"
    },
    "autoload": {
        "psr-4": {
            "PlusForta\\PdfForms\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "PlusForta\\PdfForms\\Tests\\": "tests/"
        }
    }
}
```

- [ ] **Step 2: Create `phpunit.xml`**

```xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true"
         testdox="true">
    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="Integration">
            <directory>tests/Integration</directory>
        </testsuite>
    </testsuites>
    <source>
        <include>
            <directory>src</directory>
        </include>
    </source>
</phpunit>
```

- [ ] **Step 3: Create `.gitignore`**

```
/vendor/
/output_test.pdf
.phpunit.result.cache
```

- [ ] **Step 4: Copy test fixture**

```bash
mkdir -p tests/fixtures
cp reservation_kfde_07_2023.pdf tests/fixtures/
```

- [ ] **Step 5: Create directory structure**

```bash
mkdir -p src/Parser src/Form src/Writer src/Exception tests/Unit tests/Integration
```

- [ ] **Step 6: Run `composer install`**

```bash
herd composer install
```

Expected: `vendor/` directory created, autoloader generated, PHPUnit installed.

- [ ] **Step 7: Verify PHPUnit runs**

```bash
herd php vendor/bin/phpunit --version
```

Expected: Prints PHPUnit version (10.x).

- [ ] **Step 8: Commit**

```bash
git add .gitignore composer.json composer.lock phpunit.xml tests/fixtures/reservation_kfde_07_2023.pdf
git commit -m "Scaffold plusforta/pdf-forms package"
```

---

### Task 2: Exception Classes + FieldType Enum

**Files:**
- Create: `src/Exception/PdfFormsException.php`
- Create: `src/Exception/FileNotFoundException.php`
- Create: `src/Exception/InvalidPdfException.php`
- Create: `src/Exception/NoFormFieldsException.php`
- Create: `src/Exception/FieldNotFoundException.php`
- Create: `src/Exception/WriteException.php`
- Create: `src/Form/FieldType.php`
- Create: `src/Form/Field.php`
- Create: `src/Form/FormReaderInterface.php`

These are all simple value types with no logic to TDD. Create them directly and verify they compile.

- [ ] **Step 1: Create `src/Exception/PdfFormsException.php`**

```php
<?php

declare(strict_types=1);

namespace PlusForta\PdfForms\Exception;

use RuntimeException;

class PdfFormsException extends RuntimeException
{
}
```

- [ ] **Step 2: Create the five child exception classes**

Each extends `PdfFormsException`. Same pattern for all five:

`FileNotFoundException.php`:
```php
<?php

declare(strict_types=1);

namespace PlusForta\PdfForms\Exception;

class FileNotFoundException extends PdfFormsException
{
}
```

`InvalidPdfException.php`:
```php
<?php

declare(strict_types=1);

namespace PlusForta\PdfForms\Exception;

class InvalidPdfException extends PdfFormsException
{
}
```

`NoFormFieldsException.php`:
```php
<?php

declare(strict_types=1);

namespace PlusForta\PdfForms\Exception;

class NoFormFieldsException extends PdfFormsException
{
}
```

`FieldNotFoundException.php`:
```php
<?php

declare(strict_types=1);

namespace PlusForta\PdfForms\Exception;

class FieldNotFoundException extends PdfFormsException
{
}
```

`WriteException.php`:
```php
<?php

declare(strict_types=1);

namespace PlusForta\PdfForms\Exception;

class WriteException extends PdfFormsException
{
}
```

- [ ] **Step 3: Create `src/Form/FieldType.php`**

```php
<?php

declare(strict_types=1);

namespace PlusForta\PdfForms\Form;

enum FieldType: string
{
    case Text = 'text';
    case Checkbox = 'checkbox';
    case Radio = 'radio';
    case Dropdown = 'dropdown';
    case Listbox = 'listbox';
    case Signature = 'signature';
}
```

- [ ] **Step 4: Create `src/Form/Field.php`**

```php
<?php

declare(strict_types=1);

namespace PlusForta\PdfForms\Form;

final class Field
{
    /**
     * @param string $name Fully qualified dot-notation name
     * @param FieldType $type Field type
     * @param string|bool|null $value Current value
     * @param string[] $options Available choices (radio/dropdown/listbox)
     * @param int $objectNumber PDF object number for the field node
     * @param int[] $widgetObjectNumbers PDF object numbers for widget annotations
     */
    public function __construct(
        public readonly string $name,
        public readonly FieldType $type,
        public readonly string|bool|null $value = null,
        public readonly array $options = [],
        public readonly int $objectNumber = 0,
        public readonly array $widgetObjectNumbers = [],
    ) {
    }
}
```

- [ ] **Step 5: Create `src/Form/FormReaderInterface.php`**

```php
<?php

declare(strict_types=1);

namespace PlusForta\PdfForms\Form;

use PlusForta\PdfForms\Exception\FieldNotFoundException;

interface FormReaderInterface
{
    /** @return array<string, Field> */
    public function getFields(): array;

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

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

- [ ] **Step 6: Verify all files compile**

```bash
herd php -l src/Exception/PdfFormsException.php
herd php -l src/Exception/FileNotFoundException.php
herd php -l src/Exception/InvalidPdfException.php
herd php -l src/Exception/NoFormFieldsException.php
herd php -l src/Exception/FieldNotFoundException.php
herd php -l src/Exception/WriteException.php
herd php -l src/Form/FieldType.php
herd php -l src/Form/Field.php
herd php -l src/Form/FormReaderInterface.php
```

Expected: All print "No syntax errors detected".

- [ ] **Step 7: Commit**

```bash
git add src/Exception/ src/Form/FieldType.php src/Form/Field.php src/Form/FormReaderInterface.php
git commit -m "Add exception classes, FieldType enum, Field, FormReaderInterface"
```

---

### Task 3: PdfObject Value Type

**Files:**
- Create: `src/Parser/PdfObject.php`
- Create: `tests/Unit/PdfObjectTest.php`

`PdfObject` is a passive value object that represents any PDF object type. It wraps a typed value and provides accessors. The parser will create these; the writer will read them.

- [ ] **Step 1: Write the failing test**

Create `tests/Unit/PdfObjectTest.php`:

```php
<?php

declare(strict_types=1);

namespace PlusForta\PdfForms\Tests\Unit;

use PHPUnit\Framework\TestCase;
use PlusForta\PdfForms\Parser\PdfObject;

class PdfObjectTest extends TestCase
{
    public function testCreateDictionary(): void
    {
        $obj = PdfObject::dictionary([
            'Type' => PdfObject::name('Catalog'),
            'Pages' => PdfObject::reference(2, 0),
        ]);

        $this->assertTrue($obj->isDictionary());
        $this->assertFalse($obj->isArray());
        $this->assertEquals('Catalog', $obj->get('Type')->value());
    }

    public function testCreateArray(): void
    {
        $obj = PdfObject::array([
            PdfObject::reference(1, 0),
            PdfObject::reference(2, 0),
        ]);

        $this->assertTrue($obj->isArray());
        $this->assertCount(2, $obj->value());
    }

    public function testCreateString(): void
    {
        $obj = PdfObject::string('Hello');
        $this->assertTrue($obj->isString());
        $this->assertEquals('Hello', $obj->value());
    }

    public function testCreateHexString(): void
    {
        $obj = PdfObject::hexString('48656C6C6F');
        $this->assertTrue($obj->isString());
        $this->assertEquals('Hello', $obj->value());
    }

    public function testCreateName(): void
    {
        $obj = PdfObject::name('Catalog');
        $this->assertTrue($obj->isName());
        $this->assertEquals('Catalog', $obj->value());
    }

    public function testCreateNumber(): void
    {
        $obj = PdfObject::number(42);
        $this->assertTrue($obj->isNumber());
        $this->assertEquals(42, $obj->value());

        $float = PdfObject::number(3.14);
        $this->assertEquals(3.14, $float->value());
    }

    public function testCreateBoolean(): void
    {
        $obj = PdfObject::boolean(true);
        $this->assertTrue($obj->isBoolean());
        $this->assertTrue($obj->value());
    }

    public function testCreateNull(): void
    {
        $obj = PdfObject::null();
        $this->assertTrue($obj->isNull());
        $this->assertNull($obj->value());
    }

    public function testCreateReference(): void
    {
        $obj = PdfObject::reference(10, 0);
        $this->assertTrue($obj->isReference());
        $this->assertEquals(10, $obj->objectNumber());
        $this->assertEquals(0, $obj->generationNumber());
    }

    public function testCreateStream(): void
    {
        $dict = PdfObject::dictionary(['Length' => PdfObject::number(5)]);
        $obj = PdfObject::stream($dict, 'Hello');
        $this->assertTrue($obj->isStream());
        $this->assertEquals('Hello', $obj->streamData());
    }

    public function testDictionaryGet(): void
    {
        $obj = PdfObject::dictionary([
            'Name' => PdfObject::string('test'),
        ]);

        $this->assertNotNull($obj->get('Name'));
        $this->assertNull($obj->get('Missing'));
    }

    public function testDictionarySet(): void
    {
        $obj = PdfObject::dictionary([
            'Name' => PdfObject::string('old'),
        ]);

        $updated = $obj->set('Name', PdfObject::string('new'));
        $this->assertEquals('new', $updated->get('Name')->value());
        // Original unchanged (immutable)
        $this->assertEquals('old', $obj->get('Name')->value());
    }

    public function testEncodeString(): void
    {
        $obj = PdfObject::string('Hello');
        $this->assertEquals('(Hello)', $obj->encode());
    }

    public function testEncodeStringEscapesSpecialChars(): void
    {
        $obj = PdfObject::string('Hello (world)');
        $this->assertEquals('(Hello \\(world\\))', $obj->encode());
    }

    public function testEncodeName(): void
    {
        $obj = PdfObject::name('Type');
        $this->assertEquals('/Type', $obj->encode());
    }

    public function testEncodeNumber(): void
    {
        $this->assertEquals('42', PdfObject::number(42)->encode());
        $this->assertEquals('3.14', PdfObject::number(3.14)->encode());
    }

    public function testEncodeBoolean(): void
    {
        $this->assertEquals('true', PdfObject::boolean(true)->encode());
        $this->assertEquals('false', PdfObject::boolean(false)->encode());
    }

    public function testEncodeNull(): void
    {
        $this->assertEquals('null', PdfObject::null()->encode());
    }

    public function testEncodeReference(): void
    {
        $this->assertEquals('10 0 R', PdfObject::reference(10, 0)->encode());
    }

    public function testEncodeDictionary(): void
    {
        $obj = PdfObject::dictionary([
            'Type' => PdfObject::name('Catalog'),
        ]);
        $this->assertEquals('<</Type /Catalog>>', $obj->encode());
    }

    public function testEncodeArray(): void
    {
        $obj = PdfObject::array([
            PdfObject::number(1),
            PdfObject::number(2),
        ]);
        $this->assertEquals('[1 2]', $obj->encode());
    }

    public function testUtf16BeStringDecoding(): void
    {
        // UTF-16BE BOM + "AB" encoded as UTF-16BE
        $utf16 = "\xFE\xFF\x00\x41\x00\x42";
        $obj = PdfObject::string($utf16);
        $this->assertEquals('AB', $obj->value());
    }

    public function testEncodeNameWithSpecialChars(): void
    {
        $obj = PdfObject::name('Name With Space');
        $encoded = $obj->encode();
        $this->assertStringContainsString('#20', $encoded);
        $this->assertStringStartsWith('/Name', $encoded);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

```bash
herd php vendor/bin/phpunit tests/Unit/PdfObjectTest.php
```

Expected: FAIL — class `PdfObject` not found.

- [ ] **Step 3: Implement `src/Parser/PdfObject.php`**

```php
<?php

declare(strict_types=1);

namespace PlusForta\PdfForms\Parser;

final class PdfObject
{
    private const TYPE_DICTIONARY = 'dictionary';
    private const TYPE_ARRAY = 'array';
    private const TYPE_STRING = 'string';
    private const TYPE_NAME = 'name';
    private const TYPE_NUMBER = 'number';
    private const TYPE_BOOLEAN = 'boolean';
    private const TYPE_NULL = 'null';
    private const TYPE_REFERENCE = 'reference';
    private const TYPE_STREAM = 'stream';

    private function __construct(
        private readonly string $type,
        private readonly mixed $data,
        private readonly ?self $streamDict = null,
        private readonly ?string $rawStreamData = null,
        private readonly int $objNumber = 0,
        private readonly int $genNumber = 0,
    ) {
    }

    // --- Factory methods ---

    /** @param array<string, self> $entries */
    public static function dictionary(array $entries): self
    {
        return new self(self::TYPE_DICTIONARY, $entries);
    }

    /** @param self[] $items */
    public static function array(array $items): self
    {
        return new self(self::TYPE_ARRAY, $items);
    }

    public static function string(string $value): self
    {
        // Detect UTF-16BE BOM and convert to UTF-8
        if (str_starts_with($value, "\xFE\xFF")) {
            $value = mb_convert_encoding(substr($value, 2), 'UTF-8', 'UTF-16BE');
        }
        return new self(self::TYPE_STRING, $value);
    }

    public static function hexString(string $hex): self
    {
        $decoded = hex2bin(str_replace([' ', "\r", "\n"], '', $hex));
        if ($decoded === false) {
            $decoded = '';
        }
        // Route through string() to get UTF-16BE BOM detection
        return self::string($decoded);
    }

    public static function name(string $value): self
    {
        return new self(self::TYPE_NAME, $value);
    }

    public static function number(int|float $value): self
    {
        return new self(self::TYPE_NUMBER, $value);
    }

    public static function boolean(bool $value): self
    {
        return new self(self::TYPE_BOOLEAN, $value);
    }

    public static function null(): self
    {
        return new self(self::TYPE_NULL, null);
    }

    public static function reference(int $objectNumber, int $generationNumber): self
    {
        return new self(self::TYPE_REFERENCE, null, objNumber: $objectNumber, genNumber: $generationNumber);
    }

    public static function stream(self $dictionary, string $data): self
    {
        return new self(self::TYPE_STREAM, null, streamDict: $dictionary, rawStreamData: $data);
    }

    // --- Type checks ---

    public function isDictionary(): bool { return $this->type === self::TYPE_DICTIONARY; }
    public function isArray(): bool { return $this->type === self::TYPE_ARRAY; }
    public function isString(): bool { return $this->type === self::TYPE_STRING; }
    public function isName(): bool { return $this->type === self::TYPE_NAME; }
    public function isNumber(): bool { return $this->type === self::TYPE_NUMBER; }
    public function isBoolean(): bool { return $this->type === self::TYPE_BOOLEAN; }
    public function isNull(): bool { return $this->type === self::TYPE_NULL; }
    public function isReference(): bool { return $this->type === self::TYPE_REFERENCE; }
    public function isStream(): bool { return $this->type === self::TYPE_STREAM; }

    // --- Accessors ---

    public function value(): mixed
    {
        return $this->data;
    }

    public function objectNumber(): int
    {
        return $this->objNumber;
    }

    public function generationNumber(): int
    {
        return $this->genNumber;
    }

    public function streamData(): ?string
    {
        return $this->rawStreamData;
    }

    public function streamDictionary(): ?self
    {
        return $this->streamDict;
    }

    /** Get a value from a dictionary by key. Returns null if key missing or not a dictionary. */
    public function get(string $key): ?self
    {
        if ($this->type !== self::TYPE_DICTIONARY) {
            return null;
        }
        return $this->data[$key] ?? null;
    }

    /** Return a new dictionary with the given key set. Immutable. */
    public function set(string $key, self $value): self
    {
        if ($this->type !== self::TYPE_DICTIONARY) {
            return $this;
        }
        $entries = $this->data;
        $entries[$key] = $value;
        return new self(self::TYPE_DICTIONARY, $entries);
    }

    /** Remove a key from a dictionary. Immutable. */
    public function remove(string $key): self
    {
        if ($this->type !== self::TYPE_DICTIONARY) {
            return $this;
        }
        $entries = $this->data;
        unset($entries[$key]);
        return new self(self::TYPE_DICTIONARY, $entries);
    }

    // --- Encoding (for PDF output) ---

    public function encode(): string
    {
        return match ($this->type) {
            self::TYPE_DICTIONARY => $this->encodeDictionary(),
            self::TYPE_ARRAY => $this->encodeArray(),
            self::TYPE_STRING => $this->encodeString(),
            self::TYPE_NAME => '/' . $this->encodeName(),
            self::TYPE_NUMBER => $this->encodeNumber(),
            self::TYPE_BOOLEAN => $this->data ? 'true' : 'false',
            self::TYPE_NULL => 'null',
            self::TYPE_REFERENCE => $this->objNumber . ' ' . $this->genNumber . ' R',
            self::TYPE_STREAM => $this->encodeStream(),
            default => '',
        };
    }

    private function encodeName(): string
    {
        $result = '';
        for ($i = 0; $i < strlen($this->data); $i++) {
            $ch = $this->data[$i];
            $ord = ord($ch);
            // Escape characters outside printable ASCII range (0x21-0x7E) and '#' itself
            if ($ord < 0x21 || $ord > 0x7E || $ch === '#') {
                $result .= '#' . strtoupper(sprintf('%02x', $ord));
            } else {
                $result .= $ch;
            }
        }
        return $result;
    }

    private function encodeDictionary(): string
    {
        $parts = [];
        foreach ($this->data as $key => $val) {
            $parts[] = self::name($key)->encode() . ' ' . $val->encode();
        }
        return '<<' . implode('', $parts) . '>>';
    }

    private function encodeArray(): string
    {
        $parts = [];
        foreach ($this->data as $item) {
            $parts[] = $item->encode();
        }
        return '[' . implode(' ', $parts) . ']';
    }

    private function encodeString(): string
    {
        // Check if string contains non-PDFDocEncoding characters (outside Latin-1)
        if (preg_match('/[^\x00-\xFF]/', $this->data) || !mb_check_encoding($this->data, 'ISO-8859-1')) {
            // Encode as UTF-16BE with BOM in a hex string
            $utf16 = "\xFE\xFF" . mb_convert_encoding($this->data, 'UTF-16BE', 'UTF-8');
            return '<' . bin2hex($utf16) . '>';
        }

        $escaped = str_replace(
            ['\\', '(', ')'],
            ['\\\\', '\\(', '\\)'],
            $this->data
        );
        return '(' . $escaped . ')';
    }

    private function encodeNumber(): string
    {
        if (is_int($this->data)) {
            return (string) $this->data;
        }
        // Avoid trailing zeros but keep decimal precision
        return rtrim(rtrim(sprintf('%.10f', $this->data), '0'), '.');
    }

    private function encodeStream(): string
    {
        $dict = $this->streamDict;
        $data = $this->rawStreamData ?? '';
        $dict = $dict->set('Length', self::number(strlen($data)));
        return $dict->encode() . "\nstream\n" . $data . "\nendstream";
    }
}
```

- [ ] **Step 4: Run tests to verify they pass**

```bash
herd php vendor/bin/phpunit tests/Unit/PdfObjectTest.php
```

Expected: All tests PASS.

- [ ] **Step 5: Commit**

```bash
git add src/Parser/PdfObject.php tests/Unit/PdfObjectTest.php
git commit -m "Add PdfObject value type with encode/decode"
```

---

### Task 4: PdfParser — Tokenizer + Object Parsing

**Files:**
- Create: `src/Parser/PdfParser.php`
- Create: `tests/Unit/PdfParserTest.php`
- Reference: `tests/fixtures/minimal_form.pdf` (created in this task)

The parser needs to: (1) find the xref table from `startxref`, (2) parse xref entries, (3) read the trailer dictionary, (4) parse individual PDF objects from their byte offsets, (5) follow the `/Prev` chain for incremental updates.

We'll build this incrementally: first a tokenizer that can parse PDF tokens from a byte string, then xref/trailer parsing, then full object resolution.

- [ ] **Step 1: Create a minimal synthetic PDF for unit tests**

Create `tests/fixtures/minimal_form.pdf` by writing a PHP script that generates a minimal valid PDF with one text field. This avoids depending on the real PDF for parser unit tests.

Create file `tests/create_minimal_pdf.php`:

```php
<?php
// Generates a minimal PDF with one AcroForm text field for unit testing.
// Run once: herd php tests/create_minimal_pdf.php

$objects = [];

// Object 1: Catalog
$objects[1] = "1 0 obj\n<</Type /Catalog /Pages 2 0 R /AcroForm 5 0 R>>\nendobj\n";

// Object 2: Pages
$objects[2] = "2 0 obj\n<</Type /Pages /Kids [3 0 R] /Count 1>>\nendobj\n";

// Object 3: Page
$objects[3] = "3 0 obj\n<</Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Annots [7 0 R]>>\nendobj\n";

// Object 4: Font
$objects[4] = "4 0 obj\n<</Type /Font /Subtype /Type1 /BaseFont /Helvetica>>\nendobj\n";

// Object 5: AcroForm
$objects[5] = "5 0 obj\n<</Fields [6 0 R] /DR <</Font <</Helv 4 0 R>>>>>>\nendobj\n";

// Object 6: Field (non-terminal with one widget kid)
$objects[6] = "6 0 obj\n<</FT /Tx /T (testField) /Kids [7 0 R]>>\nendobj\n";

// Object 7: Widget annotation
$objects[7] = "7 0 obj\n<</Type /Annot /Subtype /Widget /Parent 6 0 R /Rect [100 700 300 720] /DA (/Helv 12 Tf 0 g)>>\nendobj\n";

// Build PDF
$pdf = "%PDF-1.4\n%\xe2\xe3\xcf\xd3\n";
$offsets = [];

foreach ($objects as $num => $obj) {
    $offsets[$num] = strlen($pdf);
    $pdf .= $obj;
}

// xref
$xrefOffset = strlen($pdf);
$pdf .= "xref\n";
$pdf .= "0 " . (max(array_keys($objects)) + 1) . "\n";
$pdf .= "0000000000 65535 f \n";
for ($i = 1; $i <= max(array_keys($objects)); $i++) {
    $pdf .= sprintf("%010d 00000 n \n", $offsets[$i]);
}

// trailer
$pdf .= "trailer\n";
$pdf .= "<</Size " . (max(array_keys($objects)) + 1) . " /Root 1 0 R>>\n";
$pdf .= "startxref\n";
$pdf .= $xrefOffset . "\n";
$pdf .= "%%EOF\n";

file_put_contents(__DIR__ . '/fixtures/minimal_form.pdf', $pdf);
echo "Created minimal_form.pdf (" . strlen($pdf) . " bytes)\n";
```

Run it:
```bash
herd php tests/create_minimal_pdf.php
```

Expected: Creates `tests/fixtures/minimal_form.pdf`.

- [ ] **Step 2: Write failing tests for PdfParser**

Create `tests/Unit/PdfParserTest.php`:

```php
<?php

declare(strict_types=1);

namespace PlusForta\PdfForms\Tests\Unit;

use PHPUnit\Framework\TestCase;
use PlusForta\PdfForms\Exception\InvalidPdfException;
use PlusForta\PdfForms\Parser\PdfObject;
use PlusForta\PdfForms\Parser\PdfParser;

class PdfParserTest extends TestCase
{
    private string $minimalPdf;
    private string $realPdf;

    protected function setUp(): void
    {
        $this->minimalPdf = file_get_contents(__DIR__ . '/../fixtures/minimal_form.pdf');
        $this->realPdf = file_get_contents(__DIR__ . '/../fixtures/reservation_kfde_07_2023.pdf');
    }

    public function testParseMinimalPdf(): void
    {
        $parser = new PdfParser($this->minimalPdf);
        $trailer = $parser->getTrailer();

        $this->assertNotNull($trailer);
        $this->assertTrue($trailer->isDictionary());
        $this->assertNotNull($trailer->get('Root'));
    }

    public function testGetObject(): void
    {
        $parser = new PdfParser($this->minimalPdf);

        // Object 1 is the Catalog
        $catalog = $parser->getObject(1, 0);
        $this->assertNotNull($catalog);
        $this->assertTrue($catalog->isDictionary());
        $this->assertEquals('Catalog', $catalog->get('Type')->value());
    }

    public function testResolveReference(): void
    {
        $parser = new PdfParser($this->minimalPdf);
        $ref = PdfObject::reference(1, 0);

        $resolved = $parser->resolve($ref);
        $this->assertTrue($resolved->isDictionary());
        $this->assertEquals('Catalog', $resolved->get('Type')->value());
    }

    public function testResolveNonReference(): void
    {
        $parser = new PdfParser($this->minimalPdf);
        $str = PdfObject::string('hello');

        // resolve() on a non-reference returns the object as-is
        $this->assertSame($str, $parser->resolve($str));
    }

    public function testGetCatalog(): void
    {
        $parser = new PdfParser($this->minimalPdf);
        $catalog = $parser->getCatalog();

        $this->assertEquals('Catalog', $catalog->get('Type')->value());
        $this->assertNotNull($catalog->get('AcroForm'));
    }

    public function testGetAcroForm(): void
    {
        $parser = new PdfParser($this->minimalPdf);
        $catalog = $parser->getCatalog();
        $acroForm = $parser->resolve($catalog->get('AcroForm'));

        $this->assertTrue($acroForm->isDictionary());
        $this->assertNotNull($acroForm->get('Fields'));
    }

    public function testInvalidPdfThrows(): void
    {
        $this->expectException(InvalidPdfException::class);
        new PdfParser('not a pdf');
    }

    public function testParseRealPdf(): void
    {
        $parser = new PdfParser($this->realPdf);
        $catalog = $parser->getCatalog();

        $this->assertEquals('Catalog', $catalog->get('Type')->value());

        $acroFormRef = $catalog->get('AcroForm');
        $acroForm = $parser->resolve($acroFormRef);
        $this->assertNotNull($acroForm->get('Fields'));
    }

    public function testIncrementalUpdateXrefChain(): void
    {
        // The real PDF has an incremental update (trailer has /Prev)
        $parser = new PdfParser($this->realPdf);
        $trailer = $parser->getTrailer();

        // Verify we can access objects from both the original and incremental xref
        // Object 95 is Fields array from original xref
        $fields = $parser->getObject(95, 0);
        $this->assertTrue($fields->isArray());

        // Object 116 is AcroForm from incremental xref
        $acroForm = $parser->getObject(116, 0);
        $this->assertTrue($acroForm->isDictionary());
    }

    public function testParseFieldObjects(): void
    {
        $parser = new PdfParser($this->realPdf);

        // Object 98 is the "date" field
        $dateField = $parser->getObject(98, 0);
        $this->assertTrue($dateField->isDictionary());
        $this->assertEquals('date', $dateField->get('T')->value());
        $this->assertEquals('Tx', $dateField->get('FT')->value());
    }

    public function testEncryptedPdfThrows(): void
    {
        // Simulate an encrypted PDF by injecting /Encrypt in trailer
        // We just test that the parser checks for it
        $fakePdf = str_replace(
            '/Root 1 0 R',
            '/Root 1 0 R /Encrypt 99 0 R',
            $this->minimalPdf
        );

        $this->expectException(InvalidPdfException::class);
        $this->expectExceptionMessage('Encrypted');
        new PdfParser($fakePdf);
    }
}
```

- [ ] **Step 3: Run tests to verify they fail**

```bash
herd php vendor/bin/phpunit tests/Unit/PdfParserTest.php
```

Expected: FAIL — class `PdfParser` not found.

- [ ] **Step 4: Implement `src/Parser/PdfParser.php`**

This is the largest single file. Key responsibilities:

1. **`findStartXref()`** — scan backward from EOF for `startxref` keyword, extract xref offset
2. **`parseXrefTable()`** — parse classic xref entries (`OOOOOOOOOO GGGGG n/f`), follow `/Prev` chain
3. **`parseTrailer()`** — parse trailer dictionary
4. **`parseObjectAt()`** — parse a PDF object at a given byte offset (the core tokenizer)
5. **`getObject()`** / **`resolve()`** — look up by object number, resolve indirect references

The tokenizer in `parseObjectAt()` must handle:
- `<<...>>` dictionaries (nested)
- `[...]` arrays
- `(...)` literal strings (with `\` escapes and nested parens)
- `<...>` hex strings
- `/Name` name objects
- Numbers (int and float, positive and negative)
- `true`, `false`, `null` keywords
- `N G obj ... endobj` wrapper
- `stream\n...\nendstream` for stream objects
- `N G R` indirect references

```php
<?php

declare(strict_types=1);

namespace PlusForta\PdfForms\Parser;

use PlusForta\PdfForms\Exception\InvalidPdfException;

final class PdfParser
{
    /** @var array<string, int> Object number "N_G" => byte offset */
    private array $xrefEntries = [];

    private PdfObject $trailer;

    /** @var array<string, PdfObject> Cache of parsed objects */
    private array $objectCache = [];

    public function __construct(
        private readonly string $data,
    ) {
        if (!str_starts_with(ltrim($this->data), '%PDF-')) {
            throw new InvalidPdfException('Not a valid PDF: missing %PDF- header');
        }

        if (!extension_loaded('zlib')) {
            throw new InvalidPdfException('The zlib PHP extension is required for PDF parsing');
        }

        $this->parseXrefChain();

        if ($this->trailer->get('Encrypt') !== null) {
            throw new InvalidPdfException('Encrypted PDFs are not supported');
        }
    }

    public function getTrailer(): PdfObject
    {
        return $this->trailer;
    }

    public function getCatalog(): PdfObject
    {
        $rootRef = $this->trailer->get('Root');
        if ($rootRef === null) {
            throw new InvalidPdfException('No /Root in trailer');
        }
        return $this->resolve($rootRef);
    }

    public function getObject(int $objectNumber, int $generationNumber): ?PdfObject
    {
        $key = $objectNumber . '_' . $generationNumber;

        if (isset($this->objectCache[$key])) {
            return $this->objectCache[$key];
        }

        if (!isset($this->xrefEntries[$key])) {
            return null;
        }

        $offset = $this->xrefEntries[$key];
        $obj = $this->parseObjectAt($offset);
        $this->objectCache[$key] = $obj;
        return $obj;
    }

    public function resolve(PdfObject $obj): PdfObject
    {
        if (!$obj->isReference()) {
            return $obj;
        }

        $resolved = $this->getObject($obj->objectNumber(), $obj->generationNumber());
        if ($resolved === null) {
            return PdfObject::null();
        }
        return $resolved;
    }

    public function getRawData(): string
    {
        return $this->data;
    }

    // --- xref parsing ---

    private function parseXrefChain(): void
    {
        $startXrefOffset = $this->findStartXref();
        $xrefOffset = $this->readStartXrefValue($startXrefOffset);

        $firstTrailer = null;

        while ($xrefOffset !== null) {
            $this->parseXrefTableAt($xrefOffset);
            $trailer = $this->parseTrailerAt($xrefOffset);

            if ($firstTrailer === null) {
                $firstTrailer = $trailer;
            }

            $prev = $trailer->get('Prev');
            $xrefOffset = ($prev !== null && $prev->isNumber()) ? (int) $prev->value() : null;
        }

        $this->trailer = $firstTrailer;
    }

    private function findStartXref(): int
    {
        // Search backward from end of file for "startxref"
        $searchStart = max(0, strlen($this->data) - 1024);
        $searchArea = substr($this->data, $searchStart);
        $pos = strrpos($searchArea, 'startxref');
        if ($pos === false) {
            throw new InvalidPdfException('Cannot find startxref');
        }
        return $searchStart + $pos;
    }

    private function readStartXrefValue(int $offset): int
    {
        $chunk = substr($this->data, $offset, 100);
        if (preg_match('/startxref\s+(\d+)/', $chunk, $m)) {
            return (int) $m[1];
        }
        throw new InvalidPdfException('Cannot parse startxref value');
    }

    private function parseXrefTableAt(int $offset): void
    {
        $pos = $offset;

        // Expect "xref" — if not found, this may be a cross-reference stream (PDF 1.5+)
        if (substr($this->data, $pos, 4) !== 'xref') {
            // Check if it's an object (cross-reference stream)
            if (preg_match('/^\d+\s+\d+\s+obj/', substr($this->data, $pos, 30))) {
                throw new InvalidPdfException(
                    'Cross-reference streams (PDF 1.5+) are not yet supported. '
                    . 'This PDF uses a compressed xref format at offset ' . $pos
                );
            }
            throw new InvalidPdfException('Expected xref keyword at offset ' . $pos);
        }
        $pos += 4;
        $pos = $this->skipWhitespace($pos);

        // Parse subsections
        while ($pos < strlen($this->data)) {
            // Check if we hit "trailer"
            if (substr($this->data, $pos, 7) === 'trailer') {
                break;
            }

            // Read "startObj count"
            if (!preg_match('/(\d+)\s+(\d+)/', substr($this->data, $pos, 30), $m)) {
                break;
            }
            $startObj = (int) $m[1];
            $count = (int) $m[2];
            $pos += strlen($m[0]);
            $pos = $this->skipWhitespace($pos);

            // Read entries (20 bytes each: "OOOOOOOOOO GGGGG f|n \n")
            for ($i = 0; $i < $count; $i++) {
                $entry = substr($this->data, $pos, 20);
                $pos += 20;

                $entryOffset = (int) substr($entry, 0, 10);
                $entryGen = (int) substr($entry, 11, 5);
                $entryType = trim(substr($entry, 17, 1));

                if ($entryType === 'n') {
                    $key = ($startObj + $i) . '_' . $entryGen;
                    // Only store if not already present (most recent xref wins)
                    if (!isset($this->xrefEntries[$key])) {
                        $this->xrefEntries[$key] = $entryOffset;
                    }
                }
            }
        }
    }

    private function parseTrailerAt(int $xrefOffset): PdfObject
    {
        // Find "trailer" after the xref table
        $trailerPos = strpos($this->data, 'trailer', $xrefOffset);
        if ($trailerPos === false) {
            throw new InvalidPdfException('Cannot find trailer after xref');
        }

        $pos = $trailerPos + 7; // skip "trailer"
        $pos = $this->skipWhitespace($pos);
        [$trailer, ] = $this->parseValue($pos);

        if (!$trailer->isDictionary()) {
            throw new InvalidPdfException('Trailer is not a dictionary');
        }

        return $trailer;
    }

    // --- Object parsing ---

    private function parseObjectAt(int $offset): PdfObject
    {
        $pos = $offset;

        // Read "N G obj"
        if (!preg_match('/(\d+)\s+(\d+)\s+obj/', substr($this->data, $pos, 30), $m)) {
            throw new InvalidPdfException('Cannot parse object header at offset ' . $pos);
        }
        $pos += strlen($m[0]);
        $pos = $this->skipWhitespace($pos);

        [$obj, $pos] = $this->parseValue($pos);

        $pos = $this->skipWhitespace($pos);

        // Check for stream
        if (substr($this->data, $pos, 6) === 'stream') {
            $pos += 6;
            // Skip \r\n or \n after "stream"
            if ($this->data[$pos] === "\r") {
                $pos++;
            }
            if ($this->data[$pos] === "\n") {
                $pos++;
            }

            // Get stream length from dictionary
            $length = $this->resolveStreamLength($obj);
            $streamData = substr($this->data, $pos, $length);

            // Decompress if needed
            if ($obj->isDictionary()) {
                $filter = $obj->get('Filter');
                if ($filter !== null && $filter->isName() && $filter->value() === 'FlateDecode') {
                    $decompressed = @gzuncompress($streamData);
                    if ($decompressed === false) {
                        // Try raw deflate
                        $decompressed = @gzinflate($streamData);
                    }
                    if ($decompressed !== false) {
                        $streamData = $decompressed;
                    }
                }
            }

            return PdfObject::stream($obj, $streamData);
        }

        return $obj;
    }

    private function resolveStreamLength(PdfObject $dict): int
    {
        if (!$dict->isDictionary()) {
            return 0;
        }
        $lengthObj = $dict->get('Length');
        if ($lengthObj === null) {
            return 0;
        }
        if ($lengthObj->isReference()) {
            $lengthObj = $this->resolve($lengthObj);
        }
        return (int) $lengthObj->value();
    }

    // --- Token parsing / value parsing ---

    /**
     * Parse a PDF value at the given position.
     * @return array{PdfObject, int} [parsedObject, newPosition]
     */
    private function parseValue(int $pos): array
    {
        $pos = $this->skipWhitespace($pos);

        if ($pos >= strlen($this->data)) {
            return [PdfObject::null(), $pos];
        }

        $ch = $this->data[$pos];
        $ch2 = $pos + 1 < strlen($this->data) ? $this->data[$pos + 1] : '';

        // Dictionary: <<
        if ($ch === '<' && $ch2 === '<') {
            return $this->parseDictionary($pos);
        }

        // Hex string: <
        if ($ch === '<') {
            return $this->parseHexString($pos);
        }

        // Literal string: (
        if ($ch === '(') {
            return $this->parseLiteralString($pos);
        }

        // Array: [
        if ($ch === '[') {
            return $this->parseArray($pos);
        }

        // Name: /
        if ($ch === '/') {
            return $this->parseName($pos);
        }

        // Number or reference: digit or +/-
        if (ctype_digit($ch) || $ch === '+' || $ch === '-' || $ch === '.') {
            return $this->parseNumberOrReference($pos);
        }

        // Keywords: true, false, null
        if (substr($this->data, $pos, 4) === 'true') {
            return [PdfObject::boolean(true), $pos + 4];
        }
        if (substr($this->data, $pos, 5) === 'false') {
            return [PdfObject::boolean(false), $pos + 5];
        }
        if (substr($this->data, $pos, 4) === 'null') {
            return [PdfObject::null(), $pos + 4];
        }

        throw new InvalidPdfException('Unexpected token at offset ' . $pos . ': ' . substr($this->data, $pos, 20));
    }

    /** @return array{PdfObject, int} */
    private function parseDictionary(int $pos): array
    {
        $pos += 2; // skip <<
        $entries = [];

        while ($pos < strlen($this->data)) {
            $pos = $this->skipWhitespace($pos);

            // Check for >>
            if (substr($this->data, $pos, 2) === '>>') {
                $pos += 2;
                return [PdfObject::dictionary($entries), $pos];
            }

            // Key must be a name
            if ($this->data[$pos] !== '/') {
                throw new InvalidPdfException('Expected name key in dictionary at offset ' . $pos);
            }

            [$key, $pos] = $this->parseName($pos);
            [$value, $pos] = $this->parseValue($pos);
            $entries[$key->value()] = $value;
        }

        throw new InvalidPdfException('Unterminated dictionary');
    }

    /** @return array{PdfObject, int} */
    private function parseArray(int $pos): array
    {
        $pos += 1; // skip [
        $items = [];

        while ($pos < strlen($this->data)) {
            $pos = $this->skipWhitespace($pos);

            if ($this->data[$pos] === ']') {
                $pos += 1;
                return [PdfObject::array($items), $pos];
            }

            [$item, $pos] = $this->parseValue($pos);
            $items[] = $item;
        }

        throw new InvalidPdfException('Unterminated array');
    }

    /** @return array{PdfObject, int} */
    private function parseName(int $pos): array
    {
        $pos += 1; // skip /
        $name = '';

        while ($pos < strlen($this->data)) {
            $ch = $this->data[$pos];

            // Name terminates at delimiter or whitespace
            if ($this->isWhitespace($ch) || $this->isDelimiter($ch)) {
                break;
            }

            // Handle #HH hex escape
            if ($ch === '#' && $pos + 2 < strlen($this->data)) {
                $hex = substr($this->data, $pos + 1, 2);
                $name .= chr((int) hexdec($hex));
                $pos += 3;
                continue;
            }

            $name .= $ch;
            $pos++;
        }

        return [PdfObject::name($name), $pos];
    }

    /** @return array{PdfObject, int} */
    private function parseLiteralString(int $pos): array
    {
        $pos += 1; // skip (
        $str = '';
        $depth = 1;

        while ($pos < strlen($this->data)) {
            $ch = $this->data[$pos];

            if ($ch === '\\') {
                $pos++;
                if ($pos >= strlen($this->data)) break;
                $next = $this->data[$pos];
                $str .= match ($next) {
                    'n' => "\n",
                    'r' => "\r",
                    't' => "\t",
                    'b' => "\x08",
                    'f' => "\x0C",
                    '(' => '(',
                    ')' => ')',
                    '\\' => '\\',
                    default => $this->parseOctalEscape($pos, $escaped) ? $escaped : $next,
                };
                $pos++;
                continue;
            }

            if ($ch === '(') {
                $depth++;
                $str .= $ch;
                $pos++;
                continue;
            }

            if ($ch === ')') {
                $depth--;
                if ($depth === 0) {
                    $pos++;
                    return [PdfObject::string($str), $pos];
                }
                $str .= $ch;
                $pos++;
                continue;
            }

            $str .= $ch;
            $pos++;
        }

        throw new InvalidPdfException('Unterminated string literal');
    }

    private function parseOctalEscape(int &$pos, ?string &$result): bool
    {
        $ch = $this->data[$pos];
        if ($ch >= '0' && $ch <= '7') {
            $octal = $ch;
            for ($j = 1; $j < 3 && ($pos + $j) < strlen($this->data); $j++) {
                $next = $this->data[$pos + $j];
                if ($next >= '0' && $next <= '7') {
                    $octal .= $next;
                } else {
                    break;
                }
            }
            $result = chr((int) octdec($octal));
            $pos += strlen($octal) - 1; // -1 because caller will ++ too
            return true;
        }
        return false;
    }

    /** @return array{PdfObject, int} */
    private function parseHexString(int $pos): array
    {
        $pos += 1; // skip <
        $hex = '';

        while ($pos < strlen($this->data)) {
            $ch = $this->data[$pos];
            if ($ch === '>') {
                $pos++;
                return [PdfObject::hexString($hex), $pos];
            }
            if (!$this->isWhitespace($ch)) {
                $hex .= $ch;
            }
            $pos++;
        }

        throw new InvalidPdfException('Unterminated hex string');
    }

    /** @return array{PdfObject, int} */
    private function parseNumberOrReference(int $pos): array
    {
        // Read the first number
        $start = $pos;
        while ($pos < strlen($this->data) && $this->isNumberChar($this->data[$pos])) {
            $pos++;
        }
        $firstToken = substr($this->data, $start, $pos - $start);

        // Check if this could be an indirect reference: "N G R"
        $savedPos = $pos;
        $savedPos = $this->skipWhitespace($savedPos);

        if ($savedPos < strlen($this->data) && ctype_digit($this->data[$savedPos])) {
            $genStart = $savedPos;
            while ($savedPos < strlen($this->data) && ctype_digit($this->data[$savedPos])) {
                $savedPos++;
            }
            $genToken = substr($this->data, $genStart, $savedPos - $genStart);

            $savedPos = $this->skipWhitespace($savedPos);
            if ($savedPos < strlen($this->data) && $this->data[$savedPos] === 'R') {
                // It's a reference
                $savedPos++; // skip R
                return [PdfObject::reference((int) $firstToken, (int) $genToken), $savedPos];
            }
        }

        // It's a number
        $value = str_contains($firstToken, '.') ? (float) $firstToken : (int) $firstToken;
        return [PdfObject::number($value), $pos];
    }

    // --- Utility ---

    private function skipWhitespace(int $pos): int
    {
        $len = strlen($this->data);
        while ($pos < $len) {
            $ch = $this->data[$pos];
            if ($this->isWhitespace($ch)) {
                $pos++;
                continue;
            }
            // Skip PDF comments (% to end of line)
            if ($ch === '%') {
                while ($pos < $len && $this->data[$pos] !== "\n" && $this->data[$pos] !== "\r") {
                    $pos++;
                }
                continue;
            }
            break;
        }
        return $pos;
    }

    private function isWhitespace(string $ch): bool
    {
        return $ch === ' ' || $ch === "\n" || $ch === "\r" || $ch === "\t" || $ch === "\x00" || $ch === "\x0C";
    }

    private function isDelimiter(string $ch): bool
    {
        return $ch === '(' || $ch === ')' || $ch === '<' || $ch === '>' ||
               $ch === '[' || $ch === ']' || $ch === '{' || $ch === '}' ||
               $ch === '/' || $ch === '%';
    }

    private function isNumberChar(string $ch): bool
    {
        return ctype_digit($ch) || $ch === '.' || $ch === '+' || $ch === '-';
    }
}
```

- [ ] **Step 5: Run tests to verify they pass**

```bash
herd php vendor/bin/phpunit tests/Unit/PdfParserTest.php
```

Expected: All tests PASS. If any fail, debug and fix.

- [ ] **Step 6: Commit**

```bash
git add src/Parser/PdfParser.php tests/Unit/PdfParserTest.php tests/create_minimal_pdf.php tests/fixtures/minimal_form.pdf
git commit -m "Add PdfParser with xref chain, tokenizer, object resolution"
```

---

### Task 5: AcroFormReader

**Files:**
- Create: `src/Form/AcroFormReader.php`
- Create: `tests/Unit/AcroFormReaderTest.php`

Traverses the AcroForm field tree to discover fields. Handles hierarchical field names (dot-notation from `/T` entries) and distinguishes terminal fields (have `/FT`) from non-terminal nodes (container only).

- [ ] **Step 1: Write failing tests**

Create `tests/Unit/AcroFormReaderTest.php`:

```php
<?php

declare(strict_types=1);

namespace PlusForta\PdfForms\Tests\Unit;

use PHPUnit\Framework\TestCase;
use PlusForta\PdfForms\Exception\FieldNotFoundException;
use PlusForta\PdfForms\Exception\NoFormFieldsException;
use PlusForta\PdfForms\Form\AcroFormReader;
use PlusForta\PdfForms\Form\FieldType;
use PlusForta\PdfForms\Parser\PdfParser;

class AcroFormReaderTest extends TestCase
{
    public function testReadMinimalPdf(): void
    {
        $data = file_get_contents(__DIR__ . '/../fixtures/minimal_form.pdf');
        $parser = new PdfParser($data);
        $reader = new AcroFormReader($parser);

        $fields = $reader->getFields();
        $this->assertCount(1, $fields);
        $this->assertArrayHasKey('testField', $fields);
        $this->assertEquals(FieldType::Text, $fields['testField']->type);
    }

    public function testReadRealPdfFieldCount(): void
    {
        $data = file_get_contents(__DIR__ . '/../fixtures/reservation_kfde_07_2023.pdf');
        $parser = new PdfParser($data);
        $reader = new AcroFormReader($parser);

        $fields = $reader->getFields();
        $this->assertCount(7, $fields);
    }

    public function testReadRealPdfFieldNames(): void
    {
        $data = file_get_contents(__DIR__ . '/../fixtures/reservation_kfde_07_2023.pdf');
        $parser = new PdfParser($data);
        $reader = new AcroFormReader($parser);

        $fields = $reader->getFields();

        $expectedNames = [
            'date',
            'tenant.address',
            'tenant.email',
            'tenant.salutation',
            'tenant.dateOfBirth',
            'address.street',
            'address.zipAndCity',
        ];

        foreach ($expectedNames as $name) {
            $this->assertArrayHasKey($name, $fields, "Missing field: $name");
        }
    }

    public function testAllFieldsAreTextType(): void
    {
        $data = file_get_contents(__DIR__ . '/../fixtures/reservation_kfde_07_2023.pdf');
        $parser = new PdfParser($data);
        $reader = new AcroFormReader($parser);

        foreach ($reader->getFields() as $field) {
            $this->assertEquals(FieldType::Text, $field->type, "Field {$field->name} should be Text");
        }
    }

    public function testGetFieldByName(): void
    {
        $data = file_get_contents(__DIR__ . '/../fixtures/reservation_kfde_07_2023.pdf');
        $parser = new PdfParser($data);
        $reader = new AcroFormReader($parser);

        $field = $reader->getField('tenant.email');
        $this->assertEquals('tenant.email', $field->name);
        $this->assertEquals(FieldType::Text, $field->type);
    }

    public function testGetFieldNotFoundThrows(): void
    {
        $data = file_get_contents(__DIR__ . '/../fixtures/reservation_kfde_07_2023.pdf');
        $parser = new PdfParser($data);
        $reader = new AcroFormReader($parser);

        $this->expectException(FieldNotFoundException::class);
        $reader->getField('nonexistent.field');
    }

    public function testHasField(): void
    {
        $data = file_get_contents(__DIR__ . '/../fixtures/reservation_kfde_07_2023.pdf');
        $parser = new PdfParser($data);
        $reader = new AcroFormReader($parser);

        $this->assertTrue($reader->hasField('date'));
        $this->assertTrue($reader->hasField('tenant.salutation'));
        $this->assertFalse($reader->hasField('missing'));
    }

    public function testFieldHasWidgetObjectNumbers(): void
    {
        $data = file_get_contents(__DIR__ . '/../fixtures/reservation_kfde_07_2023.pdf');
        $parser = new PdfParser($data);
        $reader = new AcroFormReader($parser);

        // "date" field has two widgets (one per page): objects 128 and 61
        $dateField = $reader->getField('date');
        $this->assertCount(2, $dateField->widgetObjectNumbers);
    }

    public function testNoAcroFormThrows(): void
    {
        // Build a minimal PDF without AcroForm
        $pdf = "%PDF-1.4\n";
        $off1 = strlen($pdf);
        $pdf .= "1 0 obj\n<</Type /Catalog /Pages 2 0 R>>\nendobj\n";
        $off2 = strlen($pdf);
        $pdf .= "2 0 obj\n<</Type /Pages /Kids [] /Count 0>>\nendobj\n";
        $xref = strlen($pdf);
        $pdf .= "xref\n0 3\n0000000000 65535 f \n";
        $pdf .= sprintf("%010d 00000 n \n", $off1);
        $pdf .= sprintf("%010d 00000 n \n", $off2);
        $pdf .= "trailer\n<</Size 3 /Root 1 0 R>>\nstartxref\n" . $xref . "\n%%EOF\n";

        $parser = new PdfParser($pdf);

        $this->expectException(NoFormFieldsException::class);
        new AcroFormReader($parser);
    }
}
```

- [ ] **Step 2: Run tests to verify they fail**

```bash
herd php vendor/bin/phpunit tests/Unit/AcroFormReaderTest.php
```

Expected: FAIL — class `AcroFormReader` not found.

- [ ] **Step 3: Implement `src/Form/AcroFormReader.php`**

```php
<?php

declare(strict_types=1);

namespace PlusForta\PdfForms\Form;

use PlusForta\PdfForms\Exception\FieldNotFoundException;
use PlusForta\PdfForms\Exception\NoFormFieldsException;
use PlusForta\PdfForms\Parser\PdfObject;
use PlusForta\PdfForms\Parser\PdfParser;

final class AcroFormReader implements FormReaderInterface
{
    /** @var array<string, Field> */
    private array $fields;

    public function __construct(
        private readonly PdfParser $parser,
    ) {
        $this->fields = $this->discoverFields();
    }

    /** @return array<string, Field> */
    public function getFields(): array
    {
        return $this->fields;
    }

    public function getField(string $name): Field
    {
        if (!isset($this->fields[$name])) {
            throw new FieldNotFoundException("Field not found: $name");
        }
        return $this->fields[$name];
    }

    public function hasField(string $name): bool
    {
        return isset($this->fields[$name]);
    }

    /** @return array<string, Field> */
    private function discoverFields(): array
    {
        $catalog = $this->parser->getCatalog();
        $acroFormRef = $catalog->get('AcroForm');

        if ($acroFormRef === null) {
            throw new NoFormFieldsException('PDF has no AcroForm dictionary');
        }

        $acroForm = $this->parser->resolve($acroFormRef);
        $fieldsRef = $acroForm->get('Fields');

        if ($fieldsRef === null) {
            throw new NoFormFieldsException('AcroForm has no Fields array');
        }

        $fieldsArray = $this->parser->resolve($fieldsRef);
        if (!$fieldsArray->isArray()) {
            throw new NoFormFieldsException('AcroForm Fields is not an array');
        }

        $fields = [];
        foreach ($fieldsArray->value() as $fieldRef) {
            $this->walkFieldTree($fieldRef, '', $fields, null);
        }

        return $fields;
    }

    /**
     * Recursively walk the field tree.
     *
     * @param PdfObject $nodeRef Reference to a field/widget object
     * @param string $parentName Dot-prefix from parent nodes
     * @param array<string, Field> &$fields Accumulator
     * @param PdfObject|null $inheritedFt Inherited /FT from parent (PDF spec: /FT is inheritable)
     */
    private function walkFieldTree(PdfObject $nodeRef, string $parentName, array &$fields, ?PdfObject $inheritedFt): void
    {
        $node = $this->parser->resolve($nodeRef);

        if (!$node->isDictionary()) {
            return;
        }

        // Build the fully-qualified name
        $tObj = $node->get('T');
        $localName = ($tObj !== null && $tObj->isString()) ? $tObj->value() : null;

        $fullName = $parentName;
        if ($localName !== null) {
            $fullName = $parentName === '' ? $localName : $parentName . '.' . $localName;
        }

        // Resolve /FT with inheritance: use this node's /FT, or fall back to parent's
        $ftObj = $node->get('FT') ?? $inheritedFt;

        // Get object number for this node
        $objectNumber = $nodeRef->isReference() ? $nodeRef->objectNumber() : 0;

        // Check if this node has Kids
        $kidsObj = $node->get('Kids');
        if ($kidsObj !== null) {
            $kids = $this->parser->resolve($kidsObj);
            if ($kids->isArray()) {
                // Determine if kids are widgets (have /Subtype /Widget) or field nodes
                $hasFieldKids = false;
                $widgetObjectNumbers = [];

                foreach ($kids->value() as $kidRef) {
                    $kid = $this->parser->resolve($kidRef);
                    if ($kid->isDictionary()) {
                        $kidT = $kid->get('T');
                        $kidSubtype = $kid->get('Subtype');
                        $isWidget = $kidSubtype !== null && $kidSubtype->isName() && $kidSubtype->value() === 'Widget';
                        $hasFieldName = $kidT !== null && $kidT->isString();

                        if ($hasFieldName || (!$isWidget && $kid->get('Kids') !== null)) {
                            // This kid is a field node (has its own /T or has Kids without being a widget)
                            $hasFieldKids = true;
                        }
                    }
                }

                if ($hasFieldKids) {
                    // Non-terminal: recurse into field children, passing inherited /FT
                    foreach ($kids->value() as $kidRef) {
                        $kid = $this->parser->resolve($kidRef);
                        $kidT = $kid->get('T');
                        if ($kidT !== null && $kidT->isString()) {
                            // This is a named child field
                            $this->walkFieldTree($kidRef, $fullName, $fields, $ftObj);
                        } else {
                            // Widget annotation without its own /T (belongs to this field)
                            if ($kidRef->isReference()) {
                                $widgetObjectNumbers[] = $kidRef->objectNumber();
                            }
                        }
                    }

                    // If this node has /FT (directly or inherited), it's a terminal field with widget kids
                    if ($ftObj !== null && $fullName !== '') {
                        $fields[$fullName] = new Field(
                            name: $fullName,
                            type: $this->resolveFieldType($node, $ftObj),
                            value: $this->resolveFieldValue($node),
                            options: $this->resolveFieldOptions($node),
                            objectNumber: $objectNumber,
                            widgetObjectNumbers: $widgetObjectNumbers,
                        );
                    }
                } else {
                    // All kids are widgets — this is a terminal field
                    foreach ($kids->value() as $kidRef) {
                        if ($kidRef->isReference()) {
                            $widgetObjectNumbers[] = $kidRef->objectNumber();
                        }
                    }

                    if ($ftObj !== null && $fullName !== '') {
                        $fields[$fullName] = new Field(
                            name: $fullName,
                            type: $this->resolveFieldType($node, $ftObj),
                            value: $this->resolveFieldValue($node),
                            options: $this->resolveFieldOptions($node),
                            objectNumber: $objectNumber,
                            widgetObjectNumbers: $widgetObjectNumbers,
                        );
                    }
                }

                return;
            }
        }

        // Leaf node (widget annotation that is also the field)
        if ($ftObj !== null && $fullName !== '') {
            $fields[$fullName] = new Field(
                name: $fullName,
                type: $this->resolveFieldType($node, $ftObj),
                value: $this->resolveFieldValue($node),
                options: $this->resolveFieldOptions($node),
                objectNumber: $objectNumber,
                widgetObjectNumbers: [$objectNumber],
            );
        }
    }

    private function resolveFieldType(PdfObject $node, PdfObject $ft): FieldType
    {
        if (!$ft->isName()) {
            return FieldType::Text;
        }

        $ffObj = $node->get('Ff');
        $flags = ($ffObj !== null && $ffObj->isNumber()) ? (int) $ffObj->value() : 0;

        return match ($ft->value()) {
            'Tx' => FieldType::Text,
            'Btn' => ($flags & (1 << 15)) !== 0 ? FieldType::Radio : FieldType::Checkbox,
            'Ch' => ($flags & (1 << 17)) !== 0 ? FieldType::Dropdown : FieldType::Listbox,
            'Sig' => FieldType::Signature,
            default => FieldType::Text,
        };
    }

    private function resolveFieldValue(PdfObject $node): string|bool|null
    {
        $v = $node->get('V');
        if ($v === null || $v->isNull()) {
            return null;
        }
        if ($v->isBoolean()) {
            return $v->value();
        }
        if ($v->isName()) {
            return $v->value();
        }
        if ($v->isString()) {
            return $v->value();
        }
        return null;
    }

    /** @return string[] */
    private function resolveFieldOptions(PdfObject $node): array
    {
        $opt = $node->get('Opt');
        if ($opt === null) {
            return [];
        }
        $opt = $this->parser->resolve($opt);
        if (!$opt->isArray()) {
            return [];
        }

        $options = [];
        foreach ($opt->value() as $item) {
            if ($item->isString()) {
                $options[] = $item->value();
            } elseif ($item->isArray()) {
                $values = $item->value();
                // Export value is first, display value is second
                $options[] = isset($values[0]) && $values[0]->isString() ? $values[0]->value() : '';
            }
        }
        return $options;
    }
}
```

- [ ] **Step 4: Run tests to verify they pass**

```bash
herd php vendor/bin/phpunit tests/Unit/AcroFormReaderTest.php
```

Expected: All tests PASS.

- [ ] **Step 5: Commit**

```bash
git add src/Form/AcroFormReader.php tests/Unit/AcroFormReaderTest.php
git commit -m "Add AcroFormReader with hierarchical field name resolution"
```

---

### Task 6: PdfWriter — Incremental Update

**Files:**
- Create: `src/Writer/PdfWriter.php`
- Create: `tests/Unit/PdfWriterTest.php`

The writer takes the original PDF bytes, a list of modified objects (as `[objectNumber => PdfObject]`), and produces a new PDF by appending an incremental update.

- [ ] **Step 1: Write failing tests**

Create `tests/Unit/PdfWriterTest.php`:

```php
<?php

declare(strict_types=1);

namespace PlusForta\PdfForms\Tests\Unit;

use PHPUnit\Framework\TestCase;
use PlusForta\PdfForms\Parser\PdfObject;
use PlusForta\PdfForms\Parser\PdfParser;
use PlusForta\PdfForms\Writer\PdfWriter;

class PdfWriterTest extends TestCase
{
    public function testWriteProducesValidPdf(): void
    {
        $data = file_get_contents(__DIR__ . '/../fixtures/minimal_form.pdf');
        $parser = new PdfParser($data);

        $writer = new PdfWriter($data, $parser->getTrailer());
        $output = $writer->write();

        // Output should start with original PDF
        $this->assertStringStartsWith('%PDF-', $output);
        // And it should be parseable
        $newParser = new PdfParser($output);
        $this->assertNotNull($newParser->getCatalog());
    }

    public function testWriteWithModifiedObject(): void
    {
        $data = file_get_contents(__DIR__ . '/../fixtures/minimal_form.pdf');
        $parser = new PdfParser($data);

        // Modify object 6 (the field) to have a /V value
        $field = $parser->getObject(6, 0);
        $modified = $field->set('V', PdfObject::string('test value'));

        $writer = new PdfWriter($data, $parser->getTrailer());
        $writer->setObject(6, 0, $modified);
        $output = $writer->write();

        // Re-parse and verify
        $newParser = new PdfParser($output);
        $newField = $newParser->getObject(6, 0);
        $this->assertEquals('test value', $newField->get('V')->value());
    }

    public function testWriteWithNeedAppearances(): void
    {
        $data = file_get_contents(__DIR__ . '/../fixtures/minimal_form.pdf');
        $parser = new PdfParser($data);

        $catalog = $parser->getCatalog();
        $acroForm = $parser->resolve($catalog->get('AcroForm'));
        $modifiedAcroForm = $acroForm->set('NeedAppearances', PdfObject::boolean(true));

        $writer = new PdfWriter($data, $parser->getTrailer());
        $writer->setObject(5, 0, $modifiedAcroForm); // object 5 is AcroForm
        $output = $writer->write();

        $newParser = new PdfParser($output);
        $newCatalog = $newParser->getCatalog();
        $newAcroForm = $newParser->resolve($newCatalog->get('AcroForm'));
        $this->assertTrue($newAcroForm->get('NeedAppearances')->value());
    }

    public function testIncrementalUpdatePreservesOriginal(): void
    {
        $data = file_get_contents(__DIR__ . '/../fixtures/minimal_form.pdf');
        $parser = new PdfParser($data);

        $writer = new PdfWriter($data, $parser->getTrailer());
        $writer->setObject(6, 0, $parser->getObject(6, 0)->set('V', PdfObject::string('hello')));
        $output = $writer->write();

        // The original data should be a prefix of the output
        $this->assertStringStartsWith($data, $output);

        // Output should be longer (has appended data)
        $this->assertGreaterThan(strlen($data), strlen($output));
    }

    public function testWriteWithRealPdf(): void
    {
        $data = file_get_contents(__DIR__ . '/../fixtures/reservation_kfde_07_2023.pdf');
        $parser = new PdfParser($data);

        // Modify the date field (object 98) to have a value
        $dateField = $parser->getObject(98, 0);
        $modified = $dateField->set('V', PdfObject::string('2024-01-15'));

        $writer = new PdfWriter($data, $parser->getTrailer());
        $writer->setObject(98, 0, $modified);
        $output = $writer->write();

        // Re-parse and verify
        $newParser = new PdfParser($output);
        $newDate = $newParser->getObject(98, 0);
        $this->assertEquals('2024-01-15', $newDate->get('V')->value());
    }
}
```

- [ ] **Step 2: Run tests to verify they fail**

```bash
herd php vendor/bin/phpunit tests/Unit/PdfWriterTest.php
```

Expected: FAIL — class `PdfWriter` not found.

- [ ] **Step 3: Implement `src/Writer/PdfWriter.php`**

```php
<?php

declare(strict_types=1);

namespace PlusForta\PdfForms\Writer;

use PlusForta\PdfForms\Parser\PdfObject;

final class PdfWriter
{
    /** @var array<string, PdfObject> key = "objNum_genNum" */
    private array $modifiedObjects = [];

    public function __construct(
        private readonly string $originalData,
        private readonly PdfObject $originalTrailer,
    ) {
    }

    public function setObject(int $objectNumber, int $generationNumber, PdfObject $object): void
    {
        $key = $objectNumber . '_' . $generationNumber;
        $this->modifiedObjects[$key] = $object;
    }

    public function write(): string
    {
        if (empty($this->modifiedObjects)) {
            return $this->originalData;
        }

        $output = $this->originalData;

        // Ensure original data doesn't end with extra whitespace that could confuse parsers
        $newXrefOffset = strlen($output);

        // Write modified objects
        $xrefEntries = [];
        foreach ($this->modifiedObjects as $key => $object) {
            [$objNum, $genNum] = explode('_', $key);
            $objNum = (int) $objNum;
            $genNum = (int) $genNum;

            $xrefEntries[$objNum] = [
                'offset' => strlen($output),
                'gen' => $genNum,
            ];

            $output .= $objNum . ' ' . $genNum . " obj\n";
            $output .= $object->encode() . "\n";
            $output .= "endobj\n";
        }

        // Write xref table
        $xrefOffset = strlen($output);
        $output .= "xref\n";

        // Group consecutive object numbers into subsections
        ksort($xrefEntries);
        $subsections = $this->groupIntoSubsections($xrefEntries);

        foreach ($subsections as $subsection) {
            $firstObj = $subsection[0]['objNum'];
            $count = count($subsection);
            $output .= "$firstObj $count\n";

            foreach ($subsection as $entry) {
                $output .= sprintf("%010d %05d n \n", $entry['offset'], $entry['gen']);
            }
        }

        // Write trailer
        $prevXref = $this->findOriginalXrefOffset();
        $size = $this->getTrailerSize();

        // Update size if our objects have higher numbers
        foreach ($xrefEntries as $objNum => $entry) {
            if ($objNum + 1 > $size) {
                $size = $objNum + 1;
            }
        }

        $trailer = PdfObject::dictionary([
            'Size' => PdfObject::number($size),
            'Root' => $this->originalTrailer->get('Root'),
            'Prev' => PdfObject::number($prevXref),
        ]);

        // Preserve /Info if present
        $info = $this->originalTrailer->get('Info');
        if ($info !== null) {
            $trailer = $trailer->set('Info', $info);
        }

        // Preserve /ID if present
        $id = $this->originalTrailer->get('ID');
        if ($id !== null) {
            $trailer = $trailer->set('ID', $id);
        }

        $output .= "trailer\n";
        $output .= $trailer->encode() . "\n";
        $output .= "startxref\n";
        $output .= $xrefOffset . "\n";
        $output .= "%%EOF\n";

        return $output;
    }

    private function findOriginalXrefOffset(): int
    {
        // Find the last startxref value in the original data
        $pos = strrpos($this->originalData, 'startxref');
        if ($pos === false) {
            return 0;
        }
        $chunk = substr($this->originalData, $pos, 100);
        if (preg_match('/startxref\s+(\d+)/', $chunk, $m)) {
            return (int) $m[1];
        }
        return 0;
    }

    private function getTrailerSize(): int
    {
        $size = $this->originalTrailer->get('Size');
        if ($size !== null && $size->isNumber()) {
            return (int) $size->value();
        }
        return 0;
    }

    /**
     * @param array<int, array{offset: int, gen: int}> $entries
     * @return array<int, array<int, array{objNum: int, offset: int, gen: int}>>
     */
    private function groupIntoSubsections(array $entries): array
    {
        $subsections = [];
        $current = [];
        $lastNum = -2;

        foreach ($entries as $objNum => $entry) {
            if ($objNum !== $lastNum + 1 && !empty($current)) {
                $subsections[] = $current;
                $current = [];
            }
            $current[] = ['objNum' => $objNum, 'offset' => $entry['offset'], 'gen' => $entry['gen']];
            $lastNum = $objNum;
        }

        if (!empty($current)) {
            $subsections[] = $current;
        }

        return $subsections;
    }
}
```

- [ ] **Step 4: Run tests to verify they pass**

```bash
herd php vendor/bin/phpunit tests/Unit/PdfWriterTest.php
```

Expected: All tests PASS.

- [ ] **Step 5: Commit**

```bash
git add src/Writer/PdfWriter.php tests/Unit/PdfWriterTest.php
git commit -m "Add PdfWriter with incremental update support"
```

---

### Task 7: PdfForm — Public Fluent API

**Files:**
- Create: `src/PdfForm.php`
- Create: `tests/Integration/FillPdfTest.php`

This wires everything together: `PdfParser` → `AcroFormReader` → modify fields → `PdfWriter` → output.

- [ ] **Step 1: Write failing integration tests**

Create `tests/Integration/FillPdfTest.php`:

```php
<?php

declare(strict_types=1);

namespace PlusForta\PdfForms\Tests\Integration;

use PHPUnit\Framework\TestCase;
use PlusForta\PdfForms\Exception\FieldNotFoundException;
use PlusForta\PdfForms\Exception\FileNotFoundException;
use PlusForta\PdfForms\Form\FieldType;
use PlusForta\PdfForms\PdfForm;

class FillPdfTest extends TestCase
{
    private string $fixturePath;
    private string $outputPath;

    protected function setUp(): void
    {
        $this->fixturePath = __DIR__ . '/../fixtures/reservation_kfde_07_2023.pdf';
        $this->outputPath = sys_get_temp_dir() . '/pdf_forms_test_' . uniqid() . '.pdf';
    }

    protected function tearDown(): void
    {
        if (file_exists($this->outputPath)) {
            unlink($this->outputPath);
        }
    }

    public function testOpenFile(): void
    {
        $form = PdfForm::open($this->fixturePath);
        $this->assertInstanceOf(PdfForm::class, $form);
    }

    public function testOpenNonexistentFileThrows(): void
    {
        $this->expectException(FileNotFoundException::class);
        PdfForm::open('/nonexistent/file.pdf');
    }

    public function testLoadFromBytes(): void
    {
        $bytes = file_get_contents($this->fixturePath);
        $form = PdfForm::load($bytes);
        $this->assertInstanceOf(PdfForm::class, $form);
    }

    public function testGetFields(): void
    {
        $fields = PdfForm::open($this->fixturePath)->getFields();

        $this->assertCount(7, $fields);
        $this->assertArrayHasKey('date', $fields);
        $this->assertArrayHasKey('tenant.address', $fields);
        $this->assertEquals(FieldType::Text, $fields['date']->type);
    }

    public function testSetAndSave(): void
    {
        PdfForm::open($this->fixturePath)
            ->set('date', '2024-01-15')
            ->save($this->outputPath);

        $this->assertFileExists($this->outputPath);

        // Re-read and verify the value was set
        $fields = PdfForm::open($this->outputPath)->getFields();
        $this->assertEquals('2024-01-15', $fields['date']->value);
    }

    public function testFillMultipleFields(): void
    {
        PdfForm::open($this->fixturePath)
            ->fill([
                'date' => '2024-01-15',
                'tenant.address' => '123 Main St',
                'tenant.email' => 'john@example.com',
                'tenant.salutation' => 'Herr',
                'tenant.dateOfBirth' => '01.01.1990',
                'address.street' => 'Musterstraße 1',
                'address.zipAndCity' => '40217 Düsseldorf',
            ])
            ->save($this->outputPath);

        // Round-trip: re-read and verify all values
        $fields = PdfForm::open($this->outputPath)->getFields();
        $this->assertEquals('2024-01-15', $fields['date']->value);
        $this->assertEquals('123 Main St', $fields['tenant.address']->value);
        $this->assertEquals('john@example.com', $fields['tenant.email']->value);
        $this->assertEquals('Herr', $fields['tenant.salutation']->value);
        $this->assertEquals('01.01.1990', $fields['tenant.dateOfBirth']->value);
        $this->assertEquals('Musterstraße 1', $fields['address.street']->value);
        $this->assertEquals('40217 Düsseldorf', $fields['address.zipAndCity']->value);
    }

    public function testToString(): void
    {
        $output = PdfForm::open($this->fixturePath)
            ->set('date', '2024-01-15')
            ->toString();

        $this->assertStringStartsWith('%PDF-', $output);
        $this->assertGreaterThan(0, strlen($output));

        // Write to file and verify
        file_put_contents($this->outputPath, $output);
        $fields = PdfForm::open($this->outputPath)->getFields();
        $this->assertEquals('2024-01-15', $fields['date']->value);
    }

    public function testSetNonexistentFieldThrows(): void
    {
        $this->expectException(FieldNotFoundException::class);

        PdfForm::open($this->fixturePath)
            ->set('nonexistent.field', 'value');
    }

    public function testFillWithNonexistentFieldThrows(): void
    {
        $this->expectException(FieldNotFoundException::class);

        PdfForm::open($this->fixturePath)
            ->fill(['nonexistent' => 'value']);
    }

    public function testFlattenThrowsInPhase1(): void
    {
        $this->expectException(\BadMethodCallException::class);

        PdfForm::open($this->fixturePath)
            ->fill(['date' => '2024-01-15'])
            ->flatten();
    }

    public function testNeedAppearancesIsSet(): void
    {
        $output = PdfForm::open($this->fixturePath)
            ->set('date', '2024-01-15')
            ->toString();

        // Verify /NeedAppearances true is in the output
        $this->assertStringContainsString('NeedAppearances', $output);
    }

    public function testLoadFillAndSave(): void
    {
        $bytes = file_get_contents($this->fixturePath);
        PdfForm::load($bytes)
            ->set('date', '2024-01-15')
            ->save($this->outputPath);

        $fields = PdfForm::open($this->outputPath)->getFields();
        $this->assertEquals('2024-01-15', $fields['date']->value);
    }

    public function testFluentChainingReturnsSelf(): void
    {
        $form = PdfForm::open($this->fixturePath);

        $result = $form->set('date', '2024-01-15');
        $this->assertSame($form, $result);

        $result = $form->fill(['tenant.email' => 'test@test.com']);
        $this->assertSame($form, $result);
    }

    public function testSaveToUnwritablePathThrows(): void
    {
        $this->expectException(\PlusForta\PdfForms\Exception\WriteException::class);

        PdfForm::open($this->fixturePath)
            ->set('date', '2024-01-15')
            ->save('/nonexistent/directory/output.pdf');
    }
}
```

- [ ] **Step 2: Run tests to verify they fail**

```bash
herd php vendor/bin/phpunit tests/Integration/FillPdfTest.php
```

Expected: FAIL — class `PdfForm` not found.

- [ ] **Step 3: Implement `src/PdfForm.php`**

```php
<?php

declare(strict_types=1);

namespace PlusForta\PdfForms;

use BadMethodCallException;
use PlusForta\PdfForms\Exception\FieldNotFoundException;
use PlusForta\PdfForms\Exception\FileNotFoundException;
use PlusForta\PdfForms\Exception\WriteException;
use PlusForta\PdfForms\Form\AcroFormReader;
use PlusForta\PdfForms\Form\Field;
use PlusForta\PdfForms\Parser\PdfObject;
use PlusForta\PdfForms\Parser\PdfParser;
use PlusForta\PdfForms\Writer\PdfWriter;

final class PdfForm
{
    private PdfParser $parser;
    private AcroFormReader $reader;

    /** @var array<string, string|bool> field name => new value */
    private array $pendingValues = [];

    private function __construct(string $data)
    {
        $this->parser = new PdfParser($data);
        $this->reader = new AcroFormReader($this->parser);
    }

    public static function open(string $path): self
    {
        if (!file_exists($path)) {
            throw new FileNotFoundException("File not found: $path");
        }

        $data = file_get_contents($path);
        if ($data === false) {
            throw new FileNotFoundException("Cannot read file: $path");
        }

        return new self($data);
    }

    public static function load(string $bytes): self
    {
        return new self($bytes);
    }

    /** @return array<string, Field> */
    public function getFields(): array
    {
        return $this->reader->getFields();
    }

    public function set(string $name, string|bool $value): self
    {
        if (!$this->reader->hasField($name)) {
            throw new FieldNotFoundException("Field not found: $name");
        }
        $this->pendingValues[$name] = $value;
        return $this;
    }

    /** @param array<string, string|bool> $values */
    public function fill(array $values): self
    {
        foreach ($values as $name => $value) {
            $this->set($name, $value);
        }
        return $this;
    }

    public function flatten(): self
    {
        throw new BadMethodCallException(
            'flatten() is not yet available. Appearance stream generation (Phase 2) is required for flattening.'
        );
    }

    public function save(string $path): void
    {
        $output = $this->toString();

        $dir = dirname($path);
        if (!is_dir($dir)) {
            throw new WriteException("Directory does not exist: $dir");
        }

        $result = file_put_contents($path, $output);
        if ($result === false) {
            throw new WriteException("Cannot write to file: $path");
        }
    }

    public function toString(): string
    {
        if (empty($this->pendingValues)) {
            return $this->parser->getRawData();
        }

        $writer = new PdfWriter(
            $this->parser->getRawData(),
            $this->parser->getTrailer(),
        );

        // Set /NeedAppearances true on AcroForm
        $catalog = $this->parser->getCatalog();
        $acroFormRef = $catalog->get('AcroForm');
        $acroForm = $this->parser->resolve($acroFormRef);
        $acroForm = $acroForm->set('NeedAppearances', PdfObject::boolean(true));

        $acroFormObjNum = $acroFormRef->isReference() ? $acroFormRef->objectNumber() : 0;
        $acroFormGenNum = $acroFormRef->isReference() ? $acroFormRef->generationNumber() : 0;
        $writer->setObject($acroFormObjNum, $acroFormGenNum, $acroForm);

        // Update field values
        foreach ($this->pendingValues as $name => $value) {
            $field = $this->reader->getField($name);

            $fieldObj = $this->parser->getObject($field->objectNumber, 0);
            if ($fieldObj === null) {
                continue;
            }

            if (is_bool($value)) {
                $pdfValue = PdfObject::name($value ? 'Yes' : 'Off');
            } else {
                $pdfValue = PdfObject::string($value);
            }

            $fieldObj = $fieldObj->set('V', $pdfValue);
            $writer->setObject($field->objectNumber, 0, $fieldObj);
        }

        return $writer->write();
    }
}
```

- [ ] **Step 4: Run integration tests**

```bash
herd php vendor/bin/phpunit tests/Integration/FillPdfTest.php
```

Expected: All tests PASS.

- [ ] **Step 5: Run the full test suite**

```bash
herd php vendor/bin/phpunit
```

Expected: All tests across all suites PASS.

- [ ] **Step 6: Commit**

```bash
git add src/PdfForm.php tests/Integration/FillPdfTest.php
git commit -m "Add PdfForm fluent API with fill, save, toString, getFields"
```

---

### Task 8: Manual Verification

**Files:** None (read-only verification)

Verify the library produces a real-world usable PDF by filling the test document and opening it.

- [ ] **Step 1: Create a quick test script**

Create `test_fill.php` in the project root:

```php
<?php

require __DIR__ . '/vendor/autoload.php';

use PlusForta\PdfForms\PdfForm;

$form = PdfForm::open(__DIR__ . '/reservation_kfde_07_2023.pdf');

echo "Available fields:\n";
foreach ($form->getFields() as $name => $field) {
    echo "  - $name ({$field->type->value})\n";
}

$form->fill([
    'date' => '15.01.2024',
    'tenant.address' => "Max Mustermann\nMusterstraße 1\n40217 Düsseldorf",
    'tenant.email' => 'max@example.com',
    'tenant.salutation' => 'Herr Max Mustermann',
    'tenant.dateOfBirth' => '01.01.1990',
    'address.street' => 'Vermieterstraße 42',
    'address.zipAndCity' => '10115 Berlin',
])->save(__DIR__ . '/output_test.pdf');

echo "\nSaved to output_test.pdf\n";
echo "Open it in a PDF viewer to verify fields are filled.\n";
```

- [ ] **Step 2: Run the test script**

```bash
herd php test_fill.php
```

Expected: Prints field list, saves `output_test.pdf`.

- [ ] **Step 3: Open the output PDF and visually verify**

```bash
open output_test.pdf
```

Expected: The PDF opens in Preview.app (or default viewer) with all fields filled. The values should be visible in the form fields.

- [ ] **Step 4: Clean up test script**

```bash
rm test_fill.php output_test.pdf
```

- [ ] **Step 5: Run full test suite one final time**

```bash
herd php vendor/bin/phpunit
```

Expected: All tests PASS. The library is complete for Phase 1.
