diff --git a/src/Smalot/PdfParser/PDFObject.php b/src/Smalot/PdfParser/PDFObject.php index 378ae15d..22d049e6 100644 --- a/src/Smalot/PdfParser/PDFObject.php +++ b/src/Smalot/PdfParser/PDFObject.php @@ -208,6 +208,10 @@ public function cleanContent(string $content, string $char = 'X') * separated by \r\n. If the given string is null, or binary data * is detected instead of a document stream then return an empty * string. + * + * @see https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf#page=23 ISO 32000-1:2008, 7.3.4.2 (literal strings) + * @see https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf#page=258 ISO 32000-1:2008, 9.4.3, Table 109 (Tj, TJ) + * @see https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf#page=561 ISO 32000-1:2008, 14.6.1, Table 320 (BDC, BMC, DP, MP) */ private function formatContent(?string $content): string { @@ -307,9 +311,17 @@ private function formatContent(?string $content): string // by the next steps $pdfstrings = []; $attempt = '('; - while (preg_match('/'.preg_quote($attempt, '/').'.*?\)/s', $content, $text)) { + // The search starts at $offset. The content in front of $offset is + // collected in $processed, with each string replaced by a placeholder. + // The effort grows linearly with the number of strings, which matters + // for TJ arrays consisting of thousands of string operands. + $offset = 0; + $processed = ''; + while (preg_match('/'.preg_quote($attempt, '/').'.*?\)/s', $content, $text, \PREG_OFFSET_CAPTURE, $offset)) { + list($string, $stringPos) = $text[0]; + // Remove all escaped slashes and parentheses from the target text - $para = str_replace(['\\\\', '\\(', '\\)'], '', $text[0]); + $para = str_replace(['\\\\', '\\(', '\\)'], '', $string); // PDF strings can contain unescaped parentheses as long as // they're balanced, so check for balanced parentheses @@ -319,22 +331,19 @@ private function formatContent(?string $content): string if (')' == $para[-1] && $left == $right) { // Replace the string with a unique placeholder $id = uniqid('STRING_', true); - $pdfstrings[$id] = $text[0]; - $content = preg_replace( - '/'.preg_quote($text[0], '/').'/', - '@@@'.$id.'@@@', - $content, - 1 - ); + $pdfstrings[$id] = $string; + $processed .= substr($content, $offset, $stringPos - $offset).'@@@'.$id.'@@@'; + $offset = $stringPos + \strlen($string); // Reset to search for the next string $attempt = '('; } else { // We had unbalanced parentheses, so use the current // match as a base to find a longer string - $attempt = $text[0]; + $attempt = $string; } } + $content = $processed.substr($content, $offset); // Remove all carriage returns and line-feeds from the document stream $content = str_replace(["\r", "\n"], ' ', trim($content)); @@ -379,26 +388,32 @@ private function formatContent(?string $content): string ); } - // Restore the original content of the dictionary << >> commands - $dictstore = array_reverse($dictstore, true); - foreach ($dictstore as $id => $dict) { - $content = str_replace('###'.$id.'###', $dict, $content); + // Restore the original content of the dictionary << >> commands, all + // placeholders in one pass over the content + if ([] !== $dictstore) { + $dictMap = []; + foreach ($dictstore as $id => $dict) { + $dictMap['###'.$id.'###'] = $dict; + } + $content = strtr($content, $dictMap); } - // Restore the original string content - $pdfstrings = array_reverse($pdfstrings, true); + // Restore the original string content, all placeholders in one pass + // over the content + $stringMap = []; foreach ($pdfstrings as $id => $text) { // Strings may contain escaped newlines, or literal newlines // and we should clean these up before replacing the string // back into the content stream; this ensures no strings are // split between two lines (every command must be on one line) - $text = str_replace( + $stringMap['@@@'.$id.'@@@'] = str_replace( ["\\\r\n", "\\\r", "\\\n", "\r", "\n"], ['', '', '', '\r', '\n'], $text ); - - $content = str_replace('@@@'.$id.'@@@', $text, $content); + } + if ([] !== $stringMap) { + $content = strtr($content, $stringMap); } // Restore the original content of any inline images diff --git a/tests/PHPUnit/Integration/PDFObjectTest.php b/tests/PHPUnit/Integration/PDFObjectTest.php index 072dfd58..e0e11385 100644 --- a/tests/PHPUnit/Integration/PDFObjectTest.php +++ b/tests/PHPUnit/Integration/PDFObjectTest.php @@ -367,6 +367,66 @@ public function testFormatContentInlineImages(): void 'ET', $cleaned); } + /** + * formatContent() replaces strings and the dictionaries of marked-content + * operators by placeholders, puts each command on a line of its own and + * restores strings and dictionaries afterwards. + * + * @see https://github.com/smalot/pdfparser/issues/712 + * @see https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf#page=23 ISO 32000-1:2008, 7.3.4.2 (literal strings) + * @see https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf#page=258 ISO 32000-1:2008, 9.4.3, Table 109 (Tj, TJ) + * @see https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf#page=561 ISO 32000-1:2008, 14.6.1, Table 320 (BDC, EMC) + */ + public function testFormatContentStringsAndDictionaries(): void + { + $formatContent = new \ReflectionMethod('Smalot\PdfParser\PDFObject', 'formatContent'); + + // TODO: remove this if-clause when dropping 8.0.x support + if (version_compare(\PHP_VERSION, '8.1.0', '<')) { + $formatContent->setAccessible(true); + } + + $cases = [ + 'operands with the same content' => [ + 'BT [(a)1(a)-2(a)3(b)4(a)]TJ ET', + "BT\r\n[(a)1(a)-2(a)3(b)4(a)]TJ\r\nET", + ], + 'escaped parentheses' => [ + 'BT (a\(b\)c) Tj ET', + "BT\r\n".'(a\(b\)c) Tj'."\r\nET", + ], + 'balanced parentheses' => [ + 'BT (a(b)c) Tj (d(e(f))g) Tj ET', + "BT\r\n(a(b)c) Tj\r\n(d(e(f))g) Tj\r\nET", + ], + 'strings in front of an unterminated string' => [ + 'BT (ok) Tj (broken Tj ET', + "BT\r\n(ok) Tj\r\n(broken Tj\r\nET", + ], + 'end-of-line markers inside of strings' => [ + "BT (line1\nline2) Tj (line3\\\nline4) Tj (line5\r\nline6) Tj ET", + "BT\r\n".'(line1\nline2) Tj'."\r\n".'(line3line4) Tj'."\r\n".'(line5\r\nline6) Tj'."\r\nET", + ], + 'dictionaries, one of them with a string' => [ + '/P <> BDC BT (A) Tj ET EMC /P <> BDC BT (B) Tj ET EMC', + "/P <> BDC\r\nBT\r\n(A) Tj\r\nET\r\nEMC\r\n" + ."/P <> BDC\r\nBT\r\n(B) Tj\r\nET\r\nEMC", + ], + 'string which looks like a placeholder' => [ + 'BT (@@@STRING_1@@@ ###DICT_1###) Tj ET', + "BT\r\n(@@@STRING_1@@@ ###DICT_1###) Tj\r\nET", + ], + ]; + + foreach ($cases as $description => $case) { + $this->assertSame( + $case[1], + $formatContent->invoke($this->getPdfObjectInstance(new Document()), $case[0]), + $description + ); + } + } + public function testGetSectionsText(): void { $content = '/Shape <>BDC diff --git a/tests/PHPUnit/Unit/PDFObjectTest.php b/tests/PHPUnit/Unit/PDFObjectTest.php index d9b27161..173bf500 100644 --- a/tests/PHPUnit/Unit/PDFObjectTest.php +++ b/tests/PHPUnit/Unit/PDFObjectTest.php @@ -17,6 +17,15 @@ class PDFObjectTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + + // The recursion stack is static and only emptied by Page::getText(). + // An object, whose hash equals an entry of the stack, provides no text. + PDFObject::$recursionStack = []; + } + public function testGetTextOnNullPage(): void { static::assertSame(' ', (new PDFObject(new Document()))->getText()); @@ -95,4 +104,70 @@ public function testTextArrayObjects(): void // array. self::assertSame([' '], $page4->getTextArray()); } + + /** + * Kerned TJ arrays split a word into many small string operands, some of + * them more than once ("l", "o"). Each operand ends up at its position. + * + * @see https://github.com/smalot/pdfparser/issues/712 + * @see https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf#page=258 ISO 32000-1:2008, 9.4.3, Table 109 (TJ) + */ + public function testGetTextArrayReassemblesKernedTjArray(): void + { + $content = 'BT /F1 12 Tf 10 10 Td ' + .'[(H)10(e)-5(l)3(l)20(o)-40( )30(W)5(o)-3(r)8(l)2(d)]TJ ET'; + + self::assertSame(['Hello World '], $this->getTextArrayOfFormContent($content)); + } + + /** + * The property list of a marked-content sequence contains a string itself. + * The text which follows the BDC operator is extracted nevertheless. + * + * @see https://github.com/smalot/pdfparser/issues/712 + * @see https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf#page=561 ISO 32000-1:2008, 14.6.1, Table 320 (BDC, EMC) + */ + public function testGetTextArrayRestoresMarkedContentDictionary(): void + { + $content = '/OC << /MCID 0 /Foo (bar) >> BDC ' + .'BT /F1 12 Tf 10 10 Td (Hello) Tj ET EMC'; + + self::assertSame(['Hello '], $this->getTextArrayOfFormContent($content)); + } + + /** + * A literal string may contain balanced pairs of parentheses, which are + * part of the string. + * + * @see https://github.com/smalot/pdfparser/issues/712 + * @see https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf#page=23 ISO 32000-1:2008, 7.3.4.2 (literal strings) + */ + public function testGetTextArrayKeepsBalancedParenthesesInsideString(): void + { + $content = 'BT /F1 12 Tf 10 10 Td (a(b)c) Tj ET'; + + self::assertSame(['a(b)c '], $this->getTextArrayOfFormContent($content)); + } + + /** + * Provides the text array of a page, whose content stream invokes a form + * XObject with the given content. + */ + private function getTextArrayOfFormContent(string $content): array + { + $document = new Document(); + $document->init(); + + $form = new Form($document, null, $content, new Config()); + $header = new Header([ + 'Resources' => new Header([ + 'XObject' => new Header([ + 'Fr0' => $form, + ]), + ]), + 'Contents' => new ElementArray([new Element('/Fr0 Do', $document)], $document), + ]); + + return (new Page($document, $header))->getTextArray(); + } } diff --git a/tests/Performance/Test/KernedTjArrayFormatContentTest.php b/tests/Performance/Test/KernedTjArrayFormatContentTest.php new file mode 100644 index 00000000..b941a1aa --- /dev/null +++ b/tests/Performance/Test/KernedTjArrayFormatContentTest.php @@ -0,0 +1,86 @@ + + */ + +namespace PerformanceTests\Test; + +use PerformanceTests\AbstractPerformanceTest; +use Smalot\PdfParser\Config; +use Smalot\PdfParser\Document; +use Smalot\PdfParser\Element; +use Smalot\PdfParser\Element\ElementArray; +use Smalot\PdfParser\Header; +use Smalot\PdfParser\Page; +use Smalot\PdfParser\XObject\Form; + +/** + * PDFs that emit text as kerned TJ arrays (for fine letter spacing) split a + * single line into thousands of tiny string operands. formatContent() parks + * each operand behind a unique placeholder and restores it afterward. Its + * effort grows linearly with the number of operands. + * + * This test builds a content stream with 60,000 such operands and extracts + * its text, which takes less than a second. The time budget is exceeded by + * far, if the effort grows quadratically with the number of operands. + * + * @see https://github.com/smalot/pdfparser/issues/712 + * @see https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf#page=258 ISO 32000-1:2008, 9.4.3, Table 109 (TJ) + */ +final class KernedTjArrayFormatContentTest extends AbstractPerformanceTest +{ + /** + * @var positive-int + */ + private const OPERANDS = 60000; + + /** + * @var string + */ + protected $content; + + public function init(): void + { + // Create a string which represents a PDF that emits text + // letter-by-letter for fine kerning. + $operands = ''; + for ($i = 0; $i < self::OPERANDS; ++$i) { + $operands .= '(a)'.(($i % 20) - 10).' '; + } + + $this->content = 'BT /F1 12 Tf 10 10 Td ['.$operands.']TJ ET'; + } + + public function run(): void + { + $document = new Document(); + $document->init(); + + $form = new Form($document, null, $this->content, new Config()); + $header = new Header([ + 'Resources' => new Header([ + 'XObject' => new Header(['Fr0' => $form]), + ]), + 'Contents' => new ElementArray([new Element('/Fr0 Do', $document)], $document), + ]); + + $textArray = (new Page($document, $header))->getTextArray(); + + // Each operand provides one character, a space character is appended + // to the text. The check makes sure the time was spent on extracting + // the text. + if (1 !== \count($textArray) || self::OPERANDS + 1 !== \strlen($textArray[0])) { + throw new \RuntimeException('Text of kerned TJ array was not extracted as expected.'); + } + } + + public function getMaxEstimatedTime(): int + { + return 5; + } +} diff --git a/tests/Performance/runPerformanceTests.php b/tests/Performance/runPerformanceTests.php index fa983105..34a5a2d8 100644 --- a/tests/Performance/runPerformanceTests.php +++ b/tests/Performance/runPerformanceTests.php @@ -4,9 +4,11 @@ use PerformanceTests\Exception\PerformanceFailException; use PerformanceTests\Test\DocumentDictionaryCacheTest; +use PerformanceTests\Test\KernedTjArrayFormatContentTest; $tests = [ new DocumentDictionaryCacheTest(), + new KernedTjArrayFormatContentTest(), ]; foreach ($tests as $test) {