Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.93% covered (success)
97.93%
331 / 338
82.35% covered (warning)
82.35%
14 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
AppearanceGenerator
97.93% covered (success)
97.93%
331 / 338
82.35% covered (warning)
82.35%
14 / 17
53
0.00% covered (danger)
0.00%
0 / 1
 textField
93.75% covered (success)
93.75%
30 / 32
0.00% covered (danger)
0.00%
0 / 1
6.01
 checkbox
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
1
 radioButton
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
1
 textFieldMultiLine
96.15% covered (success)
96.15%
50 / 52
0.00% covered (danger)
0.00%
0 / 1
13
 passwordField
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 combTextField
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
7
 signatureField
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
8
 choiceField
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 pushButton
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
2
 buildAppearanceDict
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 buildStateAppearanceDict
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 textOperator
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 buildResources
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 rectDimensions
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 numVal
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
7.46
 escapeString
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 buildCircleOps
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Pdf\Core\Interactive\Form;
6
7use Phpdftk\Encoding\WinAnsiEncoder;
8use Phpdftk\Pdf\Core\Annotation\AppearanceDict;
9use Phpdftk\Pdf\Core\Content\Resources;
10use Phpdftk\Pdf\Core\Graphics\XObject\FormXObject;
11use Phpdftk\Pdf\Core\PdfArray;
12use Phpdftk\Pdf\Core\PdfDictionary;
13use Phpdftk\Pdf\Core\PdfName;
14use Phpdftk\Pdf\Core\PdfNumber;
15use Phpdftk\Pdf\Core\PdfReference;
16use Phpdftk\Pdf\Core\PdfString;
17
18/**
19 * Generates appearance streams for interactive form fields.
20 *
21 * Produces FormXObject instances that render field UI (borders,
22 * text, check marks, etc.) so fields are visible without relying
23 * on /NeedAppearances=true.
24 *
25 * Each generate*() method returns a FormXObject that should be
26 * registered with the writer and referenced from the field's
27 * widget annotation's /AP/N entry.
28 */
29final class AppearanceGenerator
30{
31    /**
32     * Generate a normal appearance for a text field.
33     *
34     * Draws a border rectangle and renders the field value using
35     * the specified font resource name and size.
36     *
37     * @param PdfArray      $rect  Widget rectangle [x1, y1, x2, y2]
38     * @param string        $fontName Font resource name (e.g., "F1")
39     * @param float         $fontSize Font size in points
40     * @param string        $value Current field value text
41     * @param int           $justification 0=left, 1=center, 2=right
42     * @param float         $borderWidth Border line width
43     * @param FontContext|null $fontContext Custom font context for composite font rendering
44     */
45    public static function textField(
46        PdfArray $rect,
47        string $fontName,
48        float $fontSize,
49        string $value = '',
50        int $justification = 0,
51        float $borderWidth = 1.0,
52        ?FontContext $fontContext = null,
53    ): FormXObject {
54        $dims = self::rectDimensions($rect);
55        $w = $dims['width'];
56        $h = $dims['height'];
57
58        $ops = [];
59
60        // Border
61        if ($borderWidth > 0) {
62            $ops[] = sprintf('%.2f w', $borderWidth);
63            $ops[] = '0.75 0.75 0.75 rg'; // light gray fill
64            $ops[] = sprintf('0 0 %.2f %.2f re', $w, $h);
65            $ops[] = 'f';
66            $ops[] = '0 0 0 RG'; // black border
67            $ops[] = sprintf('0 0 %.2f %.2f re', $w, $h);
68            $ops[] = 'S';
69        }
70
71        // Text
72        if ($value !== '') {
73            $textY = ($h - $fontSize) / 2 + $fontSize * 0.15; // rough vertical centering
74            $margin = $borderWidth + 2;
75            $textX = match ($justification) {
76                1 => $w / 2, // center — approximation without width measurement
77                2 => $w - $margin,
78                default => $margin,
79            };
80
81            $ops[] = 'BT';
82            $ops[] = sprintf('/%s %.2f Tf', $fontName, $fontSize);
83            $ops[] = '0 g';
84            $ops[] = sprintf('%.2f %.2f Td', $textX, $textY);
85            $ops[] = self::textOperator($value, $fontContext);
86            $ops[] = 'ET';
87        }
88
89        $bbox = new PdfArray([
90            new PdfNumber(0), new PdfNumber(0),
91            new PdfNumber($w), new PdfNumber($h),
92        ]);
93
94        $xObj = new FormXObject($bbox, implode("\n", $ops));
95        $xObj->resources = self::buildResources($fontName, $fontContext);
96
97        return $xObj;
98    }
99
100    /**
101     * Generate appearance streams for a checkbox field.
102     *
103     * Returns two FormXObjects: one for the "on" state (check mark)
104     * and one for the "off" state (empty box).
105     *
106     * @return array{on: FormXObject, off: FormXObject}
107     */
108    public static function checkbox(
109        PdfArray $rect,
110        float $borderWidth = 1.0,
111    ): array {
112        $dims = self::rectDimensions($rect);
113        $w = $dims['width'];
114        $h = $dims['height'];
115
116        $bbox = new PdfArray([
117            new PdfNumber(0), new PdfNumber(0),
118            new PdfNumber($w), new PdfNumber($h),
119        ]);
120
121        // Off state: empty box
122        $offOps = [];
123        $offOps[] = sprintf('%.2f w', $borderWidth);
124        $offOps[] = '0.95 0.95 0.95 rg';
125        $offOps[] = sprintf('0 0 %.2f %.2f re', $w, $h);
126        $offOps[] = 'f';
127        $offOps[] = '0 0 0 RG';
128        $offOps[] = sprintf('0 0 %.2f %.2f re', $w, $h);
129        $offOps[] = 'S';
130
131        $offXObj = new FormXObject($bbox, implode("\n", $offOps));
132        $offXObj->resources = new Resources();
133
134        // On state: box with check mark
135        $onOps = $offOps; // start with the same box
136        // Draw check mark using line segments
137        $mx = $w * 0.2;
138        $my = $h * 0.45;
139        $cx = $w * 0.45;
140        $cy = $h * 0.2;
141        $ex = $w * 0.85;
142        $ey = $h * 0.8;
143        $onOps[] = sprintf('%.2f w', max(1.5, $w * 0.08));
144        $onOps[] = '0 0 0 RG';
145        $onOps[] = sprintf('%.2f %.2f m', $mx, $my);
146        $onOps[] = sprintf('%.2f %.2f l', $cx, $cy);
147        $onOps[] = sprintf('%.2f %.2f l', $ex, $ey);
148        $onOps[] = 'S';
149
150        $onXObj = new FormXObject(clone $bbox, implode("\n", $onOps));
151        $onXObj->resources = new Resources();
152
153        return ['on' => $onXObj, 'off' => $offXObj];
154    }
155
156    /**
157     * Generate appearance streams for a radio button.
158     *
159     * @return array{on: FormXObject, off: FormXObject}
160     */
161    public static function radioButton(
162        PdfArray $rect,
163        float $borderWidth = 1.0,
164    ): array {
165        $dims = self::rectDimensions($rect);
166        $w = $dims['width'];
167        $h = $dims['height'];
168        $r = min($w, $h) / 2 - $borderWidth;
169        $cx = $w / 2;
170        $cy = $h / 2;
171
172        $bbox = new PdfArray([
173            new PdfNumber(0), new PdfNumber(0),
174            new PdfNumber($w), new PdfNumber($h),
175        ]);
176
177        // Approximate circle with 4 Bézier curves
178        $k = 0.5523; // magic constant for Bézier circle approximation
179
180        $circleOps = self::buildCircleOps($cx, $cy, $r, $k);
181
182        // Off state: empty circle
183        $offOps = [];
184        $offOps[] = sprintf('%.2f w', $borderWidth);
185        $offOps[] = '0.95 0.95 0.95 rg';
186        $offOps[] = '0 0 0 RG';
187        $offOps = array_merge($offOps, $circleOps);
188        $offOps[] = 'B'; // fill and stroke
189
190        $offXObj = new FormXObject($bbox, implode("\n", $offOps));
191        $offXObj->resources = new Resources();
192
193        // On state: circle with filled dot
194        $onOps = $offOps;
195        $dotR = $r * 0.45;
196        $dotOps = self::buildCircleOps($cx, $cy, $dotR, $k);
197        $onOps[] = '0 0 0 rg';
198        $onOps = array_merge($onOps, $dotOps);
199        $onOps[] = 'f';
200
201        $onXObj = new FormXObject(clone $bbox, implode("\n", $onOps));
202        $onXObj->resources = new Resources();
203
204        return ['on' => $onXObj, 'off' => $offXObj];
205    }
206
207    /**
208     * Generate a multi-line text field appearance.
209     *
210     * Word-wraps text to fit the field width, rendering multiple lines
211     * with the given leading (line height).
212     *
213     * @param PdfArray $rect       Widget rectangle [x1, y1, x2, y2]
214     * @param string   $fontName   Font resource name (e.g., "F1")
215     * @param float    $fontSize   Font size in points
216     * @param string   $value      Multi-line field value text
217     * @param float    $leading    Line height in points (default: fontSize × 1.2)
218     * @param float    $borderWidth Border line width
219     * @param float    $charWidth  Approximate average character width as fraction of fontSize (default 0.5)
220     * @param FontContext|null $fontContext Custom font context for composite font rendering
221     */
222    public static function textFieldMultiLine(
223        PdfArray $rect,
224        string $fontName,
225        float $fontSize,
226        string $value = '',
227        float $leading = 0,
228        float $borderWidth = 1.0,
229        float $charWidth = 0.5,
230        ?FontContext $fontContext = null,
231    ): FormXObject {
232        $dims = self::rectDimensions($rect);
233        $w = $dims['width'];
234        $h = $dims['height'];
235        if ($leading <= 0) {
236            $leading = $fontSize * 1.2;
237        }
238
239        $ops = [];
240
241        // Border
242        if ($borderWidth > 0) {
243            $ops[] = sprintf('%.2f w', $borderWidth);
244            $ops[] = '0.75 0.75 0.75 rg';
245            $ops[] = sprintf('0 0 %.2f %.2f re', $w, $h);
246            $ops[] = 'f';
247            $ops[] = '0 0 0 RG';
248            $ops[] = sprintf('0 0 %.2f %.2f re', $w, $h);
249            $ops[] = 'S';
250        }
251
252        if ($value !== '') {
253            $margin = $borderWidth + 2;
254            $usableWidth = $w - 2 * $margin;
255            $avgCharW = $fontSize * $charWidth;
256            $charsPerLine = max(1, (int) floor($usableWidth / $avgCharW));
257
258            // Split into lines (respecting explicit newlines, then word-wrap)
259            $lines = [];
260            foreach (explode("\n", $value) as $paragraph) {
261                if ($paragraph === '') {
262                    $lines[] = '';
263                    continue;
264                }
265                $words = explode(' ', $paragraph);
266                $current = '';
267                foreach ($words as $word) {
268                    $test = $current === '' ? $word : $current . ' ' . $word;
269                    if (strlen($test) > $charsPerLine && $current !== '') {
270                        $lines[] = $current;
271                        $current = $word;
272                    } else {
273                        $current = $test;
274                    }
275                }
276                if ($current !== '') {
277                    $lines[] = $current;
278                }
279            }
280
281            // Render lines from top
282            $startY = $h - $margin - $fontSize;
283            $ops[] = 'BT';
284            $ops[] = sprintf('/%s %.2f Tf', $fontName, $fontSize);
285            $ops[] = sprintf('%.2f TL', $leading);
286            $ops[] = '0 g';
287            $ops[] = sprintf('%.2f %.2f Td', $margin, $startY);
288
289            foreach ($lines as $i => $line) {
290                if ($i > 0) {
291                    $ops[] = "T*";
292                }
293                $ops[] = self::textOperator($line, $fontContext);
294            }
295            $ops[] = 'ET';
296        }
297
298        $bbox = new PdfArray([
299            new PdfNumber(0), new PdfNumber(0),
300            new PdfNumber($w), new PdfNumber($h),
301        ]);
302
303        $xObj = new FormXObject($bbox, implode("\n", $ops));
304        $xObj->resources = self::buildResources($fontName, $fontContext);
305
306        return $xObj;
307    }
308
309    /**
310     * Generate a password field appearance (renders dots instead of text).
311     */
312    public static function passwordField(
313        PdfArray $rect,
314        string $fontName,
315        float $fontSize,
316        int $characterCount = 0,
317        float $borderWidth = 1.0,
318    ): FormXObject {
319        // Render bullet characters (•) for each character
320        $masked = str_repeat("\xE2\x80\xA2", $characterCount); // U+2022 BULLET
321        // Fall back to asterisks for standard fonts without bullet glyph
322        $maskedSimple = str_repeat('*', $characterCount);
323
324        return self::textField($rect, $fontName, $fontSize, $maskedSimple, borderWidth: $borderWidth);
325    }
326
327    /**
328     * Generate a comb text field appearance (equally-spaced character cells).
329     *
330     * Each character is centered in its own cell, with vertical dividers.
331     *
332     * @param int $maxLen Maximum number of characters (/MaxLen)
333     * @param FontContext|null $fontContext Custom font context for composite font rendering
334     */
335    public static function combTextField(
336        PdfArray $rect,
337        string $fontName,
338        float $fontSize,
339        string $value = '',
340        int $maxLen = 10,
341        float $borderWidth = 1.0,
342        ?FontContext $fontContext = null,
343    ): FormXObject {
344        $dims = self::rectDimensions($rect);
345        $w = $dims['width'];
346        $h = $dims['height'];
347        $cellWidth = $w / max(1, $maxLen);
348
349        $ops = [];
350
351        // Border
352        if ($borderWidth > 0) {
353            $ops[] = sprintf('%.2f w', $borderWidth);
354            $ops[] = '0.75 0.75 0.75 rg';
355            $ops[] = sprintf('0 0 %.2f %.2f re', $w, $h);
356            $ops[] = 'f';
357            $ops[] = '0 0 0 RG';
358            $ops[] = sprintf('0 0 %.2f %.2f re', $w, $h);
359            $ops[] = 'S';
360        }
361
362        // Cell dividers
363        $ops[] = '0.7 0.7 0.7 RG';
364        $ops[] = '0.5 w';
365        for ($i = 1; $i < $maxLen; $i++) {
366            $x = $i * $cellWidth;
367            $ops[] = sprintf('%.2f 0 m %.2f %.2f l S', $x, $x, $h);
368        }
369
370        // Render each character centered in its cell
371        if ($value !== '') {
372            $textY = ($h - $fontSize) / 2 + $fontSize * 0.15;
373            $chars = mb_str_split($value);
374
375            $ops[] = 'BT';
376            $ops[] = sprintf('/%s %.2f Tf', $fontName, $fontSize);
377            $ops[] = '0 g';
378
379            // Position first character, then advance by cellWidth for each subsequent
380            $firstX = $cellWidth * 0.35;
381            $ops[] = sprintf('%.2f %.2f Td', $firstX, $textY);
382
383            foreach ($chars as $idx => $char) {
384                if ($idx >= $maxLen) {
385                    break;
386                }
387                if ($idx > 0) {
388                    $ops[] = sprintf('%.2f 0 Td', $cellWidth);
389                }
390                $ops[] = self::textOperator($char, $fontContext);
391            }
392
393            $ops[] = 'ET';
394        }
395
396        $bbox = new PdfArray([
397            new PdfNumber(0), new PdfNumber(0),
398            new PdfNumber($w), new PdfNumber($h),
399        ]);
400
401        $xObj = new FormXObject($bbox, implode("\n", $ops));
402        $xObj->resources = self::buildResources($fontName, $fontContext);
403
404        return $xObj;
405    }
406
407    /**
408     * Generate a signature field appearance.
409     *
410     * Renders a bordered box with signature information text.
411     *
412     * @param FontContext|null $fontContext Custom font context for composite font rendering
413     */
414    public static function signatureField(
415        PdfArray $rect,
416        string $fontName,
417        float $fontSize,
418        string $signer = '',
419        string $reason = '',
420        string $date = '',
421        float $borderWidth = 1.0,
422        ?FontContext $fontContext = null,
423    ): FormXObject {
424        $dims = self::rectDimensions($rect);
425        $w = $dims['width'];
426        $h = $dims['height'];
427
428        $ops = [];
429
430        // Border with light blue background
431        if ($borderWidth > 0) {
432            $ops[] = sprintf('%.2f w', $borderWidth);
433            $ops[] = '0.93 0.95 1.0 rg';
434            $ops[] = sprintf('0 0 %.2f %.2f re', $w, $h);
435            $ops[] = 'f';
436            $ops[] = '0.3 0.3 0.7 RG';
437            $ops[] = sprintf('0 0 %.2f %.2f re', $w, $h);
438            $ops[] = 'S';
439        }
440
441        // Build text lines
442        $lines = [];
443        if ($signer !== '') {
444            $lines[] = 'Digitally signed by ' . $signer;
445        }
446        if ($reason !== '') {
447            $lines[] = 'Reason: ' . $reason;
448        }
449        if ($date !== '') {
450            $lines[] = 'Date: ' . $date;
451        }
452        if ($lines === []) {
453            $lines[] = 'Digital Signature';
454        }
455
456        $leading = $fontSize * 1.3;
457        $margin = $borderWidth + 4;
458        $startY = $h - $margin - $fontSize;
459
460        $ops[] = 'BT';
461        $ops[] = sprintf('/%s %.2f Tf', $fontName, $fontSize * 0.8);
462        $ops[] = sprintf('%.2f TL', $leading);
463        $ops[] = '0.1 0.1 0.4 rg';
464        $ops[] = sprintf('%.2f %.2f Td', $margin, $startY);
465
466        foreach ($lines as $i => $line) {
467            if ($i > 0) {
468                $ops[] = "T*";
469            }
470            $ops[] = self::textOperator($line, $fontContext);
471        }
472        $ops[] = 'ET';
473
474        $bbox = new PdfArray([
475            new PdfNumber(0), new PdfNumber(0),
476            new PdfNumber($w), new PdfNumber($h),
477        ]);
478
479        $xObj = new FormXObject($bbox, implode("\n", $ops));
480        $xObj->resources = self::buildResources($fontName, $fontContext);
481
482        return $xObj;
483    }
484
485    /**
486     * Generate a normal appearance for a choice field (combo/list box).
487     *
488     * Renders the currently selected value text in a bordered box.
489     *
490     * @param FontContext|null $fontContext Custom font context for composite font rendering
491     */
492    public static function choiceField(
493        PdfArray $rect,
494        string $fontName,
495        float $fontSize,
496        string $selectedValue = '',
497        float $borderWidth = 1.0,
498        ?FontContext $fontContext = null,
499    ): FormXObject {
500        // Choice field appearance is visually identical to text field
501        return self::textField($rect, $fontName, $fontSize, $selectedValue, borderWidth: $borderWidth, fontContext: $fontContext);
502    }
503
504    /**
505     * Generate a push button appearance.
506     *
507     * @param FontContext|null $fontContext Custom font context for composite font rendering
508     */
509    public static function pushButton(
510        PdfArray $rect,
511        string $fontName,
512        float $fontSize,
513        string $label = '',
514        float $borderWidth = 1.5,
515        ?FontContext $fontContext = null,
516    ): FormXObject {
517        $dims = self::rectDimensions($rect);
518        $w = $dims['width'];
519        $h = $dims['height'];
520
521        $ops = [];
522
523        // 3D-effect border
524        $ops[] = '0.85 0.85 0.85 rg';
525        $ops[] = sprintf('0 0 %.2f %.2f re', $w, $h);
526        $ops[] = 'f';
527        // Top/left highlight
528        $ops[] = '1 1 1 RG';
529        $ops[] = sprintf('%.2f w', $borderWidth);
530        $ops[] = sprintf('0 0 m %.2f 0 l', $w);
531        $ops[] = sprintf('0 0 m 0 %.2f l', $h);
532        $ops[] = 'S';
533        // Bottom/right shadow
534        $ops[] = '0.5 0.5 0.5 RG';
535        $ops[] = sprintf('%.2f 0 m %.2f %.2f l', $w, $w, $h);
536        $ops[] = sprintf('0 %.2f m %.2f %.2f l', $h, $w, $h);
537        $ops[] = 'S';
538
539        // Label text centered
540        if ($label !== '') {
541            $textX = $w / 2;
542            $textY = ($h - $fontSize) / 2 + $fontSize * 0.15;
543
544            $ops[] = 'BT';
545            $ops[] = sprintf('/%s %.2f Tf', $fontName, $fontSize);
546            $ops[] = '0 g';
547            $ops[] = sprintf('%.2f %.2f Td', $textX, $textY);
548            $ops[] = self::textOperator($label, $fontContext);
549            $ops[] = 'ET';
550        }
551
552        $bbox = new PdfArray([
553            new PdfNumber(0), new PdfNumber(0),
554            new PdfNumber($w), new PdfNumber($h),
555        ]);
556
557        $xObj = new FormXObject($bbox, implode("\n", $ops));
558        $xObj->resources = self::buildResources($fontName, $fontContext);
559
560        return $xObj;
561    }
562
563    /**
564     * Build an AppearanceDict with a single normal appearance.
565     */
566    public static function buildAppearanceDict(PdfReference $normalRef): AppearanceDict
567    {
568        $ap = new AppearanceDict();
569        $ap->n = $normalRef;
570        return $ap;
571    }
572
573    /**
574     * Build an AppearanceDict for checkbox/radio with on/off states.
575     *
576     * @param PdfReference $onRef  Reference to the "on" FormXObject
577     * @param PdfReference $offRef Reference to the "off" FormXObject
578     * @param string $onStateName The appearance state name for "on" (e.g., "Yes")
579     */
580    public static function buildStateAppearanceDict(
581        PdfReference $onRef,
582        PdfReference $offRef,
583        string $onStateName = 'Yes',
584    ): AppearanceDict {
585        $ap = new AppearanceDict();
586        $stateDict = new PdfDictionary();
587        $stateDict->set($onStateName, $onRef);
588        $stateDict->set('Off', $offRef);
589        $ap->n = $stateDict;
590        return $ap;
591    }
592
593    // -----------------------------------------------------------------------
594    // Helpers
595    // -----------------------------------------------------------------------
596
597    /**
598     * Build a Tj text operator, using hex-encoded GIDs when a FontContext is present.
599     */
600    private static function textOperator(string $text, ?FontContext $fontContext): string
601    {
602        if ($fontContext !== null) {
603            return '<' . $fontContext->textToHex($text) . '> Tj';
604        }
605        return '(' . self::escapeString($text) . ') Tj';
606    }
607
608    /**
609     * Build a Resources dictionary, wiring the font reference when a FontContext is present.
610     */
611    private static function buildResources(string $fontName, ?FontContext $fontContext): Resources
612    {
613        $resources = new Resources();
614        if ($fontContext !== null) {
615            $resources->addFont($fontName, $fontContext->fontRef);
616        }
617        return $resources;
618    }
619
620    /**
621     * @return array{width: float, height: float}
622     */
623    private static function rectDimensions(PdfArray $rect): array
624    {
625        $items = $rect->items;
626        $x1 = self::numVal($items[0] ?? null);
627        $y1 = self::numVal($items[1] ?? null);
628        $x2 = self::numVal($items[2] ?? null);
629        $y2 = self::numVal($items[3] ?? null);
630
631        return [
632            'width' => abs($x2 - $x1),
633            'height' => abs($y2 - $y1),
634        ];
635    }
636
637    private static function numVal(mixed $val): float
638    {
639        if ($val instanceof PdfNumber) {
640            return (float) $val->toPdf();
641        }
642        if (is_int($val) || is_float($val)) {
643            return (float) $val;
644        }
645        return 0.0;
646    }
647
648    private static function escapeString(string $text): string
649    {
650        // Appearance streams without a FontContext render with a WinAnsi
651        // standard font (typically Helvetica). Convert UTF-8 input to its
652        // WinAnsi byte form so non-ASCII default/filled values display
653        // correctly. The FontContext (composite-font) path uses textToHex
654        // upstream of this method, so this branch only fires for WinAnsi.
655        $text = (new WinAnsiEncoder())->encode($text);
656        return str_replace(
657            ['\\', '(', ')'],
658            ['\\\\', '\\(', '\\)'],
659            $text,
660        );
661    }
662
663    /**
664     * Build Bézier curve operators for a circle.
665     *
666     * @return list<string>
667     */
668    private static function buildCircleOps(float $cx, float $cy, float $r, float $k): array
669    {
670        $ops = [];
671        $ops[] = sprintf('%.2f %.2f m', $cx + $r, $cy);
672        $ops[] = sprintf(
673            '%.2f %.2f %.2f %.2f %.2f %.2f c',
674            $cx + $r,
675            $cy + $r * $k,
676            $cx + $r * $k,
677            $cy + $r,
678            $cx,
679            $cy + $r,
680        );
681        $ops[] = sprintf(
682            '%.2f %.2f %.2f %.2f %.2f %.2f c',
683            $cx - $r * $k,
684            $cy + $r,
685            $cx - $r,
686            $cy + $r * $k,
687            $cx - $r,
688            $cy,
689        );
690        $ops[] = sprintf(
691            '%.2f %.2f %.2f %.2f %.2f %.2f c',
692            $cx - $r,
693            $cy - $r * $k,
694            $cx - $r * $k,
695            $cy - $r,
696            $cx,
697            $cy - $r,
698        );
699        $ops[] = sprintf(
700            '%.2f %.2f %.2f %.2f %.2f %.2f c',
701            $cx + $r * $k,
702            $cy - $r,
703            $cx + $r,
704            $cy - $r * $k,
705            $cx + $r,
706            $cy,
707        );
708        return $ops;
709    }
710}