added composer files and installed vendor packages. Also modified xsls to display base project
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user