# Phase 2: Appearance Streams & Flattening — 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:** Add appearance stream generation and flattening to the PDF forms library so filled fields render in all viewers and can be baked into static content.

**Architecture:** FontMetrics reads font width tables from embedded PDF fonts. AppearanceStreamGenerator uses FontMetrics to measure text, then emits PDF drawing operators as Form XObject streams. PdfForm wires generation into its output path. Flattening merges appearance XObjects into page content and removes form structure.

**Tech Stack:** PHP 8.1+, PHPUnit 10, existing plusforta/pdf-forms codebase (62 tests passing)

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

---

## File Map

| File | Responsibility |
|------|---------------|
| `src/Writer/FontMetrics.php` | **New.** Text width calculation from embedded PDF font data. Handles simple TrueType (/Widths array) and CIDFontType2 (/W array). |
| `src/Writer/AppearanceStreamGenerator.php` | **New.** Generates Form XObject appearance streams for text field widgets. Parses /DA, resolves fonts, measures text, emits content stream. |
| `src/Writer/PdfWriter.php` | **Modify.** Add `allocateObjectNumber()`. |
| `src/Parser/PdfParser.php` | **Modify.** Add `getPages()` method. |
| `src/PdfForm.php` | **Modify.** Wire appearance generation, implement flatten(), add needAppearances(). |
| `tests/Unit/FontMetricsTest.php` | **New.** |
| `tests/Unit/AppearanceStreamGeneratorTest.php` | **New.** |
| `tests/Integration/FillPdfTest.php` | **Modify.** Remove testFlattenThrowsInPhase1, add flatten + appearance tests. |

---

## Reference: Test PDF Font Data

The AcroForm default resources (`/DR /Font`) contain three simple TrueType fonts:

- **Object 102:** `/BaseFont /OpenSans` — `/Subtype /TrueType`, `/FirstChar 0`, `/LastChar 255`, `/Widths [600 600 ... 260 267 ...]` (256 entries), `/Encoding 109 0 R` (MacRomanEncoding + Differences)
- **Object 103:** `/BaseFont /OpenSans-Light` — same structure, different widths
- **Object 104:** `/BaseFont /OpenSans-Semibold` — same structure, different widths

Widget `/DA` strings reference these by name: `/OpenSans 8 Tf 0 g`, `/OpenSans-Light 7 Tf 0 g`, `/OpenSans-Semibold 14 Tf 0 g`.

**Page structure:**
- Page 1 (object 125): `/Contents [152 0 R 153 0 R ...]` (array of 7 streams), `/Annots 126 0 R` → `[127 128 130 132 134 136 138]`
- Page 2 (object 1): `/Contents 3 0 R` (single stream reference), `/Annots 2 0 R`

---

### Task 1: FontMetrics

**Files:**
- Create: `src/Writer/FontMetrics.php`
- Create: `tests/Unit/FontMetricsTest.php`

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

Create `tests/Unit/FontMetricsTest.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\FontMetrics;

class FontMetricsTest extends TestCase
{
    public function testSimpleFontCharWidth(): void
    {
        // Build a minimal simple font dictionary
        $font = PdfObject::dictionary([
            'Type' => PdfObject::name('Font'),
            'Subtype' => PdfObject::name('TrueType'),
            'FirstChar' => PdfObject::number(32),
            'LastChar' => PdfObject::number(34),
            'Widths' => PdfObject::array([
                PdfObject::number(250),  // space (32)
                PdfObject::number(300),  // ! (33)
                PdfObject::number(400),  // " (34)
            ]),
        ]);

        $metrics = new FontMetrics($font);

        $this->assertEquals(250, $metrics->charWidth(32));
        $this->assertEquals(300, $metrics->charWidth(33));
        $this->assertEquals(400, $metrics->charWidth(34));
    }

    public function testSimpleFontDefaultWidthForOutOfRange(): void
    {
        $font = PdfObject::dictionary([
            'Type' => PdfObject::name('Font'),
            'Subtype' => PdfObject::name('TrueType'),
            'FirstChar' => PdfObject::number(32),
            'LastChar' => PdfObject::number(32),
            'Widths' => PdfObject::array([
                PdfObject::number(250),
            ]),
        ]);

        $metrics = new FontMetrics($font);

        // Character outside range gets default width (1000)
        $this->assertEquals(1000, $metrics->charWidth(65));
    }

    public function testCidFontCharWidth(): void
    {
        // CID font with /W array: startCID [width1 width2 ...]
        $descendant = PdfObject::dictionary([
            'Subtype' => PdfObject::name('CIDFontType2'),
            'DW' => PdfObject::number(500),
            'W' => PdfObject::array([
                PdfObject::number(65),  // startCID
                PdfObject::array([      // widths for CID 65, 66, 67
                    PdfObject::number(600),
                    PdfObject::number(700),
                    PdfObject::number(650),
                ]),
            ]),
        ]);

        $font = PdfObject::dictionary([
            'Type' => PdfObject::name('Font'),
            'Subtype' => PdfObject::name('Type0'),
            'DescendantFonts' => PdfObject::array([$descendant]),
        ]);

        $metrics = new FontMetrics($font);

        $this->assertEquals(600, $metrics->charWidth(65));  // A
        $this->assertEquals(700, $metrics->charWidth(66));  // B
        $this->assertEquals(650, $metrics->charWidth(67));  // C
        $this->assertEquals(500, $metrics->charWidth(90));  // Z - default
    }

    public function testCidFontRangeWidth(): void
    {
        // CID font with range format: startCID endCID width
        $descendant = PdfObject::dictionary([
            'Subtype' => PdfObject::name('CIDFontType2'),
            'DW' => PdfObject::number(500),
            'W' => PdfObject::array([
                PdfObject::number(65),   // startCID
                PdfObject::number(90),   // endCID
                PdfObject::number(600),  // all CIDs 65-90 have width 600
            ]),
        ]);

        $font = PdfObject::dictionary([
            'Type' => PdfObject::name('Font'),
            'Subtype' => PdfObject::name('Type0'),
            'DescendantFonts' => PdfObject::array([$descendant]),
        ]);

        $metrics = new FontMetrics($font);

        $this->assertEquals(600, $metrics->charWidth(65));  // A
        $this->assertEquals(600, $metrics->charWidth(75));  // K
        $this->assertEquals(600, $metrics->charWidth(90));  // Z
        $this->assertEquals(500, $metrics->charWidth(91));  // default
    }

    public function testStringWidth(): void
    {
        $font = PdfObject::dictionary([
            'Type' => PdfObject::name('Font'),
            'Subtype' => PdfObject::name('TrueType'),
            'FirstChar' => PdfObject::number(32),
            'LastChar' => PdfObject::number(122),
            'Widths' => $this->buildWidthsArray(32, 122, 500),
        ]);

        $metrics = new FontMetrics($font);

        // "ab" = 2 chars at 500/1000 * 10pt = 2 * 5.0 = 10.0
        $this->assertEqualsWithDelta(10.0, $metrics->stringWidth('ab', 10.0), 0.01);
    }

    public function testWordWrapSingleLine(): void
    {
        $font = PdfObject::dictionary([
            'Type' => PdfObject::name('Font'),
            'Subtype' => PdfObject::name('TrueType'),
            'FirstChar' => PdfObject::number(32),
            'LastChar' => PdfObject::number(122),
            'Widths' => $this->buildWidthsArray(32, 122, 500),
        ]);

        $metrics = new FontMetrics($font);

        // Short text that fits in one line
        $lines = $metrics->wordWrap('Hello', 10.0, 100.0);
        $this->assertCount(1, $lines);
        $this->assertEquals('Hello', $lines[0]);
    }

    public function testWordWrapMultipleLines(): void
    {
        $font = PdfObject::dictionary([
            'Type' => PdfObject::name('Font'),
            'Subtype' => PdfObject::name('TrueType'),
            'FirstChar' => PdfObject::number(32),
            'LastChar' => PdfObject::number(122),
            'Widths' => $this->buildWidthsArray(32, 122, 500),
        ]);

        $metrics = new FontMetrics($font);

        // Each char is 500/1000 * 10 = 5pt. Max width 30pt = 6 chars per line
        // "Hello World" = 11 chars. Should wrap.
        $lines = $metrics->wordWrap('Hello World', 10.0, 30.0);
        $this->assertCount(2, $lines);
        $this->assertEquals('Hello', $lines[0]);
        $this->assertEquals('World', $lines[1]);
    }

    public function testWordWrapExplicitNewline(): void
    {
        $font = PdfObject::dictionary([
            'Type' => PdfObject::name('Font'),
            'Subtype' => PdfObject::name('TrueType'),
            'FirstChar' => PdfObject::number(32),
            'LastChar' => PdfObject::number(122),
            'Widths' => $this->buildWidthsArray(32, 122, 500),
        ]);

        $metrics = new FontMetrics($font);

        $lines = $metrics->wordWrap("Line1\nLine2", 10.0, 1000.0);
        $this->assertCount(2, $lines);
        $this->assertEquals('Line1', $lines[0]);
        $this->assertEquals('Line2', $lines[1]);
    }

    public function testWordWrapLongWord(): void
    {
        $font = PdfObject::dictionary([
            'Type' => PdfObject::name('Font'),
            'Subtype' => PdfObject::name('TrueType'),
            'FirstChar' => PdfObject::number(32),
            'LastChar' => PdfObject::number(122),
            'Widths' => $this->buildWidthsArray(32, 122, 500),
        ]);

        $metrics = new FontMetrics($font);

        // A word that's too long for the line — should not infinite loop, just overflow
        $lines = $metrics->wordWrap('Superlongword', 10.0, 30.0);
        $this->assertNotEmpty($lines);
        $this->assertEquals('Superlongword', $lines[0]);
    }

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

        // Object 102 is OpenSans font
        $font = $parser->getObject(102, 0);
        $metrics = new FontMetrics($font);

        // Space (char 32) should have width 260 (from the Widths array)
        $this->assertEquals(260, $metrics->charWidth(32));

        // 'A' (char 65) — check it returns a reasonable width
        $width = $metrics->charWidth(65);
        $this->assertGreaterThan(0, $width);
        $this->assertLessThan(2000, $width);
    }

    /** Helper: build a Widths PdfObject array with uniform width */
    private function buildWidthsArray(int $firstChar, int $lastChar, int $width): PdfObject
    {
        $widths = [];
        for ($i = $firstChar; $i <= $lastChar; $i++) {
            $widths[] = PdfObject::number($width);
        }
        return PdfObject::array($widths);
    }
}
```

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

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

Expected: FAIL — class `FontMetrics` not found.

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

```php
<?php

declare(strict_types=1);

namespace PlusForta\PdfForms\Writer;

use PlusForta\PdfForms\Parser\PdfObject;
use PlusForta\PdfForms\Parser\PdfParser;

final class FontMetrics
{
    private const DEFAULT_WIDTH = 1000;

    /** @var array<int, int> charCode => width in font units (1/1000 of font size) */
    private array $widths = [];

    private int $defaultWidth = self::DEFAULT_WIDTH;

    private ?PdfParser $parser = null;

    public function __construct(PdfObject $fontDictionary, ?PdfParser $parser = null)
    {
        $this->parser = $parser;
        $subtype = $fontDictionary->get('Subtype');
        $subtypeName = ($subtype !== null && $subtype->isName()) ? $subtype->value() : '';

        if ($subtypeName === 'Type0') {
            $this->loadCidFontWidths($fontDictionary);
        } else {
            $this->loadSimpleFontWidths($fontDictionary);
        }
    }

    /** Width of a single character in font units (1/1000 of font size) */
    public function charWidth(int $charCode): int
    {
        return $this->widths[$charCode] ?? $this->defaultWidth;
    }

    /** Width of a string in PDF points at the given font size */
    public function stringWidth(string $text, float $fontSize): float
    {
        $width = 0;
        $len = strlen($text);
        for ($i = 0; $i < $len; $i++) {
            $width += $this->charWidth(ord($text[$i]));
        }
        return $width * $fontSize / 1000.0;
    }

    /**
     * Break text into lines that fit within maxWidth points at the given font size.
     * Handles explicit \n and word-boundary wrapping.
     * @return string[]
     */
    public function wordWrap(string $text, float $fontSize, float $maxWidth): array
    {
        $result = [];

        // First split on explicit newlines
        $paragraphs = explode("\n", $text);

        foreach ($paragraphs as $paragraph) {
            $words = explode(' ', $paragraph);
            $currentLine = '';

            foreach ($words as $word) {
                if ($currentLine === '') {
                    // First word on line — always add it (even if too long)
                    $currentLine = $word;
                    continue;
                }

                $testLine = $currentLine . ' ' . $word;
                if ($this->stringWidth($testLine, $fontSize) <= $maxWidth) {
                    $currentLine = $testLine;
                } else {
                    $result[] = $currentLine;
                    $currentLine = $word;
                }
            }

            if ($currentLine !== '') {
                $result[] = $currentLine;
            } elseif (empty($words) || (count($words) === 1 && $words[0] === '')) {
                $result[] = '';
            }
        }

        return $result;
    }

    private function loadSimpleFontWidths(PdfObject $font): void
    {
        $firstCharObj = $font->get('FirstChar');
        $lastCharObj = $font->get('LastChar');
        $widthsObj = $font->get('Widths');

        if ($firstCharObj === null || $widthsObj === null || !$widthsObj->isArray()) {
            return;
        }

        $firstChar = (int) $firstCharObj->value();
        $widthValues = $widthsObj->value();

        foreach ($widthValues as $i => $w) {
            if ($w->isNumber()) {
                $this->widths[$firstChar + $i] = (int) $w->value();
            }
        }
    }

    private function loadCidFontWidths(PdfObject $font): void
    {
        $descendants = $font->get('DescendantFonts');
        if ($descendants === null || !$descendants->isArray()) {
            return;
        }

        $descendantItems = $descendants->value();
        if (empty($descendantItems)) {
            return;
        }

        $cidFont = $descendantItems[0];
        // Resolve indirect reference if parser is available
        if ($cidFont->isReference() && $this->parser !== null) {
            $cidFont = $this->parser->resolve($cidFont);
        }
        if (!$cidFont->isDictionary()) {
            return;
        }

        $dwObj = $cidFont->get('DW');
        if ($dwObj !== null && $dwObj->isNumber()) {
            $this->defaultWidth = (int) $dwObj->value();
        }

        $wObj = $cidFont->get('W');
        if ($wObj === null || !$wObj->isArray()) {
            return;
        }

        $this->parseCidWArray($wObj->value());
    }

    /** @param PdfObject[] $entries */
    private function parseCidWArray(array $entries): void
    {
        $i = 0;
        $count = count($entries);

        while ($i < $count) {
            $startCid = (int) $entries[$i]->value();
            $i++;

            if ($i >= $count) {
                break;
            }

            $next = $entries[$i];

            if ($next->isArray()) {
                // Format: startCID [w1 w2 w3 ...] — consecutive CIDs
                foreach ($next->value() as $j => $w) {
                    if ($w->isNumber()) {
                        $this->widths[$startCid + $j] = (int) $w->value();
                    }
                }
                $i++;
            } elseif ($next->isNumber()) {
                // Format: startCID endCID width — range with uniform width
                $endCid = (int) $next->value();
                $i++;
                if ($i < $count && $entries[$i]->isNumber()) {
                    $width = (int) $entries[$i]->value();
                    for ($cid = $startCid; $cid <= $endCid; $cid++) {
                        $this->widths[$cid] = $width;
                    }
                    $i++;
                }
            }
        }
    }
}
```

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

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

Expected: All tests PASS.

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

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

Expected: 71 tests (62 existing + 9 new), all passing.

---

### Task 2: PdfParser::getPages() + PdfWriter::allocateObjectNumber()

**Files:**
- Modify: `src/Parser/PdfParser.php`
- Modify: `src/Writer/PdfWriter.php`
- Modify: `tests/Unit/PdfParserTest.php`
- Modify: `tests/Unit/PdfWriterTest.php`

- [ ] **Step 1: Write failing test for getPages()**

Add to `tests/Unit/PdfParserTest.php`:

```php
public function testGetPages(): void
{
    $parser = new PdfParser($this->realPdf);
    $pages = $parser->getPages();

    // The test PDF has 5 pages (confirmed from PDF structure)
    $this->assertNotEmpty($pages);

    // Each entry should be objectNumber => PdfObject
    foreach ($pages as $objNum => $page) {
        $this->assertIsInt($objNum);
        $this->assertTrue($page->isDictionary());
        $type = $page->get('Type');
        $this->assertNotNull($type);
        $this->assertEquals('Page', $type->value());
    }
}

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

    // At least one page should have /Annots (the form fields are on pages)
    $hasAnnots = false;
    foreach ($pages as $page) {
        if ($page->get('Annots') !== null) {
            $hasAnnots = true;
            break;
        }
    }
    $this->assertTrue($hasAnnots, 'At least one page should have /Annots');
}
```

- [ ] **Step 2: Run to verify failure**

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

Expected: FAIL — method `getPages()` not found.

- [ ] **Step 3: Implement `getPages()` in PdfParser**

Add this method to `src/Parser/PdfParser.php`:

```php
/**
 * Walk the page tree and return all page objects.
 * @return array<int, PdfObject> objectNumber => page dictionary
 */
public function getPages(): array
{
    $catalog = $this->getCatalog();
    $pagesRef = $catalog->get('Pages');
    if ($pagesRef === null) {
        return [];
    }

    $pages = [];
    $this->collectPages($pagesRef, $pages);
    return $pages;
}

/** @param array<int, PdfObject> &$pages */
private function collectPages(PdfObject $nodeRef, array &$pages): void
{
    $objNum = $nodeRef->isReference() ? $nodeRef->objectNumber() : 0;
    $node = $this->resolve($nodeRef);

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

    $type = $node->get('Type');
    $typeName = ($type !== null && $type->isName()) ? $type->value() : '';

    if ($typeName === 'Page') {
        $pages[$objNum] = $node;
        return;
    }

    // Pages node — recurse into Kids
    $kids = $node->get('Kids');
    if ($kids === null) {
        return;
    }
    $kidsArray = $this->resolve($kids);
    if (!$kidsArray->isArray()) {
        return;
    }

    foreach ($kidsArray->value() as $kidRef) {
        $this->collectPages($kidRef, $pages);
    }
}
```

- [ ] **Step 4: Write failing test for allocateObjectNumber()**

Add to `tests/Unit/PdfWriterTest.php`:

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

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

    $first = $writer->allocateObjectNumber();
    $second = $writer->allocateObjectNumber();

    $this->assertGreaterThan(0, $first);
    $this->assertEquals($first + 1, $second);
}

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

    $writer = new PdfWriter($data, $parser->getTrailer());
    $objNum = $writer->allocateObjectNumber();

    // Write a new object at the allocated number
    $writer->setObject($objNum, 0, PdfObject::dictionary([
        'Type' => PdfObject::name('Test'),
    ]));

    $output = $writer->write();

    // Re-parse and verify the new object exists
    $newParser = new PdfParser($output);
    $obj = $newParser->getObject($objNum, 0);
    $this->assertNotNull($obj);
    $this->assertEquals('Test', $obj->get('Type')->value());
}
```

- [ ] **Step 5: Implement `allocateObjectNumber()` in PdfWriter**

Add to `src/Writer/PdfWriter.php`:

```php
private int $nextObjectNumber = 0;

public function allocateObjectNumber(): int
{
    if ($this->nextObjectNumber === 0) {
        $this->nextObjectNumber = $this->getTrailerSize();
    }

    $num = $this->nextObjectNumber;
    $this->nextObjectNumber++;
    return $num;
}
```

Also update the `write()` method's size calculation to use `$this->nextObjectNumber` if it's been set:

In the `write()` method, after the existing size calculation loop, add:

```php
// Include allocated but potentially not-yet-written objects in size
if ($this->nextObjectNumber > $size) {
    $size = $this->nextObjectNumber;
}
```

- [ ] **Step 6: Run all tests**

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

Expected: All tests pass (72 existing + 4 new = 76).

---

### Task 3: AppearanceStreamGenerator

**Files:**
- Create: `src/Writer/AppearanceStreamGenerator.php`
- Create: `tests/Unit/AppearanceStreamGeneratorTest.php`

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

Create `tests/Unit/AppearanceStreamGeneratorTest.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\AppearanceStreamGenerator;

class AppearanceStreamGeneratorTest extends TestCase
{
    private PdfParser $parser;
    private PdfObject $acroForm;

    protected function setUp(): void
    {
        $data = file_get_contents(__DIR__ . '/../fixtures/reservation_kfde_07_2023.pdf');
        $this->parser = new PdfParser($data);
        $catalog = $this->parser->getCatalog();
        $this->acroForm = $this->parser->resolve($catalog->get('AcroForm'));
    }

    public function testGenerateForTextField(): void
    {
        $generator = new AppearanceStreamGenerator($this->parser, $this->acroForm);

        // Widget 128 is the date field on page 1:
        // /DA(/OpenSans 8 Tf 0 g) /Rect[494.291 560.875 559.276 571.748]
        $widget = $this->parser->getObject(128, 0);
        $result = $generator->generate($widget, '2024-01-15');

        $this->assertTrue($result->isStream());
        $dict = $result->streamDictionary();
        $this->assertNotNull($dict);

        // Verify Form XObject structure
        $this->assertEquals('XObject', $dict->get('Type')->value());
        $this->assertEquals('Form', $dict->get('Subtype')->value());
        $this->assertNotNull($dict->get('BBox'));
        $this->assertNotNull($dict->get('Resources'));
    }

    public function testGeneratedStreamContainsBTET(): void
    {
        $generator = new AppearanceStreamGenerator($this->parser, $this->acroForm);
        $widget = $this->parser->getObject(128, 0);
        $result = $generator->generate($widget, '2024-01-15');

        $stream = $result->streamData();
        $this->assertStringContainsString('BT', $stream);
        $this->assertStringContainsString('ET', $stream);
        $this->assertStringContainsString('Tf', $stream);
    }

    public function testGeneratedStreamContainsClipping(): void
    {
        $generator = new AppearanceStreamGenerator($this->parser, $this->acroForm);
        $widget = $this->parser->getObject(128, 0);
        $result = $generator->generate($widget, '2024-01-15');

        $stream = $result->streamData();
        $this->assertStringContainsString('re W n', $stream);
    }

    public function testGeneratedStreamContainsTextValue(): void
    {
        $generator = new AppearanceStreamGenerator($this->parser, $this->acroForm);
        $widget = $this->parser->getObject(128, 0);
        $result = $generator->generate($widget, '2024-01-15');

        $stream = $result->streamData();
        $this->assertStringContainsString('2024-01-15', $stream);
    }

    public function testBBoxMatchesWidgetRect(): void
    {
        $generator = new AppearanceStreamGenerator($this->parser, $this->acroForm);

        // Widget 128: /Rect[494.291 560.875 559.276 571.748]
        $widget = $this->parser->getObject(128, 0);
        $result = $generator->generate($widget, 'test');

        $dict = $result->streamDictionary();
        $bbox = $dict->get('BBox');
        $this->assertTrue($bbox->isArray());
        $bboxValues = $bbox->value();

        // BBox should be [0 0 width height]
        $this->assertEqualsWithDelta(0, $bboxValues[0]->value(), 0.01);
        $this->assertEqualsWithDelta(0, $bboxValues[1]->value(), 0.01);

        $width = 559.276 - 494.291;  // ~64.985
        $height = 571.748 - 560.875; // ~10.873
        $this->assertEqualsWithDelta($width, $bboxValues[2]->value(), 0.1);
        $this->assertEqualsWithDelta($height, $bboxValues[3]->value(), 0.1);
    }

    public function testResourcesContainsFont(): void
    {
        $generator = new AppearanceStreamGenerator($this->parser, $this->acroForm);
        $widget = $this->parser->getObject(128, 0);
        $result = $generator->generate($widget, 'test');

        $dict = $result->streamDictionary();
        $resources = $dict->get('Resources');
        $this->assertNotNull($resources);

        $fontDict = $resources->get('Font');
        $this->assertNotNull($fontDict);
    }

    public function testMultiLineText(): void
    {
        $generator = new AppearanceStreamGenerator($this->parser, $this->acroForm);

        // Widget 130 is tenant.address: /DA(/OpenSans-Light 7 Tf 0 g) /Rect[56.6929 697.323 276.236 714.017]
        $widget = $this->parser->getObject(130, 0);
        $result = $generator->generate($widget, "Line1\nLine2");

        $stream = $result->streamData();
        // Should contain two Tj operators (one per line)
        $this->assertGreaterThanOrEqual(2, substr_count($stream, 'Tj'));
    }

    public function testDifferentFonts(): void
    {
        $generator = new AppearanceStreamGenerator($this->parser, $this->acroForm);

        // Widget 130 uses OpenSans-Light, widget 128 uses OpenSans
        $widget130 = $this->parser->getObject(130, 0);
        $widget128 = $this->parser->getObject(128, 0);

        $result130 = $generator->generate($widget130, 'test');
        $result128 = $generator->generate($widget128, 'test');

        $stream130 = $result130->streamData();
        $stream128 = $result128->streamData();

        $this->assertStringContainsString('OpenSans-Light', $stream130);
        $this->assertStringContainsString('/OpenSans ', $stream128);
    }
}
```

- [ ] **Step 2: Run to verify failure**

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

Expected: FAIL — class `AppearanceStreamGenerator` not found.

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

```php
<?php

declare(strict_types=1);

namespace PlusForta\PdfForms\Writer;

use PlusForta\PdfForms\Parser\PdfObject;
use PlusForta\PdfForms\Parser\PdfParser;

final class AppearanceStreamGenerator
{
    public function __construct(
        private readonly PdfParser $parser,
        private readonly PdfObject $acroForm,
    ) {
    }

    /**
     * Generate an appearance stream (Form XObject) for a text field widget.
     */
    public function generate(PdfObject $widget, string $value): PdfObject
    {
        // 1. Parse /DA for font name, size, color
        $da = $this->getDA($widget);
        $fontName = $this->parseFontName($da);
        $fontSize = $this->parseFontSize($da);
        $colorOps = $this->parseColorOps($da);

        // 2. Get widget Rect
        $rect = $this->getRect($widget);
        $width = $rect[2] - $rect[0];
        $height = $rect[3] - $rect[1];

        // 3. Resolve font and create metrics
        $fontRef = $this->resolveFontRef($fontName);
        $fontDict = $this->parser->resolve($fontRef);
        $metrics = new FontMetrics($fontDict);

        // 4. Get alignment
        $qObj = $widget->get('Q');
        $alignment = ($qObj !== null && $qObj->isNumber()) ? (int) $qObj->value() : 0;

        // 5. Layout text
        $padding = 2.0;
        $availableWidth = $width - (2 * $padding);
        $lines = $metrics->wordWrap($value, $fontSize, $availableWidth);

        $leading = $fontSize * 1.2;

        // 6. Build content stream
        $stream = "/Tx BMC\n";
        $stream .= "q\n";
        $stream .= sprintf("0 0 %.4f %.4f re W n\n", $width, $height);
        $stream .= "BT\n";
        $stream .= sprintf("/%s %.4f Tf\n", $fontName, $fontSize);
        if ($colorOps !== '') {
            $stream .= $colorOps . "\n";
        }

        // Position first line
        $y = $height - $padding - $fontSize;
        if ($y < 0) {
            $y = 0;
        }

        $firstLine = true;
        $prevX = 0.0;
        foreach ($lines as $line) {
            $lineWidth = $metrics->stringWidth($line, $fontSize);

            $x = match ($alignment) {
                1 => $padding + ($availableWidth - $lineWidth) / 2, // center
                2 => $padding + ($availableWidth - $lineWidth),     // right
                default => $padding,                                 // left
            };
            if ($x < $padding) {
                $x = $padding;
            }

            if ($firstLine) {
                $stream .= sprintf("%.4f %.4f Td\n", $x, $y);
                $firstLine = false;
            } else {
                $stream .= sprintf("%.4f %.4f Td\n", $x - $prevX, -$leading);
            }

            $encodedLine = $this->encodePdfString($line);
            $stream .= $encodedLine . " Tj\n";
            $prevX = $x;
        }

        $stream .= "ET\n";
        $stream .= "Q\n";
        $stream .= "EMC";

        // 7. Build Form XObject
        $dict = PdfObject::dictionary([
            'Type' => PdfObject::name('XObject'),
            'Subtype' => PdfObject::name('Form'),
            'BBox' => PdfObject::array([
                PdfObject::number(0),
                PdfObject::number(0),
                PdfObject::number(round($width, 4)),
                PdfObject::number(round($height, 4)),
            ]),
            'Resources' => PdfObject::dictionary([
                'Font' => PdfObject::dictionary([
                    $fontName => $fontRef,
                ]),
            ]),
            'Matrix' => PdfObject::array([
                PdfObject::number(1), PdfObject::number(0),
                PdfObject::number(0), PdfObject::number(1),
                PdfObject::number(0), PdfObject::number(0),
            ]),
        ]);

        return PdfObject::stream($dict, $stream);
    }

    private function getDA(PdfObject $widget): string
    {
        // Check widget first, then parent field
        $da = $widget->get('DA');
        if ($da !== null && $da->isString()) {
            return $da->value();
        }

        // Try parent
        $parent = $widget->get('Parent');
        if ($parent !== null) {
            $parentObj = $this->parser->resolve($parent);
            $da = $parentObj->get('DA');
            if ($da !== null && $da->isString()) {
                return $da->value();
            }
        }

        // Fallback to AcroForm /DA
        $da = $this->acroForm->get('DA');
        if ($da !== null && $da->isString()) {
            return $da->value();
        }

        return '/Helvetica 12 Tf 0 g';
    }

    /** @return float[] [llx, lly, urx, ury] */
    private function getRect(PdfObject $widget): array
    {
        $rect = $widget->get('Rect');
        if ($rect === null || !$rect->isArray()) {
            return [0, 0, 100, 20];
        }

        $values = $rect->value();
        return [
            (float) $values[0]->value(),
            (float) $values[1]->value(),
            (float) $values[2]->value(),
            (float) $values[3]->value(),
        ];
    }

    private function parseFontName(string $da): string
    {
        // /FontName size Tf
        if (preg_match('/\/(\S+)\s/', $da, $m)) {
            return $m[1];
        }
        return 'Helvetica';
    }

    private function parseFontSize(string $da): float
    {
        // /FontName size Tf
        if (preg_match('/\/\S+\s+([\d.]+)\s+Tf/', $da, $m)) {
            return (float) $m[1];
        }
        return 12.0;
    }

    private function parseColorOps(string $da): string
    {
        // Extract color operators (everything after Tf)
        if (preg_match('/Tf\s+(.+)$/', $da, $m)) {
            return trim($m[1]);
        }
        return '0 g';
    }

    private function resolveFontRef(string $fontName): PdfObject
    {
        $dr = $this->acroForm->get('DR');
        if ($dr === null) {
            return PdfObject::null();
        }

        $drResolved = $this->parser->resolve($dr);
        $fonts = $drResolved->get('Font');
        if ($fonts === null) {
            return PdfObject::null();
        }

        $fontsResolved = $this->parser->resolve($fonts);
        $fontRef = $fontsResolved->get($fontName);
        if ($fontRef === null) {
            return PdfObject::null();
        }

        return $fontRef;
    }

    private function encodePdfString(string $text): string
    {
        // Convert UTF-8 to Latin-1 for PDF literal string
        $latin1 = @mb_convert_encoding($text, 'ISO-8859-1', 'UTF-8');
        $roundTrip = @mb_convert_encoding($latin1, 'UTF-8', 'ISO-8859-1');

        if ($roundTrip !== $text) {
            // Characters outside Latin-1 — use hex string with UTF-16BE BOM
            $utf16 = "\xFE\xFF" . mb_convert_encoding($text, 'UTF-16BE', 'UTF-8');
            return '<' . bin2hex($utf16) . '>';
        }

        $escaped = str_replace(
            ['\\', '(', ')'],
            ['\\\\', '\\(', '\\)'],
            $latin1
        );
        return '(' . $escaped . ')';
    }
}
```

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

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

Expected: All tests PASS.

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

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

Expected: All tests pass (76 + 8 = 84).

---

### Task 4: Wire Appearance Generation into PdfForm + needAppearances()

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

- [ ] **Step 1: Add integration tests for appearance streams and needAppearances**

Add these tests to `tests/Integration/FillPdfTest.php`:

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

    // Re-parse the output
    $parser = new \PlusForta\PdfForms\Parser\PdfParser($output);

    // Widget 128 is the date field on page 1
    // After filling, it should have an /AP /N entry
    $widget = $parser->getObject(128, 0);
    $ap = $widget->get('AP');
    $this->assertNotNull($ap, 'Widget should have /AP after filling');

    $n = $ap->get('N');
    $this->assertNotNull($n, 'Widget should have /AP /N');
}

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

    // NeedAppearances should NOT be in the output
    $this->assertStringNotContainsString('NeedAppearances', $output);
}

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

    // NeedAppearances should be in the output (default behavior)
    $this->assertStringContainsString('NeedAppearances', $output);
}
```

Also **remove** `testFlattenThrowsInPhase1` (lines 129-136 of the existing test file).

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

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

Expected: FAIL — no /AP on widget (Phase 1 doesn't generate them).

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

Replace the entire `PdfForm.php` with the updated version that:
- Generates appearance streams for text fields when producing output
- Adds `needAppearances(bool $flag)` method
- Removes the `BadMethodCallException` from `flatten()` (stub for now, real implementation in Task 5)

Key changes to `toString()`:

```php
private bool $needAppearancesFlag = true;
private bool $shouldFlatten = false;
private ?array $flattenFieldNames = null;

public function needAppearances(bool $flag): self
{
    $this->needAppearancesFlag = $flag;
    return $this;
}

public function flatten(?array $fieldNames = null): self
{
    $this->shouldFlatten = true;
    $this->flattenFieldNames = $fieldNames; // null = flatten all, array = specific fields
    return $this;
}
```

**Important:** Add these `use` imports at the top of `PdfForm.php`:

```php
use PlusForta\PdfForms\Form\FieldType;
use PlusForta\PdfForms\Writer\AppearanceStreamGenerator;
```

In `toString()`, after updating `/V` values, add appearance stream generation:

```php
// Generate appearance streams for modified text fields
$catalog = $this->parser->getCatalog();
$acroFormRef = $catalog->get('AcroForm');
$acroForm = $this->parser->resolve($acroFormRef);
$generator = new AppearanceStreamGenerator($this->parser, $acroForm);

foreach ($this->pendingValues as $name => $value) {
    if (!is_string($value)) {
        continue; // Skip non-text (checkboxes etc.)
    }

    $field = $this->reader->getField($name);
    if ($field->type !== FieldType::Text) {
        continue;
    }

    foreach ($field->widgetObjectNumbers as $widgetObjNum) {
        $widgetObj = $this->parser->getObject($widgetObjNum, 0);
        if ($widgetObj === null) {
            continue;
        }

        $apStream = $generator->generate($widgetObj, $value);
        $apObjNum = $writer->allocateObjectNumber();
        $writer->setObject($apObjNum, 0, $apStream);

        // Update widget to reference the new AP stream
        $ap = PdfObject::dictionary([
            'N' => PdfObject::reference($apObjNum, 0),
        ]);
        $widgetObj = $widgetObj->set('AP', $ap);
        $writer->setObject($widgetObjNum, 0, $widgetObj);
    }
}

// NeedAppearances control
if ($this->needAppearancesFlag) {
    $acroForm = $acroForm->set('NeedAppearances', PdfObject::boolean(true));
} else {
    $acroForm = $acroForm->remove('NeedAppearances');
}
```

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

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

Expected: All tests pass. The `testFlattenThrowsInPhase1` test has been removed. New appearance stream and needAppearances tests pass.

---

### Task 5: Implement Flattening

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

- [ ] **Step 1: Add integration tests for flattening**

Add to `tests/Integration/FillPdfTest.php`:

```php
public function testFlattenAll(): void
{
    PdfForm::open($this->fixturePath)
        ->fill([
            'date' => '2024-01-15',
            'tenant.email' => 'test@example.com',
        ])
        ->flatten()
        ->save($this->outputPath);

    // Re-parse: no AcroForm should exist
    $parser = new \PlusForta\PdfForms\Parser\PdfParser(file_get_contents($this->outputPath));
    $catalog = $parser->getCatalog();
    $this->assertNull($catalog->get('AcroForm'), 'Catalog should not have /AcroForm after full flatten');

    // Verify the output is a valid, parseable PDF
    $this->assertStringStartsWith('%PDF-', file_get_contents($this->outputPath));
}

public function testFlattenSpecificFields(): void
{
    PdfForm::open($this->fixturePath)
        ->fill([
            'date' => '2024-01-15',
            'tenant.email' => 'test@example.com',
        ])
        ->flatten(['date'])
        ->save($this->outputPath);

    // Re-parse: AcroForm should still exist (partial flatten)
    $parser = new \PlusForta\PdfForms\Parser\PdfParser(file_get_contents($this->outputPath));
    $catalog = $parser->getCatalog();
    $acroForm = $parser->resolve($catalog->get('AcroForm'));
    $this->assertNotNull($acroForm, 'AcroForm should exist after partial flatten');

    // The remaining fields should still be accessible
    $reader = new \PlusForta\PdfForms\Form\AcroFormReader($parser);
    $this->assertTrue($reader->hasField('tenant.email'), 'Unflatten field should remain');
}

public function testFlattenProducesValidPdf(): 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',
        ])
        ->flatten()
        ->save($this->outputPath);

    // The output should be parseable
    $parser = new \PlusForta\PdfForms\Parser\PdfParser(file_get_contents($this->outputPath));
    $this->assertNotNull($parser->getCatalog());

    // Pages should still exist
    $pages = $parser->getPages();
    $this->assertNotEmpty($pages);
}

public function testFlattenChainsWithFill(): void
{
    $form = PdfForm::open($this->fixturePath);
    $result = $form->fill(['date' => '2024-01-15'])->flatten();
    $this->assertSame($form, $result);
}
```

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

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

Expected: FAIL — flatten() doesn't actually flatten yet.

- [ ] **Step 3: Implement flattening in PdfForm::toString()**

Add flattening logic to the end of `toString()`, before `return $writer->write()`:

```php
// Flattening
if ($this->shouldFlatten) {
    $this->performFlatten($writer, $acroFormRef, $acroForm);
}
```

New private method:

```php
private function performFlatten(PdfWriter $writer, PdfObject $acroFormRef, PdfObject $acroForm): void
{
    $flattenAll = ($this->flattenFieldNames === null);
    $fieldsToFlatten = $flattenAll
        ? array_keys($this->reader->getFields())
        : $this->flattenFieldNames;

    // Validate field names
    foreach ($fieldsToFlatten as $name) {
        if (!$this->reader->hasField($name)) {
            throw new FieldNotFoundException("Field not found: $name");
        }
    }

    // Build page-to-widget mapping
    $pages = $this->parser->getPages();
    $widgetSet = [];
    foreach ($fieldsToFlatten as $name) {
        $field = $this->reader->getField($name);
        foreach ($field->widgetObjectNumbers as $wObjNum) {
            $widgetSet[$wObjNum] = true;
        }
    }

    // Process each page
    $flatCounter = 0;
    foreach ($pages as $pageObjNum => $pageObj) {
        $annotsRef = $pageObj->get('Annots');
        if ($annotsRef === null) {
            continue;
        }

        $annots = $this->parser->resolve($annotsRef);
        if (!$annots->isArray()) {
            continue;
        }

        $doOperators = '';
        $newXObjects = [];
        $remainingAnnots = [];

        foreach ($annots->value() as $annotRef) {
            $annotObjNum = $annotRef->isReference() ? $annotRef->objectNumber() : 0;

            if (!isset($widgetSet[$annotObjNum])) {
                $remainingAnnots[] = $annotRef;
                continue;
            }

            // This widget should be flattened
            $widgetObj = $this->parser->getObject($annotObjNum, 0);
            if ($widgetObj === null) {
                $remainingAnnots[] = $annotRef;
                continue;
            }

            // Get the AP stream (should have been generated in the appearance step)
            $ap = $widgetObj->get('AP');
            if ($ap === null) {
                $remainingAnnots[] = $annotRef;
                continue;
            }
            $nRef = $ap->get('N');
            if ($nRef === null) {
                $remainingAnnots[] = $annotRef;
                continue;
            }

            $flatCounter++;
            $xobjName = 'Flat' . $flatCounter;

            // Get widget rect for positioning
            $rect = $widgetObj->get('Rect');
            if ($rect !== null && $rect->isArray()) {
                $rectValues = $rect->value();
                $llx = (float) $rectValues[0]->value();
                $lly = (float) $rectValues[1]->value();

                $doOperators .= sprintf("q 1 0 0 1 %.4f %.4f cm /%s Do Q\n", $llx, $lly, $xobjName);
                $newXObjects[$xobjName] = $nRef;
            }
        }

        if (empty($newXObjects)) {
            continue;
        }

        // Create new content stream with Do operators
        $doStreamObjNum = $writer->allocateObjectNumber();
        $doStream = PdfObject::stream(
            PdfObject::dictionary([]),
            $doOperators
        );
        $writer->setObject($doStreamObjNum, 0, $doStream);

        // Update page /Contents
        $contentsRef = $pageObj->get('Contents');
        if ($contentsRef !== null && $contentsRef->isArray()) {
            // Already an array — append
            $items = $contentsRef->value();
            $items[] = PdfObject::reference($doStreamObjNum, 0);
            $pageObj = $pageObj->set('Contents', PdfObject::array($items));
        } elseif ($contentsRef !== null) {
            // Single reference — convert to array
            $pageObj = $pageObj->set('Contents', PdfObject::array([
                $contentsRef,
                PdfObject::reference($doStreamObjNum, 0),
            ]));
        }

        // Update page /Resources /XObject (resolve indirect references first)
        $resourcesRef = $pageObj->get('Resources');
        if ($resourcesRef !== null) {
            $resources = $this->parser->resolve($resourcesRef);
            if ($resources->isDictionary()) {
                $xobjectsRef = $resources->get('XObject');
                $xobjects = ($xobjectsRef !== null) ? $this->parser->resolve($xobjectsRef) : PdfObject::dictionary([]);
                if (!$xobjects->isDictionary()) {
                    $xobjects = PdfObject::dictionary([]);
                }
                foreach ($newXObjects as $name => $ref) {
                    $xobjects = $xobjects->set($name, $ref);
                }
                $resources = $resources->set('XObject', $xobjects);
                $pageObj = $pageObj->set('Resources', $resources);
            }
        }

        // Update page /Annots
        if (empty($remainingAnnots)) {
            $pageObj = $pageObj->remove('Annots');
        } else {
            $pageObj = $pageObj->set('Annots', PdfObject::array($remainingAnnots));
        }

        // Also remove the Annots indirect object if it was a separate object
        $writer->setObject($pageObjNum, 0, $pageObj);
    }

    // Update catalog
    $catalog = $this->parser->getCatalog();
    $rootRef = $this->parser->getTrailer()->get('Root');
    $catalogObjNum = $rootRef->isReference() ? $rootRef->objectNumber() : 0;

    if ($flattenAll) {
        // Remove AcroForm entirely
        $catalog = $catalog->remove('AcroForm');
        $writer->setObject($catalogObjNum, 0, $catalog);
    } else {
        // Rebuild Fields array without flattened fields
        $remainingFields = [];
        $allFields = $this->reader->getFields();
        $fieldsArray = $this->parser->resolve($acroForm->get('Fields'));

        if ($fieldsArray->isArray()) {
            foreach ($fieldsArray->value() as $fieldRef) {
                // Keep top-level field refs that still have unflattened descendants
                $remainingFields[] = $fieldRef;
            }
        }

        // For simplicity, keep the Fields array as-is for partial flatten.
        // The flattened field objects still exist but their widgets are removed from pages.
        // A more thorough implementation would rebuild the field tree.
        $acroFormObjNum = $acroFormRef->isReference() ? $acroFormRef->objectNumber() : 0;
        $writer->setObject($acroFormObjNum, 0, $acroForm);
    }
}
```

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

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

Expected: All tests pass.

---

### Task 6: Manual Verification

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

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

Create `test_fill_phase2.php`:

```php
<?php

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

use PlusForta\PdfForms\PdfForm;

// Test 1: Fill with appearance streams (no flatten)
PdfForm::open(__DIR__ . '/reservation_kfde_07_2023.pdf')
    ->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',
    ])
    ->needAppearances(false)
    ->save(__DIR__ . '/output_appearances.pdf');

echo "Saved output_appearances.pdf (with AP streams, no NeedAppearances)\n";

// Test 2: Fill and flatten
PdfForm::open(__DIR__ . '/reservation_kfde_07_2023.pdf')
    ->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',
    ])
    ->flatten()
    ->save(__DIR__ . '/output_flattened.pdf');

echo "Saved output_flattened.pdf (flattened)\n";
echo "Open both in Preview to verify.\n";
```

- [ ] **Step 2: Run**

```bash
herd php test_fill_phase2.php
```

- [ ] **Step 3: Open and verify visually**

```bash
open output_appearances.pdf output_flattened.pdf
```

Verify:
- `output_appearances.pdf`: fields show values, fields are still editable (click to edit)
- `output_flattened.pdf`: values visible, fields are NOT editable (no form fields)

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

```bash
rm test_fill_phase2.php output_appearances.pdf output_flattened.pdf
```

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

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

Expected: All tests pass. Phase 2 complete.
