Ignore a "Q" that has no matching "q" - #838
wouterschreurs wants to merge 1 commit into
Conversation
CC @GreyWyvern |
|
This is a simple enough change. I believe I actually had a check like this in place earlier on when my own code was reading extra Q's and q's in certain special text strings unintentionally as commands. When I fixed that, I was really happy to remove this check because all of the PDFs in the test suite were now balanced. :) I suppose there are malformed PDFs out there that would still require this. I'm not at my PC right now, but I would like to know if there is a REAL live PDF file example out there of this error, and if so, would that file load up successfully in Acrobat? |
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 <noreply@anthropic.com>
88f4b2d to
3872a2b
Compare
|
Yes — there is a real file, and chasing it down changed my understanding of the bug, so I've updated the PR and its test. The file. A 20-page, 1.9 MB, PDF-1.7 product catalog that arrived as an email attachment on one of our customer-service inboxes. Our mail importer parses PDF attachments to look for order references, and this one produced 276 warnings in a single parse (138 text-showing operators × 2). It's the only occurrence in a week of production traffic, so: rare, but real, and not synthetic. It is not an authored stray One page content stream (92,247 bytes) begins, literally: 21 Per 7.2.2 a keyword token runs until white-space or a delimiter, so So your recollection was on the money — same family of problem, opposite direction. I deliberately did not try to lex the run as 21 What it actually costs today. On this file the text still comes out — 54,219 characters, byte-identical with and without the patch (sha1 Acrobat. I can't answer that one honestly: I have no PDF renderer available in the environment where I have the file, so I haven't opened it. My expectation is that it renders fine — viewers ignore an unknown keyword, and a graphics-state underflow is likewise ignored — but that's reasoning, not a test. If it would help, I can get someone to open it in Acrobat and report back. The test now reproduces the real shape rather than a lone $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());Without the guard that errors with 2 warnings; with it, it passes. The properly separated form ( I'd have attached the original, it's a third-party marketing catalog we received by email. I'm happy to produce a minimal synthetic PDF that reproduces the same |
|
For what its worth, you should check https://github.com/PrinsFrank/pdfparser. We use it in production and it is way more reliable than this PDF parser. I converted your PR to a draft, in case you are still working on it. Please mark it "ready for review" when you are done. |
|
Thanks — that's a generous pointer coming from a collaborator here, and we took it seriously rather than politely. We benchmarked both against real attachments from our ticket system and are migrating to What tipped it wasn't speed. It was a supplier order confirmation (PDF 1.2, uncompressed content streams, 474 On a German delivery note the two were equivalent — identical 8 references recovered — except this parser welded three adjacent table headers into one token ( For balance, it isn't one-sided. So this is a fit-for-our-corpus decision, not a verdict on the library. On the PR: it's ready for review — I've pushed an update since your comment. Chasing down a real-world example of the bug (which the other thread asked for) showed my original diagnosis was wrong, so both the description and the test now reflect what actually happens: the file's I don't have permission to flip the draft flag myself, so please mark it ready for review, or let me know and I'll ask a colleague to. |
|
@PrinsFrank the feedback here might be of interest to you: Unfortunately, I am super busy right now, so I can't give any timeline when a merge will happen. But I am glad that we could refine the PR so fast that it might be an easy task. |
|
@k00ni Thanks for tagging me and the recommendation! @wouterschreurs I've fixed the bug with whitespaces in references in prinsfrank/pdfparser. I'll tag a new release shortly. I'm happy to look into the issue with toUnicodeCMaps, but I need an example file that has the issue as I'm unable to reproduce it at the moment. Feel free to open an issue with the sample, or you can also send me it in private if the sample shouldn't be publicly available! |
|
@wouterschreurs and @etrias-nl: Small ping to make sure this one was closed intentionally. |
Ignore a
Qthat has no matchingqSmall, well-scoped bug fix, with a test.
Problem
PDFObject::getTextArray()pushes the current font and graphics matrix onto$clipped_font/$clipped_position_cmonq, and pops them onQ:Neither pop is guarded. When a stream restores a graphics state that was never pushed, both stacks are already empty, so
array_pop()returnsnulland:$current_position_cmisnull, and the next text-showing operator reads offsets off it →Warning: Trying to access array offset on null(PDFObject.php:909 and :910);$current_fontisnulland reachesgetTJUsingFontFallback()→TypeError: Argument #1 ($font) must be of type Font, null given.How a real file gets there
Not by authoring a stray
Q. This came from a product catalog (20 pages, PDF-1.7) that arrived as an email attachment and hit our mail importer in production, producing 276 warnings in one parse.Counting operators in the raw file gives 2111
qand 2111Q— balanced. The imbalance is created at the lexer: one page content stream (92,247 bytes) begins— 21 save-state operators concatenated with no separators. That same stream holds 355 properly separated
qand 376 properly separatedQ, and 355 + 21 = 376, so the file's intent is balanced; only the encoding of those 21 saves is broken.Per 7.2.2 a keyword token runs until white-space or a delimiter, so
qqqq…qis a single unknown keyword rather than 21 operators. PdfParser is right not to treat it asq— but then nothing is pushed while the 21 matchingQs still pop. Instrumenting theQcase on that file reports exactly 21 underflows.Introduced by #634, which replaced the single stored state with a stack; first released in v2.8.0.
Fix
Skip each pop when its stack is empty, keeping the current font and matrix — which is how a viewer treats the same stream. No attempt is made to lex the run as 21
qoperators: per spec it is not 21 operators.Test
PDFObjectTest::testGetTextWithUnmatchedGraphicsStateRestorereproduces the real shape, no PDF fixture needed:On
masterthis errors with 2 warnings; with the fix it passes. The properly separated form (q\nq\n…) behaves identically before and after — the trigger is the concatenation, not the operator count.Scope of the impact
On the file above the text still comes out: 54,219 characters, byte-identical with and without this patch (sha1
1cad49de23f860ada2b84fe9fed847836676a4ba). The cost there is 276 logged errors, not lost content. Losing the remainder of the stream requires noTfto follow theQ, so the font staysnulland theTypeErrorfires — real, but not what this file hits.Checks
Run against
masteron PHP 8.4.19:Found 0 of 82 files that can be fixed.^1, sincedev-toolscould not be installed in my environment.)The change is PHP 7.1-compatible, in line with
composer.json.