From 964c3fd0787bb7fd37876f33c143e78897e5a799 Mon Sep 17 00:00:00 2001 From: PrinsFrank <25006490+PrinsFrank@users.noreply.github.com> Date: Thu, 11 Jun 2026 20:21:15 +0200 Subject: [PATCH] Recover byte offsets from raw stream when cross reference source is corrupt --- .../CrossReferenceSourceParser.php | 12 +++- .../ObjectPositionsFromRawStreamParser.php | 67 +++++++++++++++++++ .../Source/CrossReferenceSource.php | 14 +++- .../Source/RecoveredCrossReferenceSource.php | 38 +++++++++++ .../Source/Section/CrossReferenceSection.php | 11 +++ .../SubSection/CrossReferenceSubSection.php | 21 ++++++ src/Document/Document.php | 2 +- tests/Feature/H7Stage1UpdatingTest.php | 21 ++++-- .../CrossReferenceSourceParserTest.php | 5 +- ...ObjectPositionsFromRawStreamParserTest.php | 34 ++++++++++ .../Source/CrossReferenceSourceTest.php | 7 +- 11 files changed, 220 insertions(+), 12 deletions(-) create mode 100644 src/Document/CrossReference/RawStream/ObjectPositionsFromRawStreamParser.php create mode 100644 src/Document/CrossReference/Source/RecoveredCrossReferenceSource.php create mode 100644 tests/Unit/Document/CrossReference/RawStream/ObjectPositionsFromRawStreamParserTest.php diff --git a/src/Document/CrossReference/CrossReferenceSourceParser.php b/src/Document/CrossReference/CrossReferenceSourceParser.php index 655d3605..9ee5232b 100644 --- a/src/Document/CrossReference/CrossReferenceSourceParser.php +++ b/src/Document/CrossReference/CrossReferenceSourceParser.php @@ -3,7 +3,9 @@ namespace PrinsFrank\PdfParser\Document\CrossReference; +use PrinsFrank\PdfParser\Document\CrossReference\RawStream\ObjectPositionsFromRawStreamParser; use PrinsFrank\PdfParser\Document\CrossReference\Source\CrossReferenceSource; +use PrinsFrank\PdfParser\Document\CrossReference\Source\RecoveredCrossReferenceSource; use PrinsFrank\PdfParser\Document\CrossReference\Stream\CrossReferenceStreamParser; use PrinsFrank\PdfParser\Document\CrossReference\Table\CrossReferenceTableParser; use PrinsFrank\PdfParser\Document\Dictionary\DictionaryKey\DictionaryKey; @@ -80,7 +82,15 @@ public static function parse(Stream $stream): CrossReferenceSource { $crossReferenceSections[] = $currentCrossReferenceSection; } - return new CrossReferenceSource(... $crossReferenceSections); + $crossReferenceSource = new CrossReferenceSource(... $crossReferenceSections); + if ($crossReferenceSource->hasInvalidByteOffset($stream)) { + return new RecoveredCrossReferenceSource( + ObjectPositionsFromRawStreamParser::parse($stream), + ...$crossReferenceSections, + ); + } + + return $crossReferenceSource; } private static function getCrossReferenceType(Stream $stream, int $byteOffsetLastCrossReferenceSection, int $byteOffsetEndOfCurrentLine): ?CrossReferenceType { diff --git a/src/Document/CrossReference/RawStream/ObjectPositionsFromRawStreamParser.php b/src/Document/CrossReference/RawStream/ObjectPositionsFromRawStreamParser.php new file mode 100644 index 00000000..77207156 --- /dev/null +++ b/src/Document/CrossReference/RawStream/ObjectPositionsFromRawStreamParser.php @@ -0,0 +1,67 @@ + */ + public static function parse(Stream $stream): array { + $inObjNr = $inObjGenerationNumber = $pendingObjMarker = false; + $startObjNrOffset = $objNrBuffer = $objMarkerBuffer = null; + $discoveredObjects = []; + foreach ($stream->chars(0, $stream->getSizeInBytes()) as $byteOffset => $char) { + if ($char === ' ') { + if ($inObjNr === true) { + $inObjNr = false; + $inObjGenerationNumber = true; + } elseif ($inObjGenerationNumber === true) { + $inObjGenerationNumber = false; + $pendingObjMarker = true; + } else { + $inObjNr = $inObjGenerationNumber = $pendingObjMarker = false; + $startObjNrOffset = $objNrBuffer = $objMarkerBuffer = null; + } + } elseif ($char === '0' + || $char === '1' + || $char === '2' + || $char === '3' + || $char === '4' + || $char === '5' + || $char === '6' + || $char === '7' + || $char === '8' + || $char === '9') { + if ($pendingObjMarker === true) { + $pendingObjMarker = false; + $objNrBuffer = null; + } elseif ($inObjGenerationNumber === true) { + } elseif ($inObjNr === false) { + $inObjNr = true; + $startObjNrOffset = $byteOffset; + $objNrBuffer = $char; + } elseif ($inObjNr === true) { + $objNrBuffer .= $char; + } + } elseif ($pendingObjMarker === true) { + if ($objMarkerBuffer === null && $char === 'o') { // @phpstan-ignore identical.alwaysTrue + $objMarkerBuffer = $char; + } elseif ($objMarkerBuffer === 'o' && $char === 'b') { // @phpstan-ignore identical.alwaysFalse, booleanAnd.alwaysFalse + $objMarkerBuffer .= $char; + } elseif ($objMarkerBuffer === 'ob' && $char === 'j') { // @phpstan-ignore identical.alwaysFalse, booleanAnd.alwaysFalse + $discoveredObjects[(int) $objNrBuffer] = $startObjNrOffset; + $inObjNr = $inObjGenerationNumber = $pendingObjMarker = false; + $startObjNrOffset = $objNrBuffer = $objMarkerBuffer = null; + } else { + $inObjNr = $inObjGenerationNumber = $pendingObjMarker = false; + $startObjNrOffset = $objNrBuffer = $objMarkerBuffer = null; + } + } else { + $inObjNr = $inObjGenerationNumber = $pendingObjMarker = false; + $startObjNrOffset = $objNrBuffer = $objMarkerBuffer = null; + } + } + + return $discoveredObjects; + } +} diff --git a/src/Document/CrossReference/Source/CrossReferenceSource.php b/src/Document/CrossReference/Source/CrossReferenceSource.php index 7d3e68a4..8a543de2 100644 --- a/src/Document/CrossReference/Source/CrossReferenceSource.php +++ b/src/Document/CrossReference/Source/CrossReferenceSource.php @@ -12,7 +12,9 @@ use PrinsFrank\PdfParser\Document\Dictionary\DictionaryValue\DictionaryValue; use PrinsFrank\PdfParser\Document\Dictionary\DictionaryValue\Name\NameValue; use PrinsFrank\PdfParser\Document\Dictionary\DictionaryValue\Reference\ReferenceValue; +use PrinsFrank\PdfParser\Document\Document; use PrinsFrank\PdfParser\Exception\ParseFailureException; +use PrinsFrank\PdfParser\Stream\Stream; /** Can be both from a crossReferenceTable or a crossReferenceStream */ class CrossReferenceSource { @@ -26,7 +28,7 @@ public function __construct( $this->crossReferenceSections = $crossReferenceSections; } - public function getCrossReferenceEntry(int $objNumber): CrossReferenceEntryInUseObject|CrossReferenceEntryCompressed|null { + public function getCrossReferenceEntry(int $objNumber, Document $document): CrossReferenceEntryInUseObject|CrossReferenceEntryCompressed|null { foreach ($this->crossReferenceSections as $crossReferenceSection) { $crossReferenceEntry = $crossReferenceSection->getCrossReferenceEntry($objNumber); if ($crossReferenceEntry !== null) { @@ -75,4 +77,14 @@ public function getFirstId(): string { return $firstId; } + + public function hasInvalidByteOffset(Stream $stream): bool { + foreach ($this->crossReferenceSections as $crossReferenceSection) { + if ($crossReferenceSection->hasInvalidByteOffset($stream)) { + return true; + } + } + + return false; + } } diff --git a/src/Document/CrossReference/Source/RecoveredCrossReferenceSource.php b/src/Document/CrossReference/Source/RecoveredCrossReferenceSource.php new file mode 100644 index 00000000..30a3efb2 --- /dev/null +++ b/src/Document/CrossReference/Source/RecoveredCrossReferenceSource.php @@ -0,0 +1,38 @@ + $recoveredByteOffsetMap where the key is the object nr and the value the byte offset + * + * @no-named-arguments + */ + public function __construct( + private readonly array $recoveredByteOffsetMap, + CrossReferenceSection... $crossReferenceSections, + ) { + parent::__construct(...$crossReferenceSections); + } + + #[Override] + public function getCrossReferenceEntry(int $objNumber, Document $document): CrossReferenceEntryInUseObject|CrossReferenceEntryCompressed|null { + $crossReferenceEntry = parent::getCrossReferenceEntry($objNumber, $document); + if ($crossReferenceEntry instanceof CrossReferenceEntryInUseObject + && $document->stream->read($crossReferenceEntry->byteOffsetInDecodedStream, strlen($expectedStartObjMarker = sprintf('%d %d obj', $objNumber, $crossReferenceEntry->generationNumber))) === $expectedStartObjMarker) { + return $crossReferenceEntry; + } + + if (array_key_exists($objNumber, $this->recoveredByteOffsetMap)) { + return new CrossReferenceEntryInUseObject($this->recoveredByteOffsetMap[$objNumber], 0); + } + + return $crossReferenceEntry; + } +} diff --git a/src/Document/CrossReference/Source/Section/CrossReferenceSection.php b/src/Document/CrossReference/Source/Section/CrossReferenceSection.php index ea9e4897..2e6efc22 100644 --- a/src/Document/CrossReference/Source/Section/CrossReferenceSection.php +++ b/src/Document/CrossReference/Source/Section/CrossReferenceSection.php @@ -6,6 +6,7 @@ use PrinsFrank\PdfParser\Document\CrossReference\Source\Section\SubSection\Entry\CrossReferenceEntryCompressed; use PrinsFrank\PdfParser\Document\CrossReference\Source\Section\SubSection\Entry\CrossReferenceEntryInUseObject; use PrinsFrank\PdfParser\Document\Dictionary\Dictionary; +use PrinsFrank\PdfParser\Stream\Stream; /** There are multiple crossReference sections if there are incremental updates. See 7.5.6 */ readonly class CrossReferenceSection { @@ -29,4 +30,14 @@ public function getCrossReferenceEntry(int $objNumber): CrossReferenceEntryInUse return null; } + + public function hasInvalidByteOffset(Stream $stream): bool { + foreach ($this->crossReferenceSubSections as $crossReferenceSubSection) { + if ($crossReferenceSubSection->hasInvalidByteOffset($stream)) { + return true; + } + } + + return false; + } } diff --git a/src/Document/CrossReference/Source/Section/SubSection/CrossReferenceSubSection.php b/src/Document/CrossReference/Source/Section/SubSection/CrossReferenceSubSection.php index a4347b79..31ec1e42 100644 --- a/src/Document/CrossReference/Source/Section/SubSection/CrossReferenceSubSection.php +++ b/src/Document/CrossReference/Source/Section/SubSection/CrossReferenceSubSection.php @@ -8,6 +8,7 @@ use PrinsFrank\PdfParser\Document\CrossReference\Source\Section\SubSection\Entry\CrossReferenceEntryInUseObject; use PrinsFrank\PdfParser\Exception\InvalidArgumentException; use PrinsFrank\PdfParser\Exception\RuntimeException; +use PrinsFrank\PdfParser\Stream\Stream; readonly class CrossReferenceSubSection { /** @var array */ @@ -51,4 +52,24 @@ public function getCrossReferenceEntry(int $objNumber): CrossReferenceEntryInUse return $object; } + + public function hasInvalidByteOffset(Stream $stream): bool { + foreach ($this->crossReferenceEntries as $index => $crossReferenceEntry) { + if ($crossReferenceEntry instanceof CrossReferenceEntryInUseObject === false) { + continue; + } + + if ($crossReferenceEntry->byteOffsetInDecodedStream > $stream->getSizeInBytes()) { + return true; + } + + $objNumber = $this->firstObjectNumber + $index; + $expectedObjMarker = $objNumber . ' ' . $crossReferenceEntry->generationNumber . ' obj'; + if ($stream->read($crossReferenceEntry->byteOffsetInDecodedStream, strlen($expectedObjMarker)) !== $expectedObjMarker) { + return true; + } + } + + return false; + } } diff --git a/src/Document/Document.php b/src/Document/Document.php index 39a6b678..d0072d6f 100644 --- a/src/Document/Document.php +++ b/src/Document/Document.php @@ -128,7 +128,7 @@ public function getObject(int $objectNumber, ?string $expectedDecoratorFQN = nul return $this->objectCache[$objectNumber]; } - $crossReferenceEntry = $this->crossReferenceSource->getCrossReferenceEntry($objectNumber); + $crossReferenceEntry = $this->crossReferenceSource->getCrossReferenceEntry($objectNumber, $this); if ($crossReferenceEntry === null) { return null; } diff --git a/tests/Feature/H7Stage1UpdatingTest.php b/tests/Feature/H7Stage1UpdatingTest.php index d0a1d020..e47be827 100644 --- a/tests/Feature/H7Stage1UpdatingTest.php +++ b/tests/Feature/H7Stage1UpdatingTest.php @@ -4,7 +4,7 @@ use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; -use PrinsFrank\PdfParser\Document\CrossReference\Source\CrossReferenceSource; +use PrinsFrank\PdfParser\Document\CrossReference\Source\RecoveredCrossReferenceSource; use PrinsFrank\PdfParser\Document\CrossReference\Source\Section\CrossReferenceSection; use PrinsFrank\PdfParser\Document\CrossReference\Source\Section\SubSection\CrossReferenceSubSection; use PrinsFrank\PdfParser\Document\CrossReference\Source\Section\SubSection\Entry\CrossReferenceEntryFreeObject; @@ -36,7 +36,20 @@ public function testStructure(): void { static::assertSame(Version::V1_4, $document->version); static::assertEquals( - new CrossReferenceSource( + new RecoveredCrossReferenceSource( + [ + 1 => 9, + 2 => 74, + 3 => 120, + 4 => 604, + 5 => 704, + 6 => 788, + 7 => 812, + 8 => 856, + 9 => 958, + 10 => 1062, + 11 => 1166, + ], new CrossReferenceSection( new Dictionary( new DictionaryEntry(DictionaryKey::SIZE, new IntegerValue(12)), @@ -217,7 +230,7 @@ public function testStructure(): void { $document, 7, 0, - 811, + 812, 855, ), $document, @@ -307,7 +320,7 @@ public function testStructure(): void { $document, 11, 0, - 1165, + 1166, 1269, ), $document, diff --git a/tests/Unit/Document/CrossReference/CrossReferenceSourceParserTest.php b/tests/Unit/Document/CrossReference/CrossReferenceSourceParserTest.php index 543bca60..895ad999 100644 --- a/tests/Unit/Document/CrossReference/CrossReferenceSourceParserTest.php +++ b/tests/Unit/Document/CrossReference/CrossReferenceSourceParserTest.php @@ -5,7 +5,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use PrinsFrank\PdfParser\Document\CrossReference\CrossReferenceSourceParser; -use PrinsFrank\PdfParser\Document\CrossReference\Source\CrossReferenceSource; +use PrinsFrank\PdfParser\Document\CrossReference\Source\RecoveredCrossReferenceSource; use PrinsFrank\PdfParser\Document\CrossReference\Source\Section\CrossReferenceSection; use PrinsFrank\PdfParser\Document\CrossReference\Source\Section\SubSection\CrossReferenceSubSection; use PrinsFrank\PdfParser\Document\CrossReference\Source\Section\SubSection\Entry\CrossReferenceEntryFreeObject; @@ -21,7 +21,8 @@ class CrossReferenceSourceParserTest extends TestCase { public function testParse(): void { static::assertEquals( - new CrossReferenceSource( + new RecoveredCrossReferenceSource( + [], new CrossReferenceSection( new Dictionary( new DictionaryEntry(DictionaryKey::SIZE, new IntegerValue(7)), diff --git a/tests/Unit/Document/CrossReference/RawStream/ObjectPositionsFromRawStreamParserTest.php b/tests/Unit/Document/CrossReference/RawStream/ObjectPositionsFromRawStreamParserTest.php new file mode 100644 index 00000000..f0a22e4b --- /dev/null +++ b/tests/Unit/Document/CrossReference/RawStream/ObjectPositionsFromRawStreamParserTest.php @@ -0,0 +1,34 @@ + 10, + 1232131 => 42, + ], + ObjectPositionsFromRawStreamParser::parse( + new InMemoryStream( + <<getCrossReferenceEntry(42)); - static::assertSame($crossReferenceEntry2, $crossReferenceSource->getCrossReferenceEntry(43)); - static::assertNull($crossReferenceSource->getCrossReferenceEntry(44)); + static::assertSame($crossReferenceEntry1, $crossReferenceSource->getCrossReferenceEntry(42, $this->createMock(Document::class))); + static::assertSame($crossReferenceEntry2, $crossReferenceSource->getCrossReferenceEntry(43, $this->createMock(Document::class))); + static::assertNull($crossReferenceSource->getCrossReferenceEntry(44, $this->createMock(Document::class))); } public function testGetReferenceForKey(): void {