* @copyright 2011-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-parser * * This file is part of tc-lib-pdf-parser software library. */ namespace Test; use Com\Tecnick\Pdf\Parser\Exception as PPException; /** * @phpstan-import-type RawObjectArray from \Com\Tecnick\Pdf\Parser\Process\RawObject */ class ParserProcessingTest extends TestCase { /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testParseRejectsEmptyAndInvalidData(): void { $parser = new ParserHarness(); $this->expectException(PPException::class); $this->expectExceptionMessageContains('Empty PDF data.'); $parser->parse(''); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testParseRejectsDataWithoutPdfHeader(): void { $parser = new ParserHarness(); $this->expectException(PPException::class); $this->expectExceptionMessageContains('Invalid PDF data: missing %PDF header.'); $parser->parse('not a pdf'); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testParseLoadsPositiveOffsetsOnlyAndClearsPdfData(): void { $parser = new ParserHarness(); $parser->setStubXrefData([ 'trailer' => [ 'id' => [], 'info' => '', 'root' => '1_0', 'size' => 3, ], 'xref' => [ '1_0' => 10, '2_0' => 0, '3_0' => -1, ], ]); $parser->setStubIndirectReturn([['numeric', '42', 0]]); $parsed = $parser->parse("junk%PDF-1.7\n"); $calls = $parser->getIndirectCalls(); $firstCall = $calls[0] ?? null; $this->assertCount(1, $calls); $this->assertSame(['1_0', 10, true], $firstCall); $this->assertSame('', $parser->getPdfDataPublic()); $this->assertArrayHasKey('1_0', $parsed[1]); $this->assertArrayNotHasKey('2_0', $parsed[1]); $this->assertArrayNotHasKey('3_0', $parsed[1]); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testParseUsesDecodeStreamsConfigForDirectObjects(): void { $parser = new ParserHarness(); $parser->setStubXrefData([ 'trailer' => [ 'id' => [], 'info' => '', 'root' => '1_0', 'size' => 2, ], 'xref' => [ '1_0' => 10, ], ]); $parser->setStubIndirectReturn([['numeric', '42', 0]]); $parser->parse("%PDF-1.7\n"); $calls = $parser->getIndirectCalls(); $this->assertSame(['1_0', 10, true], $calls[0] ?? null); $lazy = new ParserHarness(['decode_streams' => false]); $lazy->setStubXrefData([ 'trailer' => [ 'id' => [], 'info' => '', 'root' => '1_0', 'size' => 2, ], 'xref' => [ '1_0' => 10, ], ]); $lazy->setStubIndirectReturn([['numeric', '42', 0]]); $lazy->parse("%PDF-1.7\n"); $lazyCalls = $lazy->getIndirectCalls(); $this->assertSame(['1_0', 10, false], $lazyCalls[0] ?? null); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testParentIndirectObjectRejectsInvalidReference(): void { $parser = new ParserHarness(); $parser->setPdfDataPublic("%PDF-1.7\n1 0 obj\nendobj\n"); $this->expectException(PPException::class); $this->expectExceptionMessageContains('Invalid object reference:'); $parser->callParentGetIndirectObject('invalid', 0, true); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testParentIndirectObjectReturnsEmptyResultWhenTargetIsMissing(): void { $parser = new ParserHarness(); $parser->setPdfDataPublic("%PDF-1.7\n"); $obj = $parser->callParentGetIndirectObject('1_0', 0, true); $this->assertSame([], $obj); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testRawIndirectObjectDecodesStreamWhenDictionaryIsPresent(): void { $parser = new ParserHarness(); $parser->setRawObjectQueue([ ['<<', [['/', 'Length', 0], ['numeric', '3', 0]], 5], ['stream', 'abcdef', 12], ['endobj', 'endobj', 18], ]); $objdata = $parser->getRawIndirectObjectPublic(0, true); $this->assertCount(2, $objdata); $entry = $objdata[1] ?? null; if (!\is_array($entry)) { $this->fail('Missing decoded stream entry at index 1.'); } $this->assertSame('stream', $entry[0]); $this->assertSame('abcdef', $entry[1]); $decoded = $entry[3] ?? null; if (!\is_array($decoded)) { $this->fail('Decoded stream payload not available.'); } $this->assertSame('abc', $decoded[0]); $this->assertSame([], $decoded[1]); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testGetFiltersParsesSingleAndArraySyntax(): void { $parser = new ParserHarness(); $single = [ ['/', 'Filter', 0], ['/', 'FlateDecode', 0], ]; $filters = $parser->getFiltersPublic([], $single, 0); $this->assertSame(['FlateDecode'], $filters); $list = [ ['/', 'Filter', 0], [ '[', [ ['/', 'FlateDecode', 0], ['numeric', '1', 0], ['/', 'ASCIIHexDecode', 0], ], 0, ], ]; $filters = $parser->getFiltersPublic([], $list, 0); $this->assertSame(['FlateDecode', 'ASCIIHexDecode'], $filters); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testGetObjectValResolvesCachedAndMappedObjectReferences(): void { $parser = new ParserHarness(); $parser->setObjectsPublic([ '3_0' => [['string', 'cached', 0]], ]); $cached = $parser->getObjectValPublic(['objref', '3_0', 0]); $this->assertSame(['string', 'cached', 0], $cached); $parser = new ParserHarness(); $parser->setXrefMapPublic(['4_0' => 99]); $parser->setStubIndirectReturn([['string', 'loaded', 0]]); $loaded = $parser->getObjectValPublic(['objref', '4_0', 0]); $this->assertSame(['string', 'loaded', 0], $loaded); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testGetDecodedStreamTracksErrorsWhenConfiguredToIgnore(): void { $parser = new ParserHarness(['ignore_filter_errors' => true]); $result = $parser->getDecodedStreamPublic(['UnknownFilter'], 'sample-data'); $this->assertSame('sample-data', $result[0]); $this->assertSame(['UnknownFilter'], $result[1]); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testGetDecodedStreamThrowsWhenFilterErrorsAreNotIgnored(): void { $parser = new ParserHarness(); $this->expectException(PPException::class); $parser->getDecodedStreamPublic(['UnknownFilter'], 'sample-data'); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testParentIndirectObjectFindsObjectAfterOffsetShift(): void { $parser = new ParserHarness(); $parser->setPdfDataPublic("%PDF-1.7\nX1 0 obj\nendobj\n"); $parser->setRawObjectQueue([ ['numeric', '7', 16], ['endobj', 'endobj', 22], ]); $obj = $parser->callParentGetIndirectObject('1_0', 9, true); $this->assertSame([['numeric', '7', 16]], $obj); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testGetFiltersHandlesMissingAndInvalidArrayPayload(): void { $parser = new ParserHarness(); $filters = ['FlateDecode']; $this->assertSame($filters, $parser->getFiltersPublic($filters, [['/', 'Filter', 0]], 0)); $invalid = [ ['/', 'Filter', 0], ['[', 'invalid', 0], ]; $this->assertSame([], $parser->getFiltersPublic([], $invalid, 0)); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testGetDecodeParmsHandlesDictionaryArrayAndMissingValues(): void { $parser = new ParserHarness(); $this->assertSame([], $parser->getDecodeParmsPublic([['/', 'DecodeParms', 0]], 0)); $dict = [ ['/', 'DecodeParms', 0], [ '<<', [ ['/', 'Columns', 0], ['numeric', '5', 0], ['/', 'EarlyChange', 0], ['true', 'true', 0], ['/', 'FilterName', 0], ['/', 'FlateDecode', 0], ['/', 'Text', 0], ['string', 'abc', 0], ['/', 'Ignored', 0], ['[', [], 0], ], 0, ], ]; $this->assertSame( [ 'Columns' => 5, 'EarlyChange' => true, 'FilterName' => 'FlateDecode', 'Text' => 'abc', ], $parser->getDecodeParmsPublic($dict, 0), ); $array = [ ['/', 'DecodeParms', 0], [ '[', [ ['null', 'null', 0], [ '<<', [ ['/', 'Rows', 0], ['numeric', '2', 0], ['/', 'Enabled', 0], ['false', 'false', 0], ], 0, ], ], 0, ], ]; $this->assertSame( [ 'Rows' => 2, 'Enabled' => false, ], $parser->getDecodeParmsPublic($array, 0), ); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testDecodeStreamHandlesEmptyStreamAndDecodeParmsExtraction(): void { $parser = new ParserHarness(['ignore_filter_errors' => true]); $this->assertSame(['', []], $parser->decodeStreamPublic([], '')); $sdic = [ ['/', 'Filter', 0], ['/', 'UnknownFilter', 0], ['/', 'DecodeParms', 0], [ '<<', [ ['/', 'Columns', 0], ['numeric', '3', 0], ['/', 'Predictor', 0], ['numeric', '12', 0], ], 0, ], ]; $result = $parser->decodeStreamPublic($sdic, 'abc'); $this->assertSame('abc', $result[0]); $this->assertSame(['UnknownFilter'], $result[1]); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testParentIndirectObjectReturnsNullObjectWhenSearchMissesTwice(): void { $parser = new ParserHarness(); $parser->setPdfDataPublic("%PDF-1.7\nno objects\n"); $obj = $parser->callParentGetIndirectObject('1_0', 2, true); $this->assertSame([['null', 'null', 3]], $obj); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testGetFiltersSkipsSlashEntriesWithNonStringNames(): void { $parser = new ParserHarness(); $sdic = [ ['/', 'Filter', 0], [ '[', [ ['/', [['numeric', '1', 0]], 0], ['/', 'FlateDecode', 0], ], 0, ], ]; $filters = $parser->getFiltersPublic([], $sdic, 0); $this->assertSame(['FlateDecode'], $filters); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testGetDecodeParmsSkipsInvalidPairsAcrossLoopChecks(): void { $parser = new ParserHarness(); $sdic = [ ['/', 'DecodeParms', 0], [ '<<', [ ['numeric', '0', 0], ['numeric', '1', 0], ['/', 'MissingValue', 0], ['null', 'null', 0], ['/', 'Valid', 0], ['numeric', '7', 0], ['/', 'DanglingKey', 0], ], 0, ], ]; $params = $parser->getDecodeParmsPublic($sdic, 0); $this->assertSame(['Valid' => 7], $params); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testParseResolvesCompressedObjectFromObjectStream(): void { $parser = new ParserHarness(); $parser->setStubXrefData([ 'trailer' => [ 'id' => [], 'info' => '', 'root' => '1_0', 'size' => 3, ], 'xref' => [ '1_0' => 10, '2_0' => '1_0_0', ], ]); $parser->setStubIndirectReturn($this->buildObjectStreamObject(1, 4, '2 0 (A)')); $parsed = $parser->parse("%PDF-1.7\n"); $this->assertArrayHasKey('2_0', $parsed[1]); $obj = $parsed[1]['2_0'] ?? null; $this->assertIsArray($obj); $this->assertNotEmpty($obj); $this->assertCount(1, $parser->getIndirectCalls()); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testParseWithLazyStreamsReparsesObjectStreamForDecodedPayload(): void { $parser = new class(['decode_streams' => false]) extends ParserHarness { /** @var array */ private array $calls = []; /** * @return array */ protected function getIndirectObject(string $obj_ref, int $offset = 0, bool $decoding = true): array { $this->calls[] = [$obj_ref, $offset, $decoding]; if ($obj_ref !== '1_0') { return [['null', 'null', 0]]; } if ($decoding) { return [ [ '<<', [ ['/', 'Type', 0], ['/', 'ObjStm', 0], ['/', 'N', 0], ['numeric', '1', 0], ['/', 'First', 0], ['numeric', '4', 0], ], 0, ], ['stream', 'raw', 0, ['2 0 (A)', []]], ]; } return [ [ '<<', [ ['/', 'Type', 0], ['/', 'ObjStm', 0], ['/', 'N', 0], ['numeric', '1', 0], ['/', 'First', 0], ['numeric', '4', 0], ], 0, ], ['stream', 'raw', 0], ]; } /** @return array */ public function getCalls(): array { return $this->calls; } }; $parser->setStubXrefData([ 'trailer' => [ 'id' => [], 'info' => '', 'root' => '1_0', 'size' => 3, ], 'xref' => [ '1_0' => 10, '2_0' => '1_0_0', ], ]); $parsed = $parser->parse("%PDF-1.7\n"); $this->assertArrayHasKey('2_0', $parsed[1]); $calls = $parser->getCalls(); $this->assertSame(['1_0', 10, false], $calls[0] ?? null); $this->assertSame(['1_0', 10, true], $calls[1] ?? null); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testParseSkipsObjectAlreadyInjectedDuringIteration(): void { $parser = new class() extends ParserHarness { protected function getIndirectObject(string $obj_ref, int $offset = 0, bool $decoding = true): array { $obj = parent::getIndirectObject($obj_ref, $offset, $decoding); if ($obj_ref === '1_0') { $this->objects['2_0'] = [['string', 'prefilled', 0]]; } return $obj; } }; $parser->setStubXrefData([ 'trailer' => [ 'id' => [], 'info' => '', 'root' => '1_0', 'size' => 3, ], 'xref' => [ '1_0' => 10, '2_0' => 20, ], ]); $parser->setStubIndirectReturn([['numeric', '9', 0]]); $parsed = $parser->parse("%PDF-1.7\n"); $calls = $parser->getIndirectCalls(); $this->assertCount(1, $calls); $first = $calls[0] ?? null; $this->assertIsArray($first); $this->assertSame('1_0', $first[0]); $this->assertSame('prefilled', $parsed[1]['2_0'][0][1] ?? ''); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testParseSkipsCompressedObjectWhenStreamEnvelopeIsInvalid(): void { $parser = new ParserHarness(); $parser->setStubXrefData([ 'trailer' => [ 'id' => [], 'info' => '', 'root' => '1_0', 'size' => 3, ], 'xref' => [ '1_0' => 10, '2_0' => '1_0_0', ], ]); $parser->setStubIndirectReturn([ ['<<', [['/', 'N', 0], ['numeric', '1', 0], ['/', 'First', 0], ['numeric', '4', 0]], 0], ['stream', 'raw', 0], ]); $parsed = $parser->parse("%PDF-1.7\n"); $this->assertArrayNotHasKey('2_0', $parsed[1]); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testGetObjectValReturnsOriginalWhenCompressedLookupFails(): void { $parser = new ParserHarness(); $parser->setXrefMapPublic([ '2_0' => '1_0_0', '1_0' => 0, ]); $obj = ['objref', '2_0', 0]; $result = $parser->getObjectValPublic($obj); $this->assertSame($obj, $result); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testGetObjectValReturnsOriginalForNonPositiveOffsetAndInvalidLocator(): void { $parser = new ParserHarness(); $parser->setXrefMapPublic(['2_0' => 0]); $obj = ['objref', '2_0', 0]; $this->assertSame($obj, $parser->getObjectValPublic($obj)); $parser = new ParserHarness(); $parser->setXrefMapPublic(['2_0' => 'bad_locator']); $this->assertSame($obj, $parser->getObjectValPublic($obj)); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testGetObjectValReturnsOriginalWhenCachedObjectStreamNeedsMissingReparseOffset(): void { $parser = new ParserHarness(); $parser->setObjectsPublic([ '1_0' => [ [ '<<', [ ['/', 'Type', 0], ['/', 'ObjStm', 0], ['/', 'N', 0], ['numeric', '1', 0], ['/', 'First', 0], ['numeric', '4', 0], ], 0, ], ['stream', 'raw', 0], ], ]); $parser->setXrefMapPublic([ '2_0' => '1_0_0', ]); $obj = ['objref', '2_0', 0]; $this->assertSame($obj, $parser->getObjectValPublic($obj)); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testGetObjectValReturnsOriginalWhenIndirectObjectParsesEmpty(): void { $parser = new ParserHarness(); $parser->setXrefMapPublic(['2_0' => 10]); $parser->setStubIndirectReturn([]); $obj = ['objref', '2_0', 0]; $this->assertSame($obj, $parser->getObjectValPublic($obj)); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testGetObjectValReturnsFirstElementForResolvedCompressedObject(): void { $parser = new ParserHarness(); $parser->setXrefMapPublic([ '2_0' => '1_0_0', '1_0' => 12, ]); $parser->setStubIndirectReturn($this->buildObjectStreamObject(1, 4, '2 0 (A)')); $resolved = $parser->getObjectValPublic(['objref', '2_0', 0]); $this->assertNotSame('objref', $resolved[0]); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testGetObjectValHandlesObjectStreamIndexAndBodyValidation(): void { $parser = new ParserHarness(); $parser->setXrefMapPublic([ '2_0' => '1_0_0', '1_0' => 12, ]); $parser->setStubIndirectReturn($this->buildObjectStreamObject(1, 4, 'x y (A)')); $obj = ['objref', '2_0', 0]; $this->assertSame($obj, $parser->getObjectValPublic($obj)); $parser = new ParserHarness(); $parser->setXrefMapPublic([ '2_0' => '1_0_0', '1_0' => 12, ]); $parser->setStubIndirectReturn($this->buildObjectStreamObject(1, 4, '0 0 ()')); $this->assertSame($obj, $parser->getObjectValPublic($obj)); $parser = new ParserHarness(); $parser->setXrefMapPublic([ '2_0' => '1_0_0', '1_0' => 12, ]); $parser->setStubIndirectReturn($this->buildObjectStreamObject(0, 4, '2 0 (A)')); $this->assertSame($obj, $parser->getObjectValPublic($obj)); $parser = new ParserHarness(); $parser->setXrefMapPublic([ '2_0' => '1_0_0', '1_0' => 12, ]); $parser->setStubIndirectReturn($this->buildObjectStreamObject(1, 0, '')); $this->assertSame($obj, $parser->getObjectValPublic($obj)); $parser = new ParserHarness(); $parser->setXrefMapPublic([ '2_0' => '1_0_0', '1_0' => 12, ]); $parser->setStubIndirectReturn($this->buildObjectStreamObject(1, 6, '2 -10 ')); $this->assertSame($obj, $parser->getObjectValPublic($obj)); $parser = new ParserHarness(); $parser->setXrefMapPublic([ '2_0' => '1_0_0', '1_0' => 12, ]); $parser->setStubIndirectReturn($this->buildObjectStreamObject(1, 4, '2 0 ')); $this->assertSame($obj, $parser->getObjectValPublic($obj)); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testGetDecodeParmsHandlesSparseDictionaryIndexes(): void { $parser = new ParserHarness(); $sdic = [ ['/', 'DecodeParms', 0], [ '<<', [ 2 => ['/', 'Columns', 0], 3 => ['numeric', '5', 0], ], 0, ], ]; $this->assertSame([], $parser->getDecodeParmsPublic($sdic, 0)); } /** * @return array */ private function buildObjectStreamObject(int $n, int $first, string $decodedData): array { return [ [ '<<', [ ['/', 'Type', 0], ['/', 'ObjStm', 0], ['/', 'N', 0], ['numeric', (string) $n, 0], ['/', 'First', 0], ['numeric', (string) $first, 0], ], 0, ], ['stream', 'raw', 0, [$decodedData, []]], ]; } /** * Regression: processAngular() must bail out when getRawObject() fails * to advance $offset, rather than spinning forever and exhausting PHP * memory. Without the guard, the inner do-while loop accumulates * identical zero-length tokens at the same offset until OOM. * * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testProcessAngularBailsOnNonAdvancingByte(): void { // `<<` then `~` — a byte that processDefault() cannot consume — and // no `>>` terminator. Before the fix this hangs / OOMs in // RawObject::processAngular() at the inner do-while loop. $parser = new ParserHarness(); $parser->setPdfDataPublic('<<~'); $element = $parser->callParentGetRawObject(0); $this->assertIsArray($element); $this->assertSame('<<', $element[0]); $this->assertIsArray($element[1]); // The non-advancing guard must short-circuit within a single // iteration; the trailing array_pop then leaves $objval empty. $this->assertLessThan(5, \count($element[1])); } /** * Regression: processBracket() must bail out on a non-advancing parse, * for the same reasons as the dictionary loop above. * * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testProcessBracketBailsOnNonAdvancingByte(): void { $parser = new ParserHarness(); $parser->setPdfDataPublic('[~'); $element = $parser->callParentGetRawObject(0); $this->assertIsArray($element); $this->assertSame('[', $element[0]); $this->assertIsArray($element[1]); $this->assertLessThan(5, \count($element[1])); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testParentRawObjectParsesBooleanKeywords(): void { $parser = new ParserHarness(); $parser->setPdfDataPublic('true false'); $first = $parser->callParentGetRawObject(0); $second = $parser->callParentGetRawObject(5); $this->assertSame('boolean', $first[0]); $this->assertSame('true', $first[1]); $this->assertSame('boolean', $second[0]); $this->assertSame('false', $second[1]); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testParentRawObjectParsesNestedParentheses(): void { $parser = new ParserHarness(); $parser->setPdfDataPublic('((ab))'); $element = $parser->callParentGetRawObject(0); $this->assertSame('(', $element[0]); $this->assertSame('(ab)', $element[1]); } /** * @throws \Com\Tecnick\Pdf\Parser\Exception */ public function testParentRawObjectParsesHexStringAndMalformedHexFallback(): void { $parser = new ParserHarness(); $parser->setPdfDataPublic('< 4A 4B >'); $hex = $parser->callParentGetRawObject(0); $this->assertSame('<', $hex[0]); $this->assertSame(' 4A 4B ', $hex[1]); $parser = new ParserHarness(); $parser->setPdfDataPublic(''); $fallback = $parser->callParentGetRawObject(0); $this->assertSame('<', $fallback[0]); $this->assertSame('', $fallback[1]); $this->assertSame(4, $fallback[2]); } }