Files
2026-08-30 22:02:02 +00:00

874 lines
29 KiB
PHP

<?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);
}
}