Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.02% covered (success)
97.02%
228 / 235
95.92% covered (success)
95.92%
47 / 49
CRAP
0.00% covered (danger)
0.00%
0 / 1
Text
97.02% covered (success)
97.02%
228 / 235
95.92% covered (success)
95.92%
47 / 49
114
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 setString
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 setStrings
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
6
 addStringWithOffset
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getStringsWithOffset
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setSize
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setFillColor
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setStrokeColor
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setStroke
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 setRotation
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setCharWrap
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setLeading
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setAlignment
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setWrap
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setTextStream
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setFont
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 hasFont
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFont
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 escape
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 encodeWinAnsi
50.00% covered (warning)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
2.50
 mbWordwrap
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 getString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRawString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getStrings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSize
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFillColor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getStrokeColor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getStroke
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRotation
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCharWrap
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getNumberOfWrappedLines
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLeading
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAlignment
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getWrap
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTextStream
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasStrings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasCharWrap
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasLeading
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasAlignment
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasWrap
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasTextStream
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setTextParams
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 startStream
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 endStream
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getStream
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPartialStream
92.31% covered (success)
92.31%
48 / 52
0.00% covered (danger)
0.00%
0 / 1
25.28
 getColorStream
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
9
 calculateTextMatrix
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
10
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\Document\Page;
16
17use Pop\Color\Color;
18use Pop\Color\Color\ColorInterface;
19use Pop\Pdf\Build\Font\Exception as FontException;
20use Pop\Pdf\Document\Font;
21use OutOfRangeException;
22
23/**
24 * Pdf page text class
25 *
26 * @category   Pop
27 * @package    Pop\Pdf
28 * @author     Nick Sagona, III <nick@popphp.org>
29 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
30 * @license    https://www.popphp.org/license     New BSD License
31 * @version    6.2.0
32 */
33class Text
34{
35
36    /**
37     * Text string value
38     * @var ?string
39     */
40    protected ?string $string = null;
41
42    /**
43     * Raw (unescaped) text string value, used for CID glyph-ID conversion
44     * @var string
45     */
46    protected string $rawString = '';
47
48    /**
49     * Font used to render this text, resolved at compile time
50     * @var ?Font
51     */
52    protected ?Font $font = null;
53
54    /**
55     * Text strings as array for streaming
56     * @var array
57     */
58    protected array $strings = [];
59
60    /**
61     * Text strings with offset values
62     * @var array
63     */
64    protected array $stringsWithOffsets = [];
65
66    /**
67     * Text font size
68     * @var int|float
69     */
70    protected int|float $size = 12;
71
72    /**
73     * Text fill color
74     * @var ?ColorInterface
75     */
76    protected ?ColorInterface $fillColor = null;
77
78    /**
79     * Text stroke color
80     * @var ?ColorInterface
81     */
82    protected ?ColorInterface $strokeColor = null;
83
84    /**
85     * Text stroke
86     * @var array
87     */
88    protected array $stroke = [
89        'width'      => 0,
90        'dashLength' => null,
91        'dashGap'    => null
92    ];
93
94    /**
95     * Basic wrap based on character length
96     * @var int
97     */
98    protected int $charWrap = 0;
99
100    /**
101     * Leading for the lines for a character wrap
102     * @var int
103     */
104    protected int $leading = 0;
105
106    /**
107     * Text alignment object
108     * @var ?Text\Alignment
109     */
110    protected ?Text\Alignment $alignment = null;
111
112    /**
113     * Text wrap object
114     * @var ?Text\Wrap
115     */
116    protected ?Text\Wrap $wrap = null;
117
118    /**
119     * Text stream object
120     * @var ?Text\Stream
121     */
122    protected ?Text\Stream $stream = null;
123
124    /**
125     * Text parameters
126     * @var array
127     */
128    protected array $textParams = [
129        'c'    => 0,
130        'w'    => 0,
131        'h'    => 100,
132        'v'    => 100,
133        'rot'  => 0,
134        'rend' => 0
135    ];
136
137    /**
138     * Constructor
139     *
140     * Instantiate a PDF text object.
141     *
142     * @param ?string    $string
143     * @param int|float|null $size
144     * @param bool       $escape
145     */
146    public function __construct(?string $string = null, int|float|null $size = null, bool $escape = true)
147    {
148        if ($string !== null) {
149            $this->setString($string, $escape);
150        }
151        if ($size !== null) {
152            $this->setSize($size);
153        }
154    }
155
156    /**
157     * Set the text string
158     *
159     * @param  string $string
160     * @param  bool   $escape
161     * @return Text
162     */
163    public function setString(string $string, bool $escape = true): Text
164    {
165        if (function_exists('mb_strlen')) {
166            if (mb_strlen($string, 'UTF-8') < strlen($string)) {
167                $string = mb_convert_encoding($string, 'UTF-8', mb_detect_encoding($string));
168            }
169        }
170
171        $this->rawString = $string;
172        $this->string    = ($escape) ? $this->escape($string) : $string;
173
174        return $this;
175    }
176
177    /**
178     * Set the text strings
179     *
180     * @param  array $strings
181     * @param  bool  $escape
182     * @return Text
183     */
184    public function setStrings(array $strings, bool $escape = true): Text
185    {
186        if (function_exists('mb_strlen')) {
187            $strings = array_map(function($value) use ($escape) {
188                if ($value instanceof Text) {
189                    $v = $value->getString();
190                    if (mb_strlen($v, 'UTF-8') < strlen($v)) {
191                        return mb_convert_encoding($v, 'UTF-8', mb_detect_encoding($v));
192                    }
193                    $value->setString($v, $escape);
194                } else if (mb_strlen($value, 'UTF-8') < strlen($value)) {
195                    $value = mb_convert_encoding($value, 'UTF-8', mb_detect_encoding($value));
196                    if ($escape) {
197                        $value = $this->escape($value);
198                    }
199                }
200                return $value;
201            }, $strings);
202        }
203
204        $this->strings = $strings;
205        return $this;
206    }
207
208    /**
209     * Add a string with offset
210     *
211     * @param  string $string
212     * @param  int    $offset
213     * @return Text
214     */
215    public function addStringWithOffset(string $string, int $offset = 0): Text
216    {
217        if (function_exists('mb_strlen')) {
218            if (mb_strlen($string, 'UTF-8') < strlen($string)) {
219                $string = mb_convert_encoding($string, 'UTF-8', mb_detect_encoding($string));
220            }
221        }
222        $this->stringsWithOffsets[] = [
223            'string' => $string,
224            'offset' => $offset
225        ];
226        return $this;
227    }
228
229    /**
230     * Get strings with offset
231     *
232     * @return array
233     */
234    public function getStringsWithOffset(): array
235    {
236        return $this->stringsWithOffsets;
237    }
238
239    /**
240     * Set the text size
241     *
242     * @param  int|float $size
243     * @return Text
244     */
245    public function setSize(int|float $size): Text
246    {
247        $this->size = $size;
248        return $this;
249    }
250
251    /**
252     * Set the text fill color
253     *
254     * @param  ColorInterface $color
255     * @return Text
256     */
257    public function setFillColor(ColorInterface $color): Text
258    {
259        $this->fillColor = $color;
260        return $this;
261    }
262
263    /**
264     * Set the text stroke color
265     *
266     * @param  ColorInterface $color
267     * @return Text
268     */
269    public function setStrokeColor(ColorInterface $color): Text
270    {
271        $this->strokeColor = $color;
272        return $this;
273    }
274
275    /**
276     * Set the text stroke properties
277     *
278     * @param  int|float       $width
279     * @param  int|float|null $dashLength
280     * @param  int|float|null $dashGap
281     * @return Text
282     */
283    public function setStroke(int|float $width, int|float|null $dashLength = null, int|float|null $dashGap = null): Text
284    {
285        $this->stroke = [
286            'width'      => $width,
287            'dashLength' => $dashLength,
288            'dashGap'    => $dashGap
289        ];
290        return $this;
291    }
292
293    /**
294     * Method to set the rotation of the text
295     *
296     * @param  int|float $rotation
297     * @throws OutOfRangeException
298     * @return Text
299     */
300    public function setRotation(int|float $rotation): Text
301    {
302        if (abs($rotation) > 90) {
303            throw new OutOfRangeException('Error: The rotation parameter must be between -90 and 90 degrees.');
304        }
305        $this->textParams['rot'] = $rotation;
306        return $this;
307    }
308
309    /**
310     * Method to set the character wrap
311     *
312     * @param  int  $charWrap
313     * @param  ?int $leading
314     * @return Text
315     */
316    public function setCharWrap(int $charWrap, ?int $leading = null): Text
317    {
318        $this->charWrap = $charWrap;
319        if ($leading !== null) {
320            $this->setLeading($leading);
321        }
322        return $this;
323    }
324
325    /**
326     * Method to set the character wrap leading
327     *
328     * @param  int $leading
329     * @return Text
330     */
331    public function setLeading(int $leading): Text
332    {
333        $this->leading = $leading;
334        return $this;
335    }
336
337    /**
338     * Method to set the text alignment
339     *
340     * @param  Text\Alignment $alignment
341     * @return Text
342     */
343    public function setAlignment(Text\Alignment $alignment): Text
344    {
345        $this->alignment = $alignment;
346        return $this;
347    }
348
349    /**
350     * Method to set the text wrap
351     *
352     * @param  Text\Wrap $wrap
353     * @return Text
354     */
355    public function setWrap(Text\Wrap $wrap): Text
356    {
357        $this->wrap = $wrap;
358        return $this;
359    }
360
361    /**
362     * Method to set the text stream
363     *
364     * @param  Text\Stream $stream
365     * @return Text
366     */
367    public function setTextStream(Text\Stream $stream): Text
368    {
369        $this->stream = $stream;
370        return $this;
371    }
372
373    /**
374     * Set the font this text will be rendered with
375     *
376     * Resolved and set by Build\Compiler at compile time - determines
377     * whether getPartialStream() emits literal PDF strings or CID glyph-ID
378     * hex strings, and whether standard-font glyph coverage is validated.
379     *
380     * @param  Font $font
381     * @return Text
382     */
383    public function setFont(Font $font): Text
384    {
385        $this->font = $font;
386        return $this;
387    }
388
389    /**
390     * Has font
391     *
392     * @return bool
393     */
394    public function hasFont(): bool
395    {
396        return ($this->font !== null);
397    }
398
399    /**
400     * Get font
401     *
402     * @return ?Font
403     */
404    public function getFont(): ?Font
405    {
406        return $this->font;
407    }
408
409    /**
410     * Escape string
411     *
412     * @param  string $subject
413     * @param  mixed  $search
414     * @param  mixed  $replace
415     * @return string
416     */
417    public static function escape(string $subject, mixed $search  = null, mixed $replace = null): string
418    {
419        if (($search === null) && ($replace === null)) {
420            $search  = ["\\", '(', ')', "\n", "\r", "\t", chr(8), "\f"];
421            $replace = ["\\\\", '\(', '\)', "\\n", "\\r", "\\t", "\\b", "\\f"];
422        }
423        return str_replace($search, $replace, $subject);
424    }
425
426    /**
427     * Transcode a raw UTF-8 string to WinAnsiEncoding (Windows-1252) bytes and escape it for a literal PDF string
428     *
429     * requireGlyphCoverage() has already confirmed the font's cmap covers
430     * every character in the string, so iconv failing here should never
431     * happen in practice - the exception is a safety net against cmap/
432     * encoding drift, not an expected runtime path.
433     *
434     * @param  string $string
435     * @throws FontException
436     * @return string
437     */
438    protected function encodeWinAnsi(string $string): string
439    {
440        $encoded = @iconv('UTF-8', 'Windows-1252', $string);
441        if ($encoded === false) {
442            throw new FontException(
443                "Error: The font '" . $this->font->getName() . "' cannot represent the given text in WinAnsiEncoding."
444            );
445        }
446
447        return $this->escape($encoded);
448    }
449
450    /**
451     * Word-wrap a string by character count rather than byte count
452     *
453     * PHP's wordwrap() measures $width in bytes, so multi-byte UTF-8
454     * characters (accented Latin, Cyrillic, CJK, etc.) wrap far earlier than
455     * intended - a word made of 2-3 byte characters can blow a byte budget
456     * while still well under the intended character width. This mirrors
457     * wordwrap()'s default (non-cut) behavior using character counts instead.
458     *
459     * @param  string $string
460     * @param  int    $width
461     * @return string
462     */
463    protected function mbWordwrap(string $string, int $width): string
464    {
465        $paragraphs = explode("\n", $string);
466
467        foreach ($paragraphs as $i => $paragraph) {
468            $words   = explode(' ', $paragraph);
469            $lines   = [];
470            $current = '';
471
472            foreach ($words as $word) {
473                if ($current === '') {
474                    $current = $word;
475                } else if (mb_strlen($current) + 1 + mb_strlen($word) <= $width) {
476                    $current .= ' ' . $word;
477                } else {
478                    $lines[] = $current;
479                    $current = $word;
480                }
481            }
482
483            $lines[]        = $current;
484            $paragraphs[$i] = implode("\n", $lines);
485        }
486
487        return implode("\n", $paragraphs);
488    }
489
490    /**
491     * Get the text string
492     *
493     * @return ?string
494     */
495    public function getString(): ?string
496    {
497        return $this->string;
498    }
499
500    /**
501     * Get the raw (unescaped) text string
502     *
503     * @return string
504     */
505    public function getRawString(): string
506    {
507        return $this->rawString;
508    }
509
510    /**
511     * Get the text string array
512     *
513     * @return array
514     */
515    public function getStrings(): array
516    {
517        return $this->strings;
518    }
519
520    /**
521     * Get the text size
522     *
523     * @return int|float
524     */
525    public function getSize(): int|float
526    {
527        return $this->size;
528    }
529
530    /**
531     * Get the text fill color
532     *
533     * @return ?ColorInterface
534     */
535    public function getFillColor(): ?ColorInterface
536    {
537        return $this->fillColor;
538    }
539
540    /**
541     * Get the text stroke color
542     *
543     * @return ?ColorInterface
544     */
545    public function getStrokeColor(): ?ColorInterface
546    {
547        return $this->strokeColor;
548    }
549
550    /**
551     * Get the text stroke properties
552     *
553     * @return array
554     */
555    public function getStroke(): array
556    {
557        return $this->stroke;
558    }
559
560    /**
561     * Get the rotation of the text
562     *
563     * @return int
564     */
565    public function getRotation(): int
566    {
567        return $this->textParams['rot'];
568    }
569
570    /**
571     * Get character wrap
572     *
573     * @return int
574     */
575    public function getCharWrap(): int
576    {
577        return $this->charWrap;
578    }
579
580    /**
581     * Get number of wrapped lines from character wrap
582     *
583     * @return int
584     */
585    public function getNumberOfWrappedLines(): int
586    {
587        return count(explode("\n", $this->mbWordwrap($this->string, $this->charWrap)));
588    }
589
590    /**
591     * Get character wrap leading
592     *
593     * @return int
594     */
595    public function getLeading(): int
596    {
597        return $this->leading;
598    }
599
600    /**
601     * Get text alignment
602     *
603     * @return ?Text\Alignment
604     */
605    public function getAlignment(): ?Text\Alignment
606    {
607        return $this->alignment;
608    }
609
610    /**
611     * Get text wrap
612     *
613     * @return ?Text\Wrap
614     */
615    public function getWrap(): ?Text\Wrap
616    {
617        return $this->wrap;
618    }
619
620    /**
621     * Get text stream
622     *
623     * @return ?Text\Stream
624     */
625    public function getTextStream(): ?Text\Stream
626    {
627        return $this->stream;
628    }
629
630    /**
631     * Has text string
632     *
633     * @return bool
634     */
635    public function hasString(): bool
636    {
637        return ($this->string !== null);
638    }
639
640    /**
641     * Has text string array
642     *
643     * @return bool
644     */
645    public function hasStrings(): bool
646    {
647        return !empty($this->strings);
648    }
649
650    /**
651     * Has character wrap
652     *
653     * @return bool
654     */
655    public function hasCharWrap(): bool
656    {
657        return ($this->charWrap > 0);
658    }
659
660    /**
661     * Has character wrap leading
662     *
663     * @return bool
664     */
665    public function hasLeading(): bool
666    {
667        return ($this->leading > 0);
668    }
669
670    /**
671     * Has text alignment
672     *
673     * @return bool
674     */
675    public function hasAlignment(): bool
676    {
677        return ($this->alignment !== null);
678    }
679
680    /**
681     * Has text wrap
682     *
683     * @return bool
684     */
685    public function hasWrap(): bool
686    {
687        return ($this->wrap !== null);
688    }
689
690    /**
691     * Has text stream
692     *
693     * @return bool
694     */
695    public function hasTextStream(): bool
696    {
697        return ($this->stream !== null);
698    }
699
700    /**
701     * Set the text parameters for rendering text content
702     *
703     * @param  int|float $c    (character spacing)
704     * @param  int|float $w    (word spacing)
705     * @param  int|float $h    (horz stretch)
706     * @param  int|float $v    (vert stretch)
707     * @param  int $rot  (rotation, -90 - 90)
708     * @param  int $rend (render flag, 0 - 7)
709     * @throws OutOfRangeException
710     * @return Text
711     */
712    public function setTextParams(int|float $c = 0, int|float $w = 0, int|float $h = 100, int|float $v = 100, int $rot = 0, int $rend = 0): Text
713    {
714        // Check the rotation parameter.
715        if (abs($rot) > 90) {
716            throw new OutOfRangeException('Error: The rotation parameter must be between -90 and 90 degrees.');
717        }
718
719        // Check the render parameter.
720        if (($rend > 7) || ($rend < 0)) {
721            throw new OutOfRangeException('Error: The render parameter must be an integer between 0 and 7.');
722        }
723
724        // Set the text parameters.
725        $this->textParams['c']    = $c;
726        $this->textParams['w']    = $w;
727        $this->textParams['h']    = $h;
728        $this->textParams['v']    = $v;
729        $this->textParams['rot']  = $rot;
730        $this->textParams['rend'] = $rend;
731
732        return $this;
733    }
734
735    /**
736     * Start the text stream
737     *
738     * @param  string    $fontReference
739     * @param  int|float $x
740     * @param  int|float $y
741     * @return string
742     */
743    public function startStream(string $fontReference, int|float $x, int|float $y): string
744    {
745        $stream        = '';
746        $fontReference = substr($fontReference, 0, strpos($fontReference, ' '));
747
748        $stream .= "\nBT\n    {$fontReference} {$this->size} Tf\n    " . $this->calculateTextMatrix() .
749            " {$x} {$y} Tm\n    " . $this->textParams['c'] . " Tc " . $this->textParams['w'] .
750            " Tw " . $this->textParams['rend'] . " Tr\n";
751
752        return $stream;
753    }
754
755    /**
756     * End the text stream
757     *
758     * @return string
759     */
760    public function endStream(): string
761    {
762        return "ET\n";
763    }
764
765    /**
766     * Get the text stream
767     *
768     * @param  string    $fontReference
769     * @param  int|float $x
770     * @param  int|float $y
771     * @return string
772     */
773    public function getStream(string $fontReference, int|float $x, int|float $y): string
774    {
775        return $this->startStream($fontReference, $x, $y) . $this->getPartialStream() . $this->endStream();
776    }
777
778    /**
779     * Get the partial text stream
780     *
781     * @param  ?string $fontReference
782     * @return string
783     */
784    public function getPartialStream(?string $fontReference = null): string
785    {
786        $stream = '';
787
788        if ($fontReference !== null) {
789            $fontReference = substr($fontReference, 0, strpos($fontReference, ' '));
790            $stream       .= "    {$fontReference} {$this->size} Tf\n";
791        }
792
793        $stream .= $this->getColorStream();
794
795        $isCid      = ($this->font !== null) && $this->font->isCid();
796        $isStandard = ($this->font !== null) && $this->font->isStandard();
797
798        if ($isStandard) {
799            $this->font->requireGlyphCoverage($this->rawString);
800            foreach ($this->stringsWithOffsets as $string) {
801                $this->font->requireGlyphCoverage($string['string']);
802            }
803        }
804
805        if (count($this->stringsWithOffsets) > 0) {
806            if ($isCid) {
807                $stream .= "    [<" . $this->font->stringToGidHex($this->rawString) . ">";
808                foreach ($this->stringsWithOffsets as $string) {
809                    $stream .= " " . (0 - $string['offset']) . " <" . $this->font->stringToGidHex($string['string']) . ">";
810                }
811                $stream .= "]TJ\n";
812            } else if ($isStandard) {
813                $stream .= "    [(" . $this->encodeWinAnsi($this->rawString) . ")";
814                foreach ($this->stringsWithOffsets as $string) {
815                    $stream .= " " . (0 - $string['offset']) . " (" . $this->encodeWinAnsi($string['string']) . ")";
816                }
817                $stream .= "]TJ\n";
818            } else {
819                $stream .= "    [({$this->string})";
820                foreach ($this->stringsWithOffsets as $string) {
821                    $stream .= " " . (0 - $string['offset']) . " (" . $string['string'] . ")";
822                }
823                $stream .= "]TJ\n";
824            }
825        } else {
826            if (($this->hasCharWrap()) && (mb_strlen($this->string) > $this->charWrap)) {
827                if ((int)$this->leading == 0) {
828                    $this->leading = $this->size;
829                }
830
831                if ($isCid) {
832                    $strings = explode("\n", $this->mbWordwrap($this->rawString, $this->charWrap));
833                    foreach ($strings as $i => $string) {
834                        $stream .= "    <" . $this->font->stringToGidHex($string) . ">Tj\n";
835                        if ($i < count($strings)) {
836                            $stream .= "    0 -" . $this->leading . " Td\n";
837                        }
838                    }
839                } else if ($isStandard) {
840                    $strings = explode("\n", $this->mbWordwrap($this->rawString, $this->charWrap));
841                    foreach ($strings as $i => $string) {
842                        $stream .= "    (" . $this->encodeWinAnsi($string) . ")Tj\n";
843                        if ($i < count($strings)) {
844                            $stream .= "    0 -" . $this->leading . " Td\n";
845                        }
846                    }
847                } else {
848                    $strings = explode("\n", $this->mbWordwrap($this->string, $this->charWrap));
849                    foreach ($strings as $i => $string) {
850                        $stream .= "    ({$string})Tj\n";
851                        if ($i < count($strings)) {
852                            $stream .= "    0 -" . $this->leading . " Td\n";
853                        }
854                    }
855                }
856            } else {
857                if ($isCid) {
858                    $stream .= "    <" . $this->font->stringToGidHex($this->rawString) . ">Tj\n";
859                } else if ($isStandard) {
860                    $stream .= "    (" . $this->encodeWinAnsi($this->rawString) . ")Tj\n";
861                } else {
862                    $stream .= "    ({$this->string})Tj\n";
863                }
864            }
865        }
866
867        return $stream;
868    }
869
870    /**
871     * Get the partial color stream
872     *
873     * @return string
874     */
875    public function getColorStream(): string
876    {
877        $stream = '';
878
879        if ($this->fillColor !== null) {
880            if ($this->fillColor instanceof Color\Rgb) {
881                $stream .= '    ' . $this->fillColor->render(Color\Rgb::PERCENT) . " rg\n";
882            } else if ($this->fillColor instanceof Color\Cmyk) {
883                $stream .= '    ' . $this->fillColor->render(Color\Cmyk::PERCENT) . " k\n";
884            } else if ($this->fillColor instanceof Color\Grayscale) {
885                $stream .= '    ' . $this->fillColor->render(Color\Grayscale::PERCENT) . " g\n";
886            }
887        }
888        if ($this->strokeColor !== null) {
889            if ($this->strokeColor instanceof Color\Rgb) {
890                $stream .= '    ' . $this->strokeColor->render(Color\Rgb::PERCENT) . " RG\n";
891            } else if ($this->strokeColor instanceof Color\Cmyk) {
892                $stream .= '    ' . $this->strokeColor->render(Color\Cmyk::PERCENT) . " K\n";
893            } else if ($this->strokeColor instanceof Color\Grayscale) {
894                $stream .= '    ' . $this->strokeColor->render(Color\Grayscale::PERCENT) . " G\n";
895            }
896        }
897
898        return $stream;
899    }
900
901    /**
902     * Calculate text matrix
903     *
904     * @return string
905     */
906    protected function calculateTextMatrix(): string
907    {
908        // Define some variables.
909        $a   = '';
910        $b   = '';
911        $c   = '';
912        $d   = '';
913        $neg = null;
914
915        // Determine is the rotate parameter is negative or not.
916        $neg = ($this->textParams['rot'] < 0) ? true : false;
917
918        // Calculate the text matrix parameters.
919        $rot = abs($this->textParams['rot']);
920
921        if (($rot >= 0) && ($rot <= 45)) {
922            $factor = round(($rot / 45), 2);
923            if ($neg) {
924                $a = 1;
925                $b = '-' . $factor;
926                $c = $factor;
927                $d = 1;
928            } else {
929                $a = 1;
930                $b = $factor;
931                $c = '-' . $factor;
932                $d = 1;
933            }
934        } else if (($rot > 45) && ($rot <= 90)) {
935            $factor = round(((90 - $rot) / 45), 2);
936            if ($neg) {
937                $a = $factor;
938                $b = -1;
939                $c = 1;
940                $d = $factor;
941            } else {
942                $a = $factor;
943                $b = 1;
944                $c = -1;
945                $d = $factor;
946            }
947        }
948
949        // Adjust the text matrix parameters according to the horizontal and vertical scale parameters.
950        if ($this->textParams['h'] != 100) {
951            $a = round(($a * ($this->textParams['h'] / 100)), 2);
952            $b = round(($b * ($this->textParams['h'] / 100)), 2);
953        }
954
955        if ($this->textParams['v'] != 100) {
956            $c = round(($c * ($this->textParams['v'] / 100)), 2);
957            $d = round(($d * ($this->textParams['v'] / 100)), 2);
958        }
959
960        // Return the text matrix.
961        return "{$a} {$b} {$c} {$d}";
962    }
963
964}