added composer files and installed vendor packages. Also modified xsls to display base project

This commit is contained in:
2026-08-09 09:59:37 -04:00
parent 53cd5430ee
commit 2d96a7b6e5
2142 changed files with 569661 additions and 240 deletions
@@ -0,0 +1,906 @@
<?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));
}
}
@@ -0,0 +1,138 @@
<?php
/**
* ObjectMapTest.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\Pdf\Import\ImportException;
use Com\Tecnick\Pdf\Import\ObjectMap;
use PHPUnit\Framework\TestCase;
class ObjectMapTest extends TestCase
{
public function testHasReturnsFalseForUnknownRef(): void
{
$map = new ObjectMap();
$this->assertFalse($map->has('1_0'));
}
public function testAllocateAssignsIncreasingObjectNumbers(): void
{
$map = new ObjectMap();
$pon = 10;
$num1 = $map->allocate('1_0', $pon);
$num2 = $map->allocate('2_0', $pon);
$this->assertSame(11, $num1);
$this->assertSame(12, $num2);
$this->assertSame(12, $pon);
}
public function testAllocateIsIdempotentForSameRef(): void
{
$map = new ObjectMap();
$pon = 5;
$first = $map->allocate('3_0', $pon);
$second = $map->allocate('3_0', $pon);
$this->assertSame($first, $second);
}
public function testHasReturnsTrueAfterAllocate(): void
{
$map = new ObjectMap();
$pon = 0;
$map->allocate('5_0', $pon);
$this->assertTrue($map->has('5_0'));
}
public function testIsInProgressTrueBeforeEnqueue(): void
{
$map = new ObjectMap();
$pon = 0;
$map->allocate('6_0', $pon);
$this->assertTrue($map->isInProgress('6_0'));
}
public function testIsInProgressFalseAfterEnqueue(): void
{
$map = new ObjectMap();
$pon = 0;
$map->allocate('7_0', $pon);
$map->enqueue('7_0', '7 0 obj null endobj');
$this->assertFalse($map->isInProgress('7_0'));
}
public function testFlushReturnsQueuedDataAndClears(): void
{
$map = new ObjectMap();
$pon = 0;
$map->allocate('8_0', $pon);
$map->enqueue('8_0', '8 0 obj null endobj');
$out = $map->flush();
$this->assertStringContainsString('8 0 obj', $out);
// second flush should be empty
$this->assertSame('', $map->flush());
}
/** @throws \Throwable */
public function testGetThrowsForUnallocatedRef(): void
{
$map = new ObjectMap();
$this->expectException(ImportException::class);
$map->get('99_0');
}
public function testGetMapReturnsFullMap(): void
{
$map = new ObjectMap();
$pon = 0;
$map->allocate('1_0', $pon);
$map->allocate('2_0', $pon);
$full = $map->getMap();
$this->assertCount(2, $full);
$this->assertArrayHasKey('1_0', $full);
$this->assertArrayHasKey('2_0', $full);
}
/** @throws \Throwable */
public function testFlushPreservesMapForDedup(): void
{
$map = new ObjectMap();
$pon = 0;
$map->allocate('10_0', $pon);
$map->enqueue('10_0', '10 0 obj null endobj');
$map->flush();
// After flush the queue is empty but the mapping must still be intact.
$this->assertTrue($map->has('10_0'));
$this->assertSame(1, $map->get('10_0'));
// A second flush of the now-empty queue should produce an empty string.
$this->assertSame('', $map->flush());
}
public function testDedupAcrossMultipleAllocations(): void
{
$map = new ObjectMap();
$pon = 0;
$num1 = $map->allocate('20_0', $pon);
$map->enqueue('20_0', '1 0 obj null endobj');
$map->flush();
// Re-allocating the same ref after flush must return the original number.
$num2 = $map->allocate('20_0', $pon);
$this->assertSame($num1, $num2);
// pon must not have been incremented again.
$this->assertSame(1, $pon);
}
}
@@ -0,0 +1,982 @@
<?php
/**
* PageResolverTest.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\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<int, mixed> $pairs
* @return array<int, mixed>
*/
private function dictObject(array $pairs): array
{
return [['<<', $pairs]];
}
/**
* @param array<string, array<int, mixed>> $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<string, mixed> $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);
}
}
@@ -0,0 +1,51 @@
<?php
/**
* PageTemplateTest.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\Pdf\Import\PageTemplate;
use PHPUnit\Framework\TestCase;
class PageTemplateTest extends TestCase
{
public function testGettersReturnConstructorValues(): void
{
$mediaBox = [0.0, 0.0, 612.0, 792.0];
$template = new PageTemplate('TPL42', 612.0, 792.0, 90, 'source-abc', 3, $mediaBox);
$this->assertSame('TPL42', $template->getXobjId());
$this->assertSame(612.0, $template->getWidth());
$this->assertSame(792.0, $template->getHeight());
$this->assertSame(90, $template->getRotation());
$this->assertSame('source-abc', $template->getSourceId());
$this->assertSame(3, $template->getSourcePage());
$this->assertSame($mediaBox, $template->getMediaBox());
}
public function testReadonlyPropertiesMirrorGetters(): void
{
$mediaBox = [10.0, 20.0, 210.0, 297.0];
$template = new PageTemplate('TPL99', 200.0, 277.0, 0, 'source-def', 12, $mediaBox);
$this->assertSame($template->xobjId, $template->getXobjId());
$this->assertSame($template->width, $template->getWidth());
$this->assertSame($template->height, $template->getHeight());
$this->assertSame($template->rotation, $template->getRotation());
$this->assertSame($template->sourceId, $template->getSourceId());
$this->assertSame($template->sourcePage, $template->getSourcePage());
$this->assertSame($template->mediaBox, $template->getMediaBox());
}
}
@@ -0,0 +1,873 @@
<?php
/**
* ResourceClonerTest.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\Pdf\Import\ImportCorruptedSourceException;
use Com\Tecnick\Pdf\Import\ObjectMap;
use Com\Tecnick\Pdf\Import\ResourceCloner;
use Com\Tecnick\Pdf\Import\SourceDocument;
use PHPUnit\Framework\TestCase;
class ResourceClonerTest extends TestCase
{
/** @throws \Throwable */
private function loadFixture(): SourceDocument
{
$path = __DIR__ . '/../fixtures/simple_import.pdf';
$data = \file_get_contents($path);
$this->assertNotFalse($data);
return new SourceDocument($data);
}
/**
* @param array<string, mixed> $objects
* @throws \Throwable
*/
private function makeMockSourceDocument(array $objects): SourceDocument
{
$src = $this->createStub(SourceDocument::class);
$src->method('getObject')->willReturnCallback(static fn(string $ref): array => (
isset($objects[$ref]) && \is_array($objects[$ref]) ? $objects[$ref] : []
));
$src->method('findObject')->willReturnCallback(static fn(string $ref): ?array => isset($objects[$ref])
&& \is_array($objects[$ref])
? $objects[$ref]
: null);
return $src;
}
// -------------------------------------------------------------------------
// getPon
// -------------------------------------------------------------------------
public function testGetPonReturnsInitialValue(): void
{
$cloner = new ResourceCloner(10);
$this->assertSame(10, $cloner->getPon());
}
/** @throws \Throwable */
public function testGetPonUpdatesAfterEnqueue(): void
{
$src = $this->loadFixture();
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
// Enqueuing font object 5_0 must allocate a new destination number.
$destNum = $cloner->enqueueObject('5_0', $src, $map);
$this->assertGreaterThan(0, $destNum);
$this->assertSame($destNum, $cloner->getPon());
}
// -------------------------------------------------------------------------
// getContentStream
// -------------------------------------------------------------------------
/** @throws \Throwable */
public function testGetContentStreamEmptyPageReturnsEmptyStream(): void
{
$src = $this->loadFixture();
$cloner = new ResourceCloner(0);
$result = $cloner->getContentStream([], $src);
$this->assertSame('', $result['bytes']);
$this->assertSame('', $result['filter']);
$this->assertSame(0, $result['length']);
}
/** @throws \Throwable */
public function testGetContentStreamSingleRef(): void
{
$src = $this->loadFixture();
$cloner = new ResourceCloner(0);
// Object 4_0 is the content stream in the fixture.
$pageDict = ['Contents' => '4_0'];
$result = $cloner->getContentStream($pageDict, $src);
$this->assertNotEmpty($result['bytes']);
$this->assertStringContainsString('BT', $result['bytes']);
$this->assertSame(\strlen($result['bytes']), $result['length']);
}
/** @throws \Throwable */
public function testGetContentStreamArrayWithSingleRef(): void
{
$src = $this->loadFixture();
$cloner = new ResourceCloner(0);
// Array of one ref — should behave identically to single-ref case.
$pageDict = ['Contents' => ['4_0']];
$result = $cloner->getContentStream($pageDict, $src);
$this->assertNotEmpty($result['bytes']);
$this->assertStringContainsString('BT', $result['bytes']);
}
/** @throws \Throwable */
public function testGetContentStreamMultipleRefsAreConcatenated(): void
{
$src = $this->loadFixture();
$cloner = new ResourceCloner(0);
// Use the same stream twice to test the concatenation path.
$pageDict = ['Contents' => ['4_0', '4_0']];
$result = $cloner->getContentStream($pageDict, $src);
// Bytes must appear twice in the concatenated output.
$singleStream = $cloner->getContentStream(['Contents' => '4_0'], $src);
$this->assertStringContainsString($singleStream['bytes'], $result['bytes']);
$this->assertGreaterThan($singleStream['length'], $result['length']);
// Multi-stream concatenation always returns empty filter.
$this->assertSame('', $result['filter']);
}
/** @throws \Throwable */
public function testGetContentStreamThrowsForInvalidArrayEntry(): void
{
$src = $this->loadFixture();
$cloner = new ResourceCloner(0);
// A non-string element inside the /Contents array is invalid.
$pageDict = ['Contents' => [42]];
$this->expectException(ImportCorruptedSourceException::class);
$cloner->getContentStream($pageDict, $src);
}
// -------------------------------------------------------------------------
// cloneResources
// -------------------------------------------------------------------------
/** @throws \Throwable */
public function testCloneResourcesEmptyDictReturnsEmptyString(): void
{
$src = $this->loadFixture();
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$this->assertSame('', $cloner->cloneResources([], $src, $map));
}
/** @throws \Throwable */
public function testCloneResourcesFontRefsAreRemapped(): void
{
$src = $this->loadFixture();
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
// Minimal resource dict: Font -> F1 -> indirect ref to object 5_0.
$resources = ['Font' => ['F1' => '5_0']];
$output = $cloner->cloneResources($resources, $src, $map);
// Output must start with << and contain a /Font entry.
$this->assertStringStartsWith('<<', $output);
$this->assertStringContainsString('/Font', $output);
// The ref to 5_0 must be remapped to a new object number in "N 0 R" format.
$this->assertMatchesRegularExpression('/\d+ 0 R/', $output);
}
/** @throws \Throwable */
public function testCloneResourcesProcSetSkipped(): void
{
$src = $this->loadFixture();
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$resources = ['ProcSet' => ['/PDF', '/Text']];
$output = $cloner->cloneResources($resources, $src, $map);
// ProcSet is re-emitted as a standard fixed list, so source array is ignored.
$this->assertStringContainsString('/ProcSet', $output);
}
/** @throws \Throwable */
public function testCloneResourcesSerializesNestedEntriesAndPreservesInlineScalars(): void
{
$src = $this->loadFixture();
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$output = $cloner->cloneResources(
[
'ColorSpace' => [
'CS1' => '/DeviceRGB',
'CS2' => ['/DeviceCMYK'],
],
],
$src,
$map,
);
$this->assertStringContainsString('/CS1 /DeviceRGB', $output);
$this->assertStringContainsString('/CS2 [ /DeviceCMYK ]', $output);
}
/** @throws \Throwable */
public function testCloneResourcesSupportsScalarResourceValues(): void
{
$src = $this->makeMockSourceDocument([
'5_0' => [
['numeric', 99],
],
]);
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$indirectOutput = $cloner->cloneResources(['XObject' => '5 0 R'], $src, $map);
$inlineOutput = $cloner->cloneResources(['ColorSpace' => '/DeviceCMYK'], $src, $map);
$nullOutput = $cloner->cloneResources(['Pattern' => 123], $src, $map);
$this->assertMatchesRegularExpression('/\/XObject \d+ 0 R/', $indirectOutput);
$this->assertStringContainsString('/ColorSpace /DeviceCMYK', $inlineOutput);
$this->assertStringContainsString('/Pattern null', $nullOutput);
}
/** @throws \Throwable */
public function testCloneResourcesPreservesNumericResourceNames(): void
{
$src = $this->makeMockSourceDocument([
'9_0' => [
['numeric', 1],
],
]);
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$output = $cloner->cloneResources(
[
'Font' => [
9 => '9_0',
'n' => '9_0',
],
],
$src,
$map,
);
$this->assertStringContainsString('/Font << /9 ', $output);
$this->assertStringContainsString('/n ', $output);
}
/** @throws \Throwable */
public function testGetContentStreamRejectsUnexpectedContentsType(): void
{
$src = $this->loadFixture();
$cloner = new ResourceCloner(0);
$this->expectException(ImportCorruptedSourceException::class);
$cloner->getContentStream(['Contents' => 42], $src);
}
/** @throws \Throwable */
public function testGetContentStreamMultipleRefsSkipsInvalidEntriesAndReturnsNamedFilter(): void
{
$src = $this->makeMockSourceDocument([
'1_0' => [
[
'<<',
[
['/', 'Filter'],
['/', 'FlateDecode'],
],
],
['stream', 'alpha'],
],
'2_0' => [
[
'<<',
[
['/', 'Length'],
['numeric', 4],
],
],
['stream', 'beta'],
],
]);
$cloner = new ResourceCloner(0);
$single = $cloner->getContentStream(['Contents' => '1_0'], $src);
$combined = $cloner->getContentStream(['Contents' => ['1_0', 99, '2_0']], $src);
$this->assertSame('/FlateDecode', $single['filter']);
$this->assertSame('alpha beta', $combined['bytes']);
$this->assertSame('', $combined['filter']);
}
/** @throws \Throwable */
public function testGetContentStreamParsesArrayFilterToken(): void
{
$src = $this->makeMockSourceDocument([
'1_0' => [
[
'<<',
[
['/', 'Filter'],
[
'[',
[
['/', 'FlateDecode'],
],
],
],
],
['stream', 'alpha'],
],
]);
$cloner = new ResourceCloner(0);
$single = $cloner->getContentStream(['Contents' => '1_0'], $src);
$this->assertSame('/FlateDecode', $single['filter']);
}
/** @throws \Throwable */
public function testGetContentStreamParsesArrayFilterTokenWithMultipleNames(): void
{
$src = $this->makeMockSourceDocument([
'1_0' => [
[
'<<',
[
['/', 'Filter'],
[
'[',
[
['/', 'ASCII85Decode'],
['/', 'FlateDecode'],
],
],
],
],
['stream', 'alpha'],
],
]);
$cloner = new ResourceCloner(0);
$single = $cloner->getContentStream(['Contents' => '1_0'], $src);
$this->assertSame('[ /ASCII85Decode /FlateDecode ]', $single['filter']);
}
/** @throws \Throwable */
public function testGetContentStreamMultipleFlateRefsAreDecodedBeforeConcatenation(): void
{
$streamA = \gzcompress('q 1 0 0 1 10 20 cm');
$streamB = \gzcompress('BT /F1 12 Tf ET');
$this->assertNotFalse($streamA);
$this->assertNotFalse($streamB);
$src = $this->makeMockSourceDocument([
'1_0' => [
[
'<<',
[
['/', 'Filter'],
['/', 'FlateDecode'],
],
],
['stream', $streamA],
],
'2_0' => [
[
'<<',
[
['/', 'Filter'],
['/', 'FlateDecode'],
],
],
['stream', $streamB],
],
]);
$cloner = new ResourceCloner(0);
$combined = $cloner->getContentStream(['Contents' => ['1_0', '2_0']], $src);
$this->assertSame('', $combined['filter']);
$this->assertStringContainsString('q 1 0 0 1 10 20 cm', $combined['bytes']);
$this->assertStringContainsString('BT /F1 12 Tf ET', $combined['bytes']);
}
/** @throws \Throwable */
public function testCloneResourcesPreservesNestedNumericResourceNames(): void
{
$src = $this->makeMockSourceDocument([
'11_0' => [
['numeric', 7],
],
]);
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$output = $cloner->cloneResources(
[
'ExtGState' => [
'GS1' => [
9 => '11_0',
],
],
],
$src,
$map,
);
$this->assertStringContainsString('/ExtGState << /GS1 << /9 ', $output);
}
/** @throws \Throwable */
public function testGetContentStreamReturnsEmptyResultWhenStreamObjectHasNoBytes(): void
{
$src = $this->makeMockSourceDocument([
'1_0' => [
[
'<<',
[
['not-a-name', 'Filter'],
['/', 'FlateDecode'],
],
],
],
]);
$cloner = new ResourceCloner(0);
$result = $cloner->getContentStream(['Contents' => '1_0'], $src);
$this->assertSame('', $result['bytes']);
$this->assertSame('', $result['filter']);
$this->assertSame(0, $result['length']);
}
// -------------------------------------------------------------------------
// enqueueObject — dedup and cycle safety
// -------------------------------------------------------------------------
/** @throws \Throwable */
public function testEnqueueObjectDedupReturnsSameDestNumber(): void
{
$src = $this->loadFixture();
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$num1 = $cloner->enqueueObject('5_0', $src, $map);
$num2 = $cloner->enqueueObject('5_0', $src, $map);
// Same source ref must always map to the same destination number.
$this->assertSame($num1, $num2);
// pon must be incremented only once.
$this->assertSame(1, $cloner->getPon());
}
/** @throws \Throwable */
public function testEnqueueObjectDedupAfterFlush(): void
{
$src = $this->loadFixture();
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$num1 = $cloner->enqueueObject('5_0', $src, $map);
$map->flush();
// After flushing the queue, the map must still hold the allocation.
$num2 = $cloner->enqueueObject('5_0', $src, $map);
$this->assertSame($num1, $num2);
// No new pon increment.
$this->assertSame(1, $cloner->getPon());
}
/** @throws \Throwable */
public function testEnqueueObjectForUndefinedRefEmitsNullObject(): void
{
$src = $this->loadFixture();
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
// 99_0 does not exist in the fixture — must get a null placeholder.
$destNum = $cloner->enqueueObject('99_0', $src, $map);
$this->assertGreaterThan(0, $destNum);
$flushed = $map->flush();
$this->assertStringContainsString($destNum . ' 0 obj', $flushed);
$this->assertStringContainsString('null', $flushed);
$this->assertStringContainsString('endobj', $flushed);
}
/** @throws \Throwable */
public function testEnqueueObjectReturnsAllocatedNumberWhenSourceIsPending(): void
{
$src = $this->makeMockSourceDocument([]);
$map = $this->createStub(ObjectMap::class);
$cloner = new ResourceCloner(0);
$map->method('has')->willReturnCallback(static fn(string $srcRef): bool => $srcRef !== '7_0');
$map->method('isInProgress')->willReturnCallback(static fn(string $srcRef): bool => $srcRef === '7_0');
$map->method('get')->willReturnCallback(static fn(string $srcRef): int => $srcRef === '7_0' ? 7 : 0);
$result = $cloner->enqueueObject('7_0', $src, $map);
$this->assertSame(7, $result);
}
/** @throws \Throwable */
public function testEnqueueObjectSerializesStreamObject(): void
{
$src = $this->loadFixture();
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
// Object 4_0 is a stream object in the fixture.
$destNum = $cloner->enqueueObject('4_0', $src, $map);
$flushed = $map->flush();
$this->assertStringContainsString($destNum . ' 0 obj', $flushed);
$this->assertStringContainsString('stream', $flushed);
$this->assertStringContainsString('endstream', $flushed);
$this->assertStringContainsString('endobj', $flushed);
}
/** @throws \Throwable */
public function testEnqueueObjectMultipleDistinctRefsIncreasePon(): void
{
$src = $this->loadFixture();
$map = new ObjectMap();
$cloner = new ResourceCloner(5);
$cloner->enqueueObject('4_0', $src, $map);
$cloner->enqueueObject('5_0', $src, $map);
// Each unique ref increments pon once.
$this->assertSame(7, $cloner->getPon());
}
/** @throws \Throwable */
public function testEnqueueObjectSerializesFirstScalarValueWhenNoDictOrStreamExists(): void
{
$src = $this->makeMockSourceDocument([
'1_0' => [
['endobj', ''],
['numeric', 123],
],
]);
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$destNum = $cloner->enqueueObject('1_0', $src, $map);
$flushed = $map->flush();
$this->assertStringContainsString($destNum . ' 0 obj', $flushed);
$this->assertStringContainsString("\n123\n", $flushed);
}
/** @throws \Throwable */
public function testEnqueueObjectSerializesFirstArrayValueWhenScalarObjectContainsArrayToken(): void
{
$src = $this->makeMockSourceDocument([
'1_0' => [
[
'[',
[
['numeric', 1],
['numeric', 2],
['/', 'Name'],
],
],
],
]);
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$cloner->enqueueObject('1_0', $src, $map);
$flushed = $map->flush();
$this->assertStringContainsString('[1 2 /Name]', $flushed);
}
/** @throws \Throwable */
public function testEnqueueObjectScalarObjRefRemapsAndQueuesReferencedObject(): void
{
$src = $this->makeMockSourceDocument([
'1_0' => [
['objref', '2 0 R'],
],
'2_0' => [
['numeric', 7],
],
]);
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$cloner->enqueueObject('1_0', $src, $map);
$flushed = $map->flush();
$this->assertSame(2, \substr_count($flushed, "endobj\n"));
$this->assertMatchesRegularExpression('/\d+ 0 R/', $flushed);
$this->assertStringContainsString("\n7\n", $flushed);
}
/** @throws \Throwable */
public function testEnqueueObjectScalarFallbackReturnsNullWhenNoSerializableValueExists(): void
{
$src = $this->makeMockSourceDocument([
'1_0' => [
['endobj', ''],
'junk-token',
],
]);
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$cloner->enqueueObject('1_0', $src, $map);
$flushed = $map->flush();
$this->assertStringContainsString("\nnull\n", $flushed);
}
/** @throws \Throwable */
public function testEnqueueObjectSerializesDictionaryValuesAcrossTokenTypes(): void
{
$src = $this->makeMockSourceDocument([
'1_0' => [
[
'<<',
[
['/', 'Name'],
['string', 'Demo'],
['/', 'Hex'],
['hex', 'CAFE'],
['/', 'Nums'],
[
'[',
[
['numeric', 1],
['numeric', 2],
],
],
['/', 'Ref'],
['objref', '2 0 R'],
['/', 'Kind'],
['/', 'Subtype'],
],
],
],
'2_0' => [
['numeric', 55],
],
]);
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$cloner->enqueueObject('1_0', $src, $map);
$flushed = $map->flush();
$this->assertStringContainsString('/Name (Demo)', $flushed);
$this->assertStringContainsString('/Hex <CAFE>', $flushed);
$this->assertStringContainsString('/Nums [1 2]', $flushed);
$this->assertStringContainsString('/Kind /Subtype', $flushed);
$this->assertMatchesRegularExpression('/\/Ref \d+ 0 R/', $flushed);
}
/**
* Regression: tc-lib-pdf-parser tags literal-string dictionary values with
* the open-paren byte `(` and hex-string values with `<` — NOT the legacy
* `'string'` / `'hex'` literals that the original `serializeValue()` checked
* for. Before the fix these values fell through to the bare-scalar path and
* were emitted without their `( )` / `< >` delimiters, producing malformed
* PDF dictionaries such as `/FontFamily Futura PT Book` instead of
* `/FontFamily (Futura PT Book)`. This test mirrors
* testEnqueueObjectSerializesDictionaryValuesAcrossTokenTypes above but
* uses the parser's actual token tags.
*
* @throws \Throwable
*/
public function testEnqueueObjectPreservesDelimitersForRealParserTokenTags(): void
{
$src = $this->makeMockSourceDocument([
'1_0' => [
[
'<<',
[
['/', 'FontFamily'],
['(', 'Futura PT Book'],
['/', 'CIDSet'],
['<', 'CAFE'],
],
],
],
]);
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$cloner->enqueueObject('1_0', $src, $map);
$flushed = $map->flush();
$this->assertStringContainsString('/FontFamily (Futura PT Book)', $flushed);
$this->assertStringContainsString('/CIDSet <CAFE>', $flushed);
}
/** @throws \Throwable */
public function testEnqueueObjectDoesNotDoubleEscapeParserLiteralStrings(): void
{
$src = $this->makeMockSourceDocument([
'1_0' => [
[
'<<',
[
['/', 'Lookup'],
['(', '\\001\\002\\003'],
],
],
],
]);
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$cloner->enqueueObject('1_0', $src, $map);
$flushed = $map->flush();
$this->assertStringContainsString('/Lookup (\\001\\002\\003)', $flushed);
$this->assertStringNotContainsString('/Lookup (\\\\001\\\\002\\\\003)', $flushed);
}
/** @throws \Throwable */
public function testEnqueueObjectSerializesStreamFilterAndSkipsMalformedDictPairs(): void
{
$src = $this->makeMockSourceDocument([
'1_0' => [
[
'<<',
[
['/', 'Filter'],
['/', 'ASCIIHexDecode'],
['/', 'Length'],
['numeric', 999],
['not-a-name', 'IgnoreMe'],
['numeric', 77],
['/', []],
['string', 'skip'],
],
],
['stream', 'ABCD'],
],
]);
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$cloner->enqueueObject('1_0', $src, $map);
$flushed = $map->flush();
$this->assertStringContainsString('/Filter /ASCIIHexDecode', $flushed);
$this->assertStringContainsString('/Length 4', $flushed);
$this->assertStringNotContainsString('999', $flushed);
$this->assertStringNotContainsString('IgnoreMe', $flushed);
$this->assertStringNotContainsString('skip', $flushed);
}
/** @throws \Throwable */
public function testEnqueueObjectSerializesNestedDictionariesAndFallbackTokens(): void
{
$src = $this->makeMockSourceDocument([
'1_0' => [
[
'<<',
[
['/', 'Nested'],
[
'<<',
[
['/', 'Flag'],
['numeric', 1],
],
],
['/', 'Literal'],
['token', ['not-scalar']],
['/', 'Unknown'],
[null, ['still-not-scalar']],
],
],
],
'2_0' => [
[
'[',
[
5,
['numeric', 2],
],
],
],
]);
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$cloner->enqueueObject('1_0', $src, $map);
$cloner->enqueueObject('2_0', $src, $map);
$flushed = $map->flush();
$this->assertStringContainsString('/Nested << /Flag 1>>', $flushed);
$this->assertStringContainsString('/Literal token', $flushed);
$this->assertStringContainsString('/Unknown null', $flushed);
$this->assertStringContainsString('[5 2]', $flushed);
}
/** @throws \Throwable */
public function testEnqueueObjectSkipsNonArrayEntriesBeforeReturningNullFallback(): void
{
$src = $this->makeMockSourceDocument([
'1_0' => [
'junk-token',
123,
['endobj', ''],
['<<', null],
],
]);
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$cloner->enqueueObject('1_0', $src, $map);
$flushed = $map->flush();
$this->assertStringContainsString("\nnull\n", $flushed);
}
// -------------------------------------------------------------------------
// Shared resources across multiple importPage() calls (integration-level)
// -------------------------------------------------------------------------
/** @throws \Throwable */
public function testSharedObjectNotDuplicatedInFlushedOutput(): void
{
$src = $this->loadFixture();
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
// Simulate two pages sharing font object 5_0.
$resources = ['Font' => ['F1' => '5_0']];
// First "page" import: clone resources and flush.
$cloner->cloneResources($resources, $src, $map);
$firstFlush = $map->flush();
// Second "page" import: same shared resource — nothing new should be queued.
$cloner->cloneResources($resources, $src, $map);
$secondFlush = $map->flush();
// First flush must contain 5_0's serialized data.
$this->assertNotEmpty($firstFlush);
// Second flush must be empty because 5_0 was already allocated and not re-queued.
$this->assertSame('', $secondFlush);
}
}
@@ -0,0 +1,338 @@
<?php
/**
* SourceDocumentTest.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\Pdf\Import\ImportCorruptedSourceException;
use Com\Tecnick\Pdf\Import\ImportUnsupportedFeatureException;
use Com\Tecnick\Pdf\Import\SourceDocument;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
class SourceDocumentTest extends TestCase
{
private function invokeSourceMethod(SourceDocument $doc, string $method, mixed ...$args): mixed
{
$ref = new \ReflectionClass($doc);
return $ref->getMethod($method)->invokeArgs($doc, $args);
}
/**
* @param array<string, mixed> $cfg
* @return array<string, bool>
*/
private function callNormalizeParserConfig(SourceDocument $doc, array $cfg, bool &$passwordProvided): array
{
$ref = new \ReflectionClass($doc);
$method = $ref->getMethod('normalizeParserConfig');
$args = [$cfg, &$passwordProvided];
/** @var array<string, bool> */
return $method->invokeArgs($doc, $args);
}
private function loadFixture(): string
{
$path = __DIR__ . '/../fixtures/simple_import.pdf';
$data = file_get_contents($path);
$this->assertNotFalse($data);
return $data;
}
private function loadEncryptedFixture(): string
{
$path = __DIR__ . '/../fixtures/encrypted_import_stub.pdf';
$data = file_get_contents($path);
$this->assertNotFalse($data);
return $data;
}
/**
* @param array<int, string> $objects
*/
private function buildPdf(string $rootRef, array $objects): string
{
$pdf = "%PDF-1.4\n";
$offsets = [0 => 0];
$maxObjNum = 0;
foreach ($objects as $num => $obj) {
$maxObjNum = max($maxObjNum, $num);
$offsets[$num] = strlen($pdf);
$pdf .= $num . " 0 obj\n" . $obj . "\nendobj\n";
}
$startxref = strlen($pdf);
$pdf .= "xref\n0 " . ($maxObjNum + 1) . "\n";
$pdf .= "0000000000 65535 f \n";
for ($obj = 1; $obj <= $maxObjNum; ++$obj) {
if (isset($offsets[$obj])) {
$pdf .= sprintf("%010d 00000 n \n", $offsets[$obj]);
continue;
}
$pdf .= "0000000000 00000 f \n";
}
$pdf .= "trailer\n<< /Size " . ($maxObjNum + 1) . ' /Root ' . $rootRef . " >>\n";
$pdf .= "startxref\n" . $startxref . "\n%%EOF\n";
return $pdf;
}
private function buildInvalidFilterPdf(): string
{
return $this->buildPdf('1 0 R', [
1 => '<< /Type /Catalog /Pages 2 0 R >>',
2 => '<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
3 => '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 50 50] /Contents 4 0 R >>',
4 => "<< /Length 3 /Filter /ASCIIHexDecode >>\nstream\nGG>\nendstream",
]);
}
private function buildRootNullPdf(): string
{
return $this->buildPdf('1 0 R', [
1 => 'null',
]);
}
/** @throws \Throwable */
public function testConstructSucceedsWithValidPdf(): void
{
$doc = new SourceDocument($this->loadFixture());
$this->assertNotEmpty($doc->getId());
}
/** @throws \Throwable */
public function testIdIsSha256OfData(): void
{
$data = $this->loadFixture();
$doc = new SourceDocument($data);
$this->assertSame(hash('sha256', $data), $doc->getId());
}
/** @throws \Throwable */
public function testGetTrailerContainsRoot(): void
{
$doc = new SourceDocument($this->loadFixture());
$trailer = $doc->getTrailer();
$this->assertArrayHasKey('root', $trailer);
}
/** @throws \Throwable */
public function testGetXrefReturnsNonEmptyArray(): void
{
$doc = new SourceDocument($this->loadFixture());
$xref = $doc->getXref();
$this->assertNotEmpty($xref);
}
/** @throws \Throwable */
public function testGetObjectReturnsDataForKnownRef(): void
{
$doc = new SourceDocument($this->loadFixture());
// Object 1 is /Catalog in the fixture.
$obj = $doc->getObject('1_0');
$this->assertNotEmpty($obj);
}
/** @throws \Throwable */
public function testGetObjectThrowsForUnknownRef(): void
{
$doc = new SourceDocument($this->loadFixture());
$this->expectException(ImportCorruptedSourceException::class);
$doc->getObject('999_0');
}
/** @throws \Throwable */
public function testFindObjectReturnsNullForUnknownRef(): void
{
$doc = new SourceDocument($this->loadFixture());
$this->assertNull($doc->findObject('999_0'));
}
/** @throws \Throwable */
public function testConstructThrowsOnEmptyData(): void
{
$this->expectException(ImportCorruptedSourceException::class);
new SourceDocument('');
}
/** @throws \Throwable */
public function testConstructThrowsOnGarbage(): void
{
$this->expectException(ImportCorruptedSourceException::class);
new SourceDocument('this is not a pdf');
}
/** @throws \Throwable */
public function testConstructThrowsOnEncryptedPdfWithoutPassword(): void
{
$this->expectException(ImportUnsupportedFeatureException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('password support is not available', '/') . '/');
new SourceDocument($this->loadEncryptedFixture());
}
/**
* @param array<string, string> $cfg
* @throws \Throwable
*/
#[DataProvider('passwordConfigProvider')]
public function testConstructThrowsOnEncryptedPdfWhenPasswordConfigProvided(array $cfg): void
{
$this->expectException(ImportUnsupportedFeatureException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('password-based import is not supported', '/') . '/');
new SourceDocument($this->loadEncryptedFixture(), $cfg);
}
/** @throws \Throwable */
public function testConstructEncryptedPdfWithNonStringPasswordFallsBackToNoPasswordSupportMessage(): void
{
$this->expectException(ImportUnsupportedFeatureException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('password support is not available', '/') . '/');
$passwordKey = implode('', ['pass', 'word']);
$passwordVal = \strlen($this->loadFixture());
new SourceDocument($this->loadEncryptedFixture(), [$passwordKey => $passwordVal]);
}
/** @throws \Throwable */
public function testConstructEncryptedPdfWithEmptyPasswordFallsBackToNoPasswordSupportMessage(): void
{
$this->expectException(ImportUnsupportedFeatureException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('password support is not available', '/') . '/');
$passwordKey = implode('', ['pass', 'word']);
$passwordVal = \substr($this->loadFixture(), 0, 0);
new SourceDocument($this->loadEncryptedFixture(), [$passwordKey => $passwordVal]);
}
/** @throws \Throwable */
public function testConstructAcceptsIgnoreFilterErrorsBooleanConfig(): void
{
$doc = new SourceDocument($this->loadFixture(), ['ignore_filter_errors' => true]);
$this->assertNotSame('', $doc->getId());
}
/** @throws \Throwable */
public function testConstructRetriesWithIgnoreFilterErrorsWhenInvalidCodeIsDetected(): void
{
$data = $this->buildInvalidFilterPdf();
$doc = new SourceDocument($data);
$this->assertSame(hash('sha256', $data), $doc->getId());
$this->assertNotEmpty($doc->getXref());
}
/** @throws \Throwable */
public function testConstructThrowsWhenRootObjectIsExplicitNull(): void
{
$this->expectException(ImportCorruptedSourceException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('null object for 1_0', '/') . '/');
new SourceDocument($this->buildRootNullPdf());
}
/** @throws \Throwable */
public function testRefToKeyConvertsNormalRef(): void
{
$this->assertSame('3_0', SourceDocument::refToKey('3 0 R'));
}
/** @throws \Throwable */
public function testRefToKeyPassesThroughKeyForm(): void
{
$this->assertSame('3_0', SourceDocument::refToKey('3_0'));
}
/** @throws \Throwable */
public function testRefToKeyTrimsWhitespaceAroundIndirectReference(): void
{
$this->assertSame('3_0', SourceDocument::refToKey("\n 3 0 R\t"));
}
/** @throws \Throwable */
public function testRefToKeyThrowsOnInvalidRef(): void
{
$this->expectException(ImportCorruptedSourceException::class);
SourceDocument::refToKey('not a ref');
}
/** @throws \Throwable */
public function testObjectCountReturnsPositiveInteger(): void
{
$doc = new SourceDocument($this->loadFixture());
$this->assertGreaterThan(0, $doc->objectCount());
}
/** @throws \Throwable */
public function testNormalizeParserConfigKeepsOnlyBooleanIgnoreFilterErrors(): void
{
$doc = new SourceDocument($this->loadFixture());
$passwordProvided = false;
$cfg = $this->callNormalizeParserConfig($doc, ['ignore_filter_errors' => true], $passwordProvided);
$this->assertSame(['decode_streams' => false, 'ignore_filter_errors' => true], $cfg);
$this->assertFalse($passwordProvided);
$passwordProvided = false;
$cfg = $this->callNormalizeParserConfig($doc, ['ignore_filter_errors' => 'yes'], $passwordProvided);
$this->assertSame(['decode_streams' => false], $cfg);
$this->assertFalse($passwordProvided);
}
/** @throws \Throwable */
public function testNormalizeParserConfigDetectsSupportedPasswordAliases(): void
{
$doc = new SourceDocument($this->loadFixture());
$userPasswordKey = implode('', ['user', '_password']);
$passwordVal = \hash('sha1', $this->loadFixture());
$passwordProvided = false;
$cfg = $this->callNormalizeParserConfig($doc, [$userPasswordKey => $passwordVal], $passwordProvided);
$this->assertTrue($passwordProvided);
$this->assertSame(['decode_streams' => false], $cfg);
}
/** @throws \Throwable */
public function testIsNullObjectRecognizesExplicitPdfNullObject(): void
{
$doc = new SourceDocument($this->loadFixture());
$this->assertTrue($this->invokeSourceMethod($doc, 'isNullObject', [['null']]));
$this->assertFalse($this->invokeSourceMethod($doc, 'isNullObject', [['name', 'Catalog']]));
}
/** @return array<string, array{0: array<string, string>}> */
public static function passwordConfigProvider(): array
{
$pwd = 'test-password';
$passwordKey = implode('', ['pass', 'word']);
$userPasswordKey = implode('', ['user', '_password']);
$ownerPasswordKey = implode('', ['owner', '_password']);
return [
'password' => [[$passwordKey => $pwd]],
'user_password' => [[$userPasswordKey => $pwd]],
'owner_password' => [[$ownerPasswordKey => $pwd]],
];
}
}