diff --git a/src/Smalot/PdfParser/PDFObject.php b/src/Smalot/PdfParser/PDFObject.php index 378ae15d..9e45f253 100644 --- a/src/Smalot/PdfParser/PDFObject.php +++ b/src/Smalot/PdfParser/PDFObject.php @@ -320,12 +320,18 @@ private function formatContent(?string $content): string // 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 - ); + // Replace the first literal occurrence without compiling a regex + // per string operand (preg_quote + preg_replace is quadratic for + // kerned TJ arrays with many operands). + $stringPos = strpos($content, $text[0]); + if (false !== $stringPos) { + $content = substr_replace( + $content, + '@@@'.$id.'@@@', + $stringPos, + \strlen($text[0]) + ); + } // Reset to search for the next string $attempt = '('; @@ -381,24 +387,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); + if ([] !== $dictstore) { + $dictMap = []; + foreach ($dictstore as $id => $dict) { + $dictMap['###'.$id.'###'] = $dict; + } + $content = strtr($content, $dictMap); } - // Restore the original string content + // Restore the original string content in a single pass (strtr) instead + // of one full-content str_replace() per placeholder, which is quadratic + // for kerned TJ arrays with many string operands. $pdfstrings = array_reverse($pdfstrings, true); + $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/Unit/PDFObjectTest.php b/tests/PHPUnit/Unit/PDFObjectTest.php index d9b27161..2be70a0c 100644 --- a/tests/PHPUnit/Unit/PDFObjectTest.php +++ b/tests/PHPUnit/Unit/PDFObjectTest.php @@ -95,4 +95,81 @@ public function testTextArrayObjects(): void // array. self::assertSame([' '], $page4->getTextArray()); } + + /** + * Kerned TJ arrays split a word into many small string operands. Make sure + * they end up in the right order again (issue #712). + */ + public function testGetTextArrayReassemblesKernedTjArray(): void + { + $document = new Document(); + $document->init(); + + $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'; + + $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), + ]); + $page = new Page($document, $header); + + self::assertSame(['Hello World '], $page->getTextArray()); + } + + /** + * A << ... >> BDC dictionary around a text block must not swallow the + * text that follows it (issue #712). + */ + public function testGetTextArrayRestoresMarkedContentDictionary(): void + { + $document = new Document(); + $document->init(); + + $content = '/OC << /MCID 0 /Foo (bar) >> BDC ' + . 'BT /F1 12 Tf 10 10 Td (Hello) Tj ET EMC'; + + $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), + ]); + $page = new Page($document, $header); + + self::assertSame(['Hello '], $page->getTextArray()); + } + + /** + * A string can hold balanced unescaped parentheses; check they survive + * extraction (issue #712). + */ + public function testGetTextArrayKeepsBalancedParenthesesInsideString(): void + { + $document = new Document(); + $document->init(); + + $content = 'BT /F1 12 Tf 10 10 Td (a(b)c) Tj ET'; + + $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), + ]); + $page = new Page($document, $header); + + self::assertSame(['a(b)c '], $page->getTextArray()); + } } diff --git a/tests/Performance/Test/KernedTjArrayFormatContentTest.php b/tests/Performance/Test/KernedTjArrayFormatContentTest.php new file mode 100644 index 00000000..f3af1eea --- /dev/null +++ b/tests/Performance/Test/KernedTjArrayFormatContentTest.php @@ -0,0 +1,75 @@ + + */ + +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. + * Restoring them with one str_replace() per placeholder scans the whole + * content stream once per operand, i.e. O(operands * length) - quadratic in + * the number of operands. + * + * This test builds a content stream with 20,000 such operands and extracts + * its text. With the single-pass strtr() restoration this runs in ~1.5s here; + * with the previous per-placeholder str_replace() loop it took ~10s. The time + * budget below fails if the quadratic behaviour is reintroduced. + * + * @see https://github.com/smalot/pdfparser/issues/712 + */ +class KernedTjArrayFormatContentTest extends AbstractPerformanceTest +{ + /** + * @var string + */ + protected $content; + + public function init(): void + { + // Like a PDF that emits text letter-by-letter for fine kerning. + $operands = ''; + for ($i = 0; $i < 20000; ++$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), + ]); + + (new Page($document, $header))->getTextArray(); + } + + public function getMaxEstimatedTime(): int + { + return 5; + } +}