From 3872a2b815e987ec3d0d0f2847c79ab4206542b5 Mon Sep 17 00:00:00 2001 From: claude <81847+claude@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:37:46 +0000 Subject: [PATCH] Ignore a "Q" that has no matching "q" getTextArray() pushes the current font and graphics matrix onto $clipped_font / $clipped_position_cm on "q" and pops them on "Q". Neither pop is guarded, so a stream that restores a graphics state it never pushed pops both stacks empty: array_pop() returns null, the next text-showing operator reads array offsets off null, and a null font reaches getTJUsingFontFallback(), which is a TypeError. This happens on real files, and not because the PDF authored a stray "Q". A content stream can concatenate save-state operators without whitespace -- "qqqqqqqqqqqqqqqqqqqqq" rather than "q q q ..." -- and per 7.2.2 that lexes as a single unknown keyword instead of 21 "q" operators, so nothing is pushed while the 21 matching "Q"s still pop. Skip the pop when there is nothing to restore and keep the current state, which is how a viewer treats the same stream. This dates to #634, which replaced the single stored state with a stack; first released in v2.8.0. Co-Authored-By: Claude --- src/Smalot/PdfParser/PDFObject.php | 10 ++++++++-- tests/PHPUnit/Unit/PDFObjectTest.php | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/Smalot/PdfParser/PDFObject.php b/src/Smalot/PdfParser/PDFObject.php index 378ae15d..fb5d80f2 100644 --- a/src/Smalot/PdfParser/PDFObject.php +++ b/src/Smalot/PdfParser/PDFObject.php @@ -827,8 +827,14 @@ public function getTextArray(?Page $page = null): array // Restore previous selected font and graphics matrix case 'Q': - list($current_font, $current_font_size) = array_pop($clipped_font); - $current_position_cm = array_pop($clipped_position_cm); + // A malformed content stream can restore a graphics state it never saved, + // leaving nothing on these stacks to pop. + if ([] !== $clipped_font) { + list($current_font, $current_font_size) = array_pop($clipped_font); + } + if ([] !== $clipped_position_cm) { + $current_position_cm = array_pop($clipped_position_cm); + } break; // End marked content sequence diff --git a/tests/PHPUnit/Unit/PDFObjectTest.php b/tests/PHPUnit/Unit/PDFObjectTest.php index d9b27161..94e0759b 100644 --- a/tests/PHPUnit/Unit/PDFObjectTest.php +++ b/tests/PHPUnit/Unit/PDFObjectTest.php @@ -29,6 +29,22 @@ public function testGetTextOnPageWithoutContent(): void static::assertSame(' ', (new PDFObject($document, null, null))->getText(new Page($document))); } + /** + * A stream may concatenate save-state operators without whitespace ("qq"), + * which lexes as a single unknown token instead of two "q" operators. The + * matching "Q"s then restore a state that was never pushed, and the rest of + * the stream must still be read. + */ + public function testGetTextWithUnmatchedGraphicsStateRestore(): void + { + $document = new Document(); + $document->init(); + + $content = "qq\nBT /F1 12 Tf 10 10 Td (Hello) Tj ET\nQ\nQ\nBT 10 -20 Td (World) Tj ET"; + + static::assertSame("Hello\nWorld ", (new PDFObject($document, null, $content, new Config()))->getText()); + } + public function testTextArrayObjects(): void { $document = new Document();