# Design Spec: plusforta/pdf-forms — Phase 2

**Date:** 2026-04-04
**Status:** Draft
**Package:** `plusforta/pdf-forms`
**Namespace:** `PlusForta\PdfForms`
**Depends on:** Phase 1 (complete — 62 tests passing)

## Goal

Add appearance stream generation and flattening to the PDF forms library. Phase 1 relies on `/NeedAppearances true` which works in conformant viewers (Preview.app, Adobe Reader) but not all renderers. Phase 2 generates actual `/AP` content streams so filled values render universally, and unlocks the `flatten()` method.

## Constraints

- Same as Phase 1: PHP 8.1+, zero dependencies, pure PHP
- Must not break existing Phase 1 API (the `testFlattenThrowsInPhase1` test is removed, replaced by real flatten tests)
- Text rendering must be professional — properly sized and positioned within field rectangles
- Font metrics come from the PDF's embedded font data (no external font files)

## Scope

1. **Appearance stream generation** — build `/AP /N` streams for filled text fields
2. **Flattening** — bake appearance streams into page content as static graphics
3. **`/NeedAppearances` control** — let the caller choose whether to set the flag

Checkbox, radio, and dropdown appearance streams are out of scope for this phase (they use pre-defined appearance states, not generated text).

## Public API Changes

### `flatten()` — no longer throws

```php
// Flatten all fields (generates appearance streams, bakes into pages)
PdfForm::open('template.pdf')
    ->fill([...])
    ->flatten()
    ->save('output.pdf');

// Flatten specific fields only
PdfForm::open('template.pdf')
    ->fill([...])
    ->flatten(['date', 'tenant.email'])
    ->save('output.pdf');
```

`flatten()` accepts an optional `string[]` of field names. If omitted, all fields are flattened. Returns `$this` for chaining.

### `needAppearances()` — new method

```php
// Don't set /NeedAppearances (appearance streams are generated, flag is redundant)
PdfForm::open('template.pdf')
    ->fill([...])
    ->needAppearances(false)
    ->save('output.pdf');

// Explicitly set it (default behavior, backward compatible)
PdfForm::open('template.pdf')
    ->fill([...])
    ->needAppearances(true)
    ->save('output.pdf');
```

Controls whether `/NeedAppearances true` is written to the AcroForm dictionary. Default is `true` for backward compatibility.

### Behavior change in `toString()` / `save()`

When `fill()` or `set()` has been called, the library now **generates appearance streams for modified text fields** (`/FT /Tx`), in addition to setting `/V`. This ensures text values render in all viewers. Non-text fields (checkboxes, radios, dropdowns) continue to rely on their existing `/AP` entries and the `/NeedAppearances` flag — appearance stream generation for those types is out of scope for this phase.

The `/NeedAppearances` flag is controlled separately via `needAppearances()`.

## New Components

### AppearanceStreamGenerator

`src/Writer/AppearanceStreamGenerator.php`

Generates PDF Form XObject streams for filled text field widgets. One class, one responsibility.

**Pipeline:**

1. **Parse `/DA`** — Extract font name, size, and color from the widget's or field's default appearance string (e.g. `/OpenSans 8 Tf 0 g`). The `/DA` string uses the same operators as a content stream.

2. **Resolve font resource** — Look up the font name in the `/AcroForm /DR /Font` dictionary (the default resources). This gives us the font dictionary, which contains the `/W` (widths) array for CID fonts or `/Widths` array for simple fonts.

3. **Measure and layout text** — Using `FontMetrics`, calculate character widths and perform word-wrapping within the field's bounding box. Handle explicit `\n` line breaks.

4. **Position text** — Calculate x/y coordinates based on:
   - Widget `/Rect` (bounding box in page coordinates)
   - `/Q` alignment flag (0=left, 1=center, 2=right)
   - Font size and line height
   - A small padding inset from the rect edges

5. **Emit content stream** — Generate PDF drawing operators:
   ```
   /Tx BMC
   q
   0 0 width height re W n
   BT
   /FontName size Tf
   color rg
   x y Td
   (line1) Tj
   0 -leading Td
   (line2) Tj
   ET
   Q
   EMC
   ```
   The `re W n` sequence clips text to the field rectangle, preventing overflow.

6. **Build Form XObject** — Wrap the content stream in a PdfObject stream with:
   - `/Type /XObject`
   - `/Subtype /Form`
   - `/BBox [0 0 width height]` (derived from widget `/Rect`)
   - `/Resources` (referencing the font)
   - `/Matrix [1 0 0 1 0 0]`

**Input:** Widget annotation `PdfObject`, field value (`string`), `PdfParser` (for font resolution).

**Output:** `PdfObject` stream (the appearance XObject), plus the next available object number for allocation.

### FontMetrics

`src/Writer/FontMetrics.php`

Calculates text widths using embedded font data from the PDF. Standalone, testable without PDF parsing.

**Handles two font types:**

1. **Simple TrueType / Type1 fonts (primary path)** — the form fonts in the test PDF (`OpenSans`, `OpenSans-Light`, `OpenSans-Semibold` in `/AcroForm /DR`) are simple TrueType fonts (`/Subtype /TrueType`) with:
   - `/Widths` array: 256 entries indexed by character code from `/FirstChar` to `/LastChar`
   - `/Encoding`: e.g. `MacRomanEncoding` with optional `/Differences` array
   - Widths are in units of 1/1000 of the font size

   For width lookup, the character code (byte value after Latin-1 encoding) indexes directly into the `/Widths` array at position `charCode - FirstChar`. The `/Encoding /Differences` array may remap specific codes to different glyph names, but the `/Widths` array is already indexed by character code, so differences do not affect width lookup.

2. **CIDFontType2 fonts (secondary path)** — used for page content text (not form fields in the test PDF, but may appear in other PDFs). Widths in `/W` array:
   ```
   /W [3 [260 276] 15 [267] 68 [580] ...]
   ```
   Format: `startCID [width1 width2 ...]` for consecutive CIDs, or `startCID endCID width` for a range with uniform width. `/DW` provides default width (typically 1000). For CID fonts with `/CIDToGIDMap /Identity`, the CID equals the Unicode code point.

**Public interface:**

```php
final class FontMetrics
{
    public function __construct(PdfObject $fontDictionary, PdfParser $parser);

    /** Width of a single character in font units (1/1000 of font size) */
    public function charWidth(int $charCode): int;

    /** Width of a string in PDF points at the given font size */
    public function stringWidth(string $text, float $fontSize): float;

    /** Break text into lines that fit within maxWidth points at the given font size */
    public function wordWrap(string $text, float $fontSize, float $maxWidth): array;
}
```

The constructor detects the font type from `/Subtype` and loads the appropriate width table. For simple fonts, it reads `/FirstChar`, `/LastChar`, and `/Widths`. For CID fonts, it reads `/W` and `/DW` from the descendant font dictionary.

### PdfWriter modifications

`src/Writer/PdfWriter.php` — moderate additions:

- **`allocateObjectNumber(): int`** — returns the next available object number for new objects (appearance streams, new content streams). Reads `/Size` from the trailer on first call, increments an internal counter for subsequent calls. `write()` uses the final counter value for the new trailer's `/Size`.
- **Page content stream appending** — for flattening, a new content stream (containing `Do` operators) is created as a new object. If the page's `/Contents` is a single reference, it is replaced with an array `[originalRef, newStreamRef]`. If already an array, the new reference is appended. The existing content streams are **never modified** — they remain compressed and untouched.
- **Page resource modification** — add XObject entries to a page's `/Resources /XObject` dictionary for the flattened appearance streams.
- **Annotation removal** — remove widget entries from a page's `/Annots` array.
- **Catalog modification** — for full flatten, rewrite the catalog object with `/AcroForm` removed. The catalog's object number is obtained from the trailer's `/Root` reference.

### PdfParser additions

`src/Parser/PdfParser.php` — one new method:

- **`getPages(): array`** — walks the page tree (`Catalog → /Pages → /Kids`) and returns an array of `[objectNumber => PdfObject]` for each page. This is needed by flattening to find which page each widget annotation belongs to (by checking the page's `/Annots` array).

### PdfForm modifications

`src/PdfForm.php` — wire the new components:

- `flatten(?array $fieldNames = null): self` — no longer throws. Stores the flatten request; execution happens in `toString()`.
- `needAppearances(bool $flag): self` — stores the flag for output.
- `toString()` — updated to:
  1. Generate appearance streams for all modified fields
  2. If flattening: bake appearances into pages, remove form structure
  3. If `needAppearances` is true (default): set the flag on AcroForm

## Flattening Process

When `flatten()` is called, `toString()` performs these steps:

### Page-to-widget mapping

Before flattening, build a map of which widgets are on which page:

1. Call `PdfParser::getPages()` to get all page objects with their object numbers
2. For each page, examine its `/Annots` array
3. Match annotation object numbers against `Field::widgetObjectNumbers`
4. Result: a map of `pageObjectNumber → [widgetObjectNumber, ...]`

### Full flatten (no field names specified):

1. For each field with pending values, generate appearance streams for all its widgets
2. For each page that contains widget annotations:
   a. Create appearance XObjects as new PDF objects (via `allocateObjectNumber()`)
   b. For each widget on this page:
      - Add the XObject to the page's `/Resources /XObject` dictionary (e.g. `/Flat1`)
      - Remove the widget from the page's `/Annots` array
   c. Create a new content stream containing the `Do` operators for all flattened widgets on this page:
      ```
      q 1 0 0 1 llx lly cm /Flat1 Do Q
      q 1 0 0 1 llx2 lly2 cm /Flat2 Do Q
      ```
      Where `llx, lly` are `Rect[0], Rect[1]` — the lower-left corner of the widget in page coordinates. (Assumes page origin at (0,0), which is the standard case.)
   d. Append the new content stream to the page's `/Contents`:
      - If `/Contents` is a single reference: replace with array `[originalRef, newStreamRef]`
      - If `/Contents` is already an array: append `newStreamRef` to the end
      - The existing content streams are **never decompressed or modified**
3. Rewrite the catalog object with `/AcroForm` removed

### Partial flatten (specific field names):

Same as above, but only for the named fields. After flattening:
- Rebuild the `/AcroForm /Fields` array without the flattened fields
- Keep `/AcroForm` in the catalog (other fields remain interactive)
- Unflatten fields retain their original widget annotations

## Error Handling

No new exception types. Existing exceptions cover the cases:
- `InvalidPdfException` — if font resources can't be resolved or `/DA` is malformed
- `FieldNotFoundException` — if `flatten()` receives an unknown field name

## Limitations

- Appearance stream generation is for **text fields only** in this phase. Checkboxes, radios, and dropdowns retain their existing `/AP` entries.
- Text that overflows the field rectangle is clipped (not auto-sized). The field's specified font size is used as-is.
- Right-to-left text is not supported.
- Font subsetting is not modified — the library uses whatever fonts are already embedded.

## Testing Strategy

### Breaking test change

The Phase 1 test `testFlattenThrowsInPhase1` is **removed** and replaced by real flatten integration tests.

### Unit tests

**FontMetricsTest:**
- Parse a simple TrueType font `/Widths` array, verify char widths with `/FirstChar`/`/LastChar`
- Parse a CIDFontType2 `/W` array, verify char widths
- Handle `/DW` default width
- Calculate string width at a given font size
- Word-wrap: single line, multi-line, explicit `\n`, long words

**AppearanceStreamGeneratorTest:**
- Generate an appearance stream for a text field widget
- Verify the output is a valid Form XObject (has `/BBox`, `/Resources`, `/Subtype /Form`)
- Verify the content stream contains `BT ... ET` operators
- Verify font reference matches the `/DA` font
- Test multi-line text rendering
- Test `/Q` alignment (left, center, right)

### Integration tests (additions to FillPdfTest)

- Fill + save → re-parse → verify `/AP /N` streams exist on widget annotations
- Fill + `flatten()` → re-parse → verify no `/AcroForm` in catalog, no widget annotations in `/Annots`
- Fill + `flatten(['date'])` → verify `date` is flattened but other fields remain interactive
- Fill + `needAppearances(false)` → verify flag is absent from AcroForm
- Fill + flatten → open in Preview → visual verification (manual)
- Verify the output PDF can be re-parsed without errors (structural validity)

## Updated Project Structure

```
plusforta/pdf-forms/
├── src/
│   ├── PdfForm.php                          (modified)
│   ├── Parser/
│   │   ├── PdfParser.php                    (modified: getPages())
│   │   └── PdfObject.php                    (unchanged)
│   ├── Form/
│   │   ├── FormReaderInterface.php          (unchanged)
│   │   ├── AcroFormReader.php               (unchanged)
│   │   ├── Field.php                        (unchanged)
│   │   └── FieldType.php                    (unchanged)
│   ├── Writer/
│   │   ├── PdfWriter.php                    (modified: allocateObjectNumber, page content appending)
│   │   ├── AppearanceStreamGenerator.php    (new)
│   │   └── FontMetrics.php                  (new)
│   └── Exception/
│       └── (unchanged)
├── tests/
│   ├── Unit/
│   │   ├── FontMetricsTest.php              (new)
│   │   ├── AppearanceStreamGeneratorTest.php (new)
│   │   └── (existing unchanged)
│   ├── Integration/
│   │   └── FillPdfTest.php                  (modified: new flatten/appearance tests)
│   └── fixtures/
│       └── (existing unchanged)
└── phpunit.xml                              (unchanged)
```
