Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 26 additions & 12 deletions src/Smalot/PdfParser/PDFObject.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '(';
Expand Down Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions tests/PHPUnit/Unit/PDFObjectTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
75 changes: 75 additions & 0 deletions tests/Performance/Test/KernedTjArrayFormatContentTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

/**
* @file This file is part of the PdfParser library.
*
* @license LGPLv3
*
* @url <https://github.com/smalot/pdfparser>
*/

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;
}
}
Loading