PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 2.6.1
MxChat – AI Chatbot & Content Generation for WordPress v2.6.1
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 / Font.php

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

702 lines 23.8 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\Encoding\WinAnsiEncoding;
36 use Smalot\PdfParser\Exception\EncodingNotFoundException;
37
38 /**
39 * Class Font
40 */
41 class Font extends PDFObject
42 {
43 public const MISSING = '?';
44
45 /**
46 * @var array
47 */
48 protected $table;
49
50 /**
51 * @var array
52 */
53 protected $tableSizes;
54
55 /**
56 * Caches results from uchr.
57 *
58 * @var array
59 */
60 private static $uchrCache = [];
61
62 /**
63 * In some PDF-files encoding could be referenced by object id but object itself does not contain
64 * `/Type /Encoding` in its dictionary. These objects wouldn't be initialized as Encoding in
65 * \Smalot\PdfParser\PDFObject::factory() during file parsing (they would be just PDFObject).
66 *
67 * Therefore, we create an instance of Encoding from them during decoding and cache this value in this property.
68 *
69 * @var Encoding
70 *
71 * @see https://github.com/smalot/pdfparser/pull/500
72 */
73 private $initializedEncodingByPdfObject;
74
75 public function init()
76 {
77 // Load translate table.
78 $this->loadTranslateTable();
79 }
80
81 public function getName(): string
82 {
83 return $this->has('BaseFont') ? (string) $this->get('BaseFont') : '[Unknown]';
84 }
85
86 public function getType(): string
87 {
88 return (string) $this->header->get('Subtype');
89 }
90
91 public function getDetails(bool $deep = true): array
92 {
93 $details = [];
94
95 $details['Name'] = $this->getName();
96 $details['Type'] = $this->getType();
97 $details['Encoding'] = ($this->has('Encoding') ? (string) $this->get('Encoding') : 'Ansi');
98
99 $details += parent::getDetails($deep);
100
101 return $details;
102 }
103
104 /**
105 * @return string|bool
106 */
107 public function translateChar(string $char, bool $use_default = true)
108 {
109 $dec = hexdec(bin2hex($char));
110
111 if (\array_key_exists($dec, $this->table)) {
112 return $this->table[$dec];
113 }
114
115 // fallback for decoding single-byte ANSI characters that are not in the lookup table
116 $fallbackDecoded = $char;
117 if (
118 \strlen($char) < 2
119 && $this->has('Encoding')
120 && $this->get('Encoding') instanceof Encoding
121 ) {
122 try {
123 if (WinAnsiEncoding::class === $this->get('Encoding')->__toString()) {
124 $fallbackDecoded = self::uchr($dec);
125 }
126 } catch (EncodingNotFoundException $e) {
127 // Encoding->getEncodingClass() throws EncodingNotFoundException when BaseEncoding doesn't exists
128 // See table 5.11 on PDF 1.5 specs for more info
129 }
130 }
131
132 return $use_default ? self::MISSING : $fallbackDecoded;
133 }
134
135 /**
136 * Convert unicode character code to "utf-8" encoded string.
137 *
138 * @param int|float $code Unicode character code. Will be casted to int internally!
139 */
140 public static function uchr($code): string
141 {
142 // note:
143 // $code was typed as int before, but changed in https://github.com/smalot/pdfparser/pull/623
144 // because in some cases uchr was called with a float instead of an integer.
145 $code = (int) $code;
146
147 if (!isset(self::$uchrCache[$code])) {
148 // html_entity_decode() will not work with UTF-16 or UTF-32 char entities,
149 // therefore, we use mb_convert_encoding() instead
150 self::$uchrCache[$code] = mb_convert_encoding("&#{$code};", 'UTF-8', 'HTML-ENTITIES');
151 }
152
153 return self::$uchrCache[$code];
154 }
155
156 /**
157 * Init internal chars translation table by ToUnicode CMap.
158 */
159 public function loadTranslateTable(): array
160 {
161 if (null !== $this->table) {
162 return $this->table;
163 }
164
165 $this->table = [];
166 $this->tableSizes = [
167 'from' => 1,
168 'to' => 1,
169 ];
170
171 if ($this->has('ToUnicode')) {
172 $content = $this->get('ToUnicode')->getContent();
173 $matches = [];
174
175 // Support for multiple spacerange sections
176 if (preg_match_all('/begincodespacerange(?P<sections>.*?)endcodespacerange/s', $content, $matches)) {
177 foreach ($matches['sections'] as $section) {
178 $regexp = '/<(?P<from>[0-9A-F]+)> *<(?P<to>[0-9A-F]+)>[ \r\n]+/is';
179
180 preg_match_all($regexp, $section, $matches);
181
182 $this->tableSizes = [
183 'from' => max(1, \strlen(current($matches['from'])) / 2),
184 'to' => max(1, \strlen(current($matches['to'])) / 2),
185 ];
186
187 break;
188 }
189 }
190
191 // Support for multiple bfchar sections
192 if (preg_match_all('/beginbfchar(?P<sections>.*?)endbfchar/s', $content, $matches)) {
193 foreach ($matches['sections'] as $section) {
194 $regexp = '/<(?P<from>[0-9A-F]+)> *<(?P<to>[0-9A-F]+)>[ \r\n]+/is';
195
196 preg_match_all($regexp, $section, $matches);
197
198 $this->tableSizes['from'] = max(1, \strlen(current($matches['from'])) / 2);
199
200 foreach ($matches['from'] as $key => $from) {
201 $parts = preg_split(
202 '/([0-9A-F]{4})/i',
203 $matches['to'][$key],
204 0,
205 \PREG_SPLIT_NO_EMPTY | \PREG_SPLIT_DELIM_CAPTURE
206 );
207 $text = '';
208 foreach ($parts as $part) {
209 $text .= self::uchr(hexdec($part));
210 }
211 $this->table[hexdec($from)] = $text;
212 }
213 }
214 }
215
216 // Support for multiple bfrange sections
217 if (preg_match_all('/beginbfrange(?P<sections>.*?)endbfrange/s', $content, $matches)) {
218 foreach ($matches['sections'] as $section) {
219 // Support for : <srcCode1> <srcCode2> <dstString>
220 $regexp = '/<(?P<from>[0-9A-F]+)> *<(?P<to>[0-9A-F]+)> *<(?P<offset>[0-9A-F]+)>[ \r\n]+/is';
221
222 preg_match_all($regexp, $section, $matches);
223
224 foreach ($matches['from'] as $key => $from) {
225 $char_from = hexdec($from);
226 $char_to = hexdec($matches['to'][$key]);
227 $offset = hexdec($matches['offset'][$key]);
228
229 for ($char = $char_from; $char <= $char_to; ++$char) {
230 $this->table[$char] = self::uchr($char - $char_from + $offset);
231 }
232 }
233
234 // Support for : <srcCode1> <srcCodeN> [<dstString1> <dstString2> ... <dstStringN>]
235 // Some PDF file has 2-byte Unicode values on new lines > added \r\n
236 $regexp = '/<(?P<from>[0-9A-F]+)> *<(?P<to>[0-9A-F]+)> *\[(?P<strings>[\r\n<>0-9A-F ]+)\][ \r\n]+/is';
237
238 preg_match_all($regexp, $section, $matches);
239
240 foreach ($matches['from'] as $key => $from) {
241 $char_from = hexdec($from);
242 $strings = [];
243
244 preg_match_all('/<(?P<string>[0-9A-F]+)> */is', $matches['strings'][$key], $strings);
245
246 foreach ($strings['string'] as $position => $string) {
247 $parts = preg_split(
248 '/([0-9A-F]{4})/i',
249 $string,
250 0,
251 \PREG_SPLIT_NO_EMPTY | \PREG_SPLIT_DELIM_CAPTURE
252 );
253 $text = '';
254 foreach ($parts as $part) {
255 $text .= self::uchr(hexdec($part));
256 }
257 $this->table[$char_from + $position] = $text;
258 }
259 }
260 }
261 }
262 }
263
264 return $this->table;
265 }
266
267 /**
268 * Set custom char translation table where:
269 * - key - integer character code;
270 * - value - "utf-8" encoded value;
271 *
272 * @return void
273 */
274 public function setTable(array $table)
275 {
276 $this->table = $table;
277 }
278
279 /**
280 * Calculate text width with data from header 'Widths'. If width of character is not found then character is added to missing array.
281 */
282 public function calculateTextWidth(string $text, ?array &$missing = null): ?float
283 {
284 $index_map = array_flip($this->table);
285 $details = $this->getDetails();
286
287 // Usually, Widths key is set in $details array, but if it isn't use an empty array instead.
288 $widths = $details['Widths'] ?? [];
289
290 /*
291 * Widths array is zero indexed but table is not. We must map them based on FirstChar and LastChar
292 *
293 * Note: Without the change you would see warnings in PHP 8.4 because the values of FirstChar or LastChar
294 * can be null sometimes.
295 */
296 $width_map = array_flip(range((int) $details['FirstChar'], (int) $details['LastChar']));
297
298 $width = null;
299 $missing = [];
300 $textLength = mb_strlen($text);
301 for ($i = 0; $i < $textLength; ++$i) {
302 $char = mb_substr($text, $i, 1);
303 if (
304 !\array_key_exists($char, $index_map)
305 || !\array_key_exists($index_map[$char], $width_map)
306 || !\array_key_exists($width_map[$index_map[$char]], $widths)
307 ) {
308 $missing[] = $char;
309 continue;
310 }
311 $width_index = $width_map[$index_map[$char]];
312 $width += $widths[$width_index];
313 }
314
315 return $width;
316 }
317
318 /**
319 * Decode hexadecimal encoded string. If $add_braces is true result value would be wrapped by parentheses.
320 */
321 public static function decodeHexadecimal(string $hexa, bool $add_braces = false): string
322 {
323 // Special shortcut for XML content.
324 if (false !== stripos($hexa, '<?xml')) {
325 return $hexa;
326 }
327
328 $text = '';
329 $parts = preg_split('/(<[a-f0-9\s]+>)/si', $hexa, -1, \PREG_SPLIT_NO_EMPTY | \PREG_SPLIT_DELIM_CAPTURE);
330
331 foreach ($parts as $part) {
332 if (preg_match('/^<[a-f0-9\s]+>$/si', $part)) {
333 // strip whitespace
334 $part = preg_replace("/\s/", '', $part);
335 $part = trim($part, '<>');
336 if ($add_braces) {
337 $text .= '(';
338 }
339
340 $part = pack('H*', $part);
341 $text .= ($add_braces ? preg_replace('/\\\/s', '\\\\\\', $part) : $part);
342
343 if ($add_braces) {
344 $text .= ')';
345 }
346 } else {
347 $text .= $part;
348 }
349 }
350
351 return $text;
352 }
353
354 /**
355 * Decode string with octal-decoded chunks.
356 */
357 public static function decodeOctal(string $text): string
358 {
359 // Replace all double backslashes \\ with a special string
360 $text = strtr($text, ['\\\\' => '[**pdfparserdblslsh**]']);
361
362 // Now we can replace all octal codes without worrying about
363 // escaped backslashes
364 $text = preg_replace_callback('/\\\\([0-7]{1,3})/', function ($m) {
365 return \chr(octdec($m[1]));
366 }, $text);
367
368 // Unescape any parentheses
369 $text = str_replace(['\\(', '\\)'], ['(', ')'], $text);
370
371 // Replace instances of the special string with a single backslash
372 return str_replace('[**pdfparserdblslsh**]', '\\', $text);
373 }
374
375 /**
376 * Decode string with html entity encoded chars.
377 */
378 public static function decodeEntities(string $text): string
379 {
380 return preg_replace_callback('/#([0-9a-f]{2})/i', function ($m) {
381 return \chr(hexdec($m[1]));
382 }, $text);
383 }
384
385 /**
386 * Check if given string is Unicode text (by BOM);
387 * If true - decode to "utf-8" encoded string.
388 * Otherwise - return text as is.
389 *
390 * @todo Rename in next major release to make the name correspond to reality (for ex. decodeIfUnicode())
391 */
392 public static function decodeUnicode(string $text): string
393 {
394 if ("\xFE\xFF" === substr($text, 0, 2)) {
395 // Strip U+FEFF byte order marker.
396 $decode = substr($text, 2);
397 $text = '';
398 $length = \strlen($decode);
399
400 for ($i = 0; $i < $length; $i += 2) {
401 $text .= self::uchr(hexdec(bin2hex(substr($decode, $i, 2))));
402 }
403 }
404
405 return $text;
406 }
407
408 /**
409 * @todo Deprecated, use $this->config->getFontSpaceLimit() instead.
410 */
411 protected function getFontSpaceLimit(): int
412 {
413 return $this->config->getFontSpaceLimit();
414 }
415
416 /**
417 * Decode text by commands array.
418 */
419 public function decodeText(array $commands, float $fontFactor = 4): string
420 {
421 $word_position = 0;
422 $words = [];
423 $font_space = $this->getFontSpaceLimit() * abs($fontFactor) / 4;
424
425 foreach ($commands as $command) {
426 switch ($command[PDFObject::TYPE]) {
427 case 'n':
428 $offset = (float) trim($command[PDFObject::COMMAND]);
429 if ($offset - (float) $font_space < 0) {
430 $word_position = \count($words);
431 }
432 continue 2;
433 case '<':
434 // Decode hexadecimal.
435 $text = self::decodeHexadecimal('<'.$command[PDFObject::COMMAND].'>');
436 break;
437
438 default:
439 // Decode octal (if necessary).
440 $text = self::decodeOctal($command[PDFObject::COMMAND]);
441 }
442
443 // replace escaped chars
444 $text = str_replace(
445 ['\\\\', '\(', '\)', '\n', '\r', '\t', '\f', '\ ', '\b'],
446 [\chr(92), \chr(40), \chr(41), \chr(10), \chr(13), \chr(9), \chr(12), \chr(32), \chr(8)],
447 $text
448 );
449
450 // add content to result string
451 if (isset($words[$word_position])) {
452 $words[$word_position] .= $text;
453 } else {
454 $words[$word_position] = $text;
455 }
456 }
457
458 foreach ($words as &$word) {
459 $word = $this->decodeContent($word);
460 $word = str_replace("\t", ' ', $word);
461 }
462
463 // Remove internal "words" that are just spaces, but leave them
464 // if they are at either end of the array of words. This fixes,
465 // for example, lines that are justified to fill
466 // a whole row.
467 for ($x = \count($words) - 2; $x >= 1; --$x) {
468 if ('' === trim($words[$x], ' ')) {
469 unset($words[$x]);
470 }
471 }
472 $words = array_values($words);
473
474 // Cut down on the number of unnecessary internal spaces by
475 // imploding the string on the null byte, and checking if the
476 // text includes extra spaces on either side. If so, merge
477 // where appropriate.
478 $words = implode("\x00\x00", $words);
479 $words = str_replace(
480 [" \x00\x00 ", "\x00\x00 ", " \x00\x00", "\x00\x00"],
481 [' ', ' ', ' ', ' '],
482 $words
483 );
484
485 return $words;
486 }
487
488 /**
489 * Decode given $text to "utf-8" encoded string.
490 *
491 * @param bool $unicode This parameter is deprecated and might be removed in a future release
492 */
493 public function decodeContent(string $text, ?bool &$unicode = null): string
494 {
495 // If this string begins with a UTF-16BE BOM, then decode it
496 // directly as Unicode
497 if ("\xFE\xFF" === substr($text, 0, 2)) {
498 return $this->decodeUnicode($text);
499 }
500
501 if ($this->has('ToUnicode')) {
502 return $this->decodeContentByToUnicodeCMapOrDescendantFonts($text);
503 }
504
505 if ($this->has('Encoding')) {
506 $result = $this->decodeContentByEncoding($text);
507
508 if (null !== $result) {
509 return $result;
510 }
511 }
512
513 return $this->decodeContentByAutodetectIfNecessary($text);
514 }
515
516 /**
517 * First try to decode $text by ToUnicode CMap.
518 * If char translation not found in ToUnicode CMap tries:
519 * - If DescendantFonts exists tries to decode char by one of that fonts.
520 * - If have no success to decode by DescendantFonts interpret $text as a string with "Windows-1252" encoding.
521 * - If DescendantFonts does not exist just return "?" as decoded char.
522 *
523 * @todo Seems this is invalid algorithm that do not follow pdf-format specification. Must be rewritten.
524 */
525 private function decodeContentByToUnicodeCMapOrDescendantFonts(string $text): string
526 {
527 $bytes = $this->tableSizes['from'];
528
529 if ($bytes) {
530 $result = '';
531 $length = \strlen($text);
532
533 for ($i = 0; $i < $length; $i += $bytes) {
534 $char = substr($text, $i, $bytes);
535
536 if (false !== ($decoded = $this->translateChar($char, false))) {
537 $char = $decoded;
538 } elseif ($this->has('DescendantFonts')) {
539 if ($this->get('DescendantFonts') instanceof PDFObject) {
540 $fonts = $this->get('DescendantFonts')->getHeader()->getElements();
541 } else {
542 $fonts = $this->get('DescendantFonts')->getContent();
543 }
544 $decoded = false;
545
546 foreach ($fonts as $font) {
547 if ($font instanceof self) {
548 if (false !== ($decoded = $font->translateChar($char, false))) {
549 $decoded = mb_convert_encoding($decoded, 'UTF-8', 'Windows-1252');
550 break;
551 }
552 }
553 }
554
555 if (false !== $decoded) {
556 $char = $decoded;
557 } else {
558 $char = mb_convert_encoding($char, 'UTF-8', 'Windows-1252');
559 }
560 } else {
561 $char = self::MISSING;
562 }
563
564 $result .= $char;
565 }
566
567 $text = $result;
568 }
569
570 return $text;
571 }
572
573 /**
574 * Decode content by any type of Encoding (dictionary's item) instance.
575 */
576 private function decodeContentByEncoding(string $text): ?string
577 {
578 $encoding = $this->get('Encoding');
579
580 // When Encoding referenced by object id (/Encoding 520 0 R) but object itself does not contain `/Type /Encoding` in it's dictionary.
581 if ($encoding instanceof PDFObject) {
582 $encoding = $this->getInitializedEncodingByPdfObject($encoding);
583 }
584
585 // When Encoding referenced by object id (/Encoding 520 0 R) but object itself contains `/Type /Encoding` in it's dictionary.
586 if ($encoding instanceof Encoding) {
587 return $this->decodeContentByEncodingEncoding($text, $encoding);
588 }
589
590 // When Encoding is just string (/Encoding /WinAnsiEncoding)
591 if ($encoding instanceof Element) { // todo: ElementString class must by used?
592 return $this->decodeContentByEncodingElement($text, $encoding);
593 }
594
595 // don't double-encode strings already in UTF-8
596 if (!mb_check_encoding($text, 'UTF-8')) {
597 return mb_convert_encoding($text, 'UTF-8', 'Windows-1252');
598 }
599
600 return $text;
601 }
602
603 /**
604 * Returns already created or create a new one if not created before Encoding instance by PDFObject instance.
605 */
606 private function getInitializedEncodingByPdfObject(PDFObject $PDFObject): Encoding
607 {
608 if (!$this->initializedEncodingByPdfObject) {
609 $this->initializedEncodingByPdfObject = $this->createInitializedEncodingByPdfObject($PDFObject);
610 }
611
612 return $this->initializedEncodingByPdfObject;
613 }
614
615 /**
616 * Decode content when $encoding (given by $this->get('Encoding')) is instance of Encoding.
617 */
618 private function decodeContentByEncodingEncoding(string $text, Encoding $encoding): string
619 {
620 $result = '';
621 $length = \strlen($text);
622
623 for ($i = 0; $i < $length; ++$i) {
624 $dec_av = hexdec(bin2hex($text[$i]));
625 $dec_ap = $encoding->translateChar($dec_av);
626 $result .= self::uchr($dec_ap ?? $dec_av);
627 }
628
629 return $result;
630 }
631
632 /**
633 * Decode content when $encoding (given by $this->get('Encoding')) is instance of Element.
634 */
635 private function decodeContentByEncodingElement(string $text, Element $encoding): ?string
636 {
637 $pdfEncodingName = $encoding->getContent();
638
639 // mb_convert_encoding does not support MacRoman/macintosh,
640 // so we use iconv() here
641 $iconvEncodingName = $this->getIconvEncodingNameOrNullByPdfEncodingName($pdfEncodingName);
642
643 return $iconvEncodingName ? iconv($iconvEncodingName, 'UTF-8//TRANSLIT//IGNORE', $text) : null;
644 }
645
646 /**
647 * Convert PDF encoding name to iconv-known encoding name.
648 */
649 private function getIconvEncodingNameOrNullByPdfEncodingName(string $pdfEncodingName): ?string
650 {
651 $pdfToIconvEncodingNameMap = [
652 'StandardEncoding' => 'ISO-8859-1',
653 'MacRomanEncoding' => 'MACINTOSH',
654 'WinAnsiEncoding' => 'CP1252',
655 ];
656
657 return \array_key_exists($pdfEncodingName, $pdfToIconvEncodingNameMap)
658 ? $pdfToIconvEncodingNameMap[$pdfEncodingName]
659 : null;
660 }
661
662 /**
663 * If string seems like "utf-8" encoded string do nothing and just return given string as is.
664 * Otherwise, interpret string as "Window-1252" encoded string.
665 *
666 * @return string|false
667 */
668 private function decodeContentByAutodetectIfNecessary(string $text)
669 {
670 if (mb_check_encoding($text, 'UTF-8')) {
671 return $text;
672 }
673
674 return mb_convert_encoding($text, 'UTF-8', 'Windows-1252');
675 // todo: Why exactly `Windows-1252` used?
676 }
677
678 /**
679 * Create Encoding instance by PDFObject instance and init it.
680 */
681 private function createInitializedEncodingByPdfObject(PDFObject $PDFObject): Encoding
682 {
683 $encoding = $this->createEncodingByPdfObject($PDFObject);
684 $encoding->init();
685
686 return $encoding;
687 }
688
689 /**
690 * Create Encoding instance by PDFObject instance (without init).
691 */
692 private function createEncodingByPdfObject(PDFObject $PDFObject): Encoding
693 {
694 $document = $PDFObject->getDocument();
695 $header = $PDFObject->getHeader();
696 $content = $PDFObject->getContent();
697 $config = $PDFObject->getConfig();
698
699 return new Encoding($document, $header, $content, $config);
700 }
701 }
702