Files

907 lines
32 KiB
PHP
Raw Permalink Normal View History

2026-08-20 11:41:17 +00:00
<?php
/**
* ImporterTest.php
*
* @since 2002-08-03
* @category Library
* @package Pdf
* @author Nicola Asuni <info@tecnick.com>
* @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<string, ObjectMap> $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<int, float> $halfTurn */
$halfTurn = $this->invokeImporterMethod($importer, 'rotationMatrix', 180, 200.0, 400.0);
/** @var array<int, float> $threeQuarterTurn */
$threeQuarterTurn = $this->invokeImporterMethod($importer, 'rotationMatrix', 270, 200.0, 400.0);
/** @var array<int, float> $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<string, int> */
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));
}
}