PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.10
MxChat – AI Chatbot & Content Generation for WordPress v3.2.10
3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
mxchat-basic / includes / pdf-parser / src / Smalot / PdfParser / PDFObject.php

PDFObject.php in MxChat – AI Chatbot & Content Generation for WordPress 3.2.10, at includes/pdf-parser/src/Smalot/PdfParser/PDFObject.php

1,193 lines 47.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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