Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
55 / 55 |
|
100.00% |
2 / 2 |
CRAP | |
100.00% |
1 / 1 |
| PageClassifier | |
100.00% |
55 / 55 |
|
100.00% |
2 / 2 |
22 | |
100.00% |
1 / 1 |
| isImageOnly | |
100.00% |
39 / 39 |
|
100.00% |
1 / 1 |
14 | |||
| resolveIsImage | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
8 | |||
| 1 | <?php |
| 2 | declare(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 | */ |
| 15 | namespace Pop\Pdf\Extract\Content; |
| 16 | |
| 17 | use Pop\Pdf\Extract\Document; |
| 18 | use Pop\Pdf\Extract\Exception; |
| 19 | use Pop\Pdf\Extract\ObjectParser; |
| 20 | use Pop\Pdf\Extract\Tokenizer; |
| 21 | use 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 | */ |
| 33 | class 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 | } |