Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.87% covered (success)
97.87%
322 / 329
96.63% covered (success)
96.63%
86 / 89
CRAP
0.00% covered (danger)
0.00%
0 / 1
ContentStream
97.87% covered (success)
97.87%
322 / 329
96.63% covered (success)
96.63%
86 / 89
126
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getOperators
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 clearOperators
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 beginText
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 endText
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setFont
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 moveTextPosition
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 moveTextPositionNewLine
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 showText
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 showTextArray
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 encodeForActiveFont
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 showTextHex
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 showTextArrayHex
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
5.15
 showUnicodeText
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 showUnicodeTextKerned
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
6
 showUnicodeTextShaped
93.55% covered (success)
93.55%
29 / 31
0.00% covered (danger)
0.00%
0 / 1
9.02
 nextLine
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setTextMatrix
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 setCharSpacing
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setWordSpacing
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setHorizontalScaling
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setTextLeading
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setTextRenderingMode
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setTextRise
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 saveGraphicsState
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 restoreGraphicsState
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setLineWidth
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setLineCap
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setLineJoin
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setMiterLimit
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setDashPattern
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 setRenderingIntent
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setFlatness
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setGraphicsState
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 concatMatrix
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 moveTo
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 lineTo
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 curveTo
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 curveToV
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 curveToY
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 closePath
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 rectangle
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 stroke
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 closeAndStroke
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 fill
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 fillEvenOdd
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 fillAndStroke
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 fillAndStrokeEvenOdd
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 closeFillAndStroke
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 closeFillAndStrokeEvenOdd
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 endPath
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 clip
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 clipEvenOdd
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setStrokeColorRGB
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setFillColorRGB
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setStrokeColorCMYK
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 setFillColorCMYK
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 setStrokeColorGray
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setFillColorGray
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setStrokeColorSpace
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setFillColorSpace
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setStrokeColor
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setFillColor
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 doXObject
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 inlineImage
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 setFillRgbColor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setStrokeRgbColor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setFillCmykColor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setStrokeCmykColor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setFillGrayColor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setStrokeGrayColor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 rectangleObject
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 concatMatrixObject
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 moveToNextLineAndShowText
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setSpacingMoveAndShowText
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 paintShading
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setGlyphWidth
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setGlyphWidthAndBoundingBox
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 markedContentPoint
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 markedContentPointWithProperties
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 beginMarkedContent
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 beginMarkedContentWithProperties
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 endMarkedContent
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 beginCompatibility
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 endCompatibility
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 raw
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 toPdf
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 num
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 escapeString
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
10.00
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Pdf\Core\Content;
6
7use Phpdftk\Color\CmykColor;
8use Phpdftk\Color\GrayColor;
9use Phpdftk\Color\RgbColor;
10use Phpdftk\Encoding\TextEncoder;
11use Phpdftk\Pdf\Core\Font\RegisteredFont;
12use Phpdftk\Pdf\Core\PdfDictionary;
13use Phpdftk\Pdf\Core\PdfStream;
14use Phpdftk\Geometry\Matrix;
15use Phpdftk\Geometry\Rectangle;
16
17/**
18 * Content stream for page graphics and text.
19 *
20 * Provides a fluent API covering all PDF content stream operators
21 * as defined in the PDF 1.7 specification (ISO 32000-1).
22 */
23class ContentStream extends PdfStream
24{
25    /** @var array<int, string> */
26    private array $operators = [];
27
28    /**
29     * Encoder for the currently-active single-byte font. When set, showText
30     * and showTextArray convert their UTF-8 input to the font's byte
31     * encoding before emitting Tj/TJ. Null when no font has been set, or
32     * when the active font is a composite/CID font (those use the
33     * showTextHex / showUnicodeText path).
34     */
35    private ?TextEncoder $activeEncoder = null;
36
37    public function __construct()
38    {
39        parent::__construct(new PdfDictionary(), '');
40    }
41
42    /** @return array<int, string> */
43    public function getOperators(): array
44    {
45        return $this->operators;
46    }
47
48    /**
49     * Clear the operators array. After this, toPdf() will use
50     * $this->data directly instead of regenerating from operators.
51     */
52    public function clearOperators(): void
53    {
54        $this->operators = [];
55    }
56
57    // -----------------------------------------------------------------------
58    // Text state operators
59    // -----------------------------------------------------------------------
60
61    /** BT - Begin text object */
62    public function beginText(): self
63    {
64        $this->operators[] = 'BT';
65        return $this;
66    }
67
68    /** ET - End text object */
69    public function endText(): self
70    {
71        $this->operators[] = 'ET';
72        return $this;
73    }
74
75    /**
76     * Tf - Set font and size.
77     *
78     * Accepts either a resource name string (legacy / raw-bytes mode â€” the
79     * caller is responsible for emitting bytes in the font's encoding) or
80     * a `RegisteredFont` handle from `PdfWriter::addFont()`. When a handle
81     * is passed, the stream remembers the font's text encoder so subsequent
82     * showText/showTextArray calls accept UTF-8 directly.
83     */
84    public function setFont(RegisteredFont|string $name, float $size): self
85    {
86        if ($name instanceof RegisteredFont) {
87            $this->activeEncoder = $name->getTextEncoder();
88            $name = $name->getResourceName();
89        } else {
90            $this->activeEncoder = null;
91        }
92        $this->operators[] = sprintf('/%s %s Tf', $name, $this->num($size));
93        return $this;
94    }
95
96    /** Td - Move text position */
97    public function moveTextPosition(float $x, float $y): self
98    {
99        $this->operators[] = sprintf('%s %s Td', $this->num($x), $this->num($y));
100        return $this;
101    }
102
103    /** TD - Move text position and set leading */
104    public function moveTextPositionNewLine(float $tx, float $ty): self
105    {
106        $this->operators[] = sprintf('%s %s TD', $this->num($tx), $this->num($ty));
107        return $this;
108    }
109
110    /** Tj - Show text */
111    public function showText(string $text): self
112    {
113        $this->operators[] = $this->escapeString($this->encodeForActiveFont($text)) . ' Tj';
114        return $this;
115    }
116
117    /** TJ - Show text with individual character positioning
118     * @param array<int, string|int|float> $texts
119     */
120    public function showTextArray(array $texts): self
121    {
122        $parts = [];
123        foreach ($texts as $item) {
124            if (is_string($item)) {
125                $parts[] = $this->escapeString($this->encodeForActiveFont($item));
126            } elseif (is_int($item) || is_float($item)) {
127                $parts[] = $this->num($item);
128            }
129        }
130        $this->operators[] = '[ ' . implode(' ', $parts) . ' ] TJ';
131        return $this;
132    }
133
134    /**
135     * Run text through the active font's encoder if one was set via
136     * setFont(RegisteredFont). Otherwise return it verbatim so callers
137     * that pre-encoded their bytes continue to work.
138     */
139    private function encodeForActiveFont(string $text): string
140    {
141        return $this->activeEncoder?->encode($text) ?? $text;
142    }
143
144    /** Tj with hex-encoded string - Show text using hex-encoded glyph IDs (for CID fonts) */
145    public function showTextHex(string $hexEncodedGids): self
146    {
147        $this->operators[] = '<' . $hexEncodedGids . '> Tj';
148        return $this;
149    }
150
151    /**
152     * TJ with a hex/kern array â€” emits `[ <hex> kern <hex> kern ... ] TJ`
153     * for a CID font where shaped glyphs need per-glyph kern adjustments
154     * (positive kern moves the text position backward in 1/1000 em units,
155     * negative kern moves forward). Each `$items` entry is either a hex
156     * GID string (without `< >` delimiters) or a numeric kern in
157     * 1/1000-em units.
158     *
159     * @param array<int, string|int|float> $items
160     */
161    public function showTextArrayHex(array $items): self
162    {
163        if ($items === []) {
164            return $this;
165        }
166        $parts = [];
167        foreach ($items as $item) {
168            if (is_string($item)) {
169                if ($item === '') {
170                    continue;
171                }
172                $parts[] = '<' . $item . '>';
173            } else {
174                $parts[] = $this->num($item);
175            }
176        }
177        $this->operators[] = '[ ' . implode(' ', $parts) . ' ] TJ';
178        return $this;
179    }
180
181    /**
182     * Show Unicode text using a CID font's GID mapping.
183     *
184     * Converts UTF-8 text to hex-encoded 2-byte GID sequences and
185     * emits a Tj operator. Requires a unicode-to-GID map (typically
186     * from TrueTypeData::$fullUnicodeToGid or OpenTypeData::$fullUnicodeToGid).
187     *
188     * @param string $text UTF-8 text
189     * @param array<int, int> $unicodeToGid Unicode codepoint â†’ GID mapping
190     */
191    public function showUnicodeText(string $text, array $unicodeToGid): self
192    {
193        $hex = '';
194        foreach (mb_str_split($text) as $char) {
195            $cp = mb_ord($char);
196            $gid = $unicodeToGid[$cp] ?? 0;
197            $hex .= sprintf('%04X', $gid);
198        }
199        $this->operators[] = '<' . $hex . '> Tj';
200        return $this;
201    }
202
203    /**
204     * Emit a TJ array with kerning adjustments from GPOS data.
205     *
206     * Positive kern values in font units are negated per PDF convention
207     * (negative TJ displacements move the cursor right). Falls back to
208     * plain Tj when no kerning pairs apply.
209     *
210     * @param string $text UTF-8 text
211     * @param array<int, int> $unicodeToGid Unicode codepoint => GID mapping
212     * @param array<int, array<int, int>> $kernPairs leftGid => [rightGid => xAdvanceAdjust (design units)]
213     * @param int $unitsPerEm Font design units per em
214     */
215    public function showUnicodeTextKerned(
216        string $text,
217        array $unicodeToGid,
218        array $kernPairs,
219        int $unitsPerEm,
220    ): self {
221        $chars = mb_str_split($text);
222        if ($chars === []) {
223            return $this;
224        }
225
226        // Build array of GIDs
227        $gids = [];
228        foreach ($chars as $char) {
229            $cp = mb_ord($char);
230            $gids[] = $unicodeToGid[$cp] ?? 0;
231        }
232
233        // Build TJ array: interleave hex strings with kern adjustments
234        // TJ numeric values are in thousandths of a unit of text space.
235        // Positive = move left (loosen), negative = move right (tighten).
236        // Font kern values: negative = tighten. TJ wants the opposite sign
237        // for tightening, BUT in PDF spec the TJ displacement subtracts the
238        // value from the current point. So a positive TJ value moves LEFT
239        // (tightens). Font kern is negative for tightening, so we negate.
240        $tjParts = [];
241        $currentHex = sprintf('%04X', $gids[0]);
242
243        for ($i = 1, $count = count($gids); $i < $count; $i++) {
244            $prevGid = $gids[$i - 1];
245            $curGid = $gids[$i];
246            $kern = $kernPairs[$prevGid][$curGid] ?? 0;
247
248            if ($kern !== 0) {
249                $tjParts[] = '<' . $currentHex . '>';
250                // Scale from design units to 1/1000 em and negate
251                // (font kern negative=tighten, TJ positive=tighten)
252                $tjParts[] = (string) (int) round(-$kern * 1000 / $unitsPerEm);
253                $currentHex = sprintf('%04X', $curGid);
254            } else {
255                $currentHex .= sprintf('%04X', $curGid);
256            }
257        }
258        $tjParts[] = '<' . $currentHex . '>';
259
260        if (count($tjParts) === 1) {
261            $this->operators[] = $tjParts[0] . ' Tj';
262        } else {
263            $this->operators[] = '[ ' . implode(' ', $tjParts) . ' ] TJ';
264        }
265
266        return $this;
267    }
268
269    /**
270     * Apply OpenType GSUB ligature substitutions before kerning.
271     *
272     * Ligatures reduce the glyph count (e.g., "fi" -> single glyph),
273     * so the GID sequence may be shorter than the input. If kern pairs
274     * are provided, kerning adjustments are applied between the shaped
275     * glyphs using TJ; otherwise a plain Tj is emitted.
276     *
277     * @param string $text UTF-8 text to render
278     * @param array<int, int> $unicodeToGid Unicode codepoint â†’ GID map
279     * @param array<int, list<array{components: int[], ligature: int}>> $ligatures GSUB ligature rules
280     * @param array<int, array<int, int>> $kernPairs Kern pairs (optional)
281     * @param int $unitsPerEm Font design units per em
282     */
283    public function showUnicodeTextShaped(
284        string $text,
285        array $unicodeToGid,
286        array $ligatures,
287        array $kernPairs = [],
288        int $unitsPerEm = 1000,
289    ): self {
290        $chars = mb_str_split($text);
291        if ($chars === []) {
292            return $this;
293        }
294
295        // Convert Unicode to GIDs
296        $gids = [];
297        foreach ($chars as $char) {
298            $cp = mb_ord($char);
299            $gids[] = $unicodeToGid[$cp] ?? 0;
300        }
301
302        // Apply ligature substitutions
303        $gids = \Phpdftk\FontParser\TextShaper::applyLigatures($gids, $ligatures);
304
305        if ($gids === []) {
306            return $this;
307        }
308
309        // If we have kern pairs, emit TJ with adjustments
310        if ($kernPairs !== []) {
311            $tjParts = [];
312            $currentHex = sprintf('%04X', $gids[0]);
313
314            for ($i = 1, $count = count($gids); $i < $count; $i++) {
315                $prevGid = $gids[$i - 1];
316                $curGid = $gids[$i];
317                $kern = $kernPairs[$prevGid][$curGid] ?? 0;
318
319                if ($kern !== 0) {
320                    $tjParts[] = '<' . $currentHex . '>';
321                    $tjParts[] = (string) (int) round(-$kern * 1000 / $unitsPerEm);
322                    $currentHex = sprintf('%04X', $curGid);
323                } else {
324                    $currentHex .= sprintf('%04X', $curGid);
325                }
326            }
327            $tjParts[] = '<' . $currentHex . '>';
328
329            if (count($tjParts) === 1) {
330                $this->operators[] = $tjParts[0] . ' Tj';
331            } else {
332                $this->operators[] = '[ ' . implode(' ', $tjParts) . ' ] TJ';
333            }
334        } else {
335            // No kerning â€” simple hex string
336            $hex = '';
337            foreach ($gids as $gid) {
338                $hex .= sprintf('%04X', $gid);
339            }
340            $this->operators[] = '<' . $hex . '> Tj';
341        }
342
343        return $this;
344    }
345
346    /** T* - Move to next line */
347    public function nextLine(): self
348    {
349        $this->operators[] = 'T*';
350        return $this;
351    }
352
353    /** Tm - Set text matrix and text line matrix */
354    public function setTextMatrix(float $a, float $b, float $c, float $d, float $e, float $f): self
355    {
356        $this->operators[] = sprintf(
357            '%s %s %s %s %s %s Tm',
358            $this->num($a),
359            $this->num($b),
360            $this->num($c),
361            $this->num($d),
362            $this->num($e),
363            $this->num($f),
364        );
365        return $this;
366    }
367
368    /** Tc - Set character spacing */
369    public function setCharSpacing(float $cs): self
370    {
371        $this->operators[] = sprintf('%s Tc', $this->num($cs));
372        return $this;
373    }
374
375    /** Tw - Set word spacing */
376    public function setWordSpacing(float $ws): self
377    {
378        $this->operators[] = sprintf('%s Tw', $this->num($ws));
379        return $this;
380    }
381
382    /** Tz - Set horizontal scaling */
383    public function setHorizontalScaling(float $hs): self
384    {
385        $this->operators[] = sprintf('%s Tz', $this->num($hs));
386        return $this;
387    }
388
389    /** TL - Set text leading */
390    public function setTextLeading(float $tl): self
391    {
392        $this->operators[] = sprintf('%s TL', $this->num($tl));
393        return $this;
394    }
395
396    /** Tr - Set text rendering mode */
397    public function setTextRenderingMode(int $mode): self
398    {
399        $this->operators[] = sprintf('%d Tr', $mode);
400        return $this;
401    }
402
403    /** Ts - Set text rise */
404    public function setTextRise(float $rise): self
405    {
406        $this->operators[] = sprintf('%s Ts', $this->num($rise));
407        return $this;
408    }
409
410    // -----------------------------------------------------------------------
411    // Graphics state operators
412    // -----------------------------------------------------------------------
413
414    /** q - Save graphics state */
415    public function saveGraphicsState(): self
416    {
417        $this->operators[] = 'q';
418        return $this;
419    }
420
421    /** Q - Restore graphics state */
422    public function restoreGraphicsState(): self
423    {
424        $this->operators[] = 'Q';
425        return $this;
426    }
427
428    /** w - Set line width */
429    public function setLineWidth(float $w): self
430    {
431        $this->operators[] = sprintf('%s w', $this->num($w));
432        return $this;
433    }
434
435    /** J - Set line cap style */
436    public function setLineCap(int $cap): self
437    {
438        $this->operators[] = sprintf('%d J', $cap);
439        return $this;
440    }
441
442    /** j - Set line join style */
443    public function setLineJoin(int $join): self
444    {
445        $this->operators[] = sprintf('%d j', $join);
446        return $this;
447    }
448
449    /** M - Set miter limit */
450    public function setMiterLimit(float $limit): self
451    {
452        $this->operators[] = sprintf('%s M', $this->num($limit));
453        return $this;
454    }
455
456    /** d - Set line dash pattern
457     * @param array<int, int|float> $dash
458     */
459    public function setDashPattern(array $dash, int $phase): self
460    {
461        $dashStr = '[ ' . implode(' ', array_map([$this, 'num'], $dash)) . ' ]';
462        $this->operators[] = sprintf('%s %d d', $dashStr, $phase);
463        return $this;
464    }
465
466    /** ri - Set rendering intent */
467    public function setRenderingIntent(string $intent): self
468    {
469        $this->operators[] = sprintf('/%s ri', $intent);
470        return $this;
471    }
472
473    /** i - Set flatness tolerance */
474    public function setFlatness(float $flatness): self
475    {
476        $this->operators[] = sprintf('%s i', $this->num($flatness));
477        return $this;
478    }
479
480    /** gs - Set graphics state from ExtGState resource */
481    public function setGraphicsState(string $name): self
482    {
483        $this->operators[] = sprintf('/%s gs', $name);
484        return $this;
485    }
486
487    /** cm - Concatenate matrix to current transformation matrix */
488    public function concatMatrix(float $a, float $b, float $c, float $d, float $e, float $f): self
489    {
490        $this->operators[] = sprintf(
491            '%s %s %s %s %s %s cm',
492            $this->num($a),
493            $this->num($b),
494            $this->num($c),
495            $this->num($d),
496            $this->num($e),
497            $this->num($f),
498        );
499        return $this;
500    }
501
502    // -----------------------------------------------------------------------
503    // Path construction operators
504    // -----------------------------------------------------------------------
505
506    /** m - Move to */
507    public function moveTo(float $x, float $y): self
508    {
509        $this->operators[] = sprintf('%s %s m', $this->num($x), $this->num($y));
510        return $this;
511    }
512
513    /** l - Line to */
514    public function lineTo(float $x, float $y): self
515    {
516        $this->operators[] = sprintf('%s %s l', $this->num($x), $this->num($y));
517        return $this;
518    }
519
520    /** c - Curve to (full cubic Bezier) */
521    public function curveTo(float $x1, float $y1, float $x2, float $y2, float $x3, float $y3): self
522    {
523        $this->operators[] = sprintf(
524            '%s %s %s %s %s %s c',
525            $this->num($x1),
526            $this->num($y1),
527            $this->num($x2),
528            $this->num($y2),
529            $this->num($x3),
530            $this->num($y3),
531        );
532        return $this;
533    }
534
535    /** v - Curve to (current point replicated as first control point) */
536    public function curveToV(float $x2, float $y2, float $x3, float $y3): self
537    {
538        $this->operators[] = sprintf(
539            '%s %s %s %s v',
540            $this->num($x2),
541            $this->num($y2),
542            $this->num($x3),
543            $this->num($y3),
544        );
545        return $this;
546    }
547
548    /** y - Curve to (final point replicated as second control point) */
549    public function curveToY(float $x1, float $y1, float $x3, float $y3): self
550    {
551        $this->operators[] = sprintf(
552            '%s %s %s %s y',
553            $this->num($x1),
554            $this->num($y1),
555            $this->num($x3),
556            $this->num($y3),
557        );
558        return $this;
559    }
560
561    /** h - Close path */
562    public function closePath(): self
563    {
564        $this->operators[] = 'h';
565        return $this;
566    }
567
568    /** re - Rectangle */
569    public function rectangle(float $x, float $y, float $w, float $h): self
570    {
571        $this->operators[] = sprintf(
572            '%s %s %s %s re',
573            $this->num($x),
574            $this->num($y),
575            $this->num($w),
576            $this->num($h),
577        );
578        return $this;
579    }
580
581    // -----------------------------------------------------------------------
582    // Path painting operators
583    // -----------------------------------------------------------------------
584
585    /** S - Stroke path */
586    public function stroke(): self
587    {
588        $this->operators[] = 'S';
589        return $this;
590    }
591
592    /** s - Close and stroke */
593    public function closeAndStroke(): self
594    {
595        $this->operators[] = 's';
596        return $this;
597    }
598
599    /** f - Fill path (non-zero winding) */
600    public function fill(): self
601    {
602        $this->operators[] = 'f';
603        return $this;
604    }
605
606    /** f* - Fill path (even-odd rule) */
607    public function fillEvenOdd(): self
608    {
609        $this->operators[] = 'f*';
610        return $this;
611    }
612
613    /** B - Fill and stroke (non-zero winding) */
614    public function fillAndStroke(): self
615    {
616        $this->operators[] = 'B';
617        return $this;
618    }
619
620    /** B* - Fill and stroke (even-odd rule) */
621    public function fillAndStrokeEvenOdd(): self
622    {
623        $this->operators[] = 'B*';
624        return $this;
625    }
626
627    /** b - Close, fill, and stroke (non-zero winding) */
628    public function closeFillAndStroke(): self
629    {
630        $this->operators[] = 'b';
631        return $this;
632    }
633
634    /** b* - Close, fill, and stroke (even-odd rule) */
635    public function closeFillAndStrokeEvenOdd(): self
636    {
637        $this->operators[] = 'b*';
638        return $this;
639    }
640
641    /** n - End path without painting */
642    public function endPath(): self
643    {
644        $this->operators[] = 'n';
645        return $this;
646    }
647
648    /** W - Modify clipping path (non-zero winding) */
649    public function clip(): self
650    {
651        $this->operators[] = 'W';
652        return $this;
653    }
654
655    /** W* - Modify clipping path (even-odd rule) */
656    public function clipEvenOdd(): self
657    {
658        $this->operators[] = 'W*';
659        return $this;
660    }
661
662    // -----------------------------------------------------------------------
663    // Color operators
664    // -----------------------------------------------------------------------
665
666    /** RG - Set stroke color (DeviceRGB) */
667    public function setStrokeColorRGB(float $r, float $g, float $b): self
668    {
669        $this->operators[] = sprintf('%s %s %s RG', $this->num($r), $this->num($g), $this->num($b));
670        return $this;
671    }
672
673    /** rg - Set fill color (DeviceRGB) */
674    public function setFillColorRGB(float $r, float $g, float $b): self
675    {
676        $this->operators[] = sprintf('%s %s %s rg', $this->num($r), $this->num($g), $this->num($b));
677        return $this;
678    }
679
680    /** K - Set stroke color (DeviceCMYK) */
681    public function setStrokeColorCMYK(float $c, float $m, float $y, float $k): self
682    {
683        $this->operators[] = sprintf(
684            '%s %s %s %s K',
685            $this->num($c),
686            $this->num($m),
687            $this->num($y),
688            $this->num($k),
689        );
690        return $this;
691    }
692
693    /** k - Set fill color (DeviceCMYK) */
694    public function setFillColorCMYK(float $c, float $m, float $y, float $k): self
695    {
696        $this->operators[] = sprintf(
697            '%s %s %s %s k',
698            $this->num($c),
699            $this->num($m),
700            $this->num($y),
701            $this->num($k),
702        );
703        return $this;
704    }
705
706    /** G - Set stroke color (DeviceGray) */
707    public function setStrokeColorGray(float $g): self
708    {
709        $this->operators[] = sprintf('%s G', $this->num($g));
710        return $this;
711    }
712
713    /** g - Set fill color (DeviceGray) */
714    public function setFillColorGray(float $g): self
715    {
716        $this->operators[] = sprintf('%s g', $this->num($g));
717        return $this;
718    }
719
720    /** CS - Set stroke color space */
721    public function setStrokeColorSpace(string $name): self
722    {
723        $this->operators[] = sprintf('/%s CS', $name);
724        return $this;
725    }
726
727    /** cs - Set fill color space */
728    public function setFillColorSpace(string $name): self
729    {
730        $this->operators[] = sprintf('/%s cs', $name);
731        return $this;
732    }
733
734    /** SCN - Set stroke color (for special color spaces) */
735    public function setStrokeColor(float|int|string ...$components): self
736    {
737        $parts = array_map(fn($c) => is_string($c) ? '/' . $c : $this->num($c), $components);
738        $this->operators[] = implode(' ', $parts) . ' SCN';
739        return $this;
740    }
741
742    /** scn - Set fill color (for special color spaces) */
743    public function setFillColor(float|int|string ...$components): self
744    {
745        $parts = array_map(fn($c) => is_string($c) ? '/' . $c : $this->num($c), $components);
746        $this->operators[] = implode(' ', $parts) . ' scn';
747        return $this;
748    }
749
750    // -----------------------------------------------------------------------
751    // XObject operator
752    // -----------------------------------------------------------------------
753
754    /** Do - Invoke named XObject */
755    public function doXObject(string $name): self
756    {
757        $this->operators[] = sprintf('/%s Do', $name);
758        return $this;
759    }
760
761    // -----------------------------------------------------------------------
762    // Inline image
763    // -----------------------------------------------------------------------
764
765    /**
766     * BI...ID...EI - Inline image.
767     *
768     * @param array<string, string> $params Associative array of image dictionary entries (abbreviated keys).
769     * @param string $data   Raw image data.
770     */
771    public function inlineImage(array $params, string $data): self
772    {
773        $lines = ['BI'];
774        foreach ($params as $key => $value) {
775            $lines[] = '/' . $key . ' ' . $value;
776        }
777        $lines[] = 'ID';
778        $lines[] = $data;
779        $lines[] = 'EI';
780        $this->operators[] = implode("\n", $lines);
781        return $this;
782    }
783
784    // -----------------------------------------------------------------------
785    // Typed color methods using phpdftk/color objects
786    // -----------------------------------------------------------------------
787
788    /** Set fill color using an RgbColor value object */
789    public function setFillRgbColor(RgbColor $color): self
790    {
791        return $this->setFillColorRGB($color->r, $color->g, $color->b);
792    }
793
794    /** Set stroke color using an RgbColor value object */
795    public function setStrokeRgbColor(RgbColor $color): self
796    {
797        return $this->setStrokeColorRGB($color->r, $color->g, $color->b);
798    }
799
800    /** Set fill color using a CmykColor value object */
801    public function setFillCmykColor(CmykColor $color): self
802    {
803        return $this->setFillColorCMYK($color->c, $color->m, $color->y, $color->k);
804    }
805
806    /** Set stroke color using a CmykColor value object */
807    public function setStrokeCmykColor(CmykColor $color): self
808    {
809        return $this->setStrokeColorCMYK($color->c, $color->m, $color->y, $color->k);
810    }
811
812    /** Set fill color using a GrayColor value object */
813    public function setFillGrayColor(GrayColor $color): self
814    {
815        return $this->setFillColorGray($color->gray);
816    }
817
818    /** Set stroke color using a GrayColor value object */
819    public function setStrokeGrayColor(GrayColor $color): self
820    {
821        return $this->setStrokeColorGray($color->gray);
822    }
823
824    // -----------------------------------------------------------------------
825    // Geometry object helpers
826    // -----------------------------------------------------------------------
827
828    /** re - Rectangle using a Geometry Rectangle value object */
829    public function rectangleObject(Rectangle $r): self
830    {
831        return $this->rectangle($r->x, $r->y, $r->width, $r->height);
832    }
833
834    /** cm - Concatenate matrix using a Geometry Matrix value object */
835    public function concatMatrixObject(Matrix $m): self
836    {
837        return $this->concatMatrix($m->a, $m->b, $m->c, $m->d, $m->e, $m->f);
838    }
839
840    // -----------------------------------------------------------------------
841    // Shorthand text operators
842    // -----------------------------------------------------------------------
843
844    /** ' - Move to next line and show text (equivalent to T* string Tj) */
845    public function moveToNextLineAndShowText(string $text): self
846    {
847        $this->operators[] = $this->escapeString($text) . " '";
848        return $this;
849    }
850
851    /**
852     * " - Set word/char spacing, move to next line, and show text
853     * (equivalent to aw Tw ac Tc T* string Tj)
854     */
855    public function setSpacingMoveAndShowText(float $aw, float $ac, string $text): self
856    {
857        $this->operators[] = sprintf(
858            '%s %s %s "',
859            $this->num($aw),
860            $this->num($ac),
861            $this->escapeString($text),
862        );
863        return $this;
864    }
865
866    // -----------------------------------------------------------------------
867    // Shading operator
868    // -----------------------------------------------------------------------
869
870    /** sh - Paint the shape and colour defined by a shading pattern */
871    public function paintShading(string $name): self
872    {
873        $this->operators[] = sprintf('/%s sh', $name);
874        return $this;
875    }
876
877    // -----------------------------------------------------------------------
878    // Type 3 font glyph operators
879    // -----------------------------------------------------------------------
880
881    /** d0 - Set glyph width in a Type 3 font (coloured glyph) */
882    public function setGlyphWidth(float $wx, float $wy): self
883    {
884        $this->operators[] = sprintf('%s %s d0', $this->num($wx), $this->num($wy));
885        return $this;
886    }
887
888    /** d1 - Set glyph width and bounding box in a Type 3 font (uncoloured glyph) */
889    public function setGlyphWidthAndBoundingBox(
890        float $wx,
891        float $wy,
892        float $llx,
893        float $lly,
894        float $urx,
895        float $ury,
896    ): self {
897        $this->operators[] = sprintf(
898            '%s %s %s %s %s %s d1',
899            $this->num($wx),
900            $this->num($wy),
901            $this->num($llx),
902            $this->num($lly),
903            $this->num($urx),
904            $this->num($ury),
905        );
906        return $this;
907    }
908
909    // -----------------------------------------------------------------------
910    // Marked content operators
911    // -----------------------------------------------------------------------
912
913    /** MP - Define a marked-content point */
914    public function markedContentPoint(string $tag): self
915    {
916        $this->operators[] = sprintf('/%s MP', $tag);
917        return $this;
918    }
919
920    /**
921     * DP - Define a marked-content point with property list.
922     * $properties is either a resource name (string) or an inline dict string.
923     */
924    public function markedContentPointWithProperties(string $tag, string $properties): self
925    {
926        $this->operators[] = sprintf('/%s %s DP', $tag, $properties);
927        return $this;
928    }
929
930    /** BMC - Begin a marked-content sequence */
931    public function beginMarkedContent(string $tag): self
932    {
933        $this->operators[] = sprintf('/%s BMC', $tag);
934        return $this;
935    }
936
937    /**
938     * BDC - Begin a marked-content sequence with property list.
939     * $properties is either a resource name (e.g. '/MC0') or an inline dict string.
940     */
941    public function beginMarkedContentWithProperties(string $tag, string $properties): self
942    {
943        $this->operators[] = sprintf('/%s %s BDC', $tag, $properties);
944        return $this;
945    }
946
947    /** EMC - End a marked-content sequence */
948    public function endMarkedContent(): self
949    {
950        $this->operators[] = 'EMC';
951        return $this;
952    }
953
954    // -----------------------------------------------------------------------
955    // Compatibility operators
956    // -----------------------------------------------------------------------
957
958    /** BX - Begin a compatibility section (unknown operators are ignored) */
959    public function beginCompatibility(): self
960    {
961        $this->operators[] = 'BX';
962        return $this;
963    }
964
965    /** EX - End a compatibility section */
966    public function endCompatibility(): self
967    {
968        $this->operators[] = 'EX';
969        return $this;
970    }
971
972    // -----------------------------------------------------------------------
973    // Raw operator
974    // -----------------------------------------------------------------------
975
976    /** Emit a raw PDF operator string verbatim. */
977    public function raw(string $operator): self
978    {
979        $this->operators[] = $operator;
980        return $this;
981    }
982
983    // -----------------------------------------------------------------------
984    // Serialization
985    // -----------------------------------------------------------------------
986
987    public function toPdf(): string
988    {
989        $this->data = implode("\n", $this->operators);
990        return parent::toPdf();
991    }
992
993    // -----------------------------------------------------------------------
994    // Helpers
995    // -----------------------------------------------------------------------
996
997    /**
998     * Format a number for PDF output (no trailing zeros for floats).
999     */
1000    private function num(int|float $n): string
1001    {
1002        if (is_int($n)) {
1003            return (string) $n;
1004        }
1005        $s = rtrim(rtrim(sprintf('%.6f', $n), '0'), '.');
1006        return $s === '' || $s === '-0' ? '0' : $s;
1007    }
1008
1009    /**
1010     * Return a PDF literal string including the outer parentheses.
1011     *
1012     * Callers must NOT wrap the result again. Escapes `(`, `)`, `\`,
1013     * and control characters per ISO 32000-2 S 7.3.4.2.
1014     */
1015    private function escapeString(string $text): string
1016    {
1017        $escaped = '';
1018        $len = strlen($text);
1019        for ($i = 0; $i < $len; $i++) {
1020            $c = $text[$i];
1021            $escaped .= match ($c) {
1022                '\\' => '\\\\',
1023                '('  => '\\(',
1024                ')'  => '\\)',
1025                "\n" => '\\n',
1026                "\r" => '\\r',
1027                "\t" => '\\t',
1028                default => $c,
1029            };
1030        }
1031        return '(' . $escaped . ')';
1032    }
1033}