From ca9090a92dee496018e222cc7718d35f37b34336 Mon Sep 17 00:00:00 2001 From: Andreas Gohr Date: Thu, 18 Jun 2026 17:25:33 +0200 Subject: [PATCH] Chunked getSectionsText() to lower peak memory getSectionsText() ran the formatted content stream through a single preg_split() into an array of every line, then kept only the handful needed for text positioning. A vector-graphics-heavy page can format to hundreds of thousands of lines while yielding almost no text, so that array was the peak of the whole text-extraction phase. Split the formatted stream in, line-aligned 1 MB chunks instead. Each chunk is then preg_split(), filtered and then discarded. Where the source stream is smaller than 1 MB, there is no change in behaviour. Output is byte-for-byte identical, and there is virtually no throughput cost. Measured peak memory (parseFile + getText on the whole file) vs master, on a graphics heavy document: samples/bugs/Issue356.pdf 79.4 MB -> 59.2 MB (-25%) Other documents are unchanged (PullRequest457.pdf 146.9 MB, DocumentWithLotsOfObjects.pdf 99.1 MB, Issue391.pdf 10.4 MB). --- src/Smalot/PdfParser/PDFObject.php | 124 +++++++++++++++++------------ 1 file changed, 72 insertions(+), 52 deletions(-) diff --git a/src/Smalot/PdfParser/PDFObject.php b/src/Smalot/PdfParser/PDFObject.php index 378ae15d..1080488c 100644 --- a/src/Smalot/PdfParser/PDFObject.php +++ b/src/Smalot/PdfParser/PDFObject.php @@ -429,65 +429,65 @@ public function getSectionsText(?string $content): array { $sections = []; - // A cleaned stream has one command on every line, so split the - // cleaned stream content on \r\n into an array - $textCleaned = preg_split( - '/(\r\n|\n|\r)/', - $this->formatContent($content), - -1, - \PREG_SPLIT_NO_EMPTY - ); + // A cleaned stream has one command on every line. Splitting the whole + // string into an array up front is simplest, but a graphics-heavy page + // can have hundreds of thousands of lines, of which only a handful are + // kept below. The resulting array can take a lot of memory. + // Instead split in bounded, line-aligned chunks first and process each + // chunk, so only a small slice is ever materialized at once. + $cleaned = $this->formatContent($content); + $length = \strlen($cleaned); $inTextBlock = false; - foreach ($textCleaned as $line) { - $line = trim($line); + $chunkSize = 1024 * 1024; // 1 MB; bounds the per-chunk line array + $offset = 0; + while ($offset < $length) { + // Cut the chunk at the next line boundary so a command is never + // split across chunks; the $inTextBlock flag carries across them. + $end = min($offset + $chunkSize, $length); + if ($end < $length) { + $end += strcspn($cleaned, "\r\n", $end); + } - // Skip empty lines - if ('' === $line) { - continue; + // Split into lines. When the whole stream fits in one chunk (the + // common case) split it directly to avoid copying it via substr(). + $chunk = (0 === $offset && $length === $end) + ? $cleaned + : substr($cleaned, $offset, $end - $offset); + $textCleaned = preg_split('/(\r\n|\n|\r)/', $chunk, -1, \PREG_SPLIT_NO_EMPTY); + + // Advance past the chunk and the run of delimiters following it. + $offset = $end + strspn($cleaned, "\r\n", $end); + + // On the final chunk the source stream is no longer needed; release + // it before filtering the lines so single-chunk pages peak no higher + // than a plain whole-string split would. + if ($offset >= $length) { + $cleaned = $chunk = ''; } - // If a 'BT' is encountered, set the $inTextBlock flag - if (preg_match('/BT$/', $line)) { - $inTextBlock = true; - $sections[] = $line; - - // If an 'ET' is encountered, unset the $inTextBlock flag - } elseif ('ET' == $line) { - $inTextBlock = false; - $sections[] = $line; - } elseif ($inTextBlock) { - // If we are inside a BT ... ET text block, save all lines - $sections[] = trim($line); - } else { - // Otherwise, if we are outside of a text block, only - // save specific, necessary lines. Care should be taken - // to ensure a command being checked for *only* matches - // that command. For instance, a simple search for 'c' - // may also match the 'sc' command. See the command - // list in the formatContent() method above. - // Add more commands to save here as you find them in - // weird PDFs! - if ('q' == $line[-1] || 'Q' == $line[-1]) { - // Save and restore graphics state commands - $sections[] = $line; - } elseif (preg_match('/(?isKeptOutsideTextBlock($line)) { $sections[] = $line; } } @@ -496,6 +496,26 @@ public function getSectionsText(?string $content): array return $sections; } + /** + * Whether a (trimmed, non-empty) line outside a BT...ET text block is one of + * the few commands worth keeping for text positioning/extraction. + * + * Care should be taken to ensure a command being checked for *only* matches + * that command. For instance, a simple search for 'c' may also match the + * 'sc' command. See the command list in the formatContent() method above. + * Add more commands to keep here as you find them in weird PDFs! + */ + private function isKeptOutsideTextBlock(string $line): bool + { + return 'q' == $line[-1] || 'Q' == $line[-1] // save/restore graphics state + || preg_match('/(?