* @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\File\File as ObjFile; use Com\Tecnick\Pdf\Import\ImportCorruptedSourceException; use Com\Tecnick\Pdf\Import\Importer; use Com\Tecnick\Pdf\Import\ImportPageOutOfRangeException; use Com\Tecnick\Pdf\Import\ImportSourceNotFoundException; use Com\Tecnick\Pdf\Import\ImportUnsupportedFeatureException; use Com\Tecnick\Pdf\Import\ObjectMap; use Com\Tecnick\Pdf\Import\PageTemplate; use Com\Tecnick\Pdf\Import\SourceDocument; use PHPUnit\Framework\TestCase; class ImporterTest extends TestCase { private function getObjectProperty(object $obj, string $name): mixed { $ref = new \ReflectionClass($obj); while ($ref !== false) { if ($ref->hasProperty($name)) { return $ref->getProperty($name)->getValue($obj); } $ref = $ref->getParentClass(); } $this->fail('Property not found: ' . $name); } private function setObjectProperty(object $obj, string $name, mixed $value): void { $ref = new \ReflectionClass($obj); while ($ref !== false) { if ($ref->hasProperty($name)) { $ref->getProperty($name)->setValue($obj, $value); return; } $ref = $ref->getParentClass(); } $this->fail('Property not found: ' . $name); } private function invokeImporterMethod(Importer $importer, string $method, mixed ...$args): mixed { $ref = new \ReflectionClass($importer); return $ref->getMethod($method)->invokeArgs($importer, $args); } private function fixtureData(): string { $path = __DIR__ . '/../fixtures/simple_import.pdf'; $data = file_get_contents($path); $this->assertNotFalse($data); return $data; } private function multipageFixtureData(): string { $path = __DIR__ . '/../fixtures/multipage_import.pdf'; $data = file_get_contents($path); $this->assertNotFalse($data); return $data; } private function encryptedFixtureData(): string { $path = __DIR__ . '/../fixtures/encrypted_import_stub.pdf'; $data = file_get_contents($path); $this->assertNotFalse($data); return $data; } private function rotatedFixtureData(): string { $path = __DIR__ . '/../fixtures/rotated_import.pdf'; $data = file_get_contents($path); $this->assertNotFalse($data); return $data; } private function makeObjFile(): ObjFile { return new ObjFile(allowedPaths: ['*']); } private function makeImporter(): Importer { $xobjects = []; $pon = 0; return new Importer($xobjects, $pon, $this->makeObjFile()); } /** * Build a minimal classic-xref donor PDF with a configurable declared * /Count and a configurable number of real pages, to verify that the * declared value never drives page counting or import loops. * Only small values must ever be used here. */ private function buildDonorPdf(?int $declaredCount, int $realPages): string { $objects = []; $objects[1] = "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"; $kids = []; for ($idx = 0; $idx < $realPages; ++$idx) { $kids[] = (3 + $idx) . ' 0 R'; } $countEntry = $declaredCount === null ? '' : ' /Count ' . $declaredCount; $objects[2] = "2 0 obj\n<< /Type /Pages /Kids [" . implode(' ', $kids) . ']' . $countEntry . " >>\nendobj\n"; for ($idx = 0; $idx < $realPages; ++$idx) { $num = 3 + $idx; $objects[$num] = $num . " 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> >>\nendobj\n"; } $pdf = "%PDF-1.7\n"; $offsets = []; foreach ($objects as $num => $text) { $offsets[$num] = strlen($pdf); $pdf .= $text; } $xrefStart = strlen($pdf); $size = count($objects) + 1; $pdf .= "xref\n0 {$size}\n0000000000 65535 f \n"; foreach (array_keys($objects) as $num) { $pdf .= sprintf("%010d 00000 n \n", $offsets[$num]); } return $pdf . "trailer\n<< /Size {$size} /Root 1 0 R >>\nstartxref\n{$xrefStart}\n%%EOF"; } /** @throws \Throwable */ public function testSetImportSourceDataReturnsSha256Id(): void { $data = $this->fixtureData(); $importer = $this->makeImporter(); $srcId = $importer->setImportSourceData($data); $this->assertSame(hash('sha256', $data), $srcId); } /** @throws \Throwable */ public function testSetImportSourceFileReturnsSourceId(): void { $path = __DIR__ . '/../fixtures/simple_import.pdf'; $importer = $this->makeImporter(); $srcId = $importer->setImportSourceFile($path); $this->assertNotEmpty($srcId); } /** @throws \Throwable */ public function testSetImportSourceFileThrowsForMissingFile(): void { $importer = $this->makeImporter(); $this->expectException(ImportSourceNotFoundException::class); $importer->setImportSourceFile('/nonexistent/path/to/file.pdf'); } /** @throws \Throwable */ public function testSetImportSourceDataIsIdempotent(): void { $data = $this->fixtureData(); $importer = $this->makeImporter(); $id1 = $importer->setImportSourceData($data); $id2 = $importer->setImportSourceData($data); $this->assertSame($id1, $id2); } /** @throws \Throwable */ public function testSetImportSourceDataAcceptsPasswordOptionForUnencryptedPdf(): void { $data = $this->fixtureData(); $importer = $this->makeImporter(); $srcId = $importer->setImportSourceData($data, ['password' => 'secret']); $this->assertNotEmpty($srcId); } /** @throws \Throwable */ public function testSetImportSourceDataThrowsForEncryptedPdf(): void { $data = $this->encryptedFixtureData(); $importer = $this->makeImporter(); $this->expectException(ImportUnsupportedFeatureException::class); $this->expectExceptionMessageMatches('/' . preg_quote('encrypted PDF', '/') . '/'); $importer->setImportSourceData($data); } /** @throws \Throwable */ public function testSetImportSourceDataWithPasswordStillThrowsForEncryptedPdf(): void { $data = $this->encryptedFixtureData(); $importer = $this->makeImporter(); $this->expectException(ImportUnsupportedFeatureException::class); $this->expectExceptionMessageMatches('/' . preg_quote('password-based import is not supported', '/') . '/'); $importer->setImportSourceData($data, ['password' => 'secret']); } /** @throws \Throwable */ public function testGetSourcePageCountReturnsOne(): void { $data = $this->fixtureData(); $importer = $this->makeImporter(); $srcId = $importer->setImportSourceData($data); $this->assertSame(1, $importer->getSourcePageCount($srcId)); } /** @throws \Throwable */ public function testGetSourcePageCountThrowsForUnknownSource(): void { $importer = $this->makeImporter(); $this->expectException(ImportSourceNotFoundException::class); $importer->getSourcePageCount('invalid-source-id'); } /** @throws \Throwable */ public function testGetSourcePageCountIgnoresForgedOversizedCount(): void { // The donor declares /Count 50 but only one page is reachable // through /Kids; the declared value must not be trusted. $importer = $this->makeImporter(); $srcId = $importer->setImportSourceData($this->buildDonorPdf(50, 1)); $this->assertSame(1, $importer->getSourcePageCount($srcId)); } /** @throws \Throwable */ public function testGetSourcePageCountIgnoresUndersizedCount(): void { $importer = $this->makeImporter(); $srcId = $importer->setImportSourceData($this->buildDonorPdf(1, 2)); $this->assertSame(2, $importer->getSourcePageCount($srcId)); } /** @throws \Throwable */ public function testGetSourcePageCountWorksWithoutDeclaredCount(): void { $importer = $this->makeImporter(); $srcId = $importer->setImportSourceData($this->buildDonorPdf(null, 2)); $this->assertSame(2, $importer->getSourcePageCount($srcId)); } /** @throws \Throwable */ public function testImportPageReturnsPageTemplate(): void { $data = $this->fixtureData(); $xobjects = []; $pon = 0; $importer = new Importer($xobjects, $pon, $this->makeObjFile()); $srcId = $importer->setImportSourceData($data); $tpl = $importer->importPage($srcId, 1); $this->assertInstanceOf(PageTemplate::class, $tpl); } /** @throws \Throwable */ public function testImportPageRegistersXobject(): void { $data = $this->fixtureData(); $xobjects = []; $pon = 0; $importer = new Importer($xobjects, $pon, $this->makeObjFile()); $srcId = $importer->setImportSourceData($data); $tpl = $importer->importPage($srcId, 1); $this->assertArrayHasKey($tpl->getXobjId(), $xobjects); } /** @throws \Throwable */ public function testImportPageRebuildsMissingObjectMapForKnownSource(): void { $data = $this->fixtureData(); $xobjects = []; $pon = 0; $importer = new Importer($xobjects, $pon, $this->makeObjFile()); $srcId = $importer->setImportSourceData($data); $this->setObjectProperty($importer, 'objectMaps', []); $tpl = $importer->importPage($srcId, 1, ['cache' => false]); /** @var array $maps */ $maps = $this->getObjectProperty($importer, 'objectMaps'); $this->assertInstanceOf(PageTemplate::class, $tpl); $this->assertIsArray($maps); $this->assertArrayHasKey($srcId, $maps); $this->assertInstanceOf(ObjectMap::class, $maps[$srcId] ?? null); } /** @throws \Throwable */ public function testImportPageXobjectHasCorrectObjectNumber(): void { $data = $this->fixtureData(); $xobjects = []; $pon = 0; $importer = new Importer($xobjects, $pon, $this->makeObjFile()); $srcId = $importer->setImportSourceData($data); $tpl = $importer->importPage($srcId, 1); // The xobject's object number must be a positive integer allocated from pon. $xobjId = $tpl->getXobjId(); $xobj = []; if (isset($xobjects[$xobjId]) && \is_array($xobjects[$xobjId])) { $xobj = $xobjects[$xobjId]; } $this->assertIsArray($xobj); $this->assertArrayHasKey('n', $xobj); $this->assertGreaterThan(0, $xobj['n'] ?? 0); } /** @throws \Throwable */ public function testImportPageSwapsDimensionsForQuarterTurnRotation(): void { $data = $this->rotatedFixtureData(); $xobjects = []; $pon = 0; $importer = new Importer($xobjects, $pon, $this->makeObjFile()); $srcId = $importer->setImportSourceData($data); $tpl = $importer->importPage($srcId, 1); $this->assertSame(90, $tpl->getRotation()); $this->assertSame(500.0, $tpl->getWidth()); $this->assertSame(300.0, $tpl->getHeight()); } /** @throws \Throwable */ public function testImportPageCanIgnoreRotationWhenRequested(): void { $data = $this->rotatedFixtureData(); $xobjects = []; $pon = 0; $importer = new Importer($xobjects, $pon, $this->makeObjFile()); $srcId = $importer->setImportSourceData($data); $tpl = $importer->importPage($srcId, 1, ['respectRotation' => false, 'cache' => false]); $this->assertSame(0, $tpl->getRotation()); $this->assertSame(300.0, $tpl->getWidth()); $this->assertSame(500.0, $tpl->getHeight()); } /** @throws \Throwable */ public function testImportPageTemplateHasExpectedDimensions(): void { $data = $this->fixtureData(); $xobjects = []; $pon = 0; $importer = new Importer($xobjects, $pon, $this->makeObjFile()); $srcId = $importer->setImportSourceData($data); $tpl = $importer->importPage($srcId, 1); // fixture mediabox is 612x792; cropbox falls back to mediabox $this->assertEqualsWithDelta(612.0, $tpl->getWidth(), 0.01); $this->assertEqualsWithDelta(792.0, $tpl->getHeight(), 0.01); } /** @throws \Throwable */ public function testImportPageCacheReturnsIdenticalTemplate(): void { $data = $this->fixtureData(); $xobjects = []; $pon = 0; $importer = new Importer($xobjects, $pon, $this->makeObjFile()); $srcId = $importer->setImportSourceData($data); $tpl1 = $importer->importPage($srcId, 1); $tpl2 = $importer->importPage($srcId, 1); $this->assertSame($tpl1->getXobjId(), $tpl2->getXobjId()); } /** @throws \Throwable */ public function testImportPageThrowsForOutOfRangePage(): void { $data = $this->fixtureData(); $importer = $this->makeImporter(); $srcId = $importer->setImportSourceData($data); $this->expectException(ImportPageOutOfRangeException::class); $importer->importPage($srcId, 999); } /** @throws \Throwable */ public function testImportPageThrowsForUnknownSourceId(): void { $importer = $this->makeImporter(); $this->expectException(ImportSourceNotFoundException::class); $importer->importPage('unknown-id', 1); } /** @throws \Throwable */ public function testGetOutImportedObjectsReturnsNonEmptyString(): void { $data = $this->fixtureData(); $xobjects = []; $pon = 0; $importer = new Importer($xobjects, $pon, $this->makeObjFile()); $srcId = $importer->setImportSourceData($data); $importer->importPage($srcId, 1); $out = $importer->getOutImportedObjects(); $this->assertNotEmpty($out); $this->assertStringContainsString(' 0 obj', $out); $this->assertStringContainsString('endobj', $out); } /** @throws \Throwable */ public function testGetOutImportedObjectsClearsQueue(): void { $data = $this->fixtureData(); $xobjects = []; $pon = 0; $importer = new Importer($xobjects, $pon, $this->makeObjFile()); $srcId = $importer->setImportSourceData($data); $importer->importPage($srcId, 1); $importer->getOutImportedObjects(); $this->assertSame('', $importer->getOutImportedObjects()); } /** @throws \Throwable */ public function testCleanUpReleasesState(): void { $data = $this->fixtureData(); $importer = $this->makeImporter(); $srcId = $importer->setImportSourceData($data); $importer->cleanUp(); $this->expectException(ImportSourceNotFoundException::class); $importer->getSourcePageCount($srcId); } /** @throws \Throwable */ public function testCleanUpClearsPageIndexCache(): void { $data = $this->fixtureData(); $importer = $this->makeImporter(); $srcId = $importer->setImportSourceData($data); $this->assertSame(1, $importer->getSourcePageCount($srcId)); $importer->cleanUp(); $this->assertSame([], $this->getObjectProperty($importer, 'pageIndexes')); } public function testSelectBoxFallsBackToMediaBoxWhenRequestedBoxIsMissing(): void { $importer = $this->makeImporter(); /** @var array{0: float, 1: float, 2: float, 3: float} $box */ $box = $this->invokeImporterMethod( $importer, 'selectBox', [ 'mediaBox' => [10, 20, 210, 420], ], 'BleedBox', ); $this->assertSame([10.0, 20.0, 210.0, 420.0], $box); } public function testSelectBoxReturnsZeroBoxForInvalidCoordinates(): void { $importer = $this->makeImporter(); /** @var array{0: float, 1: float, 2: float, 3: float} $box */ $box = $this->invokeImporterMethod( $importer, 'selectBox', [ 'cropBox' => [0, 1, 2, 'bad'], ], 'CropBox', ); $this->assertSame([0.0, 0.0, 0.0, 0.0], $box); } public function testRotationMatrixSupportsHalfAndThreeQuarterTurns(): void { $importer = $this->makeImporter(); /** @var array $halfTurn */ $halfTurn = $this->invokeImporterMethod($importer, 'rotationMatrix', 180, 200.0, 400.0); /** @var array $threeQuarterTurn */ $threeQuarterTurn = $this->invokeImporterMethod($importer, 'rotationMatrix', 270, 200.0, 400.0); /** @var array $negativeQuarterTurn */ $negativeQuarterTurn = $this->invokeImporterMethod($importer, 'rotationMatrix', -90, 200.0, 400.0); $this->assertSame([-1.0, 0.0, 0.0, -1.0, 200.0, 400.0], $halfTurn); $this->assertSame([0.0, 1.0, -1.0, 0.0, 400.0, 0.0], $threeQuarterTurn); $this->assertSame($threeQuarterTurn, $negativeQuarterTurn); } /** @throws \Throwable */ public function testGetSourcePageCountThrowsWhenRootDictionaryHasNoPagesEntry(): void { $importer = $this->makeImporter(); $sourceId = 'stub-source'; $src = $this->createStub(SourceDocument::class); $src->method('getTrailer')->willReturn(['root' => '1 0 R']); $src->method('getObject')->willReturnCallback(static fn(string $ref): array => match ($ref) { '1_0' => [[ '<<', [ ['/', 'Type'], ['/', 'Catalog'], ], ]], default => [], }); $this->setObjectProperty($importer, 'sources', [$sourceId => $src]); $this->expectException(ImportCorruptedSourceException::class); $importer->getSourcePageCount($sourceId); } /** @throws \Throwable */ public function testGetSourcePageCountReturnsZeroForEmptyPageTree(): void { $importer = $this->makeImporter(); $sourceId = 'stub-source'; $src = $this->createStub(SourceDocument::class); $src->method('getTrailer')->willReturn(['root' => '1 0 R']); $src->method('getObject')->willReturnCallback(static fn(string $ref): array => match ($ref) { '1_0' => [[ '<<', [ ['/', 'Pages'], ['objref', '2 0 R'], ], ]], '2_0' => [[ '<<', [ ['/', 'Type'], ['/', 'Pages'], ['/', 'Kids'], ['[', []], ], ]], default => [], }); $this->setObjectProperty($importer, 'sources', [$sourceId => $src]); $this->assertSame(0, $importer->getSourcePageCount($sourceId)); } /** @throws \Throwable */ public function testGetSourcePageCountThrowsWhenPagesNodeHasNoKids(): void { $importer = $this->makeImporter(); $sourceId = 'stub-source'; $src = $this->createStub(SourceDocument::class); $src->method('getTrailer')->willReturn(['root' => '1 0 R']); $src->method('getObject')->willReturnCallback(static fn(string $ref): array => match ($ref) { '1_0' => [[ '<<', [ ['/', 'Pages'], ['objref', '2 0 R'], ], ]], '2_0' => [[ '<<', [ ['/', 'Type'], ['/', 'Pages'], ], ]], default => [], }); $this->setObjectProperty($importer, 'sources', [$sourceId => $src]); $this->expectException(ImportCorruptedSourceException::class); $this->expectExceptionMessageMatches('/' . preg_quote('/Kids', '/') . '/'); $importer->getSourcePageCount($sourceId); } /** @throws \Throwable */ public function testGetSourcePageCountIsCachedPerSource(): void { $importer = $this->makeImporter(); $sourceId = 'stub-source'; $src = $this->createStub(SourceDocument::class); $calls = 0; $src->method('getTrailer')->willReturn(['root' => '1 0 R']); $src->method('getObject')->willReturnCallback(static function (string $ref) use (&$calls): array { ++$calls; return match ($ref) { '1_0' => [[ '<<', [ ['/', 'Pages'], ['objref', '2 0 R'], ], ]], '2_0' => [[ '<<', [ ['/', 'Type'], ['/', 'Pages'], ['/', 'Kids'], [ '[', [ ['objref', '3 0 R'], ], ], ], ]], '3_0' => [[ '<<', [ ['/', 'Type'], ['/', 'Page'], ], ]], default => [], }; }); $this->setObjectProperty($importer, 'sources', [$sourceId => $src]); $this->assertSame(1, $importer->getSourcePageCount($sourceId)); $callsAfterFirst = $calls; $this->assertGreaterThan(0, $callsAfterFirst); $this->assertSame(1, $importer->getSourcePageCount($sourceId)); $this->assertSame($callsAfterFirst, $calls); } // ------------------------------------------------------------------------- // importPages // ------------------------------------------------------------------------- /** @throws \Throwable */ public function testImportPagesWithNullRangeImportsAllPages(): void { $data = $this->fixtureData(); $importer = $this->makeImporter(); $srcId = $importer->setImportSourceData($data); $templates = $importer->importPages($srcId); // Fixture has one page. $this->assertCount(1, $templates); assert(isset($templates[0]), "\$templates[0] must be set"); $this->assertInstanceOf(PageTemplate::class, $templates[0]); } /** @throws \Throwable */ public function testImportPagesNullRangeIgnoresForgedOversizedCount(): void { // With a forged /Count the null range must import only the pages // actually reachable through /Kids, without materializing any // /Count-sized structure. $importer = $this->makeImporter(); $srcId = $importer->setImportSourceData($this->buildDonorPdf(50, 1)); $templates = $importer->importPages($srcId, null); $this->assertCount(1, $templates); assert(isset($templates[0]), "\$templates[0] must be set"); $this->assertInstanceOf(PageTemplate::class, $templates[0]); } /** @throws \Throwable */ public function testImportPagesRangeBeyondReachablePagesThrows(): void { // The bounds check must use the verified page count, not the // forged declared /Count. $importer = $this->makeImporter(); $srcId = $importer->setImportSourceData($this->buildDonorPdf(50, 1)); $this->expectException(ImportPageOutOfRangeException::class); $this->expectExceptionMessageMatches('/' . preg_quote('out of range [1,1]', '/') . '/'); $importer->importPages($srcId, [2]); } /** @throws \Throwable */ public function testImportPagesWithExplicitRange(): void { $data = $this->fixtureData(); $importer = $this->makeImporter(); $srcId = $importer->setImportSourceData($data); $templates = $importer->importPages($srcId, [1]); $this->assertCount(1, $templates); assert(isset($templates[0]), "\$templates[0] must be set"); $this->assertInstanceOf(PageTemplate::class, $templates[0]); } /** @throws \Throwable */ public function testImportPagesMatchesImportPageResult(): void { $data = $this->fixtureData(); $xobjects = []; $pon = 0; $importer = new Importer($xobjects, $pon, $this->makeObjFile()); $srcId = $importer->setImportSourceData($data); $single = $importer->importPage($srcId, 1); $batch = $importer->importPages($srcId, [1]); assert(isset($batch[0]), "\$batch[0] must be set"); // Same page imported again (cache hit) — must return the exact same template. $this->assertSame($single->getXobjId(), $batch[0]->getXobjId()); } /** * Counting the pages and importing all of them must walk the page tree * exactly once per source: the flattened page index built on first use is * reused for every subsequent page resolution. * * @throws \Throwable */ public function testImportPagesWalksPageTreeOnlyOnce(): void { $importer = $this->makeImporter(); $src = new class($this->buildDonorPdf(null, 2)) extends SourceDocument { /** @var array */ public array $fetches = []; public function getObject(string $ref): array { $this->fetches[$ref] = ($this->fetches[$ref] ?? 0) + 1; return parent::getObject($ref); } }; $sourceId = 'counting-source'; $this->setObjectProperty($importer, 'sources', [$sourceId => $src]); $this->assertSame(2, $importer->getSourcePageCount($sourceId)); $templates = $importer->importPages($sourceId); $this->assertCount(2, $templates); // Catalog, /Pages node, and each page leaf fetched exactly once for // the count plus the whole batch import. $this->assertSame(1, $src->fetches['1_0'] ?? 0); $this->assertSame(1, $src->fetches['2_0'] ?? 0); $this->assertSame(1, $src->fetches['3_0'] ?? 0); $this->assertSame(1, $src->fetches['4_0'] ?? 0); } /** @throws \Throwable */ public function testImportPagesThrowsForUnknownSource(): void { $importer = $this->makeImporter(); $this->expectException(ImportSourceNotFoundException::class); $importer->importPages('unknown-id'); } /** @throws \Throwable */ public function testImportPagesThrowsForOutOfRangePage(): void { $data = $this->fixtureData(); $importer = $this->makeImporter(); $srcId = $importer->setImportSourceData($data); $this->expectException(ImportPageOutOfRangeException::class); $importer->importPages($srcId, [1, 999]); } // ------------------------------------------------------------------------- // Dedup: repeated import without cache must not inflate pon // ------------------------------------------------------------------------- /** @throws \Throwable */ public function testRepeatedImportNoCacheUsesSharedObjectMap(): void { $data = $this->fixtureData(); $xobjects = []; $pon = 0; $importer = new Importer($xobjects, $pon, $this->makeObjFile()); $srcId = $importer->setImportSourceData($data); // First import (no cache): allocates objects from the source. $importer->importPage($srcId, 1, ['cache' => false]); $ponAfterFirst = $pon; $this->assertGreaterThan(0, $ponAfterFirst); // Second import of the same page (cache off): shared resources are already // in the ObjectMap — only the new Form XObject itself increments pon. $importer->importPage($srcId, 1, ['cache' => false]); $ponAfterSecond = $pon; // pon must have increased by exactly 1 (the new XObject), not by the full // resource set again. $this->assertSame(1, $ponAfterSecond - $ponAfterFirst); } /** @throws \Throwable */ public function testRepeatedImportNoCacheDoesNotDuplicateAuxObjects(): void { $data = $this->fixtureData(); $xobjects = []; $pon = 0; $importer = new Importer($xobjects, $pon, $this->makeObjFile()); $srcId = $importer->setImportSourceData($data); $importer->importPage($srcId, 1, ['cache' => false]); $importer->importPage($srcId, 1, ['cache' => false]); $out = $importer->getOutImportedObjects(); // Count occurrences of the font object serialized string to verify // it appears exactly once (dedup works). $xobjCount = \substr_count($out, '/Type /XObject'); $this->assertSame(2, $xobjCount, 'Each non-cached import should produce exactly one XObject'); // The font object (5_0) should be written exactly once despite two imports. $fontCount = \substr_count($out, '/Type /Font'); $this->assertSame(1, $fontCount, 'Shared font object must not be duplicated across imports'); } // ------------------------------------------------------------------------- // Multi-page fixture tests // ------------------------------------------------------------------------- /** @throws \Throwable */ public function testGetSourcePageCountMultipage(): void { $data = $this->multipageFixtureData(); $importer = $this->makeImporter(); $srcId = $importer->setImportSourceData($data); $this->assertSame(2, $importer->getSourcePageCount($srcId)); } /** @throws \Throwable */ public function testImportPagesNullRangeImportsAllMultipagePages(): void { $data = $this->multipageFixtureData(); $xobjects = []; $pon = 0; $importer = new Importer($xobjects, $pon, $this->makeObjFile()); $srcId = $importer->setImportSourceData($data); $templates = $importer->importPages($srcId); $this->assertCount(2, $templates); assert(isset($templates[0]), "\$templates[0] must be set"); $this->assertInstanceOf(PageTemplate::class, $templates[0]); assert(isset($templates[1]), "\$templates[1] must be set"); $this->assertInstanceOf(PageTemplate::class, $templates[1]); } /** @throws \Throwable */ public function testImportPagesMultipagePartialRange(): void { $data = $this->multipageFixtureData(); $importer = $this->makeImporter(); $srcId = $importer->setImportSourceData($data); $templates = $importer->importPages($srcId, [2]); $this->assertCount(1, $templates); assert(isset($templates[0]), "\$templates[0] must be set"); $this->assertEqualsWithDelta(612.0, $templates[0]->getWidth(), 0.01); } /** @throws \Throwable */ public function testImportAllPagesMultipageSharedFontNotDuplicated(): void { $data = $this->multipageFixtureData(); $xobjects = []; $pon = 0; $importer = new Importer($xobjects, $pon, $this->makeObjFile()); $srcId = $importer->setImportSourceData($data); $importer->importPages($srcId); $out = $importer->getOutImportedObjects(); // Two pages should produce two Form XObjects. $this->assertSame(2, \substr_count($out, '/Type /XObject')); // The shared font (5_0) must appear exactly once in the output. $this->assertSame(1, \substr_count($out, '/Type /Font')); } /** * importPages(null) must import nothing when the source reports a missing or * negative page count. Otherwise \range(1, $total) would yield a descending * sequence (e.g. [1, 0]) and attempt to import bogus page numbers. * * @throws \Throwable */ public function testImportPagesWithNonPositivePageCountReturnsEmpty(): void { $xobjects = []; $pon = 0; $importer = new class($xobjects, $pon, $this->makeObjFile()) extends Importer { public int $fakeCount = 0; public function getSourcePageCount(string $sourceId): int { return $this->fakeCount; } }; $importer->fakeCount = 0; $this->assertSame([], $importer->importPages('any-source', null)); $importer->fakeCount = -3; $this->assertSame([], $importer->importPages('any-source', null)); } }