Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.11% covered (success)
91.11%
205 / 225
65.22% covered (warning)
65.22%
15 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
Layout
91.11% covered (success)
91.11%
205 / 225
65.22% covered (warning)
65.22%
15 / 23
126.61
0.00% covered (danger)
0.00%
0 / 1
 render
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 renderBareControl
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 renderNode
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 renderText
77.78% covered (success)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
3.10
 resolveTextLines
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 measureTextHeight
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 renderControl
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
10
 collectRadioGroups
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 collectRadioGroupsRecursive
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
7
 warmUpFonts
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 linearizeForm
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 linearizeNode
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 measureRadioGroupSpan
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
6.01
 applyAppearance
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 buildField
84.13% covered (success)
84.13%
53 / 63
0.00% covered (danger)
0.00%
0 / 1
32.36
 resolveControlHeight
75.00% covered (success)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 controlDefaultHeight
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
10.05
 resolveFormName
71.43% covered (success)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
5.58
 resolveDefaultFormName
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 autoFieldName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveWidth
77.78% covered (success)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
7.54
 resolveHeight
85.71% covered (success)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 resolvePercentValue
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Pdf\Build\Html\Form;
16
17use Pop\Dom\Child;
18use Pop\Pdf\Build\Html\Exception;
19use Pop\Pdf\Build\Html\Parser;
20use Pop\Pdf\Document;
21use Pop\Pdf\Document\Page\Field;
22
23/**
24 * Pdf HTML form layout class
25 *
26 * Converts a <form> subtree's controls into Document\Page\Field\* objects,
27 * block-positioned the same way Build\Html\Table\Layout lays out table
28 * rows - every control occupies its own rectangle and the cursor advances
29 * past it. Label/text content inside a <form> is rendered with simple,
30 * single-style per-line text placement, mirroring how Table\Layout already
31 * renders cell text - not the full nested inline-styling ordinary
32 * paragraphs get elsewhere in the parser.
33 *
34 * @category   Pop
35 * @package    Pop\Pdf
36 * @author     Nick Sagona, III <nick@popphp.org>
37 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
38 * @license    https://www.popphp.org/license     New BSD License
39 * @version    6.2.0
40 */
41class Layout
42{
43
44    /**
45     * Vertical gap left after each control, in points
46     */
47    protected const CONTROL_GAP = 4;
48
49    /**
50     * Render a <form> node's controls onto the document
51     *
52     * @param  Parser $parser
53     * @param  Child  $formNode
54     * @param  array  $styles
55     * @param  int    $startX
56     * @param  float  $startY
57     * @return void
58     */
59    public static function render(Parser $parser, Child $formNode, array $styles, int $startX, float $startY): void
60    {
61        $formName  = self::resolveFormName($parser, $formNode);
62        $consumedY = (float) $parser->getY();
63        $currentY  = $startY;
64
65        // Registers every font any node in the subtree could ever need
66        // BEFORE either the dry-run measurement pass or the real render
67        // loop below reads a single style - see warmUpFonts()'s own
68        // docblock for why this has to happen first, unconditionally, over
69        // the whole subtree.
70        foreach ($formNode->getChildNodes() as $child) {
71            self::warmUpFonts($parser, $child);
72        }
73
74        // Precomputed once per <form>, so a radio group's total size is
75        // known before its first option is rendered - see renderControl()'s
76        // page-break-ahead-of-a-group check and its own docblock. $sequence
77        // is a dry-run linearization of the whole subtree (see
78        // linearizeForm()) used to measure a group's REAL footprint -
79        // everything rendered between its first and last option, not just
80        // the options themselves.
81        $radioGroups        = self::collectRadioGroups($formNode);
82        $handledRadioGroups = [];
83        $sequence           = self::linearizeForm($parser, $formNode, $startX);
84
85        foreach ($formNode->getChildNodes() as $child) {
86            [$consumedY, $currentY] = self::renderNode(
87                $parser, $child, $formName, $startX, $consumedY, $currentY, $radioGroups, $handledRadioGroups, $sequence
88            );
89        }
90
91        $parser->setY((int) round($consumedY));
92        $parser->setYOverride((int) round($currentY));
93    }
94
95    /**
96     * Render a single form control with no <form> ancestor
97     *
98     * @param  Parser $parser
99     * @param  Child  $node
100     * @param  int    $startX
101     * @param  float  $startY
102     * @return void
103     */
104    public static function renderBareControl(Parser $parser, Child $node, int $startX, float $startY): void
105    {
106        $formName = self::resolveDefaultFormName($parser);
107        [$consumedY, $currentY] = self::renderControl($parser, $node, $formName, $startX, (float) $parser->getY(), $startY);
108
109        $parser->setY((int) round($consumedY));
110        $parser->setYOverride((int) round($currentY));
111    }
112
113    /**
114     * Render one child of a <form> node - a control is converted to a
115     * field; any other node has its own leaf text (if any) rendered, then
116     * is recursed into so that controls/labels nested below wrapper
117     * elements (a <div> or <p> wrapping a <label>+<input> pair, say) are
118     * not silently dropped
119     *
120     * @param  Parser $parser
121     * @param  Child  $node
122     * @param  string $formName
123     * @param  int    $x
124     * @param  float  $consumedY
125     * @param  float  $currentY
126     * @param  array  $radioGroups
127     * @param  array  $handledRadioGroups
128     * @param  array  $sequence
129     * @return array
130     */
131    protected static function renderNode(
132        Parser $parser, Child $node, string $formName, int $x, float $consumedY, float $currentY,
133        array $radioGroups = [], array &$handledRadioGroups = [], array $sequence = []
134    ): array
135    {
136        if (in_array($node->getNodeName(), ['input', 'select', 'textarea', 'button'])) {
137            return self::renderControl($parser, $node, $formName, $x, $consumedY, $currentY, $radioGroups, $handledRadioGroups, $sequence);
138        }
139
140        $text = trim((string) $node->getNodeValue());
141        if ($text !== '') {
142            [$consumedY, $currentY] = self::renderText($parser, $node, $text, $x, $consumedY, $currentY);
143        }
144
145        if ($node->hasChildNodes()) {
146            foreach ($node->getChildNodes() as $child) {
147                [$consumedY, $currentY] = self::renderNode(
148                    $parser, $child, $formName, $x, $consumedY, $currentY, $radioGroups, $handledRadioGroups, $sequence
149                );
150            }
151        }
152
153        return [$consumedY, $currentY];
154    }
155
156    /**
157     * Render a node's own leaf text as simple, single-style per-line text,
158     * baseline-aligned the same way Table\Layout::drawRow() places its own
159     * cell text (one line-height below the cursor's top edge, not at the
160     * raw, unadjusted cursor position)
161     *
162     * @param  Parser $parser
163     * @param  Child  $node
164     * @param  string $text
165     * @param  int    $x
166     * @param  float  $consumedY
167     * @param  float  $currentY
168     * @return array
169     */
170    protected static function renderText(Parser $parser, Child $node, string $text, int $x, float $consumedY, float $currentY): array
171    {
172        [$lines, $styles] = self::resolveTextLines($parser, $node, $text, $x);
173
174        foreach ($lines as $line) {
175            if ($currentY <= $parser->getPageBottomMargin()) {
176                $currentY  = $parser->newPage();
177                $consumedY = 0;
178            }
179            $parser->getPage()->addText(new Document\Page\Text($line, $styles['fontSize']), $styles['currentFont'], $x, (int) round($currentY - $styles['fontSize']));
180            $consumedY += $styles['lineHeight'];
181            $currentY  -= $styles['lineHeight'];
182        }
183
184        return [$consumedY, $currentY];
185    }
186
187    /**
188     * Resolve a leaf-text node's wrapped lines and the styles used to draw
189     * them - the shared calculation behind both renderText()'s actual
190     * drawing and measureTextHeight()'s dry-run measurement, so the two
191     * passes can never disagree about how a node's text wraps
192     *
193     * @param  Parser $parser
194     * @param  Child  $node
195     * @param  string $text
196     * @param  int    $x
197     * @return array
198     */
199    protected static function resolveTextLines(Parser $parser, Child $node, string $text, int $x): array
200    {
201        $styles     = $parser->prepareNodeStyles($node->getNodeName(), $node->getAttributes());
202        $fontObject = $parser->document()->getFont($styles['currentFont']);
203        $wrapLength = $parser->getPage()->getWidth() - $parser->getPageRightMargin() - $x;
204        $lines      = $parser->getStringLines($text, $styles['fontSize'], (int) $wrapLength, $fontObject);
205
206        return [$lines, $styles];
207    }
208
209    /**
210     * Dry-run counterpart to renderText() - the total height a leaf-text
211     * node's wrapped lines will consume, without drawing anything; used by
212     * linearizeNode() to build the measurement sequence
213     *
214     * @param  Parser $parser
215     * @param  Child  $node
216     * @param  string $text
217     * @param  int    $x
218     * @return float
219     */
220    protected static function measureTextHeight(Parser $parser, Child $node, string $text, int $x): float
221    {
222        [$lines, $styles] = self::resolveTextLines($parser, $node, $text, $x);
223        return count($lines) * $styles['lineHeight'];
224    }
225
226    /**
227     * Convert one form-control node into a Field object, size and position
228     * it, and add it to the current page
229     *
230     * @param  Parser $parser
231     * @param  Child  $node
232     * @param  string $formName
233     * @param  int    $x
234     * @param  float  $consumedY
235     * @param  float  $currentY
236     * @param  array  $radioGroups
237     * @param  array  $handledRadioGroups
238     * @param  array  $sequence
239     * @return array
240     */
241    protected static function renderControl(
242        Parser $parser, Child $node, string $formName, int $x, float $consumedY, float $currentY,
243        array $radioGroups = [], array &$handledRadioGroups = [], array $sequence = []
244    ): array
245    {
246        // Keep an HTML radio group intact on one page rather than letting it
247        // straddle a page break - a split group produces two same-named
248        // top-level AcroForm fields (invalid PDF field naming, and it breaks
249        // radio exclusivity across the split), since groupRadioFields() only
250        // ever sees one page's worth of fields at a time. This look-ahead
251        // only runs once, at the FIRST not-yet-handled option of a given
252        // group (tracked via $handledRadioGroups, threaded by reference
253        // through the render recursion the same way $consumedY/$currentY are
254        // threaded by value) - not once per option. If a single group is
255        // larger than one full page's usable height, it is still allowed to
256        // split (a narrow, accepted limitation - see the design spec's Error
257        // Handling section) - forcing a page break here doesn't prevent
258        // that, it only prevents an AVOIDABLE split.
259        //
260        // The group's needed height is measured via measureRadioGroupSpan()
261        // against $sequence - a dry-run linearization of the whole <form>
262        // (see linearizeForm()) - as everything rendered from the group's
263        // first option through its last, INCLUSIVE. Real radio-button HTML
264        // almost always has a <label> (or other text) between/around each
265        // option, so summing only the options' own heights (the original,
266        // buggy version of this look-ahead) under-measured the group and
267        // still let it straddle a page break whenever labels were present.
268        $isRadio = ($node->getNodeName() === 'input') && $node->hasAttribute('type')
269            && (strtolower($node->getAttribute('type')) === 'radio') && $node->hasAttribute('name');
270
271        if ($isRadio) {
272            $groupName = $node->getAttribute('name');
273            if (!isset($handledRadioGroups[$groupName]) && isset($radioGroups[$groupName]) && (count($radioGroups[$groupName]) >= 2)) {
274                $handledRadioGroups[$groupName] = true;
275
276                $groupHeight = self::measureRadioGroupSpan($sequence, $radioGroups[$groupName]);
277
278                if ($currentY - $groupHeight <= $parser->getPageBottomMargin()) {
279                    $currentY  = $parser->newPage();
280                    $consumedY = 0;
281                }
282            }
283        }
284
285        [$field, $width, $height] = self::buildField($parser, $node);
286        self::applyAppearance($field, $parser, $node);
287
288        if ($currentY - $height <= $parser->getPageBottomMargin()) {
289            $currentY  = $parser->newPage();
290            $consumedY = 0;
291        }
292
293        $field->setWidth((int) $width)->setHeight((int) $height);
294        $parser->getPage()->addField($field, $formName, $x, (int) round($currentY - $height));
295
296        $consumedY += $height + self::CONTROL_GAP;
297        $currentY  -= $height + self::CONTROL_GAP;
298
299        return [$consumedY, $currentY];
300    }
301
302    /**
303     * Walk an entire <form> subtree (all descendants, not just direct
304     * children) and collect every <input type="radio"> node that has a
305     * `name` attribute, keyed by that name, in document order - used by
306     * renderControl()'s look-ahead page-break check so a radio group's total
307     * size is known before its first option is rendered, without having to
308     * re-walk the subtree once per option
309     *
310     * @param  Child $formNode
311     * @return array
312     */
313    protected static function collectRadioGroups(Child $formNode): array
314    {
315        $groups = [];
316        self::collectRadioGroupsRecursive($formNode, $groups);
317        return $groups;
318    }
319
320    /**
321     * Recursive worker for collectRadioGroups()
322     *
323     * @param  Child $node
324     * @param  array $groups
325     * @return void
326     */
327    private static function collectRadioGroupsRecursive(Child $node, array &$groups): void
328    {
329        foreach ($node->getChildNodes() as $child) {
330            if (($child->getNodeName() === 'input') && $child->hasAttribute('type')
331                && (strtolower($child->getAttribute('type')) === 'radio') && $child->hasAttribute('name')) {
332                $groups[$child->getAttribute('name')][] = $child;
333            }
334            if ($child->hasChildNodes()) {
335                self::collectRadioGroupsRecursive($child, $groups);
336            }
337        }
338    }
339
340    /**
341     * Walk an entire <form> subtree and call Parser::prepareNodeStyles()
342     * UNCONDITIONALLY on every single node - text nodes and control nodes
343     * alike, regardless of any early-return branch elsewhere (e.g.
344     * resolveHeight()'s explicit height/width attribute check) that would
345     * otherwise skip it for that node.
346     *
347     * This exists because prepareNodeStyles() is NOT read-only: for any
348     * comma-separated font-family stack (e.g. "Courier, Arial"), it resolves
349     * to whichever stack entry is ALREADY registered on the document at the
350     * moment it runs (order-sensitive), and it registers (addFont()) any
351     * standard CSS font it resolves to that isn't registered yet - a real,
352     * permanent side effect on $parser->document(). linearizeForm() (the
353     * dry-run measurement pass) and the real render loop below both call
354     * prepareNodeStyles() - directly or via resolveTextLines()/
355     * resolveControlHeight()/applyAppearance() - but NOT on the same set of
356     * nodes (resolveHeight() skips it for a control with an explicit
357     * height/width, while applyAppearance() always calls it) and NOT at the
358     * same point in time (the measurement pass runs to completion before the
359     * render loop starts). Two passes, two different moments to resolve the
360     * same font stack against a document whose registered-fonts set can
361     * change in between: a node could measure against one font and render
362     * against another, changing its wrapped line count and therefore its
363     * real height out from under the measurement that was supposed to
364     * account for it - silently reproducing the exact "group's real
365     * footprint under-measured, group still splits across a page" bug this
366     * whole look-ahead exists to prevent, through a new mechanism.
367     *
368     * Warming up every node's fonts ONCE, before either pass begins, makes
369     * every font resolution in both passes read from the SAME, already-final
370     * registered-fonts set - so the two passes can never disagree, no matter
371     * what order anything else happens in. Must call prepareNodeStyles() on
372     * a superset of whatever either pass calls it on for styling purposes;
373     * this walks the whole subtree, so it always is.
374     *
375     * "Superset", not "exact set", is the operative word: this visits nodes
376     * (wrapper elements, <option>s) that neither pass ever actually styles
377     * for real, because they never render leaf text and aren't a control.
378     * Before this method existed, such a node's own bad font-family
379     * declaration was simply never looked at, so it was silently harmless.
380     * prepareNodeStyles() throws (Html\Exception) when a declared
381     * font-family can't resolve to any registered/standard font - every
382     * addFont() call inside it happens in the same branch as the
383     * corresponding throw (never after), so a call that throws never
384     * partially registers a font. Swallowing that exception here is
385     * therefore safe and lossless: it only stops THIS warm-up call from
386     * failing the whole document over a style nothing ever draws. A node
387     * that genuinely renders text or is a control still calls
388     * prepareNodeStyles() for real later (via the actual measurement/render
389     * pass) and throws exactly as it always has - this catch only guards the
390     * warm-up call, never the recursion into the node's children, since a
391     * node's own bad style says nothing about whether its children's styles
392     * are resolvable.
393     *
394     * @param  Parser $parser
395     * @param  Child  $node
396     * @return void
397     */
398    protected static function warmUpFonts(Parser $parser, Child $node): void
399    {
400        try {
401            $parser->prepareNodeStyles($node->getNodeName(), $node->getAttributes());
402        } catch (Exception) {
403            // Intentionally swallowed - see docblock above.
404        }
405
406        if ($node->hasChildNodes()) {
407            foreach ($node->getChildNodes() as $child) {
408                self::warmUpFonts($parser, $child);
409            }
410        }
411    }
412
413    /**
414     * Build a dry-run, ordered linearization of an entire <form> subtree -
415     * one [node, heightContribution] pair per node that would either be
416     * rendered as a control or contribute its own leaf text - mirroring
417     * renderNode()'s traversal exactly, but only accumulating height instead
418     * of drawing anything (no side effects: never touches $parser's page or
419     * cursor state). Built ONCE per <form>, before the real render loop
420     * starts, so it can be reused by measureRadioGroupSpan() without
421     * re-walking the subtree per group/option. Same "measure separately from
422     * render" idea as Table\Layout::measureRowHeight() vs. drawRow().
423     *
424     * @param  Parser $parser
425     * @param  Child  $formNode
426     * @param  int    $x
427     * @return array
428     */
429    protected static function linearizeForm(Parser $parser, Child $formNode, int $x): array
430    {
431        $sequence = [];
432        foreach ($formNode->getChildNodes() as $child) {
433            self::linearizeNode($parser, $child, $x, $sequence);
434        }
435        return $sequence;
436    }
437
438    /**
439     * Recursive worker for linearizeForm() - mirrors renderNode()'s own
440     * branching (control tag vs. leaf text vs. recurse into children)
441     * exactly, appending a [node, heightContribution] pair per accounted-for
442     * node instead of rendering it
443     *
444     * @param  Parser $parser
445     * @param  Child  $node
446     * @param  int    $x
447     * @param  array  $sequence
448     * @return void
449     */
450    private static function linearizeNode(Parser $parser, Child $node, int $x, array &$sequence): void
451    {
452        if (in_array($node->getNodeName(), ['input', 'select', 'textarea', 'button'])) {
453            $sequence[] = [$node, self::resolveControlHeight($parser, $node) + self::CONTROL_GAP];
454            return;
455        }
456
457        $text = trim((string) $node->getNodeValue());
458        if ($text !== '') {
459            $sequence[] = [$node, self::measureTextHeight($parser, $node, $text, $x)];
460        }
461
462        if ($node->hasChildNodes()) {
463            foreach ($node->getChildNodes() as $child) {
464                self::linearizeNode($parser, $child, $x, $sequence);
465            }
466        }
467    }
468
469    /**
470     * Measure a radio group's real vertical span against a linearized
471     * $sequence (see linearizeForm()): the index of the FIRST entry whose
472     * node belongs to the group through the index of the LAST such entry,
473     * summing every entry's height contribution in between INCLUSIVE - not
474     * just the radio entries, everything interleaved (labels, other text,
475     * anything else). Any content BEFORE the group's first option is
476     * excluded on purpose - it has already been rendered (and already
477     * reflected in the caller's $currentY) by the time renderControl()'s
478     * look-ahead runs at that first option.
479     *
480     * @param  array $sequence
481     * @param  array $groupNodes
482     * @return float
483     */
484    protected static function measureRadioGroupSpan(array $sequence, array $groupNodes): float
485    {
486        $groupIds = [];
487        foreach ($groupNodes as $groupNode) {
488            $groupIds[spl_object_id($groupNode)] = true;
489        }
490
491        $firstIndex = null;
492        $lastIndex  = null;
493
494        foreach ($sequence as $index => $entry) {
495            if (isset($groupIds[spl_object_id($entry[0])])) {
496                $firstIndex ??= $index;
497                $lastIndex = $index;
498            }
499        }
500
501        if ($firstIndex === null) {
502            return 0.0;
503        }
504
505        $span = 0.0;
506        for ($i = $firstIndex; $i <= $lastIndex; $i++) {
507            $span += $sequence[$i][1];
508        }
509
510        return $span;
511    }
512
513    /**
514     * Apply CSS border/background appearance onto a field, reusing the same
515     * styles Parser::prepareNodeStyles() already resolves for boxed text -
516     * border-width is the real gate (matching normal CSS semantics and
517     * Parser::drawBox()'s own convention), since borderColor otherwise
518     * always defaults to [0, 0, 0] whether or not a border was requested
519     *
520     * @param  Field\AbstractField $field
521     * @param  Parser              $parser
522     * @param  Child               $node
523     * @return void
524     */
525    protected static function applyAppearance(Field\AbstractField $field, Parser $parser, Child $node): void
526    {
527        $styles = $parser->prepareNodeStyles($node->getNodeName(), $node->getAttributes());
528
529        if ($styles['borderWidth'] > 0) {
530            $field->setBorderWidth((int) $styles['borderWidth']);
531            $field->setBorderColor($styles['borderColor']);
532        }
533        if ($styles['backgroundColor'] !== null) {
534            $field->setBackgroundColor($styles['backgroundColor']);
535        }
536    }
537
538    /**
539     * Build the right Field subclass (sized, but not yet positioned) for
540     * one HTML form-control node
541     *
542     * @param  Parser $parser
543     * @param  Child  $node
544     * @return array
545     */
546    protected static function buildField(Parser $parser, Child $node): array
547    {
548        $tag  = $node->getNodeName();
549        $type = $node->hasAttribute('type') ? strtolower($node->getAttribute('type')) : 'text';
550        $name = $node->hasAttribute('name') ? $node->getAttribute('name') : self::autoFieldName($parser);
551
552        if ($tag === 'textarea') {
553            $field = new Field\Text($name);
554            $field->setMultiline();
555            $value = trim((string) $node->getNodeValue());
556            if ($value !== '') {
557                $field->setValue($value);
558            }
559            return [$field, self::resolveWidth($parser, $node, 300), self::resolveControlHeight($parser, $node)];
560        }
561
562        if ($tag === 'select') {
563            $field = new Field\Choice($name);
564            if ($node->hasAttribute('multiple')) {
565                $field->setMultiSelect();
566            } else {
567                $field->setCombo();
568            }
569            foreach ($node->getChildNodes() as $option) {
570                if ($option->getNodeName() !== 'option') {
571                    continue;
572                }
573                $optionValue = $option->hasAttribute('value') ? $option->getAttribute('value') : trim((string) $option->getNodeValue());
574                $optionLabel = trim((string) $option->getNodeValue());
575                $field->addOption($optionValue, $optionLabel);
576                if ($option->hasAttribute('selected')) {
577                    $field->setValue($optionValue);
578                }
579            }
580            return [$field, self::resolveWidth($parser, $node, 150), self::resolveControlHeight($parser, $node)];
581        }
582
583        if (($tag === 'button') || ($type === 'submit') || ($type === 'reset')) {
584            $field = new Field\Button($name);
585            $field->setPushButton();
586            // Without a caption, a push button rendered as an unnamed,
587            // captionless widget with nothing visible on the page (no CSS
588            // border meant literally nothing rendered) - contradicting the
589            // spec's "renders but performs no action" (it didn't render at
590            // all). <button>...</button> uses its own text content;
591            // <input type=submit|reset> uses its value attribute, falling
592            // back to a sensible per-type default when absent.
593            if ($tag === 'button') {
594                $caption = trim((string) $node->getNodeValue());
595            } else {
596                $caption = $node->hasAttribute('value')
597                    ? $node->getAttribute('value')
598                    : (($type === 'reset') ? 'Reset' : 'Submit');
599            }
600            if ($caption !== '') {
601                $field->setCaption($caption);
602                // A caption needs a real font resource before Compiler can
603                // draw it into an appearance stream - /MK /CA alone is not
604                // reliably synthesized into visible text by most viewers.
605                $field->setFont('Arial');
606            }
607            return [$field, self::resolveWidth($parser, $node, 80), self::resolveControlHeight($parser, $node)];
608        }
609
610        if ($type === 'checkbox') {
611            $field = new Field\Button($name);
612            // setValue() always runs (checked or not) - Compiler now derives each
613            // checkbox/radio's on-state export name from getValue(), independent of
614            // setChecked(). Conflating the two (only calling setValue() when checked)
615            // was a real, fixed bug discovered during this plan's own Task 4 review.
616            $field->setValue($node->hasAttribute('value') ? $node->getAttribute('value') : 'Yes');
617            if ($node->hasAttribute('checked')) {
618                $field->setChecked();
619            }
620            return [$field, self::resolveWidth($parser, $node, 14), self::resolveControlHeight($parser, $node)];
621        }
622
623        if ($type === 'radio') {
624            $field = new Field\Button($name);
625            $field->setRadio();
626            // Unlike checkbox (which has no siblings to collide with, so a
627            // shared 'Yes' default is harmless), a valueless radio MUST be
628            // left with a null value here - Compiler::prepareRadioGroup()'s
629            // per-index fallback naming ('Option' . ($index + 1)) only
630            // engages when getValue() === null. Defaulting to 'Yes' here
631            // would give every valueless sibling in the group the same
632            // export name, reproducing the exact all-options-checked bug
633            // that fallback was added to prevent.
634            if ($node->hasAttribute('value')) {
635                $field->setValue($node->getAttribute('value'));
636            }
637            if ($node->hasAttribute('checked')) {
638                $field->setChecked();
639            }
640            return [$field, self::resolveWidth($parser, $node, 14), self::resolveControlHeight($parser, $node)];
641        }
642
643        if ($type === 'hidden') {
644            $field = new Field\Text($name);
645            if ($node->hasAttribute('value')) {
646                $field->setValue($node->getAttribute('value'));
647            }
648            return [$field, 0.0, self::resolveControlHeight($parser, $node)];
649        }
650
651        // text, email, password, file, tel, url, search, or any unrecognized type
652        $field = new Field\Text($name);
653        if ($type === 'password') {
654            $field->setPassword();
655        } else if ($type === 'file') {
656            $field->setFileSelect();
657        }
658        if ($node->hasAttribute('value')) {
659            $field->setValue($node->getAttribute('value'));
660        }
661        return [$field, self::resolveWidth($parser, $node, 200), self::resolveControlHeight($parser, $node)];
662    }
663
664    /**
665     * Resolve a form-control node's own height, honoring its rows/height
666     * HTML attribute or CSS height the same way resolveHeight() always has,
667     * but falling back to the RIGHT per-tag/type default (see
668     * controlDefaultHeight()) instead of a single hardcoded number - the
669     * single source of truth buildField() (real render) and
670     * linearizeNode()'s dry-run measurement pass both call, so the two can
671     * never disagree about how tall a control is
672     *
673     * @param  Parser $parser
674     * @param  Child  $node
675     * @return float
676     */
677    protected static function resolveControlHeight(Parser $parser, Child $node): float
678    {
679        // A hidden field never renders, so - like the original hardcoded
680        // buildField() branch - its height is always 0, regardless of any
681        // height attribute/CSS a caller might have set on it.
682        if (($node->getNodeName() === 'input') && $node->hasAttribute('type')
683            && (strtolower($node->getAttribute('type')) === 'hidden')) {
684            return 0.0;
685        }
686
687        return self::resolveHeight($parser, $node, self::controlDefaultHeight($node));
688    }
689
690    /**
691     * The sane per-tag/type default height buildField() has always used per
692     * control kind, factored out so resolveControlHeight() is the only place
693     * that needs to know it
694     *
695     * @param  Child $node
696     * @return int
697     */
698    protected static function controlDefaultHeight(Child $node): int
699    {
700        $tag  = $node->getNodeName();
701        $type = $node->hasAttribute('type') ? strtolower($node->getAttribute('type')) : 'text';
702
703        if ($tag === 'textarea') {
704            return 60;
705        }
706        if ($tag === 'select') {
707            return 20;
708        }
709        if (($tag === 'button') || ($type === 'submit') || ($type === 'reset')) {
710            return 24;
711        }
712        if (($type === 'checkbox') || ($type === 'radio')) {
713            return 14;
714        }
715        if ($type === 'hidden') {
716            return 0;
717        }
718
719        // text, email, password, file, tel, url, search, or any unrecognized type
720        return 20;
721    }
722
723    /**
724     * Resolve (and create, if new) the Document\Form for a <form> node,
725     * named from id -> name -> an auto-generated name
726     *
727     * @param  Parser $parser
728     * @param  Child  $formNode
729     * @return string
730     */
731    protected static function resolveFormName(Parser $parser, Child $formNode): string
732    {
733        $name = $formNode->hasAttribute('id') ? $formNode->getAttribute('id')
734            : ($formNode->hasAttribute('name') ? $formNode->getAttribute('name') : null);
735
736        if ($name === null) {
737            $name = 'form' . (count($parser->document()->getForms()) + 1);
738        }
739
740        if ($parser->document()->getForm($name) === null) {
741            $parser->document()->addForm(new Document\Form($name));
742        }
743
744        return $name;
745    }
746
747    /**
748     * Resolve (and create, if new) the single implicit Document\Form used
749     * for form controls with no <form> ancestor
750     *
751     * @param  Parser $parser
752     * @return string
753     */
754    protected static function resolveDefaultFormName(Parser $parser): string
755    {
756        $name = '__default__';
757        if ($parser->document()->getForm($name) === null) {
758            $parser->document()->addForm(new Document\Form($name));
759        }
760        return $name;
761    }
762
763    /**
764     * Auto-generate a field name for a control with no name attribute
765     *
766     * @param  Parser $parser
767     * @return string
768     */
769    protected static function autoFieldName(Parser $parser): string
770    {
771        return 'field' . (count($parser->getPage()->getFields()) + 1);
772    }
773
774    /**
775     * Resolve a control's width from its size/cols/width HTML attribute,
776     * CSS width, or a sane default - the width HTML attribute and CSS width
777     * are both percent-aware (e.g. "50%"), resolved against the page width,
778     * matching how Parser::addNodeToDocument()'s <img> handling already
779     * treats a trailing '%' on width/height
780     *
781     * @param  Parser $parser
782     * @param  Child  $node
783     * @param  int    $default
784     * @return float
785     */
786    protected static function resolveWidth(Parser $parser, Child $node, int $default): float
787    {
788        if (($node->getNodeName() === 'input') && $node->hasAttribute('size')) {
789            return ((float) $node->getAttribute('size')) * 7.0;
790        }
791        if (($node->getNodeName() === 'textarea') && $node->hasAttribute('cols')) {
792            return ((float) $node->getAttribute('cols')) * 7.0;
793        }
794        $pageWidth = $parser->getPage()->getWidth();
795        if ($node->hasAttribute('width')) {
796            return self::resolvePercentValue((string) $node->getAttribute('width'), $pageWidth);
797        }
798        $styles = $parser->prepareNodeStyles($node->getNodeName(), $node->getAttributes());
799        return ($styles['width'] !== null) ? self::resolvePercentValue((string) $styles['width'], $pageWidth) : (float) $default;
800    }
801
802    /**
803     * Resolve a control's height from its rows/height HTML attribute, CSS
804     * height, or a sane default - the height HTML attribute and CSS height
805     * are both percent-aware (e.g. "50%"), resolved against the page
806     * height, matching how Parser::addNodeToDocument()'s <img> handling
807     * already treats a trailing '%' on width/height
808     *
809     * @param  Parser $parser
810     * @param  Child  $node
811     * @param  int    $default
812     * @return float
813     */
814    protected static function resolveHeight(Parser $parser, Child $node, int $default): float
815    {
816        if (($node->getNodeName() === 'textarea') && $node->hasAttribute('rows')) {
817            return ((float) $node->getAttribute('rows')) * 16.0;
818        }
819        $pageHeight = $parser->getPage()->getHeight();
820        if ($node->hasAttribute('height')) {
821            return self::resolvePercentValue((string) $node->getAttribute('height'), $pageHeight);
822        }
823        $styles = $parser->prepareNodeStyles($node->getNodeName(), $node->getAttributes());
824        return ($styles['height'] !== null) ? self::resolvePercentValue((string) $styles['height'], $pageHeight) : (float) $default;
825    }
826
827    /**
828     * Resolve a raw HTML-attribute/CSS size value, honoring a trailing '%'
829     * as a percentage of the given basis (page width or height) rather
830     * than a literal point value
831     *
832     * @param  string $value
833     * @param  float  $basis
834     * @return float
835     */
836    protected static function resolvePercentValue(string $value, float $basis): float
837    {
838        $value = trim($value);
839        if (str_ends_with($value, '%')) {
840            return $basis * ((float) rtrim($value, '%') / 100);
841        }
842        return (float) $value;
843    }
844
845}