Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.78% covered (success)
97.78%
220 / 225
92.00% covered (success)
92.00%
23 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
Stream
97.78% covered (success)
97.78%
220 / 225
92.00% covered (success)
92.00%
23 / 25
120
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
1
 setStartX
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setStartY
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setEdgeX
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setEdgeY
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setCurrentX
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setCurrentY
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getStartX
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getStartY
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getEdgeX
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getEdgeY
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCurrentX
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCurrentY
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addText
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 setCurrentStyle
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 hasTextStreams
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTextStreams
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
4
 getStream
97.33% covered (success)
97.33%
73 / 75
0.00% covered (danger)
0.00%
0 / 1
45
 measureHeight
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
19
 getOrphanStream
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 hasOrphans
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
23
 overflowsEdgeX
100.00% covered (success)
100.00%
1 / 1
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
 getColorStream
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 hasOrphanIndex
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
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\Text;
16
17use Pop\Color\Color;
18use Pop\Pdf\Build\Font\Exception as FontException;
19use Pop\Pdf\Document\Font;
20use Pop\Pdf\Document\Page\Text;
21
22/**
23 * Pdf page text stream class
24 *
25 * @category   Pop
26 * @package    Pop\Pdf
27 * @author     Nick Sagona, III <nick@popphp.org>
28 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
29 * @license    https://www.popphp.org/license     New BSD License
30 * @version    6.0.0
31 */
32class Stream
33{
34
35    /**
36     * Start X
37     * @var int|float|null
38     */
39    protected int|float|null $startX = null;
40
41    /**
42     * Start Y
43     * @var int|float|null
44     */
45    protected int|float|null $startY = null;
46
47    /**
48     * Edge X boundary
49     * @var ?int
50     */
51    protected int|float|null $edgeX = null;
52
53    /**
54     * Edge Y boundary
55     * @var int|float|null
56     */
57    protected int|float|null $edgeY = null;
58    /**
59     * Current X
60     * @var int|float|null
61     */
62    protected int|float|null $currentX = null;
63
64    /**
65     * Current Y
66     * @var int|float|null
67     */
68    protected int|float|null $currentY = null;
69
70    /**
71    * Text streams
72     * @var array
73     */
74    protected array $streams = [];
75
76    /**
77     * Text styles
78     * @var array
79     */
80    protected array $styles = [];
81
82    /**
83     * Orphan index
84     * @var array
85     */
86    protected array $orphanIndex = [];
87
88    /**
89     * Constructor
90     *
91     * Instantiate a PDF text stream object.
92     *
93     * @param int|float      $startX
94     * @param int|float      $startY
95     * @param int|float      $edgeX
96     * @param int|float|null $edgeY
97     */
98    public function __construct(int|float $startX, int|float $startY, int|float $edgeX, int|float|null $edgeY = null)
99    {
100        $this->setStartX($startX);
101        $this->setStartY($startY);
102        $this->setEdgeX($edgeX);
103        $this->setEdgeY($edgeY);
104    }
105
106    /**
107     * Set start X
108     *
109     * @param  int|float $startX
110     * @return Stream
111     */
112    public function setStartX(int|float $startX): Stream
113    {
114        $this->startX = $startX;
115        return $this;
116    }
117
118    /**
119     * Set start Y
120     *
121     * @param  int|float $startY
122     * @return Stream
123     */
124    public function setStartY(int|float $startY): Stream
125    {
126        $this->startY = $startY;
127        return $this;
128    }
129
130    /**
131     * Set edge X boundary
132     *
133     * @param  int|float $edgeX
134     * @return Stream
135     */
136    public function setEdgeX(int|float $edgeX): Stream
137    {
138        $this->edgeX = $edgeX;
139        return $this;
140    }
141
142    /**
143     * Set edge Y boundary
144     *
145     * @param  int|float|null $edgeY
146     * @return Stream
147     */
148    public function setEdgeY(int|float|null $edgeY): Stream
149    {
150        $this->edgeY = $edgeY;
151        return $this;
152    }
153
154    /**
155     * Set current X
156     *
157     * @param  int|float $currentX
158     * @return Stream
159     */
160    public function setCurrentX(int|float $currentX): Stream
161    {
162        $this->currentX = $currentX;
163        return $this;
164    }
165
166    /**
167     * Set current Y
168     *
169     * @param  int|float $currentY
170     * @return Stream
171     */
172    public function setCurrentY(int|float $currentY): Stream
173    {
174        $this->currentY = $currentY;
175        return $this;
176    }
177
178    /**
179     * Get start X
180     *
181     * @return int|float|null
182     */
183    public function getStartX(): int|float|null
184    {
185        return $this->startX;
186    }
187
188    /**
189     * Get start Y
190     *
191     * @return int|float|null
192     */
193    public function getStartY(): int|float|null
194    {
195        return $this->startY;
196    }
197
198    /**
199     * Get edge X boundary
200     *
201     * @return int|float|null
202     */
203    public function getEdgeX(): int|float|null
204    {
205        return $this->edgeX;
206    }
207
208    /**
209     * Get edge Y boundary
210     *
211     * @return int|float|null
212     */
213    public function getEdgeY(): int|float|null
214    {
215        return $this->edgeY;
216    }
217
218    /**
219     * Get current X
220     *
221     * @return int|float|null
222     */
223    public function getCurrentX(): int|float|null
224    {
225        return $this->currentX;
226    }
227
228    /**
229     * Get current Y
230     *
231     * @return int|float|null
232     */
233    public function getCurrentY(): int|float|null
234    {
235        return $this->currentY;
236    }
237
238    /**
239     * Add text to the stream
240     *
241     * @param  string         $string
242     * @param  int|float|null $y
243     * @param  bool           $newLine
244     * @return Stream
245     */
246    public function addText(string $string, int|float|null $y = null, bool $newLine = false): Stream
247    {
248        $this->streams[] = [
249            'string'  => $string,
250            'y'       => $y,
251            'newLine' => $newLine,
252        ];
253
254        return $this;
255    }
256
257    /**
258     * Set the current style
259     *
260     * @param  string                $font
261     * @param  int|float             $size
262     * @param  ?Color\ColorInterface $color
263     * @param  ?string               $align
264     * @return Stream
265     */
266    public function setCurrentStyle(string $font, int|float $size, ?Color\ColorInterface $color = null, ?string $align = null): Stream
267    {
268        $key = (!empty($this->streams)) ? count($this->streams) : 0;
269        $this->styles[$key] = [
270            'font'  => $font,
271            'size'  => $size,
272            'color' => $color,
273            'align' => $align,
274        ];
275
276        return $this;
277    }
278
279    /**
280     * Has text streams
281     *
282     * @return bool
283     */
284    public function hasTextStreams(): bool
285    {
286        return !empty($this->streams);
287    }
288
289    /**
290     * Get text stream
291     *
292     * @return array
293     */
294    public function getTextStreams(): array
295    {
296        $streams      = $this->streams;
297        $currentFont  = 'Arial';
298        $currentSize  = 10;
299        $currentColor = new Color\Rgb(0, 0, 0);
300        $currentAlign = null;
301
302        if (isset($this->styles[0])) {
303            $currentFont  = $this->styles[0]['font'] ?? 'Arial';
304            $currentSize  = $this->styles[0]['size'] ?? 10;
305            $currentColor = $this->styles[0]['color'] ?? new Color\Rgb(0, 0, 0);
306            $currentAlign = $this->styles[0]['align'] ?? null;
307        }
308
309        foreach ($streams as $i => $stream) {
310            if (isset($this->styles[$i])) {
311                $currentFont  = $this->styles[$i]['font'] ?? $currentFont;
312                $currentSize  = $this->styles[$i]['size'] ?? $currentSize;
313                $currentColor = $this->styles[$i]['color'] ?? $currentColor;
314                $currentAlign = $this->styles[$i]['align'] ?? $currentAlign;
315            }
316            $streams[$i]['font']  = $currentFont;
317            $streams[$i]['size']  = $currentSize;
318            $streams[$i]['color'] = $currentColor;
319            $streams[$i]['align'] = $currentAlign;
320        }
321
322        return $streams;
323    }
324
325    /**
326     * Get stream
327     *
328     * @param  array $fonts
329     * @param  array $fontReferences
330     * @return string
331     */
332    public function getStream(array $fonts, array $fontReferences): string
333    {
334        if ($this->currentX === null) {
335            $this->currentX = $this->startX;
336        }
337        if ($this->currentY === null) {
338            $this->currentY = $this->startY;
339        }
340        $fontName       = null;
341        $fontReference  = null;
342        $fontSize       = null;
343        $curFont        = null;
344
345        foreach ($this->styles as $style) {
346            if (($fontReference === null) && !empty($style['font']) && isset($fontReferences[$style['font']])) {
347                $fontName      = $style['font'];
348                $fontReference = substr($fontReferences[$fontName], 0, strpos($fontReferences[$fontName], ' '));
349                $curFont       = $fonts[$fontName] ?? null;
350            }
351            if (($fontSize === null) && !empty($style['size'])) {
352                $fontSize = $style['size'];
353            }
354        }
355
356        $firstStringWidth = null;
357        if (count($this->streams) == 1) {
358            $fontName         = $this->styles[0]['font'];
359            $fontReference    = substr($fontReferences[$fontName], 0, strpos($fontReferences[$fontName], ' '));
360            $fontSize         = (!empty($this->styles[0]['size'])) ? $this->styles[0]['size'] : $fontSize;
361            $curFont          = $fonts[$fontName] ?? null;
362            $firstStringWidth = $curFont->getStringWidth($this->streams[0]['string'], $fontSize);
363        }
364
365        if (($firstStringWidth !== null) && isset($style['align']) && ($style['align'] == 'center') && ($firstStringWidth <= $this->edgeX - $this->startX)) {
366            $centerX = ($this->currentX + $this->edgeX - $this->startX - $firstStringWidth) / 2;
367            $stream  = "\nBT\n    {$fontReference} {$fontSize} Tf\n    1 0 0 1 {$centerX} {$this->currentY} Tm\n    0 Tc 0 Tw 0 Tr\n";
368            $stream .= "    {$fontReference} {$fontSize} Tf\n";
369            if (($curFont instanceof Font) && $curFont->isCid()) {
370                $stream .= "    <" . $curFont->stringToGidHex($this->streams[0]['string']) . ">Tj\n";
371            } else if (($curFont instanceof Font) && $curFont->isStandard()) {
372                $curFont->requireGlyphCoverage($this->streams[0]['string']);
373                $stream .= "    (" . $this->encodeWinAnsi($this->streams[0]['string'], $curFont) . ")Tj\n";
374            } else {
375                $stream .= "    (" . Text::escape($this->streams[0]['string']) . ")Tj\n";
376            }
377        } else {
378            $stream  = "\nBT\n    {$fontReference} {$fontSize} Tf\n    1 0 0 1 {$this->currentX} {$this->currentY} Tm\n    0 Tc 0 Tw 0 Tr\n";
379
380            foreach ($this->streams as $i => $str) {
381                if (isset($this->styles[$i]) && !empty($this->styles[$i]['font']) && isset($fontReferences[$this->styles[$i]['font']])) {
382                    $fontName      = $this->styles[$i]['font'];
383                    $fontReference = substr($fontReferences[$fontName], 0, strpos($fontReferences[$fontName], ' '));
384                    $fontSize      = (!empty($this->styles[$i]['size'])) ? $this->styles[$i]['size'] : $fontSize;
385                    $curFont       = $fonts[$fontName] ?? null;
386                    $stream       .= "    {$fontReference} {$fontSize} Tf\n";
387                }
388                if (isset($this->styles[$i]) && !empty($this->styles[$i]['color'])) {
389                    $stream .= $this->getColorStream($this->styles[$i]['color']);
390                }
391
392                if ($str['newLine']) {
393                    $nextY             = ($str['y'] !== null) ? $str['y'] : $fontSize;
394                    $stream           .= "    0 -" . $nextY . " Td\n";
395                    $this->currentX    = $this->startX;
396                    $this->currentY   -= $nextY;
397                }
398
399                $curString = explode(' ', $str['string']);
400
401                foreach ($curString as $j => $string) {
402                    $newX = $this->currentX + $curFont->getStringWidth($string, $fontSize);
403                    if ($this->overflowsEdgeX($this->currentX, $newX)) {
404                        $nextY             = ($str['y'] !== null) ? $str['y'] : $fontSize;
405                        $stream           .= "    0 -" . $nextY . " Td\n";
406                        $this->currentX    = $this->startX;
407                        $this->currentY   -= $nextY;
408                        if (($this->edgeY !== null) && ($this->currentY <= $this->edgeY) && ($this->currentX == $this->startX)) {
409                            break;
410                        }
411                    }
412
413                    if (!isset($curString[$j + 1])) {
414                        if (isset($this->streams[$i + 1]) &&
415                            preg_match('/[a-zA-Z0-9]/', substr($this->streams[$i + 1]['string'], 0, 1))) {
416                            $string .= ' ';
417                        }
418                    } else {
419                        $string .= ' ';
420                    }
421
422                    if (($curFont instanceof Font) && $curFont->isCid()) {
423                        $stream .= "    <" . $curFont->stringToGidHex($string) . ">Tj\n";
424                    } else if (($curFont instanceof Font) && $curFont->isStandard()) {
425                        $curFont->requireGlyphCoverage($string);
426                        $stream .= "    (" . $this->encodeWinAnsi($string, $curFont) . ")Tj\n";
427                    } else {
428                        $stream .= "    (" . Text::escape($string) . ")Tj\n";
429                    }
430                    if ($curFont !== null) {
431                        $this->currentX += $curFont->getStringWidth($string, $fontSize);
432                    }
433                }
434                if (($this->edgeY !== null) && ($this->currentY <= $this->edgeY) && ($this->currentX == $this->startX)) {
435                    $this->orphanIndex = [$i, $j];
436                    break;
437                }
438            }
439        }
440
441
442
443
444        $stream .= "ET\n";
445
446        return $stream;
447    }
448
449    /**
450     * Measure the total rendered height of this stream's content
451     *
452     * Ignores edgeY/page boundaries entirely - it answers "how tall is this
453     * content" (all wrapped lines, unpaginated), not "does this fit on one
454     * page". Reuses the same word-wrap-at-edgeX decision (overflowsEdgeX())
455     * that getStream() and hasOrphans() use, so the height this reports can
456     * never disagree with where getStream() actually breaks a line. Used by
457     * the parser to advance its page cursor by the real rendered height of
458     * a text node instead of a flat, guessed amount.
459     *
460     * @param  array $fonts
461     * @return float
462     */
463    public function measureHeight(array $fonts): float
464    {
465        $currentX = $this->startX;
466        $fontName = null;
467        $fontSize = null;
468        $curFont  = null;
469
470        foreach ($this->styles as $style) {
471            if (!empty($style['font'])) {
472                $fontName = $style['font'];
473                $curFont  = $fonts[$fontName] ?? null;
474            }
475            if (($fontSize === null) && !empty($style['size'])) {
476                $fontSize = $style['size'];
477            }
478        }
479
480        $totalHeight = (float) $fontSize;
481
482        foreach ($this->streams as $i => $str) {
483            if (isset($this->styles[$i]) && !empty($this->styles[$i]['font'])) {
484                $fontName = $this->styles[$i]['font'];
485                $fontSize = (!empty($this->styles[$i]['size'])) ? $this->styles[$i]['size'] : $fontSize;
486                $curFont  = $fonts[$fontName] ?? null;
487            }
488
489            if ($str['newLine']) {
490                $totalHeight += ($str['y'] !== null) ? $str['y'] : $fontSize;
491                $currentX     = $this->startX;
492            }
493
494            $curString = explode(' ', $str['string']);
495
496            foreach ($curString as $j => $string) {
497                $newX = ($curFont !== null) ? $currentX + $curFont->getStringWidth($string, $fontSize) : $currentX;
498
499                if ($this->overflowsEdgeX($currentX, $newX)) {
500                    $totalHeight += ($str['y'] !== null) ? $str['y'] : $fontSize;
501                    $currentX     = $this->startX;
502                }
503
504                if (!isset($curString[$j + 1])) {
505                    if (isset($this->streams[$i + 1]) &&
506                        preg_match('/[a-zA-Z0-9]/', substr($this->streams[$i + 1]['string'], 0, 1))) {
507                        $string .= ' ';
508                    }
509                } else {
510                    $string .= ' ';
511                }
512
513                if ($curFont !== null) {
514                    $currentX += $curFont->getStringWidth($string, $fontSize);
515                }
516            }
517        }
518
519        return $totalHeight;
520    }
521
522    /**
523     * Resume stream from orphaned index
524     *
525     * @return Stream
526     */
527    public function getOrphanStream(): Stream
528    {
529        $offset        = array_search($this->orphanIndex[0], array_keys($this->streams));
530        $this->streams = array_slice($this->streams, $offset, null, true);
531
532        if ($this->orphanIndex[1] > 0) {
533            $strings = array_slice(explode(' ', $this->streams[$this->orphanIndex[0]]['string']), $this->orphanIndex[1], null, true);
534            $this->streams[$this->orphanIndex[0]]['string'] = implode(' ', $strings);
535        }
536
537        $this->orphanIndex = [];
538        return $this;
539    }
540
541    /**
542     * Prepare stream
543     *
544     * @param  array $fonts
545     * @return bool
546     */
547    public function hasOrphans(array $fonts): bool
548    {
549        $this->currentX = $this->startX;
550        $this->currentY = $this->startY;
551        $fontName       = null;
552        $fontSize       = null;
553        $curFont        = null;
554
555        foreach ($this->styles as $style) {
556            if (!empty($style['font'])) {
557                $fontName = $style['font'];
558                $curFont  = $fonts[$fontName] ?? null;
559            }
560            if (($fontSize === null) && !empty($style['size'])) {
561                $fontSize = $style['size'];
562            }
563        }
564
565        foreach ($this->streams as $i => $str) {
566            if (isset($this->styles[$i]) && !empty($this->styles[$i]['font'])) {
567                $fontName = $this->styles[$i]['font'];
568                $fontSize = (!empty($this->styles[$i]['size'])) ? $this->styles[$i]['size'] : $fontSize;
569                $curFont  = $fonts[$fontName] ?? null;
570            }
571
572            $curString = explode(' ', $str['string']);
573
574            foreach ($curString as $j => $string) {
575                $newX = ($curFont !== null) ? $this->currentX + $curFont->getStringWidth($string, $fontSize) : $this->currentX;
576
577                if ($this->overflowsEdgeX($this->currentX, $newX)) {
578                    $nextY             = ($str['y'] !== null) ? $str['y'] : $fontSize;
579                    $this->currentX    = $this->startX;
580                    $this->currentY   -= $nextY;
581                    if (($this->edgeY !== null) && ($this->currentY <= $this->edgeY) && ($this->currentX == $this->startX)) {
582                        break;
583                    }
584                }
585
586                if (!isset($curString[$j + 1])) {
587                    if (isset($this->streams[$i + 1]) &&
588                        preg_match('/[a-zA-Z0-9]/', substr($this->streams[$i + 1]['string'], 0, 1))) {
589                        $string .= ' ';
590                    }
591                } else {
592                    $string .= ' ';
593                }
594
595                if ($curFont !== null) {
596                    $this->currentX += $curFont->getStringWidth($string, $fontSize);
597                }
598            }
599            if (($this->edgeY !== null) && ($this->currentY <= $this->edgeY) && ($this->currentX == $this->startX)) {
600                $this->orphanIndex = [$i, $j];
601                break;
602            }
603        }
604
605        return (!empty($this->orphanIndex));
606    }
607
608    /**
609     * Determine whether placing this word would overflow the horizontal edge
610     *
611     * Shared by hasOrphans(), getStream(), and measureHeight() so all three
612     * agree on exactly where a line wraps - they previously used two
613     * different, disagreeing conditions (one checked only the position
614     * after the previous word; the other also looked ahead to the word
615     * about to be placed), which caused hasOrphans() to disagree with
616     * getStream() about where a page-spanning paragraph actually overflows,
617     * silently dropping a block of words at the page boundary.
618     *
619     * @param  float $currentX
620     * @param  float $newX
621     * @return bool
622     */
623    protected function overflowsEdgeX(float $currentX, float $newX): bool
624    {
625        return ($this->edgeX !== null) && (($currentX >= $this->edgeX) || ($newX >= $this->edgeX));
626    }
627
628    /**
629     * Transcode a raw UTF-8 string to WinAnsiEncoding (Windows-1252) bytes and escape it for a literal PDF string
630     *
631     * requireGlyphCoverage() has already confirmed the font's cmap covers
632     * every character in the string, so iconv failing here should never
633     * happen in practice - the exception is a safety net against cmap/
634     * encoding drift, not an expected runtime path.
635     *
636     * @param  string $string
637     * @param  Font   $font
638     * @throws FontException
639     * @return string
640     */
641    protected function encodeWinAnsi(string $string, Font $font): string
642    {
643        $encoded = @iconv('UTF-8', 'Windows-1252', $string);
644        if ($encoded === false) {
645            throw new FontException(
646                "Error: The font '" . $font->getName() . "' cannot represent the given text in WinAnsiEncoding."
647            );
648        }
649
650        return Text::escape($encoded);
651    }
652
653    /**
654     * Get the partial color stream
655     *
656     * @param  Color\ColorInterface $color
657     * @return string
658     */
659    public function getColorStream(Color\ColorInterface $color): string
660    {
661        $stream = '';
662
663        if ($color instanceof Color\Rgb) {
664            $stream .= '    ' . $color->render(Color\Rgb::PERCENT) . " rg\n";
665        } else if ($color instanceof Color\Cmyk) {
666            $stream .= '    ' . $color->render(Color\Cmyk::PERCENT) . " k\n";
667        } else if ($color instanceof Color\Grayscale) {
668            $stream .= '    ' . $color->render(Color\Grayscale::PERCENT) . " g\n";
669        }
670
671        return $stream;
672    }
673
674    /**
675     * Check if the text stream has orphan streams due to the page bottom
676     *
677     * @return bool
678     */
679    public function hasOrphanIndex(): bool
680    {
681        return (!empty($this->orphanIndex));
682    }
683
684}