* @copyright 2002-2026 Nicola Asuni - Tecnick.com LTD * @license https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE) * @link https://github.com/tecnickcom/tc-lib-pdf * * This file is part of tc-lib-pdf software library. */ namespace Test\Import; use Com\Tecnick\Pdf\Import\ImportCorruptedSourceException; use Com\Tecnick\Pdf\Import\ImportPageOutOfRangeException; use Com\Tecnick\Pdf\Import\PageResolver; use Com\Tecnick\Pdf\Import\SourceDocument; use PHPUnit\Framework\TestCase; class PageResolverTest extends TestCase { private function invokeResolverMethod(PageResolver $resolver, string $method, mixed ...$args): mixed { $ref = new \ReflectionClass($resolver); return $ref->getMethod($method)->invokeArgs($resolver, $args); } /** @throws \Throwable */ private function loadDoc(): SourceDocument { $path = __DIR__ . '/../fixtures/simple_import.pdf'; $data = file_get_contents($path); $this->assertNotFalse($data); return new SourceDocument($data); } /** * @param array $pairs * @return array */ private function dictObject(array $pairs): array { return [['<<', $pairs]]; } /** * @param array> $objects * @throws \Throwable */ private function mockDoc(array $objects): SourceDocument { $doc = $this->createStub(SourceDocument::class); $doc->method('getTrailer')->willReturn(['root' => '1 0 R']); $doc->method('getObject')->willReturnCallback(static fn(string $ref): array => $objects[$ref] ?? []); $doc->method('findObject')->willReturnCallback(static fn(string $ref): ?array => $objects[$ref] ?? null); return $doc; } /** @throws \Throwable */ public function testResolvePage1ReturnsExpectedMediaBox(): void { $resolver = new PageResolver(); $resolved = $resolver->resolve($this->loadDoc(), 1); $this->assertArrayHasKey('mediaBox', $resolved); $this->assertCount(4, $resolved['mediaBox']); $this->assertEqualsWithDelta(612.0, $resolved['mediaBox'][2], 0.001); $this->assertEqualsWithDelta(792.0, $resolved['mediaBox'][3], 0.001); } /** @throws \Throwable */ public function testResolvePage1HasResources(): void { $resolver = new PageResolver(); $resolved = $resolver->resolve($this->loadDoc(), 1); $this->assertArrayHasKey('resources', $resolved); } /** @throws \Throwable */ public function testResolvePage1RotateIsZero(): void { $resolver = new PageResolver(); $resolved = $resolver->resolve($this->loadDoc(), 1); $this->assertSame(0, $resolved['rotate']); } /** @throws \Throwable */ public function testResolveThrowsForPageZero(): void { $resolver = new PageResolver(); $this->expectException(ImportPageOutOfRangeException::class); $resolver->resolve($this->loadDoc(), 0); } /** @throws \Throwable */ public function testResolveThrowsForPageOutOfRange(): void { $resolver = new PageResolver(); $this->expectException(ImportPageOutOfRangeException::class); $resolver->resolve($this->loadDoc(), 999); } /** @throws \Throwable */ public function testResolveThrowsWhenRootPagesEntryIsMissing(): void { $resolver = new PageResolver(); $doc = $this->mockDoc([ '1_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Catalog'], ]), ]); $this->expectException(ImportCorruptedSourceException::class); $this->expectExceptionMessageMatches('/' . preg_quote('missing /Pages entry', '/') . '/'); $resolver->resolve($doc, 1); } /** @throws \Throwable */ public function testResolveParsesInheritedRotateAndIndirectResources(): void { $resolver = new PageResolver(); $doc = $this->mockDoc([ '1_0' => $this->dictObject([ ['/', 'Pages'], ['objref', '2 0 R'], ]), '2_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Pages'], ['/', 'Kids'], [ '[', [ ['objref', '3 0 R'], ], ], ['/', 'MediaBox'], [ '[', [ ['numeric', 0], ['numeric', 0], ['numeric', 612], ['numeric', 792], ], ], ['/', 'Rotate'], ['numeric', '180'], ['/', 'Resources'], ['objref', '4 0 R'], ]), '3_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Page'], ]), '4_0' => $this->dictObject([ ['/', 'Font'], [ '<<', [ ['/', 'F1'], ['objref', '5 0 R'], ], ], ]), '5_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Font'], ]), ]); $resolved = $resolver->resolve($doc, 1); $this->assertSame(180, $resolved['rotate']); $this->assertArrayHasKey('Font', $resolved['resources']); if (!isset($resolved['resources']['Font']) || !\is_array($resolved['resources']['Font'])) { $this->fail('Expected Font resources array.'); } $this->assertArrayHasKey('F1', $resolved['resources']['Font']); } /** @throws \Throwable */ public function testResolveSkipsInvalidKidsAndUsesInlineResourcesAndBoxFallbacks(): void { $resolver = new PageResolver(); $doc = $this->mockDoc([ '1_0' => $this->dictObject([ ['/', 'Pages'], ['objref', '2 0 R'], ]), '2_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Pages'], ['/', 'Kids'], [ '[', [ ['numeric', 7], ['objref', '3 0 R'], ], ], ['/', 'MediaBox'], [ '[', [ ['numeric', 0], ['numeric', 0], ['numeric', 400], ['numeric', 600], ], ], ]), '3_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Page'], ['/', 'CropBox'], ['string', 'invalid'], ['/', 'Rotate'], ['numeric', 90], ['/', 'Resources'], [ '<<', [ ['/', 'ProcSet'], [ '[', [ ['/', '/PDF'], ['/', '/Text'], ], ], ], ], ]), ]); $resolved = $resolver->resolve($doc, 1); $this->assertSame(90, $resolved['rotate']); $this->assertSame([0.0, 0.0, 400.0, 600.0], $resolved['mediaBox']); $this->assertSame($resolved['mediaBox'], $resolved['cropBox']); $this->assertSame($resolved['cropBox'], $resolved['bleedBox']); $this->assertSame($resolved['cropBox'], $resolved['trimBox']); $this->assertSame($resolved['cropBox'], $resolved['artBox']); $this->assertSame(['/PDF', '/Text'], $resolved['resources']['ProcSet'] ?? null); } /** @throws \Throwable */ public function testResolveThrowsWhenResolvedPageIsMissingMediaBox(): void { $resolver = new PageResolver(); $doc = $this->mockDoc([ '1_0' => $this->dictObject([ ['/', 'Pages'], ['objref', '2 0 R'], ]), '2_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Pages'], ['/', 'Kids'], [ '[', [ ['objref', '3 0 R'], ], ], ]), '3_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Page'], ]), ]); $this->expectException(ImportCorruptedSourceException::class); $this->expectExceptionMessageMatches('/' . preg_quote('missing /MediaBox', '/') . '/'); $resolver->resolve($doc, 1); } /** @throws \Throwable */ public function testResolveAcceptsIndirectMediaBoxReference(): void { $resolver = new PageResolver(); $doc = $this->mockDoc([ '1_0' => $this->dictObject([ ['/', 'Pages'], ['objref', '2 0 R'], ]), '2_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Pages'], ['/', 'Kids'], [ '[', [ ['objref', '3 0 R'], ], ], ]), '3_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Page'], ['/', 'MediaBox'], ['objref', '8 0 R'], ]), '8_0' => [ [ '[', [ ['numeric', 0], ['numeric', 0], ['numeric', 612], ['numeric', 792], ], ], ], ]); $resolved = $resolver->resolve($doc, 1); $this->assertSame([0.0, 0.0, 612.0, 792.0], $resolved['mediaBox']); $this->assertSame($resolved['mediaBox'], $resolved['cropBox']); } /** @throws \Throwable */ public function testResolveThrowsWhenIndirectMediaBoxReferenceIsMissing(): void { $resolver = new PageResolver(); $doc = $this->mockDoc([ '1_0' => $this->dictObject([ ['/', 'Pages'], ['objref', '2 0 R'], ]), '2_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Pages'], ['/', 'Kids'], [ '[', [ ['objref', '3 0 R'], ], ], ]), '3_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Page'], ['/', 'MediaBox'], ['objref', '999 0 R'], ]), ]); $this->expectException(ImportCorruptedSourceException::class); $this->expectExceptionMessageMatches('/' . preg_quote('missing /MediaBox', '/') . '/'); $resolver->resolve($doc, 1); } /** @throws \Throwable */ public function testResolveThrowsWhenIndirectMediaBoxObjectIsMalformed(): void { $resolver = new PageResolver(); $doc = $this->mockDoc([ '1_0' => $this->dictObject([ ['/', 'Pages'], ['objref', '2 0 R'], ]), '2_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Pages'], ['/', 'Kids'], [ '[', [ ['objref', '3 0 R'], ], ], ]), '3_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Page'], ['/', 'MediaBox'], ['objref', '8 0 R'], ]), '8_0' => [ ['keyword', 'invalid-box-token'], ], ]); $this->expectException(ImportCorruptedSourceException::class); $this->expectExceptionMessageMatches('/' . preg_quote('missing /MediaBox', '/') . '/'); $resolver->resolve($doc, 1); } /** @throws \Throwable */ public function testResolveMergesParentAndChildResourceDictionaries(): void { $resolver = new PageResolver(); $doc = $this->mockDoc([ '1_0' => $this->dictObject([ ['/', 'Pages'], ['objref', '2 0 R'], ]), '2_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Pages'], ['/', 'Kids'], [ '[', [ ['objref', '3 0 R'], ], ], ['/', 'MediaBox'], [ '[', [ ['numeric', 0], ['numeric', 0], ['numeric', 200], ['numeric', 300], ], ], ['/', 'Resources'], [ '<<', [ ['/', 'Font'], [ '<<', [ ['/', 'F1'], ['objref', '10 0 R'], ], ], ['/', 'XObject'], [ '<<', [ ['/', 'Im1'], ['objref', '11 0 R'], ], ], ], ], ]), '3_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Page'], ['/', 'Resources'], [ '<<', [ ['/', 'Font'], [ '<<', [ ['/', 'F2'], ['objref', '12 0 R'], ], ], ], ], ]), '10_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Font'], ]), '11_0' => $this->dictObject([ ['/', 'Type'], ['/', 'XObject'], ]), '12_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Font'], ]), ]); $resolved = $resolver->resolve($doc, 1); $resources = $resolved['resources']; $this->assertIsArray($resources); if (!isset($resources['Font']) || !\is_array($resources['Font'])) { $this->fail('Expected merged Font dictionary.'); } if (!isset($resources['XObject']) || !\is_array($resources['XObject'])) { $this->fail('Expected inherited XObject dictionary.'); } $this->assertArrayHasKey('F1', $resources['Font']); $this->assertArrayHasKey('F2', $resources['Font']); $this->assertArrayHasKey('Im1', $resources['XObject']); } /** @throws \Throwable */ public function testResolveThrowsForPagesNodeWithoutKidsArray(): void { $resolver = new PageResolver(); $doc = $this->mockDoc([ '1_0' => $this->dictObject([ ['/', 'Pages'], ['objref', '2 0 R'], ]), '2_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Pages'], ]), ]); $this->expectException(ImportCorruptedSourceException::class); $this->expectExceptionMessageMatches('/' . preg_quote('/Kids', '/') . '/'); $resolver->resolve($doc, 1); } /** @throws \Throwable */ public function testResolveThrowsForUnexpectedPageTreeNodeType(): void { $resolver = new PageResolver(); $doc = $this->mockDoc([ '1_0' => $this->dictObject([ ['/', 'Pages'], ['objref', '2 0 R'], ]), '2_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Catalog'], ['/', 'Kids'], [ '[', [ ['objref', '3 0 R'], ], ], ]), '3_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Page'], ]), ]); $this->expectException(ImportCorruptedSourceException::class); $this->expectExceptionMessageMatches('/' . preg_quote('Unexpected page tree node type', '/') . '/'); $resolver->resolve($doc, 1); } public function testObjectToDictThrowsWhenDictionaryElementIsMissing(): void { $resolver = new PageResolver(); $this->expectException(ImportCorruptedSourceException::class); $this->expectExceptionMessageMatches('/' . preg_quote('Expected dictionary object', '/') . '/'); $this->invokeResolverMethod($resolver, 'objectToDict', ['not-an-array-element']); } public function testParseDictArraySkipsMalformedKeysAndParsesFallbackValueTypes(): void { $resolver = new PageResolver(); /** @var array $parsed */ $parsed = $this->invokeResolverMethod($resolver, 'parseDictArray', [ ['numeric', 1], ['string', 'ignored-non-name-key'], ['/', 123], ['string', 'ignored-non-string-name'], ['/', '/Literal'], 'plain-text', ['/', '/Ref'], ['objref'], ['/', '/Unknown'], ['keyword', 'fallback-value'], ['/', '/Empty'], [], ]); $this->assertSame( [ 'Literal' => 'plain-text', 'Ref' => '', 'Unknown' => 'fallback-value', 'Empty' => null, ], \array_intersect_key($parsed, [ 'Literal' => true, 'Ref' => true, 'Unknown' => true, 'Empty' => true, ]), ); $this->assertArrayNotHasKey('123', $parsed); } /** * A cyclic /Pages tree (intermediate nodes referencing one another, with no * Page leaf to terminate the descent) must be rejected instead of recursing * until the stack/memory is exhausted. * * @throws \Throwable */ public function testResolveThrowsOnCyclicPageTree(): void { $resolver = new PageResolver(); $doc = $this->mockDoc([ '1_0' => $this->dictObject([ ['/', 'Pages'], ['objref', '2 0 R'], ]), '2_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Pages'], ['/', 'Kids'], [ '[', [ ['objref', '3 0 R'], ], ], ]), '3_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Pages'], ['/', 'Kids'], [ '[', [ ['objref', '2 0 R'], ], ], ]), ]); $this->expectException(ImportCorruptedSourceException::class); $this->expectExceptionMessageMatches('/' . preg_quote('Duplicate or cyclic reference', '/') . '/'); $resolver->resolve($doc, 1); } /** * A /Pages node listing the same kid twice is malformed (every node has a * single /Parent) and, if tolerated, would multiply the traversal * exponentially with tree depth besides corrupting page numbering. * * @throws \Throwable */ public function testResolveThrowsOnDuplicateSiblingReference(): void { $resolver = new PageResolver(); $doc = $this->mockDoc([ '1_0' => $this->dictObject([ ['/', 'Pages'], ['objref', '2 0 R'], ]), '2_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Pages'], ['/', 'Kids'], [ '[', [ ['objref', '3 0 R'], ['objref', '3 0 R'], ], ], ]), '3_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Page'], ]), ]); $this->expectException(ImportCorruptedSourceException::class); $this->expectExceptionMessageMatches('/' . preg_quote('Duplicate or cyclic reference', '/') . '/'); $resolver->resolve($doc, 2); } // ------------------------------------------------------------------------- // countPages // ------------------------------------------------------------------------- /** @throws \Throwable */ public function testCountPagesReturnsReachablePageCount(): void { $resolver = new PageResolver(); $this->assertSame(1, $resolver->countPages($this->loadDoc())); } /** @throws \Throwable */ public function testCountPagesThrowsWhenRootPagesEntryIsMissing(): void { $resolver = new PageResolver(); $doc = $this->mockDoc([ '1_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Catalog'], ]), ]); $this->expectException(ImportCorruptedSourceException::class); $this->expectExceptionMessageMatches('/' . preg_quote('missing /Pages entry', '/') . '/'); $resolver->countPages($doc); } /** @throws \Throwable */ public function testCountPagesReturnsZeroForEmptyKidsArray(): void { $resolver = new PageResolver(); $doc = $this->mockDoc([ '1_0' => $this->dictObject([ ['/', 'Pages'], ['objref', '2 0 R'], ]), '2_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Pages'], ['/', 'Kids'], ['[', []], ]), ]); $this->assertSame(0, $resolver->countPages($doc)); } /** @throws \Throwable */ public function testCountPagesThrowsOnDuplicateKidReference(): void { $resolver = new PageResolver(); $doc = $this->mockDoc([ '1_0' => $this->dictObject([ ['/', 'Pages'], ['objref', '2 0 R'], ]), '2_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Pages'], ['/', 'Kids'], [ '[', [ ['objref', '3 0 R'], ['objref', '3 0 R'], ], ], ]), '3_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Page'], ]), ]); $this->expectException(ImportCorruptedSourceException::class); $this->expectExceptionMessageMatches('/' . preg_quote('Duplicate or cyclic reference', '/') . '/'); $resolver->countPages($doc); } /** @throws \Throwable */ public function testCountPagesThrowsWhenNodeBudgetExceeded(): void { $resolver = new PageResolver(); $doc = $this->mockDoc([ '1_0' => $this->dictObject([ ['/', 'Pages'], ['objref', '2 0 R'], ]), '2_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Pages'], ['/', 'Kids'], [ '[', [ ['objref', '3 0 R'], ], ], ]), '3_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Page'], ]), ]); $this->expectException(ImportCorruptedSourceException::class); $this->expectExceptionMessageMatches('/' . preg_quote('maximum node budget', '/') . '/'); $resolver->countPages($doc, 1); } /** @throws \Throwable */ public function testCountPagesThrowsForUnexpectedNodeType(): void { $resolver = new PageResolver(); $doc = $this->mockDoc([ '1_0' => $this->dictObject([ ['/', 'Pages'], ['objref', '2 0 R'], ]), '2_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Catalog'], ]), ]); $this->expectException(ImportCorruptedSourceException::class); $this->expectExceptionMessageMatches('/' . preg_quote('Unexpected page tree node type', '/') . '/'); $resolver->countPages($doc); } /** @throws \Throwable */ public function testCountPagesThrowsForPagesNodeWithoutKids(): void { $resolver = new PageResolver(); $doc = $this->mockDoc([ '1_0' => $this->dictObject([ ['/', 'Pages'], ['objref', '2 0 R'], ]), '2_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Pages'], ]), ]); $this->expectException(ImportCorruptedSourceException::class); $this->expectExceptionMessageMatches('/' . preg_quote('missing /Kids', '/') . '/'); $resolver->countPages($doc); } // ------------------------------------------------------------------------- // buildPageIndex / resolveFromIndex // ------------------------------------------------------------------------- /** @throws \Throwable */ public function testBuildPageIndexReturnsPagesInDocumentOrderWithInheritedAttributes(): void { $resolver = new PageResolver(); $doc = $this->mockDoc([ '1_0' => $this->dictObject([ ['/', 'Pages'], ['objref', '2 0 R'], ]), '2_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Pages'], ['/', 'Kids'], [ '[', [ ['objref', '3 0 R'], ['objref', '6 0 R'], ], ], ['/', 'MediaBox'], [ '[', [ ['numeric', 0], ['numeric', 0], ['numeric', 300], ['numeric', 500], ], ], ]), '3_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Pages'], ['/', 'Kids'], [ '[', [ ['objref', '4 0 R'], ['objref', '5 0 R'], ], ], ]), '4_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Page'], ['/', 'Rotate'], ['numeric', 90], ]), '5_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Page'], ['/', 'Rotate'], ['numeric', 180], ]), '6_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Page'], ['/', 'Rotate'], ['numeric', 270], ]), ]); $index = $resolver->buildPageIndex($doc); $this->assertCount(3, $index); // Leaves of the nested /Pages node come before the root's second kid. $this->assertSame([90, 180, 270], \array_column($index, 'Rotate')); // The root /Pages MediaBox is inherited by every leaf. foreach ($index as $pageDict) { $this->assertSame([0, 0, 300, 500], $pageDict['MediaBox'] ?? null); } } /** @throws \Throwable */ public function testResolveFromIndexThrowsForPageZero(): void { $resolver = new PageResolver(); $doc = $this->loadDoc(); $index = $resolver->buildPageIndex($doc); $this->expectException(ImportPageOutOfRangeException::class); $resolver->resolveFromIndex($doc, $index, 0); } /** @throws \Throwable */ public function testResolveFromIndexThrowsForPageBeyondIndex(): void { $resolver = new PageResolver(); $doc = $this->loadDoc(); $index = $resolver->buildPageIndex($doc); $this->expectException(ImportPageOutOfRangeException::class); $this->expectExceptionMessageMatches('/' . preg_quote('document has fewer pages', '/') . '/'); $resolver->resolveFromIndex($doc, $index, 2); } /** @throws \Throwable */ public function testResolveFromIndexDoesNotWalkTheTreeAgain(): void { $resolver = new PageResolver(); $calls = 0; $objects = [ '1_0' => $this->dictObject([ ['/', 'Pages'], ['objref', '2 0 R'], ]), '2_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Pages'], ['/', 'Kids'], [ '[', [ ['objref', '3 0 R'], ], ], ['/', 'MediaBox'], [ '[', [ ['numeric', 0], ['numeric', 0], ['numeric', 200], ['numeric', 200], ], ], ]), '3_0' => $this->dictObject([ ['/', 'Type'], ['/', 'Page'], ]), ]; $doc = $this->createStub(SourceDocument::class); $doc->method('getTrailer')->willReturn(['root' => '1 0 R']); $doc->method('getObject')->willReturnCallback(static function (string $ref) use ($objects, &$calls): array { ++$calls; return $objects[$ref] ?? []; }); $index = $resolver->buildPageIndex($doc); $callsAfterBuild = $calls; $this->assertGreaterThan(0, $callsAfterBuild); $resolved = $resolver->resolveFromIndex($doc, $index, 1); $this->assertSame([0.0, 0.0, 200.0, 200.0], $resolved['mediaBox']); $this->assertSame($callsAfterBuild, $calls); } }