generated from jric11/baseProject
Initial commit
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));
|
||||
}
|
||||
}
|
||||
@@ -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]],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user