Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
55 / 55
100.00% covered (success)
100.00%
2 / 2
CRAP
100.00% covered (success)
100.00%
1 / 1
PageClassifier
100.00% covered (success)
100.00%
55 / 55
100.00% covered (success)
100.00%
2 / 2
22
100.00% covered (success)
100.00%
1 / 1
 isImageOnly
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
14
 resolveIsImage
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
8
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\Exception;
19use Pop\Pdf\Extract\ObjectParser;
20use Pop\Pdf\Extract\Tokenizer;
21use Pop\Pdf\Extract\Value;
22
23/**
24 * Pdf extract content page classifier 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 PageClassifier
34{
35
36    /**
37     * Operators that paint/mark the page
38     */
39    protected const PAINT_OPERATORS = ['f', 'F', 'f*', 'S', 's', 'B', 'B*', 'b', 'b*', 'sh'];
40
41    /**
42     * Text-showing operators
43     */
44    protected const TEXT_OPERATORS = ['Tj', 'TJ', "'", '"'];
45
46    /**
47     * Determine if a page's content is nothing but a single drawn image
48     *
49     * @param  Document $doc
50     * @param  PageInfo $page
51     * @return bool
52     */
53    public static function isImageOnly(Document $doc, PageInfo $page): bool
54    {
55        $tokenizer    = new Tokenizer($page->content);
56        $objectParser = new ObjectParser($tokenizer);
57        $operandStack = [];
58        $imageCount   = 0;
59
60        while (true) {
61            $savedPos = $tokenizer->getPosition();
62            $peek     = $tokenizer->next();
63
64            if ($peek['type'] === 'eof') {
65                break;
66            }
67
68            if (($peek['type'] === 'keyword') && ($peek['value'] === 'BI')) {
69                // An inline image is unexpected extra content for this
70                // classifier's purposes (real scan-to-PDF output uses a
71                // full-page XObject image, not inline images) - disqualify
72                // immediately rather than skipping over it.
73                return false;
74            }
75
76            $tokenizer->setPosition($savedPos);
77
78            try {
79                $value = $objectParser->parseValue();
80            } catch (Exception $e) {
81                // Malformed operand - skip it and keep scanning, matching
82                // Interpreter::interpret()'s established resilience pattern.
83                // The tokenizer's position has already advanced past the
84                // offending token, so this always makes forward progress.
85                $operandStack = [];
86                continue;
87            }
88
89            if (!($value instanceof Value\Keyword)) {
90                $operandStack[] = $value;
91                continue;
92            }
93
94            $op = $value->keyword;
95
96            if (in_array($op, self::TEXT_OPERATORS, true)) {
97                return false;
98            }
99
100            if (in_array($op, self::PAINT_OPERATORS, true)) {
101                return false;
102            }
103
104            if ($op === 'Do') {
105                $name = end($operandStack);
106                if (!($name instanceof Value\Name)) {
107                    $operandStack = [];
108                    continue;
109                }
110
111                $isImage = self::resolveIsImage($doc, $page, $name->name);
112
113                if ($isImage === null) {
114                    // Unresolvable XObject (missing resource, circular
115                    // reference, etc.) - can't prove this page is safe, so
116                    // the safe default is "not image-only", not "assume
117                    // it's fine".
118                    return false;
119                }
120
121                if (!$isImage) {
122                    // A Form XObject draw is unexpected extra content -
123                    // disqualify outright rather than recursing into it.
124                    return false;
125                }
126
127                $imageCount++;
128
129                if ($imageCount > 1) {
130                    // Already disqualified (more than one image) - no need
131                    // to keep scanning the rest of a potentially very long
132                    // content stream just to reach the same conclusion.
133                    return false;
134                }
135            }
136
137            $operandStack = [];
138        }
139
140        return $imageCount === 1;
141    }
142
143    /**
144     * Resolve a Do operand's XObject and determine if it's an Image (true), a Form (false), or unresolvable (null)
145     *
146     * @param  Document $doc
147     * @param  PageInfo $page
148     * @param  string   $name
149     * @return ?bool
150     */
151    protected static function resolveIsImage(Document $doc, PageInfo $page, string $name): ?bool
152    {
153        try {
154            $xobjects = $doc->resolve($page->resources['XObject'] ?? null);
155
156            if (!is_array($xobjects) || !isset($xobjects[$name])) {
157                return null;
158            }
159
160            $xobject = $doc->resolve($xobjects[$name]);
161
162            if (!($xobject instanceof Value\Stream)) {
163                return null;
164            }
165
166            $subtype = $xobject->dict['Subtype'] ?? null;
167
168            if (!($subtype instanceof Value\Name)) {
169                return null;
170            }
171
172            if ($subtype->name === 'Image') {
173                return true;
174            }
175
176            if ($subtype->name === 'Form') {
177                return false;
178            }
179
180            return null;
181        } catch (Exception $e) {
182            return null;
183        }
184    }
185
186}