Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
321 / 321
100.00% covered (success)
100.00%
11 / 11
CRAP
100.00% covered (success)
100.00%
1 / 1
Interpreter
100.00% covered (success)
100.00%
321 / 321
100.00% covered (success)
100.00%
11 / 11
144
100.00% covered (success)
100.00%
1 / 1
 run
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 resetState
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 interpret
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
8
 skipInlineImage
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
14
 dispatch
100.00% covered (success)
100.00%
175 / 175
100.00% covered (success)
100.00%
1 / 1
86
 handleDo
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
12
 showText
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 emitRun
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
10
 isSuppressingForActualText
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 isReversedActive
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 decodePdfTextString
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
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\Extract\Content;
16
17use Pop\Pdf\Extract\Document;
18use Pop\Pdf\Extract\Filter\Registry;
19use Pop\Pdf\Extract\ObjectParser;
20use Pop\Pdf\Extract\Tokenizer;
21use Pop\Pdf\Extract\Value;
22
23/**
24 * Pdf extract content interpreter 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.0.0
32 */
33class Interpreter
34{
35
36    /**
37     * Maximum Do-operator (Form XObject) recursion depth
38     */
39    protected const MAX_DO_DEPTH = 32;
40
41    /**
42     * Document being interpreted against
43     * @var Document
44     */
45    protected Document $doc;
46
47    /**
48     * Text runs emitted so far
49     * @var array
50     */
51    protected array $runs = [];
52
53    /**
54     * Text matrix
55     * @var array
56     */
57    protected array $tm = ['a' => 1.0, 'b' => 0.0, 'i' => 0.0, 'j' => 1.0, 'x' => 0.0, 'y' => 0.0];
58
59    /**
60     * Text line matrix translation
61     * @var array
62     */
63    protected array $td = ['x' => 0.0, 'y' => 0.0];
64
65    /**
66     * Current transformation matrix
67     * @var array
68     */
69    protected array $cm = ['a' => 1.0, 'b' => 0.0, 'i' => 0.0, 'j' => 1.0, 'x' => 0.0, 'y' => 0.0];
70
71    /**
72     * Text leading
73     * @var float
74     */
75    protected float $leading = 0.0;
76
77    /**
78     * Active font resource name
79     * @var ?string
80     */
81    protected ?string $fontName = null;
82
83    /**
84     * Active font size
85     * @var float
86     */
87    protected float $fontSize = 0.0;
88
89    /**
90     * Character spacing
91     * @var float
92     */
93    protected float $charSpace = 0.0;
94
95    /**
96     * Word spacing
97     * @var float
98     */
99    protected float $wordSpace = 0.0;
100
101    /**
102     * Horizontal scaling percentage
103     * @var float
104     */
105    protected float $horizScale = 100.0;
106
107    /**
108     * Graphics state stack, pushed/popped by q/Q
109     * @var array
110     */
111    protected array $qStack = [];
112
113    /**
114     * Marked-content stack, pushed/popped by BDC/BMC/EMC
115     * @var array
116     */
117    protected array $markedStack = [];
118
119    /**
120     * Position captured for a pending ActualText replacement run
121     * @var mixed
122     */
123    protected mixed $actualTextPos = null;
124
125    /**
126     * Last emitted run's x position, used to compute the next run's separator
127     * @var ?float
128     */
129    protected ?float $lastX = null;
130
131    /**
132     * Last emitted run's y position, used to compute the next run's separator
133     * @var ?float
134     */
135    protected ?float $lastY = null;
136
137    /**
138     * Active font's resolved dictionary
139     * @var mixed
140     */
141    protected mixed $fontResolved = null;
142
143    /**
144     * Active font's precomputed cache key, computed once per Tf
145     * @var ?string
146     */
147    protected ?string $fontResolvedKey = null;
148
149    /**
150     * Interpret a page's content stream against a document/resources, returning the extracted text runs
151     *
152     * @param  Document $doc
153     * @param  string   $content
154     * @param  array    $resources
155     * @return array
156     */
157    public function run(Document $doc, string $content, array $resources): array
158    {
159        $this->doc = $doc;
160        $this->resetState();
161
162        $this->interpret($content, $resources, [], 0);
163
164        return $this->runs;
165    }
166
167    /**
168     * Reset all interpreter state to its initial values
169     *
170     * @return void
171     */
172    protected function resetState(): void
173    {
174        $this->runs = [];
175
176        $this->tm = ['a' => 1.0, 'b' => 0.0, 'i' => 0.0, 'j' => 1.0, 'x' => 0.0, 'y' => 0.0];
177        $this->td = ['x' => 0.0, 'y' => 0.0];
178        $this->cm = ['a' => 1.0, 'b' => 0.0, 'i' => 0.0, 'j' => 1.0, 'x' => 0.0, 'y' => 0.0];
179
180        $this->leading         = 0.0;
181        $this->fontName        = null;
182        $this->fontResolved    = null;
183        $this->fontResolvedKey = null;
184        $this->fontSize        = 0.0;
185        $this->charSpace  = 0.0;
186        $this->wordSpace  = 0.0;
187        $this->horizScale = 100.0;
188
189        $this->qStack        = [];
190        $this->markedStack   = [];
191        $this->actualTextPos = null;
192
193        $this->lastX = null;
194        $this->lastY = null;
195    }
196
197    /**
198     * Tokenize and dispatch a content stream's operators
199     *
200     * @param  string $content
201     * @param  array  $resources
202     * @param  array  $doStack
203     * @param  int    $depth
204     * @return void
205     */
206    protected function interpret(string $content, array $resources, array $doStack, int $depth): void
207    {
208        $tokenizer    = new Tokenizer($content);
209        $objectParser = new ObjectParser($tokenizer);
210        $operandStack = [];
211
212        while (true) {
213            $savedPos = $tokenizer->getPosition();
214            $peek     = $tokenizer->next();
215
216            if ($peek['type'] === 'eof') {
217                break;
218            }
219
220            if (($peek['type'] === 'keyword') && ($peek['value'] === 'BI')) {
221                $this->skipInlineImage($tokenizer);
222                $operandStack = [];
223                continue;
224            }
225
226            $tokenizer->setPosition($savedPos);
227
228            try {
229                $value = $objectParser->parseValue();
230            } catch (\Pop\Pdf\Extract\Exception $e) {
231                // A malformed operand (stray delimiter, truncated array/dict,
232                // etc.) must not lose every run already extracted on this
233                // page - skip it and keep going. The tokenizer's position
234                // has already advanced past the offending token, so this
235                // always makes forward progress.
236                $operandStack = [];
237                continue;
238            }
239
240            if ($value instanceof Value\Keyword) {
241                try {
242                    $this->dispatch($value->keyword, $operandStack, $resources, $doStack, $depth);
243                } catch (\Pop\Pdf\Extract\Exception $e) {
244                    // A resolve() failure inside any operator handler (e.g.
245                    // a circular reference in /Resources /Font or /XObject)
246                    // must not lose every run already extracted on this
247                    // page - degrade this operator to a no-op and keep
248                    // going, matching the malformed-operand handling above.
249                }
250                $operandStack = [];
251            } else {
252                $operandStack[] = $value;
253            }
254        }
255    }
256
257    /**
258     * Skip over an inline image (BI...ID...EI) without interpreting it
259     *
260     * @param  Tokenizer $tokenizer
261     * @return void
262     */
263    protected function skipInlineImage(Tokenizer $tokenizer): void
264    {
265        $objectParser = new ObjectParser($tokenizer);
266
267        while (true) {
268            $savedPos = $tokenizer->getPosition();
269            $peek     = $tokenizer->next();
270
271            if ($peek['type'] === 'eof') {
272                return;
273            }
274
275            if (($peek['type'] === 'keyword') && ($peek['value'] === 'ID')) {
276                break;
277            }
278
279            $tokenizer->setPosition($savedPos);
280
281            try {
282                $objectParser->parseValue();
283            } catch (\Pop\Pdf\Extract\Exception $e) {
284                // Malformed BI key/value pair - keep scanning for ID rather
285                // than aborting the whole page.
286                continue;
287            }
288        }
289
290        $data = $tokenizer->getData();
291        $pos  = $tokenizer->getPosition();
292
293        if (($pos < strlen($data)) && Tokenizer::isWhitespace($data[$pos])) {
294            $pos++;
295        }
296
297        $length = strlen($data);
298        $eiPos  = $pos;
299
300        while (true) {
301            $eiPos = strpos($data, 'EI', $eiPos);
302
303            if ($eiPos === false) {
304                $tokenizer->setPosition($length);
305                return;
306            }
307
308            $beforeOk = ($eiPos === 0) || Tokenizer::isWhitespace($data[$eiPos - 1]);
309            $afterPos = $eiPos + 2;
310            $afterOk  = ($afterPos >= $length) || Tokenizer::isWhitespace($data[$afterPos]);
311
312            if ($beforeOk && $afterOk) {
313                $tokenizer->setPosition($afterPos);
314                return;
315            }
316
317            $eiPos += 2;
318        }
319    }
320
321    /**
322     * Dispatch one operator with its operands, updating interpreter state and/or emitting runs
323     *
324     * @param  string $op
325     * @param  array  $operands
326     * @param  array  $resources
327     * @param  array  $doStack
328     * @param  int    $depth
329     * @return void
330     */
331    protected function dispatch(string $op, array $operands, array $resources, array $doStack, int $depth): void
332    {
333        switch ($op) {
334            case 'q':
335                $this->qStack[] = [
336                    'fontName'        => $this->fontName,
337                    'fontResolved'    => $this->fontResolved,
338                    'fontResolvedKey' => $this->fontResolvedKey,
339                    'fontSize'        => $this->fontSize,
340                    'cm'              => $this->cm,
341                ];
342                break;
343
344            case 'Q':
345                $saved = array_pop($this->qStack);
346                if ($saved !== null) {
347                    $this->fontName        = $saved['fontName'];
348                    $this->fontResolved    = $saved['fontResolved'];
349                    $this->fontResolvedKey = $saved['fontResolvedKey'];
350                    $this->fontSize        = $saved['fontSize'];
351                    $this->cm              = $saved['cm'];
352                }
353                break;
354
355            case 'cm':
356                if (count($operands) >= 6) {
357                    [$a, $b, $c, $d, $e, $f] = array_slice($operands, -6);
358                    $this->cm = [
359                        'a' => is_numeric($a) ? (float) $a : 0.0,
360                        'b' => is_numeric($b) ? (float) $b : 0.0,
361                        'i' => is_numeric($c) ? (float) $c : 0.0,
362                        'j' => is_numeric($d) ? (float) $d : 0.0,
363                        'x' => is_numeric($e) ? (float) $e : 0.0,
364                        'y' => is_numeric($f) ? (float) $f : 0.0,
365                    ];
366                }
367                break;
368
369            case 'BT':
370                $this->tm = ['a' => 1.0, 'b' => 0.0, 'i' => 0.0, 'j' => 1.0, 'x' => 0.0, 'y' => 0.0];
371                $this->td = ['x' => 0.0, 'y' => 0.0];
372                break;
373
374            case 'ET':
375                break;
376
377            case 'BDC':
378                $tag  = $operands[count($operands) - 2] ?? null;
379                $prop = $operands[count($operands) - 1] ?? null;
380
381                $propsDict = null;
382                if (is_array($prop)) {
383                    $propsDict = $prop;
384                } elseif ($prop instanceof Value\Name) {
385                    $properties = $this->doc->resolve($resources['Properties'] ?? null);
386                    if (is_array($properties) && isset($properties[$prop->name])) {
387                        $resolved = $this->doc->resolve($properties[$prop->name]);
388                        if (is_array($resolved)) {
389                            $propsDict = $resolved;
390                        }
391                    }
392                }
393
394                $actualText = null;
395                if (is_array($propsDict) && isset($propsDict['ActualText']) && is_string($propsDict['ActualText'])) {
396                    $actualText = $this->decodePdfTextString($propsDict['ActualText']);
397                }
398
399                if ($actualText !== null) {
400                    $this->markedStack[] = ['type' => 'actualText', 'text' => $actualText];
401                } else {
402                    $this->markedStack[] = ['type' => 'plain'];
403                }
404                break;
405
406            case 'BMC':
407                $tag = end($operands);
408                if (($tag instanceof Value\Name) && ($tag->name === 'ReversedChars')) {
409                    $this->markedStack[] = ['type' => 'reversedChars'];
410                } else {
411                    $this->markedStack[] = ['type' => 'plain'];
412                }
413                break;
414
415            case 'EMC':
416                $marker = array_pop($this->markedStack);
417
418                if (($marker !== null) && ($marker['type'] === 'actualText') && ($this->actualTextPos !== null)) {
419                    $this->emitRun(null, $marker['text'], $this->actualTextPos['x'], $this->actualTextPos['y']);
420                }
421
422                $this->actualTextPos = null;
423                break;
424
425            case 'Do':
426                $name = end($operands);
427                if ($name instanceof Value\Name) {
428                    $this->handleDo($name->name, $resources, $doStack, $depth);
429                }
430                break;
431
432            case 'Tf':
433                if (count($operands) >= 2) {
434                    $name = $operands[count($operands) - 2];
435                    $size = $operands[count($operands) - 1];
436
437                    $this->fontName        = ($name instanceof Value\Name) ? $name->name : null;
438                    $this->fontSize        = is_numeric($size) ? (float) $size : 0.0;
439                    $this->fontResolved    = null;
440                    $this->fontResolvedKey = null;
441
442                    if ($this->fontName !== null) {
443                        $fonts = $this->doc->resolve($resources['Font'] ?? null);
444                        if (is_array($fonts) && isset($fonts[$this->fontName])) {
445                            $resolvedFont = $this->doc->resolve($fonts[$this->fontName]);
446                            if (is_array($resolvedFont)) {
447                                $this->fontResolved = $resolvedFont;
448                                // Computed ONCE per Tf (font activation), not once per
449                                // Tj/TJ run - a font may back thousands of runs before
450                                // the next Tf, and hashing the full resolved dict per
451                                // run would be an O(runs x dict-size) cost.
452                                $this->fontResolvedKey = md5(serialize($resolvedFont));
453                            }
454                        }
455                    }
456                }
457                break;
458
459            case 'Tc':
460                if (count($operands) >= 1) {
461                    $val = end($operands);
462                    $this->charSpace = is_numeric($val) ? (float) $val : 0.0;
463                }
464                break;
465
466            case 'Tw':
467                if (count($operands) >= 1) {
468                    $val = end($operands);
469                    $this->wordSpace = is_numeric($val) ? (float) $val : 0.0;
470                }
471                break;
472
473            case 'Tz':
474                if (count($operands) >= 1) {
475                    $val = end($operands);
476                    $this->horizScale = is_numeric($val) ? (float) $val : 100.0;
477                }
478                break;
479
480            case 'TL':
481                if (count($operands) >= 1) {
482                    $yVal = end($operands);
483                    $y = is_numeric($yVal) ? (float) $yVal : 0.0;
484                    $this->leading = -$y * $this->tm['b'] - $y * $this->tm['j'];
485                }
486                break;
487
488            case 'Td':
489            case 'TD':
490                if (count($operands) >= 2) {
491                    $xVal = $operands[count($operands) - 2];
492                    $yVal = $operands[count($operands) - 1];
493                    $x = is_numeric($xVal) ? (float) $xVal : 0.0;
494                    $y = is_numeric($yVal) ? (float) $yVal : 0.0;
495
496                    if ($op === 'TD') {
497                        $this->leading = -$y * $this->tm['b'] - $y * $this->tm['j'];
498                    }
499
500                    $this->td['x'] += $x * $this->tm['a'] + $x * $this->tm['i'];
501                    $this->td['y'] += $y * $this->tm['b'] + $y * $this->tm['j'];
502                }
503                break;
504
505            case 'T*':
506                $this->td['x']  = 0.0;
507                $this->td['y'] += $this->leading;
508                break;
509
510            case 'Tm':
511                if (count($operands) >= 6) {
512                    [$a, $b, $c, $d, $e, $f] = array_slice($operands, -6);
513                    $this->tm = [
514                        'a' => is_numeric($a) ? (float) $a : 0.0,
515                        'b' => is_numeric($b) ? (float) $b : 0.0,
516                        'i' => is_numeric($c) ? (float) $c : 0.0,
517                        'j' => is_numeric($d) ? (float) $d : 0.0,
518                        'x' => is_numeric($e) ? (float) $e : 0.0,
519                        'y' => is_numeric($f) ? (float) $f : 0.0,
520                    ];
521                }
522                break;
523
524            case 'Tj':
525                if (count($operands) >= 1) {
526                    $value = end($operands);
527                    if (is_string($value)) {
528                        $this->showText($value);
529                    }
530                }
531                break;
532
533            case 'TJ':
534                if (count($operands) >= 1) {
535                    $array = end($operands);
536                    if (is_array($array)) {
537                        foreach ($array as $element) {
538                            if (is_string($element)) {
539                                $this->showText($element);
540                            } elseif (is_numeric($element)) {
541                                // TJ's numeric adjustment is a purely horizontal
542                                // (writing-direction) advance - unlike Td/TD's
543                                // x,y pair, it has no y-component to project.
544                                $adj = -($element / 1000) * $this->fontSize;
545                                $this->td['x'] += $adj * $this->tm['a'] + $adj * $this->tm['i'];
546                            }
547                        }
548                    }
549                }
550                break;
551
552            case "'":
553                $this->td['x']  = 0.0;
554                $this->td['y'] += $this->leading;
555                if (count($operands) >= 1) {
556                    $value = end($operands);
557                    if (is_string($value)) {
558                        $this->showText($value);
559                    }
560                }
561                break;
562
563            case '"':
564                if (count($operands) >= 3) {
565                    $wsVal = $operands[count($operands) - 3];
566                    $csVal = $operands[count($operands) - 2];
567                    $this->wordSpace = is_numeric($wsVal) ? (float) $wsVal : 0.0;
568                    $this->charSpace = is_numeric($csVal) ? (float) $csVal : 0.0;
569                    $this->td['x']   = 0.0;
570                    $this->td['y']  += $this->leading;
571                    $value = end($operands);
572                    if (is_string($value)) {
573                        $this->showText($value);
574                    }
575                }
576                break;
577
578            default:
579                // Unrecognized/irrelevant operator (path construction, color,
580                // clipping, etc.) - operands were already pushed and are
581                // discarded here; no state or output effect.
582                break;
583        }
584    }
585
586    /**
587     * Handle the Do operator, recursively interpreting a Form XObject's content stream
588     *
589     * @param  string $name
590     * @param  array  $resources
591     * @param  array  $doStack
592     * @param  int    $depth
593     * @return void
594     */
595    protected function handleDo(string $name, array $resources, array $doStack, int $depth): void
596    {
597        if ($depth >= self::MAX_DO_DEPTH) {
598            // Hard cap regardless of the objNum cycle guard below - a
599            // directly-inline (non-indirect-reference) Form stream has no
600            // objNum for that guard to key on, so a self-invoking inline
601            // Form would otherwise recurse unbounded.
602            return;
603        }
604
605        $xobjects = $this->doc->resolve($resources['XObject'] ?? null);
606
607        if (!is_array($xobjects) || !isset($xobjects[$name])) {
608            return;
609        }
610
611        $ref    = $xobjects[$name];
612        $objNum = ($ref instanceof Value\Reference) ? $ref->objNum : null;
613
614        if (($objNum !== null) && isset($doStack[$objNum])) {
615            return;
616        }
617
618        $xobject = $this->doc->resolve($ref);
619
620        if (!($xobject instanceof Value\Stream)) {
621            return;
622        }
623
624        $subtype = $xobject->dict['Subtype'] ?? null;
625        if (!($subtype instanceof Value\Name) || ($subtype->name !== 'Form')) {
626            return;
627        }
628
629        $formResources = $this->doc->resolve($xobject->dict['Resources'] ?? null);
630        $formResources = is_array($formResources) ? $formResources : $resources;
631
632        $content = Registry::decode($xobject->raw, $xobject->dict['Filter'] ?? null, $xobject->dict['DecodeParms'] ?? null, $this->doc->getDecodeBudget());
633
634        $savedFontName        = $this->fontName;
635        $savedFontResolved    = $this->fontResolved;
636        $savedFontResolvedKey = $this->fontResolvedKey;
637        $savedFontSize        = $this->fontSize;
638        $savedCm              = $this->cm;
639        $savedQStack          = $this->qStack;
640        $savedMarkedStack     = $this->markedStack;
641
642        if ($objNum !== null) {
643            $doStack[$objNum] = true;
644        }
645
646        try {
647            $this->interpret($content, $formResources, $doStack, $depth + 1);
648        } finally {
649            $this->fontName        = $savedFontName;
650            $this->fontResolved    = $savedFontResolved;
651            $this->fontResolvedKey = $savedFontResolvedKey;
652            $this->fontSize        = $savedFontSize;
653            $this->cm              = $savedCm;
654            // A Form's content stream must not be able to corrupt the
655            // caller's graphics-state/marked-content stacks - an unbalanced
656            // q/Q or BDC/EMC inside the form (malformed or adversarial)
657            // would otherwise pop/leave-open the CALLER's frames, since
658            // these stacks are shared instance state across the recursive
659            // interpret() call. Restore full snapshots, not just depth.
660            $this->qStack      = $savedQStack;
661            $this->markedStack = $savedMarkedStack;
662        }
663    }
664
665    /**
666     * Show text (Tj/TJ/'/"), either emitting a run or capturing an ActualText substitution's position
667     *
668     * @param  string $bytes
669     * @return void
670     */
671    protected function showText(string $bytes): void
672    {
673        $x = $this->cm['x'] + $this->tm['x'] + $this->td['x'];
674        $y = $this->cm['y'] + $this->tm['y'] + $this->td['y'];
675
676        if ($this->isSuppressingForActualText()) {
677            if ($this->actualTextPos === null) {
678                $this->actualTextPos = ['x' => $x, 'y' => $y];
679            }
680            return;
681        }
682
683        $this->emitRun($bytes, null, $x, $y);
684    }
685
686    /**
687     * Emit a text run, computing its separator relative to the previous run
688     *
689     * @param  ?string $rawBytes
690     * @param  ?string $decodedText
691     * @param  float   $x
692     * @param  float   $y
693     * @return void
694     */
695    protected function emitRun(?string $rawBytes, ?string $decodedText, float $x, float $y): void
696    {
697        $factorX = -$this->fontSize * $this->tm['a'] - $this->fontSize * $this->tm['i'];
698        $factorY =  $this->fontSize * $this->tm['b'] + $this->fontSize * $this->tm['j'];
699
700        $separator = TextRun::SEPARATOR_NONE;
701
702        if ($this->lastX !== null) {
703            $deltaY = $y - $this->lastY;
704
705            if (abs($deltaY) >= (abs($factorY) / 4)) {
706                $separator = TextRun::SEPARATOR_NEWLINE;
707            } else {
708                $deltaX = $x - $this->lastX;
709
710                if ($deltaX >= abs($factorX * 7)) {
711                    $separator = TextRun::SEPARATOR_TAB;
712                } elseif ($deltaX >= abs($factorX * 2)) {
713                    $separator = TextRun::SEPARATOR_SPACE;
714                }
715            }
716        }
717
718        $fontResourceName = ($decodedText !== null) ? null : $this->fontName;
719        $font             = ($decodedText !== null) ? null : $this->fontResolved;
720        $fontCacheKey     = ($decodedText !== null) ? null : $this->fontResolvedKey;
721        $this->runs[] = new TextRun($fontResourceName, $rawBytes, $decodedText, $x, $y, $separator, $this->isReversedActive(), $font, $fontCacheKey);
722
723        $length      = ($rawBytes !== null) ? strlen($rawBytes) : (($decodedText !== null) ? strlen($decodedText) : 0);
724        $this->lastX = $x - ($length * ($factorX / 2));
725        $this->lastY = $y;
726    }
727
728    /**
729     * Determine if the marked-content stack is currently suppressing output for an ActualText replacement
730     *
731     * @return bool
732     */
733    protected function isSuppressingForActualText(): bool
734    {
735        foreach ($this->markedStack as $marker) {
736            if ($marker['type'] === 'actualText') {
737                return true;
738            }
739        }
740
741        return false;
742    }
743
744    /**
745     * Determine if the marked-content stack currently has /ReversedChars active
746     *
747     * @return bool
748     */
749    protected function isReversedActive(): bool
750    {
751        foreach ($this->markedStack as $marker) {
752            if ($marker['type'] === 'reversedChars') {
753                return true;
754            }
755        }
756
757        return false;
758    }
759
760    /**
761     * Decode a PDF text string, converting a UTF-16BE (BOM-prefixed) value to UTF-8
762     *
763     * @param  string $value
764     * @return string
765     */
766    protected function decodePdfTextString(string $value): string
767    {
768        if ((strlen($value) >= 2) && (substr($value, 0, 2) === "\xFE\xFF")) {
769            $utf16 = substr($value, 2);
770
771            return @mb_convert_encoding($utf16, 'UTF-8', 'UTF-16BE');
772        }
773
774        return $value;
775    }
776
777}