| 1 |
<?php |
| 2 |
|
| 3 |
/** |
| 4 |
* @file |
| 5 |
* This file is part of the PdfParser library. |
| 6 |
* |
| 7 |
* @author Sébastien MALOT <sebastien@malot.fr> |
| 8 |
* |
| 9 |
* @date 2017-01-03 |
| 10 |
* |
| 11 |
* @license LGPLv3 |
| 12 |
* |
| 13 |
* @url <https://github.com/smalot/pdfparser> |
| 14 |
* |
| 15 |
* PdfParser is a pdf library written in PHP, extraction oriented. |
| 16 |
* Copyright (C) 2017 - Sébastien MALOT <sebastien@malot.fr> |
| 17 |
* |
| 18 |
* This program is free software: you can redistribute it and/or modify |
| 19 |
* it under the terms of the GNU Lesser General Public License as published by |
| 20 |
* the Free Software Foundation, either version 3 of the License, or |
| 21 |
* (at your option) any later version. |
| 22 |
* |
| 23 |
* This program is distributed in the hope that it will be useful, |
| 24 |
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 25 |
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 26 |
* GNU Lesser General Public License for more details. |
| 27 |
* |
| 28 |
* You should have received a copy of the GNU Lesser General Public License |
| 29 |
* along with this program. |
| 30 |
* If not, see <http://www.pdfparser.org/sites/default/LICENSE.txt>. |
| 31 |
*/ |
| 32 |
|
| 33 |
namespace Smalot\PdfParser; |
| 34 |
|
| 35 |
use Smalot\PdfParser\XObject\Form; |
| 36 |
use Smalot\PdfParser\XObject\Image; |
| 37 |
|
| 38 |
/** |
| 39 |
* Class PDFObject |
| 40 |
*/ |
| 41 |
class PDFObject |
| 42 |
{ |
| 43 |
public const TYPE = 't'; |
| 44 |
|
| 45 |
public const OPERATOR = 'o'; |
| 46 |
|
| 47 |
public const COMMAND = 'c'; |
| 48 |
|
| 49 |
/** |
| 50 |
* The recursion stack. |
| 51 |
* |
| 52 |
* @var array |
| 53 |
*/ |
| 54 |
public static $recursionStack = []; |
| 55 |
|
| 56 |
/** |
| 57 |
* @var Document|null |
| 58 |
*/ |
| 59 |
protected $document; |
| 60 |
|
| 61 |
/** |
| 62 |
* @var Header |
| 63 |
*/ |
| 64 |
protected $header; |
| 65 |
|
| 66 |
/** |
| 67 |
* @var string |
| 68 |
*/ |
| 69 |
protected $content; |
| 70 |
|
| 71 |
/** |
| 72 |
* @var Config|null |
| 73 |
*/ |
| 74 |
protected $config; |
| 75 |
|
| 76 |
/** |
| 77 |
* @var bool |
| 78 |
*/ |
| 79 |
protected $addPositionWhitespace = false; |
| 80 |
|
| 81 |
public function __construct( |
| 82 |
Document $document, |
| 83 |
?Header $header = null, |
| 84 |
?string $content = null, |
| 85 |
?Config $config = null |
| 86 |
) { |
| 87 |
$this->document = $document; |
| 88 |
$this->header = $header ?? new Header(); |
| 89 |
$this->content = $content; |
| 90 |
$this->config = $config; |
| 91 |
} |
| 92 |
|
| 93 |
public function init() |
| 94 |
{ |
| 95 |
} |
| 96 |
|
| 97 |
public function getDocument(): Document |
| 98 |
{ |
| 99 |
return $this->document; |
| 100 |
} |
| 101 |
|
| 102 |
public function getHeader(): ?Header |
| 103 |
{ |
| 104 |
return $this->header; |
| 105 |
} |
| 106 |
|
| 107 |
public function getConfig(): ?Config |
| 108 |
{ |
| 109 |
return $this->config; |
| 110 |
} |
| 111 |
|
| 112 |
/** |
| 113 |
* @return Element|PDFObject|Header |
| 114 |
*/ |
| 115 |
public function get(string $name) |
| 116 |
{ |
| 117 |
return $this->header->get($name); |
| 118 |
} |
| 119 |
|
| 120 |
public function has(string $name): bool |
| 121 |
{ |
| 122 |
return $this->header->has($name); |
| 123 |
} |
| 124 |
|
| 125 |
public function getDetails(bool $deep = true): array |
| 126 |
{ |
| 127 |
return $this->header->getDetails($deep); |
| 128 |
} |
| 129 |
|
| 130 |
public function getContent(): ?string |
| 131 |
{ |
| 132 |
return $this->content; |
| 133 |
} |
| 134 |
|
| 135 |
/** |
| 136 |
* Creates a duplicate of the document stream with |
| 137 |
* strings and other items replaced by $char. Formerly |
| 138 |
* getSectionsText() used this output to more easily gather offset |
| 139 |
* values to extract text from the *actual* document stream. |
| 140 |
* |
| 141 |
* @deprecated function is no longer used and will be removed in a future release |
| 142 |
* |
| 143 |
* @internal |
| 144 |
*/ |
| 145 |
public function cleanContent(string $content, string $char = 'X') |
| 146 |
{ |
| 147 |
$char = $char[0]; |
| 148 |
$content = str_replace(['\\\\', '\\)', '\\('], $char.$char, $content); |
| 149 |
|
| 150 |
// Remove image bloc with binary content |
| 151 |
preg_match_all('/\s(BI\s.*?(\sID\s).*?(\sEI))\s/s', $content, $matches, \PREG_OFFSET_CAPTURE); |
| 152 |
foreach ($matches[0] as $part) { |
| 153 |
$content = substr_replace($content, str_repeat($char, \strlen($part[0])), $part[1], \strlen($part[0])); |
| 154 |
} |
| 155 |
|
| 156 |
// Clean content in square brackets [.....] |
| 157 |
preg_match_all('/\[((\(.*?\)|[0-9\.\-\s]*)*)\]/s', $content, $matches, \PREG_OFFSET_CAPTURE); |
| 158 |
foreach ($matches[1] as $part) { |
| 159 |
$content = substr_replace($content, str_repeat($char, \strlen($part[0])), $part[1], \strlen($part[0])); |
| 160 |
} |
| 161 |
|
| 162 |
// Clean content in round brackets (.....) |
| 163 |
preg_match_all('/\((.*?)\)/s', $content, $matches, \PREG_OFFSET_CAPTURE); |
| 164 |
foreach ($matches[1] as $part) { |
| 165 |
$content = substr_replace($content, str_repeat($char, \strlen($part[0])), $part[1], \strlen($part[0])); |
| 166 |
} |
| 167 |
|
| 168 |
// Clean structure |
| 169 |
if ($parts = preg_split('/(<|>)/s', $content, -1, \PREG_SPLIT_NO_EMPTY | \PREG_SPLIT_DELIM_CAPTURE)) { |
| 170 |
$content = ''; |
| 171 |
$level = 0; |
| 172 |
foreach ($parts as $part) { |
| 173 |
if ('<' == $part) { |
| 174 |
++$level; |
| 175 |
} |
| 176 |
|
| 177 |
$content .= (0 == $level ? $part : str_repeat($char, \strlen($part))); |
| 178 |
|
| 179 |
if ('>' == $part) { |
| 180 |
--$level; |
| 181 |
} |
| 182 |
} |
| 183 |
} |
| 184 |
|
| 185 |
// Clean BDC and EMC markup |
| 186 |
preg_match_all( |
| 187 |
'/(\/[A-Za-z0-9\_]*\s*'.preg_quote($char).'*BDC)/s', |
| 188 |
$content, |
| 189 |
$matches, |
| 190 |
\PREG_OFFSET_CAPTURE |
| 191 |
); |
| 192 |
foreach ($matches[1] as $part) { |
| 193 |
$content = substr_replace($content, str_repeat($char, \strlen($part[0])), $part[1], \strlen($part[0])); |
| 194 |
} |
| 195 |
|
| 196 |
preg_match_all('/\s(EMC)\s/s', $content, $matches, \PREG_OFFSET_CAPTURE); |
| 197 |
foreach ($matches[1] as $part) { |
| 198 |
$content = substr_replace($content, str_repeat($char, \strlen($part[0])), $part[1], \strlen($part[0])); |
| 199 |
} |
| 200 |
|
| 201 |
return $content; |
| 202 |
} |
| 203 |
|
| 204 |
/** |
| 205 |
* Takes a string of PDF document stream text and formats |
| 206 |
* it into a multi-line string with one PDF command on each line, |
| 207 |
* separated by \r\n. If the given string is null, or binary data |
| 208 |
* is detected instead of a document stream then return an empty |
| 209 |
* string. |
| 210 |
*/ |
| 211 |
private function formatContent(?string $content): string |
| 212 |
{ |
| 213 |
if (null === $content) { |
| 214 |
return ''; |
| 215 |
} |
| 216 |
|
| 217 |
// Outside of (String) and inline image content in PDF document |
| 218 |
// streams, all text should conform to UTF-8. Test for binary |
| 219 |
// content by deleting everything after the first open- |
| 220 |
// parenthesis ( which indicates the beginning of a string, or |
| 221 |
// the first ID command which indicates the beginning of binary |
| 222 |
// inline image content. Then test what remains for valid |
| 223 |
// UTF-8. If it's not UTF-8, return an empty string as this |
| 224 |
// $content is most likely binary. Unfortunately, using |
| 225 |
// mb_check_encoding(..., 'UTF-8') is not strict enough, so the |
| 226 |
// following regexp, adapted from the W3, is used. See: |
| 227 |
// https://www.w3.org/International/questions/qa-forms-utf-8.en |
| 228 |
// We use preg_replace() instead of preg_match() to avoid "JIT |
| 229 |
// stack limit exhausted" errors on larger files. |
| 230 |
$utf8Filter = preg_replace('/( |
| 231 |
[\x09\x0A\x0D\x20-\x7E] | # ASCII |
| 232 |
[\xC2-\xDF][\x80-\xBF] | # non-overlong 2-byte |
| 233 |
\xE0[\xA0-\xBF][\x80-\xBF] | # excluding overlongs |
| 234 |
[\xE1-\xEC\xEE\xEF][\x80-\xBF]{2} | # straight 3-byte |
| 235 |
\xED[\x80-\x9F][\x80-\xBF] | # excluding surrogates |
| 236 |
\xF0[\x90-\xBF][\x80-\xBF]{2} | # planes 1-3 |
| 237 |
[\xF1-\xF3][\x80-\xBF]{3} | # planes 4-15 |
| 238 |
\xF4[\x80-\x8F][\x80-\xBF]{2} # plane 16 |
| 239 |
)/xs', '', preg_replace('/(\(|ID\s).*$/s', '', $content)); |
| 240 |
|
| 241 |
if ('' !== $utf8Filter) { |
| 242 |
return ''; |
| 243 |
} |
| 244 |
|
| 245 |
// Find all inline image content and replace them so they aren't |
| 246 |
// affected by the next steps |
| 247 |
$pdfInlineImages = []; |
| 248 |
$offsetBI = 0; |
| 249 |
while (preg_match('/\sBI\s(\/.+?)\sID\s(.+?)\sEI(?=\s|$)/s', $content, $text, \PREG_OFFSET_CAPTURE, $offsetBI)) { |
| 250 |
// Attempt to detemine if this instance of the 'BI' command |
| 251 |
// actually occured within a (string) using the following |
| 252 |
// steps: |
| 253 |
|
| 254 |
// Step 1: Remove any escaped slashes and parentheses from |
| 255 |
// the alleged image characteristics data |
| 256 |
$para = str_replace(['\\\\', '\\(', '\\)'], '', $text[1][0]); |
| 257 |
|
| 258 |
// Step 2: Remove all correctly ordered and balanced |
| 259 |
// parentheses from (strings) |
| 260 |
do { |
| 261 |
$paraTest = $para; |
| 262 |
$para = preg_replace('/\(([^()]*)\)/', '$1', $paraTest); |
| 263 |
} while ($para != $paraTest); |
| 264 |
|
| 265 |
$paraOpen = strpos($para, '('); |
| 266 |
$paraClose = strpos($para, ')'); |
| 267 |
|
| 268 |
// Check: If the remaining text contains a close parenthesis |
| 269 |
// ')' AND it occurs before any open parenthesis, then we |
| 270 |
// are almost certain to be inside a (string) |
| 271 |
if (0 < $paraClose && (false === $paraOpen || $paraClose < $paraOpen)) { |
| 272 |
// Bump the search offset forward and match again |
| 273 |
$offsetBI = (int) $text[1][1]; |
| 274 |
continue; |
| 275 |
} |
| 276 |
|
| 277 |
// Step 3: Double check that this is actually inline image |
| 278 |
// data by parsing the alleged image characteristics as a |
| 279 |
// dictionary |
| 280 |
$dict = $this->parseDictionary('<<'.$text[1][0].'>>'); |
| 281 |
|
| 282 |
// Check if an image Width and Height are set in the dict |
| 283 |
if ((isset($dict['W']) || isset($dict['Width'])) |
| 284 |
&& (isset($dict['H']) || isset($dict['Height']))) { |
| 285 |
$id = uniqid('IMAGE_', true); |
| 286 |
$pdfInlineImages[$id] = [ |
| 287 |
preg_replace(['/\r\n/', '/\r/', '/\n/'], ' ', $text[1][0]), |
| 288 |
preg_replace(['/\r\n/', '/\r/', '/\n/'], '', $text[2][0]), |
| 289 |
]; |
| 290 |
$content = preg_replace( |
| 291 |
'/'.preg_quote($text[0][0], '/').'/', |
| 292 |
'^^^'.$id.'^^^', |
| 293 |
$content, |
| 294 |
1 |
| 295 |
); |
| 296 |
} else { |
| 297 |
// If there was no valid dictionary, or a height and width |
| 298 |
// weren't specified, then we don't know what this is, so |
| 299 |
// just leave it alone; bump the search offset forward and |
| 300 |
// match again |
| 301 |
$offsetBI = (int) $text[1][1]; |
| 302 |
} |
| 303 |
} |
| 304 |
|
| 305 |
// Find all strings () and replace them so they aren't affected |
| 306 |
// by the next steps |
| 307 |
$pdfstrings = []; |
| 308 |
$attempt = '('; |
| 309 |
while (preg_match('/'.preg_quote($attempt, '/').'.*?\)/s', $content, $text)) { |
| 310 |
// Remove all escaped slashes and parentheses from the target text |
| 311 |
$para = str_replace(['\\\\', '\\(', '\\)'], '', $text[0]); |
| 312 |
|
| 313 |
// PDF strings can contain unescaped parentheses as long as |
| 314 |
// they're balanced, so check for balanced parentheses |
| 315 |
$left = preg_match_all('/\(/', $para); |
| 316 |
$right = preg_match_all('/\)/', $para); |
| 317 |
|
| 318 |
if (')' == $para[-1] && $left == $right) { |
| 319 |
// Replace the string with a unique placeholder |
| 320 |
$id = uniqid('STRING_', true); |
| 321 |
$pdfstrings[$id] = $text[0]; |
| 322 |
$content = preg_replace( |
| 323 |
'/'.preg_quote($text[0], '/').'/', |
| 324 |
'@@@'.$id.'@@@', |
| 325 |
$content, |
| 326 |
1 |
| 327 |
); |
| 328 |
|
| 329 |
// Reset to search for the next string |
| 330 |
$attempt = '('; |
| 331 |
} else { |
| 332 |
// We had unbalanced parentheses, so use the current |
| 333 |
// match as a base to find a longer string |
| 334 |
$attempt = $text[0]; |
| 335 |
} |
| 336 |
} |
| 337 |
|
| 338 |
// Remove all carriage returns and line-feeds from the document stream |
| 339 |
$content = str_replace(["\r", "\n"], ' ', trim($content)); |
| 340 |
|
| 341 |
// Find all dictionary << >> commands and replace them so they |
| 342 |
// aren't affected by the next steps |
| 343 |
$dictstore = []; |
| 344 |
while (preg_match('/(<<.*?>> *)(BDC|BMC|DP|MP)/s', $content, $dicttext)) { |
| 345 |
$dictid = uniqid('DICT_', true); |
| 346 |
$dictstore[$dictid] = $dicttext[1]; |
| 347 |
$content = preg_replace( |
| 348 |
'/'.preg_quote($dicttext[0], '/').'/', |
| 349 |
' ###'.$dictid.'###'.$dicttext[2], |
| 350 |
$content, |
| 351 |
1 |
| 352 |
); |
| 353 |
} |
| 354 |
|
| 355 |
// Normalize white-space in the document stream |
| 356 |
$content = preg_replace('/\s{2,}/', ' ', $content); |
| 357 |
|
| 358 |
// Find all valid PDF operators and add \r\n after each; this |
| 359 |
// ensures there is just one command on every line |
| 360 |
// Source: https://ia801001.us.archive.org/1/items/pdf1.7/pdf_reference_1-7.pdf - Appendix A |
| 361 |
// Source: https://archive.org/download/pdf320002008/PDF32000_2008.pdf - Annex A |
| 362 |
// Note: PDF Reference 1.7 lists 'I' and 'rI' as valid commands, while |
| 363 |
// PDF 32000:2008 lists them as 'i' and 'ri' respectively. Both versions |
| 364 |
// appear here in the list for completeness. |
| 365 |
$operators = [ |
| 366 |
'b*', 'b', 'BDC', 'BMC', 'B*', 'BI', 'BT', 'BX', 'B', 'cm', 'cs', 'c', 'CS', |
| 367 |
'd0', 'd1', 'd', 'Do', 'DP', 'EMC', 'EI', 'ET', 'EX', 'f*', 'f', 'F', 'gs', |
| 368 |
'g', 'G', 'h', 'i', 'ID', 'I', 'j', 'J', 'k', 'K', 'l', 'm', 'MP', 'M', 'n', |
| 369 |
'q', 'Q', 're', 'rg', 'ri', 'rI', 'RG', 'scn', 'sc', 'sh', 's', 'SCN', 'SC', |
| 370 |
'S', 'T*', 'Tc', 'Td', 'TD', 'Tf', 'TJ', 'Tj', 'TL', 'Tm', 'Tr', 'Ts', 'Tw', |
| 371 |
'Tz', 'v', 'w', 'W*', 'W', 'y', '\'', '"', |
| 372 |
]; |
| 373 |
foreach ($operators as $operator) { |
| 374 |
$content = preg_replace( |
| 375 |
'/(?<!\w|\/)'.preg_quote($operator, '/').'(?![\w10\*])/', |
| 376 |
$operator."\r\n", |
| 377 |
$content |
| 378 |
); |
| 379 |
} |
| 380 |
|
| 381 |
// Restore the original content of the dictionary << >> commands |
| 382 |
$dictstore = array_reverse($dictstore, true); |
| 383 |
foreach ($dictstore as $id => $dict) { |
| 384 |
$content = str_replace('###'.$id.'###', $dict, $content); |
| 385 |
} |
| 386 |
|
| 387 |
// Restore the original string content |
| 388 |
$pdfstrings = array_reverse($pdfstrings, true); |
| 389 |
foreach ($pdfstrings as $id => $text) { |
| 390 |
// Strings may contain escaped newlines, or literal newlines |
| 391 |
// and we should clean these up before replacing the string |
| 392 |
// back into the content stream; this ensures no strings are |
| 393 |
// split between two lines (every command must be on one line) |
| 394 |
$text = str_replace( |
| 395 |
["\\\r\n", "\\\r", "\\\n", "\r", "\n"], |
| 396 |
['', '', '', '\r', '\n'], |
| 397 |
$text |
| 398 |
); |
| 399 |
|
| 400 |
$content = str_replace('@@@'.$id.'@@@', $text, $content); |
| 401 |
} |
| 402 |
|
| 403 |
// Restore the original content of any inline images |
| 404 |
$pdfInlineImages = array_reverse($pdfInlineImages, true); |
| 405 |
foreach ($pdfInlineImages as $id => $image) { |
| 406 |
$content = str_replace( |
| 407 |
'^^^'.$id.'^^^', |
| 408 |
"\r\nBI\r\n".$image[0]." ID\r\n".$image[1]." EI\r\n", |
| 409 |
$content |
| 410 |
); |
| 411 |
} |
| 412 |
|
| 413 |
$content = trim(preg_replace(['/(\r\n){2,}/', '/\r\n +/'], "\r\n", $content)); |
| 414 |
|
| 415 |
return $content; |
| 416 |
} |
| 417 |
|
| 418 |
/** |
| 419 |
* getSectionsText() now takes an entire, unformatted |
| 420 |
* document stream as a string, cleans it, then filters out |
| 421 |
* commands that aren't needed for text positioning/extraction. It |
| 422 |
* returns an array of unprocessed PDF commands, one command per |
| 423 |
* element. |
| 424 |
* |
| 425 |
* @internal |
| 426 |
*/ |
| 427 |
public function getSectionsText(?string $content): array |
| 428 |
{ |
| 429 |
$sections = []; |
| 430 |
|
| 431 |
// A cleaned stream has one command on every line, so split the |
| 432 |
// cleaned stream content on \r\n into an array |
| 433 |
$textCleaned = preg_split( |
| 434 |
'/(\r\n|\n|\r)/', |
| 435 |
$this->formatContent($content), |
| 436 |
-1, |
| 437 |
\PREG_SPLIT_NO_EMPTY |
| 438 |
); |
| 439 |
|
| 440 |
$inTextBlock = false; |
| 441 |
foreach ($textCleaned as $line) { |
| 442 |
$line = trim($line); |
| 443 |
|
| 444 |
// Skip empty lines |
| 445 |
if ('' === $line) { |
| 446 |
continue; |
| 447 |
} |
| 448 |
|
| 449 |
// If a 'BT' is encountered, set the $inTextBlock flag |
| 450 |
if (preg_match('/BT$/', $line)) { |
| 451 |
$inTextBlock = true; |
| 452 |
$sections[] = $line; |
| 453 |
|
| 454 |
// If an 'ET' is encountered, unset the $inTextBlock flag |
| 455 |
} elseif ('ET' == $line) { |
| 456 |
$inTextBlock = false; |
| 457 |
$sections[] = $line; |
| 458 |
} elseif ($inTextBlock) { |
| 459 |
// If we are inside a BT ... ET text block, save all lines |
| 460 |
$sections[] = trim($line); |
| 461 |
} else { |
| 462 |
// Otherwise, if we are outside of a text block, only |
| 463 |
// save specific, necessary lines. Care should be taken |
| 464 |
// to ensure a command being checked for *only* matches |
| 465 |
// that command. For instance, a simple search for 'c' |
| 466 |
// may also match the 'sc' command. See the command |
| 467 |
// list in the formatContent() method above. |
| 468 |
// Add more commands to save here as you find them in |
| 469 |
// weird PDFs! |
| 470 |
if ('q' == $line[-1] || 'Q' == $line[-1]) { |
| 471 |
// Save and restore graphics state commands |
| 472 |
$sections[] = $line; |
| 473 |
} elseif (preg_match('/(?<!\w)B[DM]C$/', $line)) { |
| 474 |
// Begin marked content sequence |
| 475 |
$sections[] = $line; |
| 476 |
} elseif (preg_match('/(?<!\w)[DM]P$/', $line)) { |
| 477 |
// Marked content point |
| 478 |
$sections[] = $line; |
| 479 |
} elseif (preg_match('/(?<!\w)EMC$/', $line)) { |
| 480 |
// End marked content sequence |
| 481 |
$sections[] = $line; |
| 482 |
} elseif (preg_match('/(?<!\w)cm$/', $line)) { |
| 483 |
// Graphics position change commands |
| 484 |
$sections[] = $line; |
| 485 |
} elseif (preg_match('/(?<!\w)Tf$/', $line)) { |
| 486 |
// Font change commands |
| 487 |
$sections[] = $line; |
| 488 |
} elseif (preg_match('/(?<!\w)Do$/', $line)) { |
| 489 |
// Invoke named XObject command |
| 490 |
$sections[] = $line; |
| 491 |
} |
| 492 |
} |
| 493 |
} |
| 494 |
|
| 495 |
return $sections; |
| 496 |
} |
| 497 |
|
| 498 |
private function getDefaultFont(?Page $page = null): Font |
| 499 |
{ |
| 500 |
$fonts = []; |
| 501 |
if (null !== $page) { |
| 502 |
$fonts = $page->getFonts(); |
| 503 |
} |
| 504 |
|
| 505 |
$firstFont = $this->document->getFirstFont(); |
| 506 |
if (null !== $firstFont) { |
| 507 |
$fonts[] = $firstFont; |
| 508 |
} |
| 509 |
|
| 510 |
if (\count($fonts) > 0) { |
| 511 |
return reset($fonts); |
| 512 |
} |
| 513 |
|
| 514 |
return new Font($this->document, null, null, $this->config); |
| 515 |
} |
| 516 |
|
| 517 |
/** |
| 518 |
* Decode a '[]TJ' command and attempt to use alternate |
| 519 |
* fonts if the current font results in output that contains |
| 520 |
* Unicode control characters. |
| 521 |
* |
| 522 |
* @internal |
| 523 |
* |
| 524 |
* @param array<int,array<string,string|bool>> $command |
| 525 |
*/ |
| 526 |
private function getTJUsingFontFallback(Font $font, array $command, ?Page $page = null, float $fontFactor = 4): string |
| 527 |
{ |
| 528 |
$orig_text = $font->decodeText($command, $fontFactor); |
| 529 |
$text = $orig_text; |
| 530 |
|
| 531 |
// If we make this a Config option, we can add a check if it's |
| 532 |
// enabled here. |
| 533 |
if (null !== $page) { |
| 534 |
$font_ids = array_keys($page->getFonts()); |
| 535 |
|
| 536 |
// If the decoded text contains UTF-8 control characters |
| 537 |
// then the font page being used is probably the wrong one. |
| 538 |
// Loop through the rest of the fonts to see if we can get |
| 539 |
// a good decode. Allow x09 to x0d which are whitespace. |
| 540 |
while (preg_match('/[\x00-\x08\x0e-\x1f\x7f]/u', $text) || false !== strpos(bin2hex($text), '00')) { |
| 541 |
// If we're out of font IDs, then give up and use the |
| 542 |
// original string |
| 543 |
if (0 == \count($font_ids)) { |
| 544 |
return $orig_text; |
| 545 |
} |
| 546 |
|
| 547 |
// Try the next font ID |
| 548 |
$font = $page->getFont(array_shift($font_ids)); |
| 549 |
$text = $font->decodeText($command, $fontFactor); |
| 550 |
} |
| 551 |
} |
| 552 |
|
| 553 |
return $text; |
| 554 |
} |
| 555 |
|
| 556 |
/** |
| 557 |
* Expects a string that is a full PDF dictionary object, |
| 558 |
* including the outer enclosing << >> angle brackets |
| 559 |
* |
| 560 |
* @internal |
| 561 |
* |
| 562 |
* @throws \Exception |
| 563 |
*/ |
| 564 |
public function parseDictionary(string $dictionary): array |
| 565 |
{ |
| 566 |
// Normalize whitespace |
| 567 |
$dictionary = preg_replace(['/\r/', '/\n/', '/\s{2,}/'], ' ', trim($dictionary)); |
| 568 |
|
| 569 |
if ('<<' != substr($dictionary, 0, 2)) { |
| 570 |
throw new \Exception('Not a valid dictionary object.'); |
| 571 |
} |
| 572 |
|
| 573 |
$parsed = []; |
| 574 |
$stack = []; |
| 575 |
$currentName = ''; |
| 576 |
$arrayTypeNumeric = false; |
| 577 |
|
| 578 |
// Remove outer layer of dictionary, and split on tokens |
| 579 |
$split = preg_split( |
| 580 |
'/(<<|>>|\[|\]|\/[^\s\/\[\]\(\)<>]*)/', |
| 581 |
trim(preg_replace('/^<<|>>$/', '', $dictionary)), |
| 582 |
-1, |
| 583 |
\PREG_SPLIT_NO_EMPTY | \PREG_SPLIT_DELIM_CAPTURE |
| 584 |
); |
| 585 |
|
| 586 |
foreach ($split as $token) { |
| 587 |
$token = trim($token); |
| 588 |
switch ($token) { |
| 589 |
case '': |
| 590 |
break; |
| 591 |
|
| 592 |
// Open numeric array |
| 593 |
case '[': |
| 594 |
$parsed[$currentName] = []; |
| 595 |
$arrayTypeNumeric = true; |
| 596 |
|
| 597 |
// Move up one level in the stack |
| 598 |
$stack[\count($stack)] = &$parsed; |
| 599 |
$parsed = &$parsed[$currentName]; |
| 600 |
$currentName = ''; |
| 601 |
break; |
| 602 |
|
| 603 |
// Open hashed array |
| 604 |
case '<<': |
| 605 |
$parsed[$currentName] = []; |
| 606 |
$arrayTypeNumeric = false; |
| 607 |
|
| 608 |
// Move up one level in the stack |
| 609 |
$stack[\count($stack)] = &$parsed; |
| 610 |
$parsed = &$parsed[$currentName]; |
| 611 |
$currentName = ''; |
| 612 |
break; |
| 613 |
|
| 614 |
// Close numeric array |
| 615 |
case ']': |
| 616 |
// Revert string type arrays back to a single element |
| 617 |
if (\is_array($parsed) && 1 == \count($parsed) |
| 618 |
&& isset($parsed[0]) && \is_string($parsed[0]) |
| 619 |
&& '' !== $parsed[0] && '/' != $parsed[0][0]) { |
| 620 |
$parsed = '['.$parsed[0].']'; |
| 621 |
} |
| 622 |
// Close hashed array |
| 623 |
// no break |
| 624 |
case '>>': |
| 625 |
$arrayTypeNumeric = false; |
| 626 |
|
| 627 |
// Move down one level in the stack |
| 628 |
$parsed = &$stack[\count($stack) - 1]; |
| 629 |
unset($stack[\count($stack) - 1]); |
| 630 |
break; |
| 631 |
|
| 632 |
default: |
| 633 |
// If value begins with a slash, then this is a name |
| 634 |
// Add it to the appropriate array |
| 635 |
if ('/' == substr($token, 0, 1)) { |
| 636 |
$currentName = substr($token, 1); |
| 637 |
if (true == $arrayTypeNumeric) { |
| 638 |
$parsed[] = $currentName; |
| 639 |
$currentName = ''; |
| 640 |
} |
| 641 |
} elseif ('' != $currentName) { |
| 642 |
if (false == $arrayTypeNumeric) { |
| 643 |
$parsed[$currentName] = $token; |
| 644 |
} |
| 645 |
$currentName = ''; |
| 646 |
} elseif ('' == $currentName) { |
| 647 |
$parsed[] = $token; |
| 648 |
} |
| 649 |
} |
| 650 |
} |
| 651 |
|
| 652 |
return $parsed; |
| 653 |
} |
| 654 |
|
| 655 |
/** |
| 656 |
* Returns the text content of a PDF as a string. Attempts to add |
| 657 |
* whitespace for spacing and line-breaks where appropriate. |
| 658 |
* |
| 659 |
* getText() leverages getTextArray() to get the content |
| 660 |
* of the document, setting the addPositionWhitespace flag to true |
| 661 |
* so whitespace is inserted in a logical way for reading by |
| 662 |
* humans. |
| 663 |
*/ |
| 664 |
public function getText(?Page $page = null): string |
| 665 |
{ |
| 666 |
$this->addPositionWhitespace = true; |
| 667 |
$result = $this->getTextArray($page); |
| 668 |
$this->addPositionWhitespace = false; |
| 669 |
|
| 670 |
return implode('', $result).' '; |
| 671 |
} |
| 672 |
|
| 673 |
/** |
| 674 |
* Returns the text content of a PDF as an array of strings. No |
| 675 |
* extra whitespace is inserted besides what is actually encoded in |
| 676 |
* the PDF text. |
| 677 |
* |
| 678 |
* @throws \Exception |
| 679 |
*/ |
| 680 |
public function getTextArray(?Page $page = null): array |
| 681 |
{ |
| 682 |
$result = []; |
| 683 |
$text = []; |
| 684 |
|
| 685 |
$marked_stack = []; |
| 686 |
$last_written_position = false; |
| 687 |
|
| 688 |
$sections = $this->getSectionsText($this->content); |
| 689 |
$current_font = $this->getDefaultFont($page); |
| 690 |
$current_font_size = 1; |
| 691 |
$current_text_leading = 0; |
| 692 |
|
| 693 |
$current_position = ['x' => false, 'y' => false]; |
| 694 |
$current_position_tm = [ |
| 695 |
'a' => 1, 'b' => 0, 'c' => 0, |
| 696 |
'i' => 0, 'j' => 1, 'k' => 0, |
| 697 |
'x' => 0, 'y' => 0, 'z' => 1, |
| 698 |
]; |
| 699 |
$current_position_td = ['x' => 0, 'y' => 0]; |
| 700 |
$current_position_cm = [ |
| 701 |
'a' => 1, 'b' => 0, 'c' => 0, |
| 702 |
'i' => 0, 'j' => 1, 'k' => 0, |
| 703 |
'x' => 0, 'y' => 0, 'z' => 1, |
| 704 |
]; |
| 705 |
|
| 706 |
$clipped_font = []; |
| 707 |
$clipped_position_cm = []; |
| 708 |
|
| 709 |
self::$recursionStack[] = $this->getUniqueId(); |
| 710 |
|
| 711 |
foreach ($sections as $section) { |
| 712 |
$commands = $this->getCommandsText($section); |
| 713 |
foreach ($commands as $command) { |
| 714 |
switch ($command[self::OPERATOR]) { |
| 715 |
// Begin text object |
| 716 |
case 'BT': |
| 717 |
// Reset text positioning matrices |
| 718 |
$current_position_tm = [ |
| 719 |
'a' => 1, 'b' => 0, 'c' => 0, |
| 720 |
'i' => 0, 'j' => 1, 'k' => 0, |
| 721 |
'x' => 0, 'y' => 0, 'z' => 1, |
| 722 |
]; |
| 723 |
$current_position_td = ['x' => 0, 'y' => 0]; |
| 724 |
$current_text_leading = 0; |
| 725 |
break; |
| 726 |
|
| 727 |
// Begin marked content sequence with property list |
| 728 |
case 'BDC': |
| 729 |
if (preg_match('/(<<.*>>)$/', $command[self::COMMAND], $match)) { |
| 730 |
$dict = $this->parseDictionary($match[1]); |
| 731 |
|
| 732 |
// Check for ActualText block |
| 733 |
if (isset($dict['ActualText']) && \is_string($dict['ActualText']) && '' !== $dict['ActualText']) { |
| 734 |
if ('[' == $dict['ActualText'][0]) { |
| 735 |
// Simulate a 'TJ' command on the stack |
| 736 |
$marked_stack[] = [ |
| 737 |
'ActualText' => $this->getCommandsText($dict['ActualText'].'TJ')[0], |
| 738 |
]; |
| 739 |
} elseif ('<' == $dict['ActualText'][0] || '(' == $dict['ActualText'][0]) { |
| 740 |
// Simulate a 'Tj' command on the stack |
| 741 |
$marked_stack[] = [ |
| 742 |
'ActualText' => $this->getCommandsText($dict['ActualText'].'Tj')[0], |
| 743 |
]; |
| 744 |
} |
| 745 |
} |
| 746 |
} |
| 747 |
break; |
| 748 |
|
| 749 |
// Begin marked content sequence |
| 750 |
case 'BMC': |
| 751 |
if ('ReversedChars' == $command[self::COMMAND]) { |
| 752 |
// Upon encountering a ReversedChars command, |
| 753 |
// add the characters we've built up so far to |
| 754 |
// the result array |
| 755 |
$result = array_merge($result, $text); |
| 756 |
|
| 757 |
// Start a fresh $text array that will contain |
| 758 |
// reversed characters |
| 759 |
$text = []; |
| 760 |
|
| 761 |
// Add the reversed text flag to the stack |
| 762 |
$marked_stack[] = ['ReversedChars' => true]; |
| 763 |
} |
| 764 |
break; |
| 765 |
|
| 766 |
// set graphics position matrix |
| 767 |
case 'cm': |
| 768 |
$args = preg_split('/\s+/s', $command[self::COMMAND]); |
| 769 |
$current_position_cm = [ |
| 770 |
'a' => (float) $args[0], 'b' => (float) $args[1], 'c' => 0, |
| 771 |
'i' => (float) $args[2], 'j' => (float) $args[3], 'k' => 0, |
| 772 |
'x' => (float) $args[4], 'y' => (float) $args[5], 'z' => 1, |
| 773 |
]; |
| 774 |
break; |
| 775 |
|
| 776 |
case 'Do': |
| 777 |
if (null !== $page) { |
| 778 |
$args = preg_split('/\s/s', $command[self::COMMAND]); |
| 779 |
$id = trim(array_pop($args), '/ '); |
| 780 |
$xobject = $page->getXObject($id); |
| 781 |
|
| 782 |
// @todo $xobject could be a ElementXRef object, which would then throw an error |
| 783 |
if (\is_object($xobject) && $xobject instanceof self && !\in_array($xobject->getUniqueId(), self::$recursionStack, true)) { |
| 784 |
// Not a circular reference. |
| 785 |
$text[] = $xobject->getText($page); |
| 786 |
} |
| 787 |
} |
| 788 |
break; |
| 789 |
|
| 790 |
// Marked content point with (DP) & without (MP) property list |
| 791 |
case 'DP': |
| 792 |
case 'MP': |
| 793 |
break; |
| 794 |
|
| 795 |
// End text object |
| 796 |
case 'ET': |
| 797 |
break; |
| 798 |
|
| 799 |
// Store current selected font and graphics matrix |
| 800 |
case 'q': |
| 801 |
$clipped_font[] = [$current_font, $current_font_size]; |
| 802 |
$clipped_position_cm[] = $current_position_cm; |
| 803 |
break; |
| 804 |
|
| 805 |
// Restore previous selected font and graphics matrix |
| 806 |
case 'Q': |
| 807 |
list($current_font, $current_font_size) = array_pop($clipped_font); |
| 808 |
$current_position_cm = array_pop($clipped_position_cm); |
| 809 |
break; |
| 810 |
|
| 811 |
// End marked content sequence |
| 812 |
case 'EMC': |
| 813 |
$data = false; |
| 814 |
if (\count($marked_stack)) { |
| 815 |
$marked = array_pop($marked_stack); |
| 816 |
$action = key($marked); |
| 817 |
$data = $marked[$action]; |
| 818 |
|
| 819 |
switch ($action) { |
| 820 |
// If we are in ReversedChars mode... |
| 821 |
case 'ReversedChars': |
| 822 |
// Reverse the characters we've built up so far |
| 823 |
foreach ($text as $key => $t) { |
| 824 |
$text[$key] = implode('', array_reverse( |
| 825 |
mb_str_split($t, 1, mb_internal_encoding()) |
| 826 |
)); |
| 827 |
} |
| 828 |
|
| 829 |
// Add these characters to the result array |
| 830 |
$result = array_merge($result, $text); |
| 831 |
|
| 832 |
// Start a fresh $text array that will contain |
| 833 |
// non-reversed characters |
| 834 |
$text = []; |
| 835 |
break; |
| 836 |
|
| 837 |
case 'ActualText': |
| 838 |
// Use the content of the ActualText as a command |
| 839 |
$command = $data; |
| 840 |
break; |
| 841 |
} |
| 842 |
} |
| 843 |
|
| 844 |
// If this EMC command has been transformed into a 'Tj' |
| 845 |
// or 'TJ' command because of being ActualText, then bypass |
| 846 |
// the break to proceed to the writing section below. |
| 847 |
if ('Tj' != $command[self::OPERATOR] && 'TJ' != $command[self::OPERATOR]) { |
| 848 |
break; |
| 849 |
} |
| 850 |
|
| 851 |
// no break |
| 852 |
case "'": |
| 853 |
case '"': |
| 854 |
if ("'" == $command[self::OPERATOR] || '"' == $command[self::OPERATOR]) { |
| 855 |
// Move to next line and write text |
| 856 |
$current_position['x'] = 0; |
| 857 |
$current_position_td['x'] = 0; |
| 858 |
$current_position_td['y'] += $current_text_leading; |
| 859 |
} |
| 860 |
// no break |
| 861 |
case 'Tj': |
| 862 |
$command[self::COMMAND] = [$command]; |
| 863 |
// no break |
| 864 |
case 'TJ': |
| 865 |
// Check the marked content stack for flags |
| 866 |
$actual_text = false; |
| 867 |
$reverse_text = false; |
| 868 |
foreach ($marked_stack as $marked) { |
| 869 |
if (isset($marked['ActualText'])) { |
| 870 |
$actual_text = true; |
| 871 |
} |
| 872 |
if (isset($marked['ReversedChars'])) { |
| 873 |
$reverse_text = true; |
| 874 |
} |
| 875 |
} |
| 876 |
|
| 877 |
// Account for text position ONLY just before we write text |
| 878 |
if (false === $actual_text && \is_array($last_written_position)) { |
| 879 |
// If $last_written_position is an array, that |
| 880 |
// means we have stored text position coordinates |
| 881 |
// for placing an ActualText |
| 882 |
$currentX = $last_written_position[0]; |
| 883 |
$currentY = $last_written_position[1]; |
| 884 |
$last_written_position = false; |
| 885 |
} else { |
| 886 |
$currentX = $current_position_cm['x'] + $current_position_tm['x'] + $current_position_td['x']; |
| 887 |
$currentY = $current_position_cm['y'] + $current_position_tm['y'] + $current_position_td['y']; |
| 888 |
} |
| 889 |
$whiteSpace = ''; |
| 890 |
|
| 891 |
$factorX = -$current_font_size * $current_position_tm['a'] - $current_font_size * $current_position_tm['i']; |
| 892 |
$factorY = $current_font_size * $current_position_tm['b'] + $current_font_size * $current_position_tm['j']; |
| 893 |
|
| 894 |
if (true === $this->addPositionWhitespace && false !== $current_position['x']) { |
| 895 |
$curY = $currentY - $current_position['y']; |
| 896 |
if (abs($curY) >= abs($factorY) / 4) { |
| 897 |
$whiteSpace = "\n"; |
| 898 |
} else { |
| 899 |
if (true === $reverse_text) { |
| 900 |
$curX = $current_position['x'] - $currentX; |
| 901 |
} else { |
| 902 |
$curX = $currentX - $current_position['x']; |
| 903 |
} |
| 904 |
|
| 905 |
// In abs($factorX * 7) below, the 7 is chosen arbitrarily |
| 906 |
// as the number of apparent "spaces" in a document we |
| 907 |
// would need before considering them a "tab". In the |
| 908 |
// future, we might offer this value to users as a config |
| 909 |
// option. |
| 910 |
if ($curX >= abs($factorX * 7)) { |
| 911 |
$whiteSpace = "\t"; |
| 912 |
} elseif ($curX >= abs($factorX * 2)) { |
| 913 |
$whiteSpace = ' '; |
| 914 |
} |
| 915 |
} |
| 916 |
} |
| 917 |
|
| 918 |
$newtext = $this->getTJUsingFontFallback( |
| 919 |
$current_font, |
| 920 |
$command[self::COMMAND], |
| 921 |
$page, |
| 922 |
$factorX |
| 923 |
); |
| 924 |
|
| 925 |
// If there is no ActualText pending then write |
| 926 |
if (false === $actual_text) { |
| 927 |
$newtext = str_replace(["\r", "\n"], '', $newtext); |
| 928 |
if (false !== $reverse_text) { |
| 929 |
// If we are in ReversedChars mode, add the whitespace last |
| 930 |
$text[] = preg_replace('/ $/', ' ', $newtext.$whiteSpace); |
| 931 |
} else { |
| 932 |
// Otherwise add the whitespace first |
| 933 |
if (' ' === $whiteSpace && isset($text[\count($text) - 1])) { |
| 934 |
$text[\count($text) - 1] = preg_replace('/ $/', '', $text[\count($text) - 1]); |
| 935 |
} |
| 936 |
$text[] = preg_replace('/^[ \t]{2}/', ' ', $whiteSpace.$newtext); |
| 937 |
} |
| 938 |
|
| 939 |
// Record the position of this inserted text for comparison |
| 940 |
// with the next text block. |
| 941 |
// Provide a 'fudge' factor guess on how wide this text block |
| 942 |
// is based on the number of characters. This helps limit the |
| 943 |
// number of tabs inserted, but isn't perfect. |
| 944 |
$factor = $factorX / 2; |
| 945 |
$current_position = [ |
| 946 |
'x' => $currentX - mb_strlen($newtext) * $factor, |
| 947 |
'y' => $currentY, |
| 948 |
]; |
| 949 |
} elseif (false === $last_written_position) { |
| 950 |
// If there is an ActualText in the pipeline |
| 951 |
// store the position this undisplayed text |
| 952 |
// *would* have been written to, so the |
| 953 |
// ActualText is displayed in the right spot |
| 954 |
$last_written_position = [$currentX, $currentY]; |
| 955 |
$current_position['x'] = $currentX; |
| 956 |
} |
| 957 |
break; |
| 958 |
|
| 959 |
// move to start of next line |
| 960 |
case 'T*': |
| 961 |
$current_position['x'] = 0; |
| 962 |
$current_position_td['x'] = 0; |
| 963 |
$current_position_td['y'] += $current_text_leading; |
| 964 |
break; |
| 965 |
|
| 966 |
// set character spacing |
| 967 |
case 'Tc': |
| 968 |
break; |
| 969 |
|
| 970 |
// move text current point and set leading |
| 971 |
case 'Td': |
| 972 |
case 'TD': |
| 973 |
// move text current point |
| 974 |
$args = preg_split('/\s+/s', $command[self::COMMAND]); |
| 975 |
$y = (float) array_pop($args); |
| 976 |
$x = (float) array_pop($args); |
| 977 |
|
| 978 |
if ('TD' == $command[self::OPERATOR]) { |
| 979 |
$current_text_leading = -$y * $current_position_tm['b'] - $y * $current_position_tm['j']; |
| 980 |
} |
| 981 |
|
| 982 |
$current_position_td = [ |
| 983 |
'x' => $current_position_td['x'] + $x * $current_position_tm['a'] + $x * $current_position_tm['i'], |
| 984 |
'y' => $current_position_td['y'] + $y * $current_position_tm['b'] + $y * $current_position_tm['j'], |
| 985 |
]; |
| 986 |
break; |
| 987 |
|
| 988 |
case 'Tf': |
| 989 |
$args = preg_split('/\s/s', $command[self::COMMAND]); |
| 990 |
$size = (float) array_pop($args); |
| 991 |
$id = trim(array_pop($args), '/'); |
| 992 |
if (null !== $page) { |
| 993 |
$new_font = $page->getFont($id); |
| 994 |
// If an invalid font ID is given, do not update the font. |
| 995 |
// This should theoretically never happen, as the PDF spec states for the Tf operator: |
| 996 |
// "The specified font value shall match a resource name in the Font entry of the default resource dictionary" |
| 997 |
// (https://www.adobe.com/content/dam/acom/en/devnet/pdf/pdfs/PDF32000_2008.pdf, page 435) |
| 998 |
// But we want to make sure that malformed PDFs do not simply crash. |
| 999 |
if (null !== $new_font) { |
| 1000 |
$current_font = $new_font; |
| 1001 |
$current_font_size = $size; |
| 1002 |
} |
| 1003 |
} |
| 1004 |
break; |
| 1005 |
|
| 1006 |
// set leading |
| 1007 |
case 'TL': |
| 1008 |
$y = (float) $command[self::COMMAND]; |
| 1009 |
$current_text_leading = -$y * $current_position_tm['b'] + -$y * $current_position_tm['j']; |
| 1010 |
break; |
| 1011 |
|
| 1012 |
// set text position matrix |
| 1013 |
case 'Tm': |
| 1014 |
$args = preg_split('/\s+/s', $command[self::COMMAND]); |
| 1015 |
$current_position_tm = [ |
| 1016 |
'a' => (float) $args[0], 'b' => (float) $args[1], 'c' => 0, |
| 1017 |
'i' => (float) $args[2], 'j' => (float) $args[3], 'k' => 0, |
| 1018 |
'x' => (float) $args[4], 'y' => (float) $args[5], 'z' => 1, |
| 1019 |
]; |
| 1020 |
break; |
| 1021 |
|
| 1022 |
// set text rendering mode |
| 1023 |
case 'Ts': |
| 1024 |
break; |
| 1025 |
|
| 1026 |
// set super/subscripting text rise |
| 1027 |
case 'Ts': |
| 1028 |
break; |
| 1029 |
|
| 1030 |
// set word spacing |
| 1031 |
case 'Tw': |
| 1032 |
break; |
| 1033 |
|
| 1034 |
// set horizontal scaling |
| 1035 |
case 'Tz': |
| 1036 |
break; |
| 1037 |
|
| 1038 |
default: |
| 1039 |
} |
| 1040 |
} |
| 1041 |
} |
| 1042 |
|
| 1043 |
$result = array_merge($result, $text); |
| 1044 |
|
| 1045 |
return $result; |
| 1046 |
} |
| 1047 |
|
| 1048 |
/** |
| 1049 |
* getCommandsText() expects the content of $text_part to be an |
| 1050 |
* already formatted, single-line command from a document stream. |
| 1051 |
* The companion function getSectionsText() returns a document |
| 1052 |
* stream as an array of single commands for just this purpose. |
| 1053 |
* Because of this, the argument $offset is no longer used, and |
| 1054 |
* may be removed in a future PdfParser release. |
| 1055 |
* |
| 1056 |
* A better name for this function would be getCommandText() |
| 1057 |
* since it now always works on just one command. |
| 1058 |
*/ |
| 1059 |
public function getCommandsText(string $text_part, int &$offset = 0): array |
| 1060 |
{ |
| 1061 |
$commands = $matches = []; |
| 1062 |
|
| 1063 |
preg_match('/^(([\/\[\(<])?.*)(?<!\w)([a-z01\'\"*]+)$/i', $text_part, $matches); |
| 1064 |
|
| 1065 |
// If no valid command is detected, return an empty array |
| 1066 |
if (!isset($matches[1]) || !isset($matches[2]) || !isset($matches[3])) { |
| 1067 |
return []; |
| 1068 |
} |
| 1069 |
|
| 1070 |
$type = $matches[2]; |
| 1071 |
$operator = $matches[3]; |
| 1072 |
$command = trim($matches[1]); |
| 1073 |
|
| 1074 |
if ('TJ' == $operator) { |
| 1075 |
$subcommand = []; |
| 1076 |
$command = trim($command, '[]'); |
| 1077 |
do { |
| 1078 |
$oldCommand = $command; |
| 1079 |
|
| 1080 |
// Search for parentheses string () format |
| 1081 |
if (preg_match('/^ *\((.*?)(?<![^\\\\]\\\\)\) *(-?[\d.]+)?/', $command, $tjmatch)) { |
| 1082 |
$subcommand[] = [ |
| 1083 |
self::TYPE => '(', |
| 1084 |
self::OPERATOR => 'TJ', |
| 1085 |
self::COMMAND => $tjmatch[1], |
| 1086 |
]; |
| 1087 |
if (isset($tjmatch[2]) && trim($tjmatch[2])) { |
| 1088 |
$subcommand[] = [ |
| 1089 |
self::TYPE => 'n', |
| 1090 |
self::OPERATOR => '', |
| 1091 |
self::COMMAND => $tjmatch[2], |
| 1092 |
]; |
| 1093 |
} |
| 1094 |
$command = substr($command, \strlen($tjmatch[0])); |
| 1095 |
} |
| 1096 |
|
| 1097 |
// Search for hexadecimal <> format |
| 1098 |
if (preg_match('/^ *<([0-9a-f\s]*)> *(-?[\d.]+)?/i', $command, $tjmatch)) { |
| 1099 |
$tjmatch[1] = preg_replace('/\s/', '', $tjmatch[1]); |
| 1100 |
$subcommand[] = [ |
| 1101 |
self::TYPE => '<', |
| 1102 |
self::OPERATOR => 'TJ', |
| 1103 |
self::COMMAND => $tjmatch[1], |
| 1104 |
]; |
| 1105 |
if (isset($tjmatch[2]) && trim($tjmatch[2])) { |
| 1106 |
$subcommand[] = [ |
| 1107 |
self::TYPE => 'n', |
| 1108 |
self::OPERATOR => '', |
| 1109 |
self::COMMAND => $tjmatch[2], |
| 1110 |
]; |
| 1111 |
} |
| 1112 |
$command = substr($command, \strlen($tjmatch[0])); |
| 1113 |
} |
| 1114 |
} while ($command != $oldCommand); |
| 1115 |
|
| 1116 |
$command = $subcommand; |
| 1117 |
} elseif ('Tj' == $operator || "'" == $operator || '"' == $operator) { |
| 1118 |
// Depending on the string type, trim the data of the |
| 1119 |
// appropriate delimiters |
| 1120 |
if ('(' == $type) { |
| 1121 |
// Don't use trim() here since a () string may end with |
| 1122 |
// a balanced or escaped right parentheses, and trim() |
| 1123 |
// will delete both. Both strings below are valid: |
| 1124 |
// eg. (String()) |
| 1125 |
// eg. (String\)) |
| 1126 |
$command = preg_replace('/^\(|\)$/', '', $command); |
| 1127 |
} elseif ('<' == $type) { |
| 1128 |
$command = trim($command, '<>'); |
| 1129 |
} |
| 1130 |
} elseif ('/' == $type) { |
| 1131 |
$command = substr($command, 1); |
| 1132 |
} |
| 1133 |
|
| 1134 |
$commands[] = [ |
| 1135 |
self::TYPE => $type, |
| 1136 |
self::OPERATOR => $operator, |
| 1137 |
self::COMMAND => $command, |
| 1138 |
]; |
| 1139 |
|
| 1140 |
return $commands; |
| 1141 |
} |
| 1142 |
|
| 1143 |
public static function factory( |
| 1144 |
Document $document, |
| 1145 |
Header $header, |
| 1146 |
?string $content, |
| 1147 |
?Config $config = null |
| 1148 |
): self { |
| 1149 |
switch ($header->get('Type')->getContent()) { |
| 1150 |
case 'XObject': |
| 1151 |
switch ($header->get('Subtype')->getContent()) { |
| 1152 |
case 'Image': |
| 1153 |
return new Image($document, $header, $config->getRetainImageContent() ? $content : null, $config); |
| 1154 |
|
| 1155 |
case 'Form': |
| 1156 |
return new Form($document, $header, $content, $config); |
| 1157 |
} |
| 1158 |
|
| 1159 |
return new self($document, $header, $content, $config); |
| 1160 |
|
| 1161 |
case 'Pages': |
| 1162 |
return new Pages($document, $header, $content, $config); |
| 1163 |
|
| 1164 |
case 'Page': |
| 1165 |
return new Page($document, $header, $content, $config); |
| 1166 |
|
| 1167 |
case 'Encoding': |
| 1168 |
return new Encoding($document, $header, $content, $config); |
| 1169 |
|
| 1170 |
case 'Font': |
| 1171 |
$subtype = $header->get('Subtype')->getContent(); |
| 1172 |
$classname = '\Smalot\PdfParser\Font\Font'.$subtype; |
| 1173 |
|
| 1174 |
if (class_exists($classname)) { |
| 1175 |
return new $classname($document, $header, $content, $config); |
| 1176 |
} |
| 1177 |
|
| 1178 |
return new Font($document, $header, $content, $config); |
| 1179 |
|
| 1180 |
default: |
| 1181 |
return new self($document, $header, $content, $config); |
| 1182 |
} |
| 1183 |
} |
| 1184 |
|
| 1185 |
/** |
| 1186 |
* Returns unique id identifying the object. |
| 1187 |
*/ |
| 1188 |
protected function getUniqueId(): string |
| 1189 |
{ |
| 1190 |
return spl_object_hash($this); |
| 1191 |
} |
| 1192 |
} |
| 1193 |
|