Keep the current revision of objects superseded by an incremental update - #845
mgralikowski wants to merge 1 commit into
Conversation
Cross-reference stream entries of type 2 (compressed objects) were recorded under a "<stream>_0_<index>" key, so the object's own number was lost and the newest-first rule applied to type 1 entries did not cover them. Parser then unpacked every object stream wholesale and let its members overwrite objects parsed before. In an incrementally updated file an older revision's object stream still carries stale copies of objects a later revision rewrote. For a file saved by macOS Preview this reverted the /Pages node to its first revision, so getPages() returned 1 page instead of 13. Type 2 entries are now keyed by object number with the same first-seen rule, remembering the object stream that holds the current revision, and Parser only takes the object stream members the cross-reference data places there. Refs smalot#471
|
Hi! Fun to be referenced in an investigation from many years ago, and I think your solution is better than my initial proposal. Please note that I've moved away from using this library, and have since started my own from scratch. There is some conflict of interest with me replying to this. ;) Some context if you're interestedIssue #471 and resulting PR #533 was for me the final straw that made me decide to write a new parser. When looking further into this, I concluded that trailer parsing did not conform to the PDF specification section 7.5.According to the spec, Parsers should start at the EOF marker, read the This parser doesn't do that, instead it scans the entire document with a regex for any trailers and keeps the last one. That is correct in most cases, but not spec-compliant and more fragile. My initial PR was made in May of 2022, and was left open for a while before being closed by me. It shouldn't have been merged anyway, because my initial approach was naive and didn't fix the underlying bugs. Part of the issue with this library is that the original author is not involved anymore, and it's difficult to have an overview of how it works end to end. That's why I decided to start on my own library from scratch in modern PHP. |
TL;DR:
getPages()returns a wrong, too low page count for PDFs that were saved incrementally and use object streams. We hit it in production as 1 page for a 13-page contract re-saved in macOS Preview, which put content meant for the last page on the first one.Refs #471: this is the collision @PrinsFrank narrowed down there (
$this->objects[$id] = $objectinParser::parseObject()), with a fix that follows the cross-reference data instead of keeping both copies.Problem
An incremental update (ISO 32000-1, 7.5.6) appends new revisions of objects, and the newest cross-reference section decides which one is current. The object streams of an older revision (7.5.7) still carry the stale copies.
Two things combine:
RawDataParserstores cross-reference stream entries of type 2 (compressed objects) under a"<stream>_0_<index>"key. The object's own number is lost, so the newest-first rule that type 1 entries follow (if (!isset($xref['xref'][$index]))) does not apply to them.Parser::parseObject()unpacks every object stream wholesale and overwrites whatever was parsed before under the same id.Files re-saved on macOS (Preview, Pages) can carry an older revision of
/Pagesin an object stream and the current one uncompressed. The stale copy wins, andgetPages()returns too few pages; we hit 1 page for a 13-page contract. The same happens after a signing stamp appends an incremental update.Still present in
v2.13.0-beta1(4e964c7, also current master) and inv2.12.5: both new tests below fail there with 1 page instead of 2.Fix
RawDataParser: type 2 entries are keyed by object number ("<num>_0", as compressed objects always have generation 0), with the same first-seen rule as type 1 entries. The object stream holding the current revision is remembered in$xref['objstm']. The-1offset marker is unchanged, soiterateIndirectObjects()still skips them.Parser: when unpacking an object stream, only the members the cross-reference data places in that stream are taken. Members that a later revision rewrote uncompressed, or moved to a newer object stream, are skipped.Tests
PageTest::testGetPagesIncrementalUpdateObjectStreamon the new samplesamples/bugs/IncrementalUpdateObjectStream.pdf(35 KB, a public lorem-ipsum sample re-saved on macOS). It has three revisions: the second stores/Pages(/Count 1) in an object stream, the third rewrites it uncompressed (/Count 2). Fails on master (1 page instead of 2).IncrementalUpdateTestbuilds two-revision PDFs in memory and covers both directions:/Pagesin revision 1, uncompressed in revision 2: fails on master;The full suite passes: 245 tests, 1441 assertions (PHP 8.3). The changed files lint on PHP 7.1, and the sample parses to 2 pages there. PHPStan and PHP-CS-Fixer report no issues.