Skip to content

Refuse to overwrite a document OverWrite() cannot read (mirrors mpdf/mpdf#747) - #42

Open
jakejackson1 wants to merge 1 commit into
gravitypdffrom
mirror/747-overwrite-line-endings
Open

Refuse to overwrite a document OverWrite() cannot read (mirrors mpdf/mpdf#747)#42
jakejackson1 wants to merge 1 commit into
gravitypdffrom
mirror/747-overwrite-line-endings

Conversation

@jakejackson1

Copy link
Copy Markdown
Member

Summary

This mirrors the intent of mpdf/mpdf#747, so the fork carries a fix while the upstream PR sits open. It closes mpdf/mpdf#626. The upstream patch itself is not usable — see below.

OverWrite() finds the cross-reference table, the page tree and the startxref with three patterns, and reads the captures without checking that any of them matched:

preg_match("/xref\n0 (\d+)\n(.*?)\ntrailer/s", $pdf, $m);
$xref_objid = $m[1];
preg_match_all('/(\d{10}) (\d{5}) (f|n)/', $m[2], $x);

Given a document written with CRLF line endings — what the reporter had — none of them match. On PHP 8 that is Undefined array key 1, then key 2, then a null handed to preg_match_all(), then two more from the other two sites. That is the Undefined Offset: 1 - mPDF.php Line 29198 in the issue, and the screenshots under it.

Then the method carries on. It writes a new cross-reference table out of the empty $xref, and a startxref computed from an empty $m. What comes back is a document with none of the text replaced and a table Ghostscript reports as xref table was repaired. A reader that does not repair gets nothing.

Try it

// a document mPDF wrote, with its line endings changed in transit
file_put_contents('crlf.pdf', str_replace("\n", "\r\n", file_get_contents('source.pdf')));

$mpdf = new \Mpdf\Mpdf();
$out = $mpdf->OverWrite('crlf.pdf', ['MAIN HEADING'], ['replacement'], 'S');

Before: five PHP warnings, and 1,460 bytes of PDF with MAIN HEADING still in it and a broken cross-reference table.
After: Mpdf\MpdfException: Cannot overwrite "crlf.pdf": no cross-reference table of the kind mPDF writes was found in it.

Test plan

  • tests/Mpdf/OverWriteTest.php, six cases, each building its source document with mPDF and cleaning up after itself.
    • testTextIsReplacedOnEveryPage and testTextIsReplacedInACompressedDocument — the working path, uncompressed and compressed. Both pass on gravitypdf; they are the controls that nothing this method could already do has been taken away.
    • testTheCrossReferenceTableStillPointsAtItself — reads the startxref back out of the result and checks the bytes at that offset are xref\n0 . Passes on both.
    • testADocumentWrittenWithOtherLineEndingsIsRefused — makes a CRLF copy of an mPDF document. On gravitypdf this returns a damaged file; here it throws.
    • testADocumentFromSomewhereElseIsRefusedtests/data/pdfs/compressed-xref.pdf, a fixture already in the repository. Also returns something on gravitypdf.
    • testRefusingRaisesNothingOfItsOwn — installs an error handler and asserts nothing at all was raised on the way to the exception. This is the one that holds the issue's actual report. On gravitypdf the handler collects five messages.
  • No snapshot. OverWrite() rewrites a file that already exists and is not part of rendering; all twenty snapshot documents regenerated on this branch and on gravitypdf are byte-identical once the creation date and the random /ID are normalised.
  • composer test — 1128 tests, 2652 assertions, up from 1122/2643.
  • composer cs clean. phpstan output identical to gravitypdf.
More info — why refusing rather than reading CRLF, and the state of the upstream patch

Why not just accept \R

mpdf/mpdf#747 changes the four patterns to accept any line ending:

-  preg_match("/xref\n0 (\d+)\n(.*?)\ntrailer/s", $pdf, $m);
+  preg_match("/xref\R0 (\d+)\R(.*?)\Rtrailer/s", $pdf, $m);

Reading CRLF is not enough on its own. The method writes LF back — $newstr and $newxref are both built with "\n" — and its bookkeeping models the size change as

$changes[($xref[$obj + 1][0])] = ($newlen - $oldlen) + (strlen($newlen) - strlen($oldlen));

which counts the stream and the length digits and nothing else. Every line ending it converted would move an offset the table names, uncounted, and the result would be the same repaired-on-open document by a different route. Refusing is the outcome that leaves the caller with something they can act on. OverWrite() only ever worked on documents mPDF wrote itself — it needs <</Length N>>\nstream\n exactly as mPDF writes it — and mPDF writes LF.

The rest of the upstream patch

Its rewrite of the object loop cannot run:

  • the uncompressed pattern names two capture groups length, which PCRE refuses to compile without (?J);
  • the uncompressed branch never captures stream, which the body then reads;
  • objects are rebuilt ending enobj, not endobj;
  • and the uncompressed branch interpolates {$newLen}, which is not a variable in scope — $newlen is.

Its test extends \PHPUnit_Framework_TestCase and calls SetImportUse(), neither of which exists any more.

The other guards

Two reads inside the loop are guarded here as well. The per-object preg_match result is checked before $m[2] is read, and $xref[$obj + 1] is checked before the offset change is recorded against it — a page tree naming an object the table does not carry now stops, rather than rewriting the document with offsets that do not add up.

)

OverWrite() finds the cross-reference table, the page tree and the startxref
with three patterns and then reads the captures without checking any of them
matched. Given a document written with CRLF line endings - what the reporter
had - none of them match. On PHP 8 that is "Undefined array key 1", then key
2, then a null handed to preg_match_all(), then two more; and then the method
carries on and writes a cross-reference table built out of nothing. What
comes back is a document with none of the text replaced and a table readers
report as damaged and repair. Ghostscript says "xref table was repaired".

Check each match and throw, naming the file and what could not be found.
Also guard the per-object read inside the loop and the xref entry the offset
bookkeeping needs, so a page tree pointing outside the table stops rather
than producing offsets that do not add up.

The documents this method can read are unaffected: text is still replaced on
every page, compressed and not, and the table it writes still points at
itself. All twenty snapshots are unchanged.

mpdf#747 instead accepts \R as the line ending. Reading CRLF is not
enough on its own - the method writes LF back and its offset arithmetic does
not model the difference, so the table would still be wrong. Refusing is the
outcome that leaves the caller with something they can act on. That PR's
rewrite of the object loop is also not usable: it names two capture groups
"length" in one pattern, which will not compile, and writes "enobj" and an
undefined $newLen into the objects it rebuilds.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@jakejackson1 jakejackson1 added bug Something isn't working create-upstream-pr labels Sep 7, 2026
@jakejackson1

Copy link
Copy Markdown
Member Author

Add snapshot test to verify it can override

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working create-upstream-pr

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OverWrite(): does not handle CRLF documents correctly

1 participant