Initial commit

This commit is contained in:
2026-08-20 11:41:17 +00:00
commit 6949695eac
2334 changed files with 646393 additions and 0 deletions
@@ -0,0 +1,413 @@
<?php
/**
* ParserFixesTest.php
*
* @since 2011-05-23
* @category Library
* @package Pdfparser
* @author Nicola Asuni <info@tecnick.com>
* @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\Parser;
use PHPUnit\Framework\TestCase;
/**
* Regression tests for parser hardening and stream-decoding fixes.
*
* @phpstan-import-type RawObjectArray from \Com\Tecnick\Pdf\Parser\Process\RawObject
*/
class ParserFixesTest extends TestCase
{
/**
* F1: the same parser instance must be reusable for multiple documents.
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testParserInstanceIsReusableAcrossDocuments(): void
{
$pdf = (string) \file_get_contents('resources/test/example_005.pdf');
$parser = new Parser(['ignore_filter_errors' => true]);
$first = $parser->parse($pdf);
$second = $parser->parse($pdf);
$this->assertSame(\md5(\serialize($first)), \md5(\serialize($second)));
}
/**
* F2: a truncated object body running to EOF must not emit PHP warnings.
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testTruncatedObjectBodyDoesNotEmitWarnings(): void
{
$header = "%PDF-1.4\n";
$objBody = "1 0 obj\n(unterminated literal string with no closing parenthesis\n";
$objOffset = \strlen($header);
$document = $header . $objBody;
$xrefOffset = \strlen($document);
$document .=
"xref\n0 2\n0000000000 65535 f \n"
. $this->xrefInUseEntry($objOffset)
. "trailer\n<< /Size 2 /Root 1 0 R >>\nstartxref\n"
. $xrefOffset
. "\n%%EOF";
$warnings = [];
\set_error_handler(static function (int $_errno, string $errstr) use (&$warnings): bool {
$warnings[] = $errstr;
return true;
});
try {
$parser = new Parser(['ignore_filter_errors' => true]);
$parser->parse($document);
} finally {
\restore_error_handler();
}
$this->assertSame([], $warnings);
}
/**
* F3: a false "endstream" marker inside the payload must not truncate the stream
* when a direct /Length declares the real length.
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testStreamIsNotTruncatedAtFalseEndstreamWithDirectLength(): void
{
$payload = "ABC endstream FAKE\nXYZ-real-tail";
$this->assertSame(
$payload,
$this->extractStreamPayload($this->buildFalseEndstreamPdf((string) \strlen($payload), $payload)),
);
}
/**
* F3: the same protection must work when /Length is an indirect reference.
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testStreamIsNotTruncatedAtFalseEndstreamWithIndirectLength(): void
{
$payload = "ABC endstream FAKE\nXYZ-real-tail";
$this->assertSame($payload, $this->extractStreamPayload($this->buildFalseEndstreamPdf('5 0 R', $payload)));
}
/**
* F4: a PNG predictor declared in DecodeParms of a regular FlateDecode stream
* must be reversed (Colors/BitsPerComponent honoured).
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testPngPredictorIsReversedForRegularFlateStream(): void
{
$colors = 3;
$columns = 2;
$rawRows = [
[10, 20, 30, 40, 50, 60],
[11, 22, 33, 44, 55, 66],
[200, 100, 50, 7, 8, 9],
];
$rawFlat = '';
foreach ($rawRows as $row) {
$rawFlat .= \pack('C*', ...$row);
}
$predicted = '';
foreach ($rawRows as $row) {
// PNG Sub filter (type 1): encoded = raw - left
$predicted .= \chr(1);
$count = \count($row);
for ($i = 0; $i < $count; ++$i) {
$left = $i >= $colors ? (int) ($row[$i - $colors] ?? 0) : 0;
$predicted .= \chr(((int) ($row[$i] ?? 0) - $left) & 0xff);
}
}
$compressed = (string) \gzcompress($predicted);
$dict =
'<< /Length '
. \strlen($compressed)
. ' /Filter /FlateDecode'
. ' /DecodeParms << /Predictor 12 /Colors '
. $colors
. ' /BitsPerComponent 8 /Columns '
. $columns
. ' >> >>';
$header = "%PDF-1.4\n";
$obj1 = "1 0 obj\n" . $dict . "\nstream\n" . $compressed . "\nendstream\nendobj\n";
$obj1Offset = \strlen($header);
$document = $header . $obj1;
$obj2Offset = \strlen($document);
$document .= "2 0 obj\n<< /Type /Catalog >>\nendobj\n";
$xrefOffset = \strlen($document);
$document .=
"xref\n0 3\n0000000000 65535 f \n"
. $this->xrefInUseEntry($obj1Offset)
. $this->xrefInUseEntry($obj2Offset)
. "trailer\n<< /Size 3 /Root 2 0 R >>\nstartxref\n"
. $xrefOffset
. "\n%%EOF";
$parser = new Parser();
[, $objects] = $parser->parse($document);
$decoded = null;
foreach ($objects['1_0'] ?? [] as $element) {
if ($element[0] !== 'stream' || !\array_key_exists(3, $element)) {
continue;
}
$decoded = $element[3][0];
}
$this->assertSame($rawFlat, $decoded);
}
/**
* F4: a TIFF Predictor 2 declared in DecodeParms of a regular FlateDecode stream
* must be reversed.
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testTiffPredictorIsReversedForRegularFlateStream(): void
{
$colors = 1;
$columns = 4;
$rawRows = [
[5, 10, 3, 250],
[1, 2, 3, 4],
];
$rawFlat = '';
foreach ($rawRows as $row) {
$rawFlat .= \pack('C*', ...$row);
}
$predicted = '';
foreach ($rawRows as $row) {
// TIFF horizontal differencing: encoded = sample - sample-to-the-left
for ($i = 0; $i < $columns; ++$i) {
$left = $i >= $colors ? (int) ($row[$i - $colors] ?? 0) : 0;
$predicted .= \chr(((int) ($row[$i] ?? 0) - $left) & 0xff);
}
}
$compressed = (string) \gzcompress($predicted);
$dict =
'<< /Length '
. \strlen($compressed)
. ' /Filter /FlateDecode'
. ' /DecodeParms << /Predictor 2 /Colors '
. $colors
. ' /BitsPerComponent 8 /Columns '
. $columns
. ' >> >>';
$header = "%PDF-1.4\n";
$obj1 = "1 0 obj\n" . $dict . "\nstream\n" . $compressed . "\nendstream\nendobj\n";
$obj1Offset = \strlen($header);
$document = $header . $obj1;
$obj2Offset = \strlen($document);
$document .= "2 0 obj\n<< /Type /Catalog >>\nendobj\n";
$xrefOffset = \strlen($document);
$document .=
"xref\n0 3\n0000000000 65535 f \n"
. $this->xrefInUseEntry($obj1Offset)
. $this->xrefInUseEntry($obj2Offset)
. "trailer\n<< /Size 3 /Root 2 0 R >>\nstartxref\n"
. $xrefOffset
. "\n%%EOF";
$parser = new Parser();
[, $objects] = $parser->parse($document);
$decoded = null;
foreach ($objects['1_0'] ?? [] as $element) {
if ($element[0] !== 'stream' || !\array_key_exists(3, $element)) {
continue;
}
$decoded = $element[3][0];
}
$this->assertSame($rawFlat, $decoded);
}
/**
* F4: a cross-reference stream that uses a predictor must still be decoded
* correctly (the predictor must not be applied twice).
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testCrossReferenceStreamWithPredictorIsNotDoubleDecoded(): void
{
$width = [1, 2, 1];
$rowlen = \max(0, (int) \array_sum($width));
$header = "%PDF-1.4\n";
$obj1Offset = \strlen($header);
$document = $header . "1 0 obj\n<< /Type /Catalog >>\nendobj\n";
$xrefObjOffset = \strlen($document);
$entries = [
[0, 0, 0],
[1, $obj1Offset, 0],
[1, $xrefObjOffset, 0],
];
$predicted = '';
$prev = \array_fill(0, $rowlen, 0);
foreach ($entries as $entry) {
$row = $this->encodeXrefEntry($entry, $width);
$predicted .= \chr(2); // PNG Up filter
for ($i = 0; $i < $rowlen; ++$i) {
$predicted .= \chr(((int) ($row[$i] ?? 0) - (int) ($prev[$i] ?? 0)) & 0xff);
}
$prev = $row;
}
$compressed = (string) \gzcompress($predicted);
$dict =
'<< /Type /XRef /Size 3 /Root 1 0 R /W [1 2 1] /Filter /FlateDecode'
. ' /DecodeParms << /Predictor 12 /Columns '
. $rowlen
. ' >> /Length '
. \strlen($compressed)
. ' >>';
$document .= "2 0 obj\n" . $dict . "\nstream\n" . $compressed . "\nendstream\nendobj\n";
$document .= "startxref\n" . $xrefObjOffset . "\n%%EOF";
$parser = new Parser();
[$xref, $objects] = $parser->parse($document);
$this->assertSame($obj1Offset, $xref['xref']['1_0'] ?? null);
$this->assertSame($xrefObjOffset, $xref['xref']['2_0'] ?? null);
$this->assertSame('1_0', $xref['trailer']['root']);
$this->assertSame('<<', $objects['1_0'][0][0] ?? null);
}
/**
* F7: a trailer dictionary that contains a nested dictionary must be parsed
* without being truncated at the first ">>".
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testTrailerWithNestedDictionaryIsParsed(): void
{
$header = "%PDF-1.4\n";
$objOffset = \strlen($header);
$document = $header . "1 0 obj\n<< /Type /Catalog >>\nendobj\n";
$xrefOffset = \strlen($document);
$document .=
"xref\n0 2\n0000000000 65535 f \n"
. $this->xrefInUseEntry($objOffset)
. "trailer\n<< /Custom << /Nested 1 >> /Size 2 /Root 1 0 R >>\nstartxref\n"
. $xrefOffset
. "\n%%EOF";
$parser = new Parser();
[$xref] = $parser->parse($document);
$this->assertSame('1_0', $xref['trailer']['root']);
$this->assertSame(2, $xref['trailer']['size']);
}
/**
* Build a PDF whose object 1 stream contains a false "endstream" marker.
*
* @param string $lengthEntry The /Length entry value (a number or an "N 0 R" reference).
* @param string $payload The real stream payload.
*/
private function buildFalseEndstreamPdf(string $lengthEntry, string $payload): string
{
$length = \strlen($payload);
$header = "%PDF-1.4\n";
$obj1 = "1 0 obj\n<< /Length " . $lengthEntry . " >>\nstream\n" . $payload . "\nendstream\nendobj\n";
$obj1Offset = \strlen($header);
$document = $header . $obj1;
$obj2Offset = \strlen($document);
$document .= "2 0 obj\n<< /Type /Catalog >>\nendobj\n";
$obj5Offset = \strlen($document);
$document .= "5 0 obj\n" . $length . "\nendobj\n";
$xrefOffset = \strlen($document);
$document .=
"xref\n0 6\n0000000000 65535 f \n"
. $this->xrefInUseEntry($obj1Offset)
. $this->xrefInUseEntry($obj2Offset)
. "0000000000 00000 f \n"
. "0000000000 00000 f \n"
. $this->xrefInUseEntry($obj5Offset)
. "trailer\n<< /Size 6 /Root 2 0 R >>\nstartxref\n"
. $xrefOffset
. "\n%%EOF";
return $document;
}
/**
* Parse a PDF and return the raw payload of the stream in object "1_0".
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
private function extractStreamPayload(string $document): string
{
$parser = new Parser(['decode_streams' => false]);
[, $objects] = $parser->parse($document);
foreach ($objects['1_0'] ?? [] as $element) {
if ($element[0] === 'stream' && \is_string($element[1])) {
return $element[1];
}
}
return '';
}
/**
* Encode a single xref-stream entry into its byte values using the given field widths.
*
* @param array{0: int, 1: int, 2: int} $entry Entry values (type, field2, field3).
* @param array{0: int, 1: int, 2: int} $width Field widths in bytes.
*
* @return array<int, int> Byte values for the encoded row.
*/
private function encodeXrefEntry(array $entry, array $width): array
{
$bytes = [];
foreach ([0, 1, 2] as $field) {
$value = $entry[$field] ?? 0;
$fieldWidth = $width[$field] ?? 0;
for ($byte = $fieldWidth - 1; $byte >= 0; --$byte) {
$bytes[] = ($value >> ($byte * 8)) & 0xff;
}
}
return $bytes;
}
/**
* Build a classic in-use xref entry line for the given object offset.
*/
private function xrefInUseEntry(int $offset): string
{
return \sprintf("%010d 00000 n \n", $offset);
}
}
@@ -0,0 +1,300 @@
<?php
/**
* ParserHarness.php
*
* @since 2011-05-23
* @category Library
* @package Pdfparser
* @author Nicola Asuni <info@tecnick.com>
* @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\Parser;
/**
* @phpstan-import-type RawObjectArray from \Com\Tecnick\Pdf\Parser\Process\RawObject
*/
class ParserHarness extends Parser
{
/**
* @var array{
* 'trailer': array{
* 'encrypt'?: string,
* 'id': array<int, string>,
* 'info': string,
* 'root': string,
* 'size': int,
* },
* 'xref': array<string, int|string>,
* }
*/
private array $stubXrefData = [
'trailer' => [
'id' => [],
'info' => '',
'root' => '',
'size' => 0,
],
'xref' => [],
];
/** @var array<int, array{0:string,1:int,2:bool}> */
private array $indirectCalls = [];
/** @var array<int, RawObjectArray> */
private array $stubIndirectReturn = [['null', 'null', 0]];
/** @var array<int, RawObjectArray> */
private array $rawObjectQueue = [];
private bool $useParentIndirect = false;
private bool $useParentRawObject = false;
/**
* @param array{
* 'trailer': array{
* 'encrypt'?: string,
* 'id': array<int, string>,
* 'info': string,
* 'root': string,
* 'size': int,
* },
* 'xref': array<string, int|string>,
* } $xref
*/
public function setStubXrefData(array $xref): void
{
$this->stubXrefData = $xref;
}
/** @return array<int, array{0:string,1:int,2:bool}> */
public function getIndirectCalls(): array
{
return $this->indirectCalls;
}
/**
* @param array<int, RawObjectArray> $obj
*/
public function setStubIndirectReturn(array $obj): void
{
$this->stubIndirectReturn = $obj;
}
/**
* @param array<int, RawObjectArray> $queue
*/
public function setRawObjectQueue(array $queue): void
{
$this->rawObjectQueue = $queue;
}
public function setPdfDataPublic(string $data): void
{
$this->pdfdata = $data;
}
public function getPdfDataPublic(): string
{
return $this->pdfdata;
}
/**
* @param array<string, array<int, RawObjectArray>> $objects
*/
public function setObjectsPublic(array $objects): void
{
$this->objects = $objects;
}
/**
* @param array<string, int|string> $xref
*/
public function setXrefMapPublic(array $xref): void
{
$this->xref = [
'trailer' => [
'id' => [],
'info' => '',
'root' => '',
'size' => 0,
],
'xref' => $xref,
];
}
/**
* @param RawObjectArray $obj
*
* @return RawObjectArray
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function getObjectValPublic(array $obj): array
{
return $this->getObjectVal($obj);
}
/**
* @param array<string> $filters
* @param array<int, RawObjectArray> $sdic
*
* @return array<string>
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function getFiltersPublic(array $filters, array $sdic, int $key): array
{
return $this->getFilters($filters, $sdic, $key);
}
/**
* @param array<int, RawObjectArray> $sdic
*
* @return array<string, mixed>
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function getDecodeParmsPublic(array $sdic, int $key): array
{
return $this->getDecodeParms($sdic, $key);
}
/**
* @param array<string> $filters
* @param array<string, mixed> $params
*
* @return array{0:string,1:array<string>}
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function getDecodedStreamPublic(array $filters, string $stream, array $params = []): array
{
return $this->getDecodedStream($filters, $stream, $params);
}
/**
* @param array<int, RawObjectArray> $sdic
*
* @return array{0:string,1:array<string>}
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function decodeStreamPublic(array $sdic, string $stream): array
{
return $this->decodeStream($sdic, $stream);
}
/**
* @return array<int, RawObjectArray>
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function getRawIndirectObjectPublic(int $offset, bool $decoding): array
{
return $this->getRawIndirectObject($offset, $decoding);
}
/**
* @return array<int, RawObjectArray>
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function callParentGetIndirectObject(string $obj_ref, int $offset = 0, bool $decoding = true): array
{
$this->useParentIndirect = true;
try {
return $this->getIndirectObject($obj_ref, $offset, $decoding);
} finally {
$this->useParentIndirect = false;
}
}
/**
* Test-only: bypass the queue override below and run the real inherited
* getRawObject against `$this->pdfdata`. Lets tests exercise the real
* processAngular / processBracket loops with arbitrary byte input.
*
* @return RawObjectArray
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function callParentGetRawObject(int $offset = 0): array
{
$this->useParentRawObject = true;
try {
return $this->getRawObject($offset);
} finally {
$this->useParentRawObject = false;
}
}
/**
* @param array{
* 'trailer'?: array{
* 'encrypt'?: string,
* 'id': array<int, string>,
* 'info': string,
* 'root': string,
* 'size': int,
* },
* 'xref'?: array<string, int|string>,
* } $xref
*
* @return array{
* 'trailer': array{
* 'encrypt'?: string,
* 'id': array<int, string>,
* 'info': string,
* 'root': string,
* 'size': int,
* },
* 'xref': array<string, int|string>,
* }
*/
protected function getXrefData(int $offset = 0, array $xref = []): array
{
$unused = [$offset, $xref];
unset($unused);
return $this->stubXrefData;
}
/**
* @return array<int, RawObjectArray>
*/
protected function getIndirectObject(string $obj_ref, int $offset = 0, bool $decoding = true): array
{
if ($this->useParentIndirect) {
return parent::getIndirectObject($obj_ref, $offset, $decoding);
}
$this->indirectCalls[] = [$obj_ref, $offset, $decoding];
return $this->stubIndirectReturn;
}
/**
* @return RawObjectArray
*/
protected function getRawObject(int $offset = 0): array
{
if ($this->useParentRawObject) {
return parent::getRawObject($offset);
}
if (empty($this->rawObjectQueue)) {
return ['endobj', 'endobj', $offset];
}
return \array_shift($this->rawObjectQueue);
}
}
@@ -0,0 +1,920 @@
<?php
/**
* ParserProcessingTest.php
*
* @since 2011-05-23
* @category Library
* @package Pdfparser
* @author Nicola Asuni <info@tecnick.com>
* @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<int, array{0:string,1:int,2:bool}> */
private array $calls = [];
/**
* @return array<int, RawObjectArray>
*/
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<int, array{0:string,1:int,2:bool}> */
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<int, RawObjectArray>
*/
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('<GG>');
$fallback = $parser->callParentGetRawObject(0);
$this->assertSame('<', $fallback[0]);
$this->assertSame('', $fallback[1]);
$this->assertSame(4, $fallback[2]);
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
/**
* ParserTest.php
*
* @since 2011-05-23
* @category Library
* @package Pdfparser
* @author Nicola Asuni <info@tecnick.com>
* @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\Parser;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
/**
* Filter Test
*
* @since 2011-05-23
* @category Library
* @package PdfParser
* @author Nicola Asuni <info@tecnick.com>
* @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
*/
class ParserTest extends TestCase
{
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
#[DataProvider('getParseProvider')]
public function testParse(string $filename, string $hash): void
{
$cfg = [
'ignore_filter_errors' => true,
];
$rawdata = \file_get_contents($filename);
$this->assertNotFalse($rawdata);
$parser = new Parser($cfg);
$data = $parser->parse($rawdata);
$this->assertEquals($hash, \md5(\serialize($data)));
}
/**
* @return array<int, array{0:string, 1:string}>
*/
public static function getParseProvider(): array
{
return [
['resources/test/example_005.pdf', '510a5ea860470dae0781f4bd8d5eb250'],
['resources/test/example_036.pdf', '2869501cf41a4c4a0402c00832329e25'],
['resources/test/example_046.pdf', 'cfaee514b9c09aa282b4e2a8f0061a3d'],
];
}
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testParseHandlesMultiRangeXrefIndexRegression(): void
{
$parser = new Parser(['ignore_filter_errors' => true]);
$data = $parser->parse($this->buildMultiRangeXrefIndexPdf());
$xref = $data[0];
$objects = $data[1];
$this->assertSame('41_0', $xref['trailer']['root']);
$this->assertArrayNotHasKey('encrypt', $xref['trailer']);
$this->assertNotNull($objects['41_0'][0] ?? null);
$this->assertSame('<<', $objects['41_0'][0][0]);
$this->assertNotNull($objects['42_0'][0] ?? null);
$this->assertSame('<<', $objects['42_0'][0][0]);
$this->assertNotNull($objects['43_0'][0] ?? null);
$this->assertSame('<<', $objects['43_0'][0][0]);
$this->assertSame('7aa83a972ca79bafe26be3620b7512e2', \md5(\serialize($data)));
}
private function buildMultiRangeXrefIndexPdf(): string
{
$pdf = "%PDF-1.7\n";
$offsets = [];
$addObject = static function (string &$pdfData, array &$objOffsets, int $objNum, string $body): void {
$objOffsets[$objNum] = \strlen($pdfData);
$pdfData .= $objNum . " 0 obj\n" . $body . "\nendobj\n";
};
$addObject($pdf, $offsets, 3, '<< /Dummy 3 >>');
$addObject($pdf, $offsets, 15, '<< /Dummy 15 >>');
$addObject($pdf, $offsets, 17, '<< /Dummy 17 >>');
$addObject($pdf, $offsets, 18, '<< /Dummy 18 >>');
$addObject($pdf, $offsets, 41, '<< /Type /Catalog /Pages 42 0 R >>');
$addObject($pdf, $offsets, 42, '<< /Type /Pages /Count 1 /Kids [43 0 R] >>');
$addObject($pdf, $offsets, 43, '<< /Type /Page /Parent 42 0 R /MediaBox [0 0 10 10] >>');
$indexObjects = [3, 15, 17, 18, 41, 42, 43];
$bytes = [];
foreach ($indexObjects as $objNum) {
$offset = (int) ($offsets[$objNum] ?? 0);
$bytes[] = 0;
$bytes[] = 1;
$bytes[] = ($offset >> 16) & 0xff;
$bytes[] = ($offset >> 8) & 0xff;
$bytes[] = $offset & 0xff;
}
$stream = \pack('C*', ...$bytes);
$streamLen = \strlen($stream);
$xrefBody =
'<< /Type /XRef'
. ' /Size 60'
. ' /Root 41 0 R'
. ' /Index [3 1 15 1 17 2 41 3]'
. ' /W [1 3 0]'
. ' /Length '
. $streamLen
. ' /DecodeParms << /Columns 4 /Predictor 12 >>'
. " >>\nstream\n"
. $stream
. "\nendstream";
$addObject($pdf, $offsets, 50, $xrefBody);
$startxref = (int) ($offsets[50] ?? 0);
$pdf .= "startxref\n" . $startxref . "\n%%EOF";
return $pdf;
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
/**
* TestCase.php
*
* @since 2011-05-23
* @category Library
* @package Pdfparser
* @author Nicola Asuni <info@tecnick.com>
* @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 PHPUnit\Framework\TestCase as FrameworkTestCase;
/**
* Base test case with cross-version helpers.
*/
abstract class TestCase extends FrameworkTestCase
{
/**
* Assert that the expected exception message contains the given substring.
*
* Uses expectExceptionMessageMatches(), the only message assertion that is
* available and not deprecated across PHPUnit 11.5, 12.5 and 13.2 (PHP 8.2+).
* The deprecated expectExceptionMessage() and the 13.2-only
* expectExceptionMessageIsOrContains() are intentionally avoided.
*
* @param string $message Substring expected within the exception message.
*/
protected function expectExceptionMessageContains(string $message): void
{
$this->expectExceptionMessageMatches('/' . preg_quote($message, '/') . '/');
}
}
+175
View File
@@ -0,0 +1,175 @@
<?php
/**
* XrefHarness.php
*
* @since 2011-05-23
* @category Library
* @package Pdfparser
* @author Nicola Asuni <info@tecnick.com>
* @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\Process\Xref;
/**
* @phpstan-import-type RawObjectArray from \Com\Tecnick\Pdf\Parser\Process\RawObject
*/
class XrefHarness extends Xref
{
/** @var array<int, RawObjectArray> */
private array $stubIndirectObject = [];
/** @var RawObjectArray|null */
private ?array $stubRawObject = null;
public function setPdfDataPublic(string $pdfdata): void
{
$this->pdfdata = $pdfdata;
}
/**
* @param array<int, RawObjectArray> $indirectObject
*/
public function setStubIndirectObject(array $indirectObject): void
{
$this->stubIndirectObject = $indirectObject;
}
/**
* @param RawObjectArray $rawObject
*/
public function setStubRawObject(array $rawObject): void
{
$this->stubRawObject = $rawObject;
}
/**
* @param array{
* 'trailer'?: array{
* 'encrypt'?: string,
* 'id': array<int, string>,
* 'info': string,
* 'root': string,
* 'size': int,
* },
* 'xref'?: array<string, int|string>,
* } $xref
*
* @return array{
* 'trailer': array{
* 'encrypt'?: string,
* 'id': array<int, string>,
* 'info': string,
* 'root': string,
* 'size': int,
* },
* 'xref': array<string, int|string>,
* }
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function getXrefDataPublic(int $offset = 0, array $xref = []): array
{
return $this->getXrefData($offset, $xref);
}
/**
* @param array{
* 'trailer'?: array{
* 'encrypt'?: string,
* 'id': array<int, string>,
* 'info': string,
* 'root': string,
* 'size': int,
* },
* 'xref': array<string, int|string>,
* } $xref
*
* @return array{
* 'trailer': array{
* 'encrypt'?: string,
* 'id': array<int, string>,
* 'info': string,
* 'root': string,
* 'size': int,
* },
* 'xref': array<string, int|string>,
* }
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function decodeXrefPublic(int $startxref, array $xref): array
{
return $this->decodeXref($startxref, $xref);
}
/**
* @param array{
* 'trailer'?: array{
* 'encrypt'?: string,
* 'id': array<int, string>,
* 'info': string,
* 'root': string,
* 'size': int,
* },
* 'xref': array<string, int|string>,
* } $xref
*
* @return array{
* 'trailer': array{
* 'encrypt'?: string,
* 'id': array<int, string>,
* 'info': string,
* 'root': string,
* 'size': int,
* },
* 'xref': array<string, int|string>,
* }
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function decodeXrefStreamPublic(int $startxref, array $xref): array
{
return $this->decodeXrefStream($startxref, $xref);
}
/**
* @param array<int, array<int, int>> $sdata
* @param array<int, array<int, int>> $ddata
* @param array<int, int> $wbt
*/
public function processDdataPublic(array &$sdata, array $ddata, array $wbt): void
{
$this->processDdata($sdata, $ddata, $wbt);
}
/**
* @return array<int, RawObjectArray>
*/
protected function getIndirectObject(string $obj_ref, int $offset = 0, bool $decoding = true): array
{
$unused = [$obj_ref, $offset, $decoding];
unset($unused);
return $this->stubIndirectObject;
}
/**
* @return RawObjectArray
*/
protected function getRawObject(int $offset = 0): array
{
if ($this->stubRawObject !== null) {
return $this->stubRawObject;
}
return parent::getRawObject($offset);
}
}
@@ -0,0 +1,198 @@
<?php
/**
* XrefStreamHarness.php
*
* @since 2011-05-23
* @category Library
* @package Pdfparser
* @author Nicola Asuni <info@tecnick.com>
* @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\Process\XrefStream;
/**
* @phpstan-import-type RawObjectArray from \Com\Tecnick\Pdf\Parser\Process\RawObject
*/
class XrefStreamHarness extends XrefStream
{
/**
* @param array{
* 'trailer': array{
* 'encrypt'?: string,
* 'id': array<int, string>,
* 'info': string,
* 'root': string,
* 'size': int,
* },
* 'xref': array<string, int|string>,
* } $xref
* @param array<int, array<int, int>> $sdata
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function processObjIndexesPublic(array &$xref, int &$obj_num, array $sdata): void
{
$this->processObjIndexes($xref, $obj_num, $sdata);
}
/**
* @param array{
* 'trailer': array{
* 'encrypt'?: string,
* 'id': array<int, string>,
* 'info': string,
* 'root': string,
* 'size': int,
* },
* 'xref': array<string, int|string>,
* } $xref
* @param array<int, int> $objNumbers
* @param array<int, array<int, int>> $sdata
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function processObjIndexesMapPublic(array &$xref, array $objNumbers, array $sdata): void
{
$this->processObjIndexesMap($xref, $objNumbers, $sdata);
}
/**
* @param array{
* 'trailer': array{
* 'encrypt'?: string,
* 'id': array<int, string>,
* 'info': string,
* 'root': string,
* 'size': int,
* },
* 'xref': array<string, int|string>,
* } $xref
* @param array<int, int> $sdatum
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function processSingleObjIndexPublic(array &$xref, int $objNum, array $sdatum): void
{
$this->processSingleObjIndex($xref, $objNum, $sdatum);
}
/**
* @param RawObjectArray|null $indexObj
*
* @return array<int, array{0:int, 1:int}>|null
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function parseXrefIndexSectionsPublic(?array $indexObj): ?array
{
return $this->parseXrefIndexSections($indexObj);
}
/**
* @param array<int, array{0:int, 1:int}> $indexSections
*
* @return array<int, int>
*/
public function buildXrefObjectNumbersPublic(array $indexSections): array
{
return $this->buildXrefObjectNumbers($indexSections);
}
/**
* @param array<int, array<int, int>> $sdata
* @param array<int, array<int, int>> $ddata
* @param array<int, int> $prev_row
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function pngUnpredictorPublic(array $sdata, array &$ddata, int $columns, array $prev_row): void
{
$this->pngUnpredictor($sdata, $ddata, $columns, $prev_row);
}
/**
* @param array<int, array<int, int>> $ddata
* @param array{0:int, 1:int, 2:int} $rows
*/
public function minDistancePublic(array &$ddata, int $key, int $row_value, int $jdx, array $rows): void
{
$this->minDistance($ddata, $key, $row_value, $jdx, $rows);
}
/** @param RawObjectArray|null $next */
public function processXrefPrevPublic(?array $next, ?int &$prevxref): void
{
$this->processXrefPrev($next, $prevxref);
}
/** @param RawObjectArray|null $next */
public function processXrefDecodeParmsPublic(?array $next, int &$columns, int &$predictor): void
{
$this->processXrefDecodeParms($next, $columns, $predictor);
}
/**
* @param array<int, RawObjectArray> $sarr
* @param array{
* trailer: array{encrypt?: string, id: array<int, string>, info: string, root: string, size: int},
* xref: array<string, int|string>,
* } $xref
*/
public function processXrefTypeFtPublic(string $type, array $sarr, int $key, array &$xref, bool $filltrailer): void
{
$this->processXrefTypeFt($type, $sarr, $key, $xref, $filltrailer);
}
/**
* @param array<int, RawObjectArray> $sarr
* @param array{
* trailer: array{encrypt?: string, id: array<int, string>, info: string, root: string, size: int},
* xref: array<string, int|string>,
* } $xref
*
* @return array{
* trailer: array{encrypt?: string, id: array<int, string>, info: string, root: string, size: int},
* xref: array<string, int|string>,
* }
*/
public function processXrefObjrefPublic(string $type, array $sarr, int $key, array $xref): array
{
return $this->processXrefObjref($type, $sarr, $key, $xref);
}
/**
* @param array<int, RawObjectArray> $sarr
* @param array{
* trailer: array{encrypt?: string, id: array<int, string>, info: string, root: string, size: int},
* xref: array<string, int|string>,
* } $xref
* @param array<int, int> $wbt
* @param array{
* index_sections: array<int, array{0:int, 1:int}>|null,
* prevxref: int|null,
* predictor: int,
* columns: int,
* size: int|null,
* valid_crs: bool
* } $state
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function processXrefTypePublic(
array $sarr,
array &$xref,
array &$wbt,
array &$state,
bool $filltrailer,
): void {
$this->processXrefType($sarr, $xref, $wbt, $state, $filltrailer);
}
}
+971
View File
@@ -0,0 +1,971 @@
<?php
/**
* XrefTest.php
*
* @since 2011-05-23
* @category Library
* @package Pdfparser
* @author Nicola Asuni <info@tecnick.com>
* @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;
class XrefTest extends TestCase
{
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testProcessObjIndexesHandlesInUseAndCompressedObjects(): void
{
$xref = [
'trailer' => [
'id' => [],
'info' => '',
'root' => '',
'size' => 0,
],
'xref' => [],
];
$obj_num = 3;
$sdata = [
[1, 42, 0],
[2, 7, 4],
[0, 0, 0],
];
$parser = new XrefStreamHarness();
$parser->processObjIndexesPublic($xref, $obj_num, $sdata);
$this->assertSame(6, $obj_num);
$this->assertSame(42, $xref['xref']['3_0'] ?? null);
$this->assertSame('7_0_4', $xref['xref']['4_0'] ?? null);
}
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testPngUnpredictorDecodesAndRejectsUnknownPredictor(): void
{
$parser = new XrefStreamHarness();
$decoded = [];
$parser->pngUnpredictorPublic([[0, 10]], $decoded, 1, [0]);
if (!isset($decoded[0][0])) {
$this->fail('Decoded first row value is missing.');
}
$this->assertSame(10, $decoded[0][0]);
$decoded = [];
$parser->pngUnpredictorPublic([[1, 5]], $decoded, 1, [0]);
if (!isset($decoded[0][0])) {
$this->fail('Decoded first row value is missing.');
}
$this->assertSame(5, $decoded[0][0]);
$decoded = [];
$parser->pngUnpredictorPublic([[2, 3]], $decoded, 1, [4]);
if (!isset($decoded[0][0])) {
$this->fail('Decoded first row value is missing.');
}
$this->assertSame(7, $decoded[0][0]);
$decoded = [];
$parser->pngUnpredictorPublic([[4, 8]], $decoded, 1, [1]);
if (!isset($decoded[0][0])) {
$this->fail('Decoded first row value is missing.');
}
$this->assertSame(9, $decoded[0][0]);
$this->expectException(PPException::class);
$this->expectExceptionMessageContains('Unknown PNG predictor');
$decoded = [];
$parser->pngUnpredictorPublic([[9, 1]], $decoded, 1, [0]);
}
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testDecodeXrefParsesEntriesAndTrailer(): void
{
$pdf =
"xref\r\n"
. "0 2\r\n"
. "0000000000 65535 f\r\n"
. "0000000017 00000 n\r\n"
. "trailer << /Size 2 /Root 1 0 R /Info 2 0 R /Encrypt 3 0 R /ID [<AA><BB>] >>\r\n";
$parser = new XrefHarness();
$parser->setPdfDataPublic($pdf);
$xref = $parser->decodeXrefPublic(0, ['xref' => []]);
$this->assertSame(17, $xref['xref']['1_0'] ?? null);
$this->assertSame(2, $xref['trailer']['size']);
$this->assertSame('1_0', $xref['trailer']['root']);
$this->assertSame('2_0', $xref['trailer']['info']);
$this->assertSame('3_0', $xref['trailer']['encrypt'] ?? null);
$this->assertSame(['AA', 'BB'], $xref['trailer']['id']);
}
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testDecodeXrefLeavesEncryptUnsetWhenMissing(): void
{
$pdf =
"xref\r\n"
. "0 1\r\n"
. "0000000017 00000 n\r\n"
. "trailer << /Size 1 /Root 1 0 R /Info 2 0 R /ID [<AA><BB>] >>\r\n";
$parser = new XrefHarness();
$parser->setPdfDataPublic($pdf);
$xref = $parser->decodeXrefPublic(0, ['xref' => []]);
$this->assertArrayNotHasKey('encrypt', $xref['trailer']);
}
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testDecodeXrefStreamParsesRowsAndTrailer(): void
{
$stream_data = \pack('C*', 0, 1, 10, 0, 0, 2, 5, 1);
$parser = new XrefHarness();
$parser->setStubRawObject(['objref', '5_0', 0]);
$parser->setStubIndirectObject([
[
'<<',
[
['/', 'Type', 0],
['/', 'XRef', 0],
['/', 'W', 0],
['[', [['numeric', '1', 0], ['numeric', '1', 0], ['numeric', '1', 0]], 0],
['/', 'Index', 0],
['[', [['numeric', '0', 0], ['numeric', '2', 0]], 0],
['/', 'Size', 0],
['numeric', '2', 0],
['/', 'Root', 0],
['objref', '1_0', 0],
['/', 'ID', 0],
['[', [['hex', 'AA', 0], ['hex', 'BB', 0]], 0],
['/', 'DecodeParms', 0],
['<<', [['/', 'Columns', 0], ['numeric', '3', 0], ['/', 'Predictor', 0], ['numeric', '10', 0]], 0],
],
0,
],
['stream', $stream_data, 0, [$stream_data, []]],
]);
$xref = $parser->decodeXrefStreamPublic(100, ['xref' => []]);
$this->assertSame(10, $xref['xref']['0_0'] ?? null);
$this->assertSame('5_0_1', $xref['xref']['1_0'] ?? null);
$this->assertSame('1_0', $xref['trailer']['root']);
$this->assertSame(2, $xref['trailer']['size']);
$this->assertSame(['AA', 'BB'], $xref['trailer']['id']);
}
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testDecodeXrefStreamMapsMultiRangeIndexSections(): void
{
$stream_data = \pack(
'C*',
0,
1,
0,
0,
30,
0,
1,
0,
0,
31,
0,
1,
0,
0,
32,
0,
1,
0,
0,
33,
0,
1,
0,
0,
34,
0,
1,
0,
0,
35,
0,
1,
0,
0,
36,
);
$parser = new XrefHarness();
$parser->setStubRawObject(['objref', '5_0', 0]);
$parser->setStubIndirectObject([
[
'<<',
[
['/', 'Type', 0],
['/', 'XRef', 0],
['/', 'W', 0],
['[', [['numeric', '1', 0], ['numeric', '3', 0], ['numeric', '0', 0]], 0],
['/', 'Index', 0],
[
'[',
[
['numeric', '3', 0],
['numeric', '1', 0],
['numeric', '15', 0],
['numeric', '1', 0],
['numeric', '17', 0],
['numeric', '2', 0],
['numeric', '41', 0],
['numeric', '3', 0],
],
0,
],
['/', 'Size', 0],
['numeric', '44', 0],
['/', 'DecodeParms', 0],
['<<', [['/', 'Columns', 0], ['numeric', '4', 0], ['/', 'Predictor', 0], ['numeric', '10', 0]], 0],
],
0,
],
['stream', $stream_data, 0, [$stream_data, []]],
]);
$xref = $parser->decodeXrefStreamPublic(100, ['xref' => []]);
$this->assertSame(30, $xref['xref']['3_0'] ?? null);
$this->assertSame(31, $xref['xref']['15_0'] ?? null);
$this->assertSame(32, $xref['xref']['17_0'] ?? null);
$this->assertSame(33, $xref['xref']['18_0'] ?? null);
$this->assertSame(34, $xref['xref']['41_0'] ?? null);
$this->assertSame(35, $xref['xref']['42_0'] ?? null);
$this->assertSame(36, $xref['xref']['43_0'] ?? null);
}
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testDecodeXrefStreamFallsBackToSizeWhenIndexIsMissing(): void
{
$stream_data = \pack('C*', 0, 1, 0, 0, 20, 0, 1, 0, 0, 21);
$parser = new XrefHarness();
$parser->setStubRawObject(['objref', '5_0', 0]);
$parser->setStubIndirectObject([
[
'<<',
[
['/', 'Type', 0],
['/', 'XRef', 0],
['/', 'W', 0],
['[', [['numeric', '1', 0], ['numeric', '3', 0], ['numeric', '0', 0]], 0],
['/', 'Size', 0],
['numeric', '2', 0],
['/', 'DecodeParms', 0],
['<<', [['/', 'Columns', 0], ['numeric', '4', 0], ['/', 'Predictor', 0], ['numeric', '10', 0]], 0],
],
0,
],
['stream', $stream_data, 0, [$stream_data, []]],
]);
$xref = $parser->decodeXrefStreamPublic(100, ['xref' => []]);
$this->assertSame(20, $xref['xref']['0_0'] ?? null);
$this->assertSame(21, $xref['xref']['1_0'] ?? null);
}
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testDecodeXrefStreamRejectsOddIndexArrayLength(): void
{
$stream_data = \pack('C*', 0, 1, 0, 0, 20);
$parser = new XrefHarness();
$parser->setStubRawObject(['objref', '5_0', 0]);
$parser->setStubIndirectObject([
[
'<<',
[
['/', 'Type', 0],
['/', 'XRef', 0],
['/', 'W', 0],
['[', [['numeric', '1', 0], ['numeric', '3', 0], ['numeric', '0', 0]], 0],
['/', 'Index', 0],
['[', [['numeric', '3', 0], ['numeric', '1', 0], ['numeric', '15', 0]], 0],
['/', 'Size', 0],
['numeric', '16', 0],
['/', 'DecodeParms', 0],
['<<', [['/', 'Columns', 0], ['numeric', '4', 0], ['/', 'Predictor', 0], ['numeric', '10', 0]], 0],
],
0,
],
['stream', $stream_data, 0, [$stream_data, []]],
]);
$this->expectException(PPException::class);
$this->expectExceptionMessageContains('Invalid xref stream Index array: expected even number of values');
$parser->decodeXrefStreamPublic(100, ['xref' => []]);
}
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testDecodeXrefStreamRejectsRowCountMismatchAgainstIndexCoverage(): void
{
$stream_data = \pack('C*', 0, 1, 0, 0, 20, 0, 1, 0, 0, 21);
$parser = new XrefHarness();
$parser->setStubRawObject(['objref', '5_0', 0]);
$parser->setStubIndirectObject([
[
'<<',
[
['/', 'Type', 0],
['/', 'XRef', 0],
['/', 'W', 0],
['[', [['numeric', '1', 0], ['numeric', '3', 0], ['numeric', '0', 0]], 0],
['/', 'Index', 0],
['[', [['numeric', '3', 0], ['numeric', '3', 0]], 0],
['/', 'Size', 0],
['numeric', '20', 0],
['/', 'DecodeParms', 0],
['<<', [['/', 'Columns', 0], ['numeric', '4', 0], ['/', 'Predictor', 0], ['numeric', '10', 0]], 0],
],
0,
],
['stream', $stream_data, 0, [$stream_data, []]],
]);
$this->expectException(PPException::class);
$this->expectExceptionMessageContains('Invalid xref stream row count: expected 3 rows from Index, got 2');
$parser->decodeXrefStreamPublic(100, ['xref' => []]);
}
public function testProcessDdataUsesDefaultTypeWhenFirstFieldWidthIsZero(): void
{
$parser = new XrefHarness();
$sdata = [];
$parser->processDdataPublic($sdata, [[9, 3]], [0, 1, 1]);
$this->assertSame([1, 9, 3], $sdata[0] ?? null);
}
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testGetXrefDataThrowsWhenStartxrefIsMissing(): void
{
$parser = new XrefHarness();
$parser->setPdfDataPublic("%PDF-1.7\nNo xref markers here");
$this->expectException(PPException::class);
$this->expectExceptionMessageContains('Unable to find startxref (1)');
$parser->getXrefDataPublic();
}
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testProcessObjIndexesIgnoresUnknownEntryTypes(): void
{
$xref = [
'trailer' => [
'id' => [],
'info' => '',
'root' => '',
'size' => 0,
],
'xref' => [],
];
$obj_num = 9;
$parser = new XrefStreamHarness();
$parser->processObjIndexesPublic($xref, $obj_num, [[9, 1, 2]]);
$this->assertSame(10, $obj_num);
$this->assertSame([], $xref['xref']);
}
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testPngUnpredictorCoversAveragePredictor(): void
{
$parser = new XrefStreamHarness();
$decoded = [];
$parser->pngUnpredictorPublic([[3, 4]], $decoded, 1, [2]);
if (!isset($decoded[0][0])) {
$this->fail('Decoded first row value is missing.');
}
$this->assertSame(5, $decoded[0][0]);
}
public function testMinDistanceCoversAllOutcomeBranches(): void
{
$parser = new XrefStreamHarness();
$ddata = [[0]];
$parser->minDistancePublic($ddata, 0, 5, 0, [7, 2, 0]);
$this->assertSame(12, $ddata[0][0] ?? null);
$parser->minDistancePublic($ddata, 0, 5, 0, [10, 3, 9]);
$this->assertSame(8, $ddata[0][0] ?? null);
$parser->minDistancePublic($ddata, 0, 5, 0, [0, 10, 4]);
$this->assertSame(9, $ddata[0][0] ?? null);
}
public function testProcessXrefPrevAndDecodeParmsHandleInvalidInput(): void
{
$parser = new XrefStreamHarness();
$prevxref = null;
$parser->processXrefPrevPublic(['name', 'x', 0], $prevxref);
$this->assertNull($prevxref);
$parser->processXrefPrevPublic(['numeric', '17', 0], $prevxref);
$this->assertSame(17, $prevxref);
$columns = 9;
$predictor = 3;
$parser->processXrefDecodeParmsPublic(['name', 'x', 0], $columns, $predictor);
$this->assertSame(9, $columns);
$this->assertSame(3, $predictor);
$parser->processXrefDecodeParmsPublic(
['<<', [['/', 'Columns', 0], ['numeric', '-5', 0], ['/', 'Predictor', 0], ['numeric', '12', 0]], 0],
$columns,
$predictor,
);
$this->assertSame(0, $columns);
$this->assertSame(12, $predictor);
}
public function testProcessXrefTypeFtAndObjrefCoverTrailerBranches(): void
{
$parser = new XrefStreamHarness();
$xref = [
'trailer' => [
'id' => [],
'info' => '',
'root' => '',
'size' => 0,
],
'xref' => [],
];
$sarr = [
['/', 'Root', 0],
['objref', '1_0', 0],
['/', 'Info', 0],
['objref', '2_0', 0],
['/', 'Encrypt', 0],
['objref', '3_0', 0],
['/', 'Size', 0],
['numeric', '4', 0],
['/', 'ID', 0],
['[', [['hex', 'AA', 0], ['hex', 'BB', 0]], 0],
['/', 'ID', 0],
['[', [['hex', '', 0], ['hex', 'BB', 0]], 0],
];
$parser->processXrefTypeFtPublic('Root', $sarr, 0, $xref, true);
$parser->processXrefTypeFtPublic('Info', $sarr, 2, $xref, true);
$parser->processXrefTypeFtPublic('Encrypt', $sarr, 4, $xref, true);
$parser->processXrefTypeFtPublic('Size', $sarr, 6, $xref, true);
$parser->processXrefTypeFtPublic('ID', $sarr, 8, $xref, true);
$parser->processXrefTypeFtPublic('ID', $sarr, 10, $xref, true);
$this->assertSame('1_0', $xref['trailer']['root']);
$this->assertSame('2_0', $xref['trailer']['info']);
$this->assertSame('3_0', $xref['trailer']['encrypt'] ?? null);
$this->assertSame(4, $xref['trailer']['size']);
$this->assertSame(['AA', 'BB'], $xref['trailer']['id']);
$before = $xref;
$parser->processXrefTypeFtPublic('Size', $sarr, 6, $xref, false);
$this->assertSame($before, $xref);
$objref = $parser->processXrefObjrefPublic('Root', [['/', 'Root', 0], ['name', 'x', 0]], 0, $xref);
$this->assertSame($xref, $objref);
}
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testDecodeXrefThrowsWhenTrailerIsMissing(): void
{
$parser = new XrefHarness();
$parser->setPdfDataPublic("xref\r\n0 1\r\n0000000000 65535 f\r\n");
$this->expectException(PPException::class);
$this->expectExceptionMessageContains('Unable to find trailer');
$parser->decodeXrefPublic(0, ['xref' => []]);
}
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testGetXrefDataDetectsRepeatedOffsetLoop(): void
{
$parser = new XrefHarness();
$parser->setPdfDataPublic("%PDF-1.7\ninvalid\n");
try {
$parser->getXrefDataPublic(5, ['xref' => []]);
} catch (PPException $exception) {
$this->assertContains($exception->getMessage(), [
'Unable to find startxref (3)',
'Unable to find xref (4)',
]);
}
$this->expectException(PPException::class);
$this->expectExceptionMessageContains('LOOP: this XRef offset has been already processed');
$parser->getXrefDataPublic(5, ['xref' => []]);
}
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testGetXrefDataFindsObjectStreamStartxrefFromOffset(): void
{
$stream_data = \pack('C*', 0, 1, 9, 0);
$parser = new XrefHarness();
$parser->setPdfDataPublic('AAAAA12 0 obj .... xref');
$parser->setStubRawObject(['objref', '12_0', 0]);
$parser->setStubIndirectObject([
[
'<<',
[
['/', 'Type', 0],
['/', 'XRef', 0],
['/', 'W', 0],
['[', [['numeric', '1', 0], ['numeric', '1', 0], ['numeric', '1', 0]], 0],
['/', 'Index', 0],
['[', [['numeric', '0', 0], ['numeric', '1', 0]], 0],
['/', 'Size', 0],
['numeric', '1', 0],
['/', 'DecodeParms', 0],
['<<', [['/', 'Columns', 0], ['numeric', '3', 0], ['/', 'Predictor', 0], ['numeric', '10', 0]], 0],
],
0,
],
['stream', $stream_data, 0, [$stream_data, []]],
]);
$xref = $parser->getXrefDataPublic(5, ['xref' => []]);
$this->assertSame(9, $xref['xref']['0_0'] ?? null);
}
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testGetXrefDataFindsStartxrefMarkerWhenOffsetIsNonZero(): void
{
$body = 'xxxxxxxxxxxxxxxxxxxx';
$pdf =
$body
. "xref\r\n"
. "0 1\r\n"
. "0000000001 00000 n\r\n"
. "trailer << /Size 1 >>\r\n"
. "startxref\n20\n%%EOF";
$parser = new XrefHarness();
$parser->setPdfDataPublic($pdf);
$start = (int) \strpos($pdf, 'startxref');
$xref = $parser->getXrefDataPublic($start - 1, ['xref' => []]);
$this->assertSame(1, $xref['xref']['0_0'] ?? null);
}
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testGetXrefDataThrowsStartxref3ForNonZeroOffsetWithoutMarkers(): void
{
$parser = new XrefHarness();
$parser->setPdfDataPublic('%PDF-1.7\nbody without xref hints');
$this->expectException(PPException::class);
$this->expectExceptionMessageContains('Unable to find startxref (3)');
$parser->getXrefDataPublic(5, ['xref' => []]);
}
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testDecodeXrefCallsGetXrefDataWhenTrailerContainsPrev(): void
{
$parser = new class() extends XrefHarness {
public int $capturedOffset = -1;
protected function getXrefData(int $offset = 0, array $xref = []): array
{
$this->capturedOffset = $offset;
$xref['xref']['prev_0'] = $offset;
return [
'trailer' => $xref['trailer'] ?? ['id' => [], 'info' => '', 'root' => '', 'size' => 0],
'xref' => $xref['xref'],
];
}
};
$parser->setPdfDataPublic("xref\r\n0 1\r\n0000000001 00000 n\r\ntrailer << /Size 1 /Prev 11 >>\r\n");
$xref = $parser->decodeXrefPublic(0, ['xref' => []]);
$this->assertSame(11, $parser->capturedOffset);
$this->assertSame(11, $xref['xref']['prev_0'] ?? null);
}
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testDecodeXrefStreamThrowsWhenRawObjectValueIsNotString(): void
{
$parser = new XrefHarness();
$parser->setStubRawObject(['numeric', [], 0]);
$this->expectException(PPException::class);
$this->expectExceptionMessageContains('Unable to find xref stream');
$parser->decodeXrefStreamPublic(0, ['xref' => []]);
}
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testDecodeXrefStreamHandlesNonArrayDictionaryPayload(): void
{
$stream_data = \pack('C*', 0, 1, 9, 0);
$parser = new XrefHarness();
$parser->setStubRawObject(['objref', '1_0', 0]);
$parser->setStubIndirectObject([
['<<', 'not-array', 0],
['stream', $stream_data, 0, [$stream_data, []]],
]);
$xref = $parser->decodeXrefStreamPublic(0, ['xref' => ['9_0' => 99]]);
$this->assertSame(99, $xref['xref']['9_0'] ?? null);
}
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testDecodeXrefStreamCallsGetXrefDataWhenPrevIsPresent(): void
{
$stream_data = \pack('C*', 0, 1, 9, 0);
$parser = new class() extends XrefHarness {
public int $capturedOffset = -1;
protected function getXrefData(int $offset = 0, array $xref = []): array
{
$this->capturedOffset = $offset;
$xref['xref']['prev_0'] = $offset;
return [
'trailer' => $xref['trailer'] ?? ['id' => [], 'info' => '', 'root' => '', 'size' => 0],
'xref' => $xref['xref'],
];
}
};
$parser->setStubRawObject(['objref', '1_0', 0]);
$parser->setStubIndirectObject([
[
'<<',
[
['/', 'Type', 0],
['/', 'XRef', 0],
['/', 'W', 0],
['[', [['numeric', '1', 0], ['numeric', '1', 0], ['numeric', '1', 0]], 0],
['/', 'Index', 0],
['[', [['numeric', '0', 0], ['numeric', '1', 0]], 0],
['/', 'Size', 0],
['numeric', '1', 0],
['/', 'Prev', 0],
['numeric', '77', 0],
['/', 'DecodeParms', 0],
['<<', [['/', 'Columns', 0], ['numeric', '3', 0], ['/', 'Predictor', 0], ['numeric', '10', 0]], 0],
],
0,
],
['stream', $stream_data, 0, [$stream_data, []]],
]);
$xref = $parser->decodeXrefStreamPublic(0, ['xref' => []]);
$this->assertSame(77, $parser->capturedOffset);
$this->assertSame(77, $xref['xref']['prev_0'] ?? null);
}
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testProcessObjIndexesMapRejectsSparseRowLookup(): void
{
$parser = new XrefStreamHarness();
$xref = [
'trailer' => [
'id' => [],
'info' => '',
'root' => '',
'size' => 0,
],
'xref' => [],
];
$this->expectException(PPException::class);
$this->expectExceptionMessageContains('Invalid xref stream row at index 2');
$parser->processObjIndexesMapPublic($xref, [2 => 10], [[1, 20, 0]]);
}
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testProcessSingleObjIndexRejectsMissingMandatoryFields(): void
{
$parser = new XrefStreamHarness();
$xref = [
'trailer' => [
'id' => [],
'info' => '',
'root' => '',
'size' => 0,
],
'xref' => [],
];
try {
$parser->processSingleObjIndexPublic($xref, 8, [1]);
$this->fail('Expected missing offset exception for in-use entry.');
} catch (PPException $exception) {
$this->assertSame(
'Invalid xref stream entry for object 8: missing offset for in-use entry',
$exception->getMessage(),
);
}
$this->expectException(PPException::class);
$this->expectExceptionMessageContains(
'Invalid xref stream entry for object 9: missing object stream reference for compressed entry',
);
$parser->processSingleObjIndexPublic($xref, 9, [2, 55]);
}
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testParseXrefIndexSectionsCoversNullAndValidationErrors(): void
{
$parser = new XrefStreamHarness();
$this->assertNull($parser->parseXrefIndexSectionsPublic(null));
$this->assertNull($parser->parseXrefIndexSectionsPublic(['[', 'oops', 0]));
try {
$parser->parseXrefIndexSectionsPublic(['[', [['name', 'x', 0], ['numeric', '1', 0]], 0]);
$this->fail('Expected invalid numeric token exception.');
} catch (PPException $exception) {
$this->assertSame('Invalid xref stream Index array: expected numeric values', $exception->getMessage());
}
$this->expectException(PPException::class);
$this->expectExceptionMessageContains('Invalid xref stream Index array: values must be non-negative');
$parser->parseXrefIndexSectionsPublic(['[', [['numeric', '-1', 0], ['numeric', '1', 0]], 0]);
}
public function testBuildXrefObjectNumbersExpandsSections(): void
{
$parser = new XrefStreamHarness();
$this->assertSame([3, 4, 5, 10], $parser->buildXrefObjectNumbersPublic([[3, 3], [10, 1]]));
$this->assertSame([], $parser->buildXrefObjectNumbersPublic([]));
}
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testDecodeXrefStreamThrowsWhenIndexAndSizeAreMissing(): void
{
$stream_data = \pack('C*', 1, 9, 0);
$parser = new XrefHarness();
$parser->setStubRawObject(['objref', '5_0', 0]);
$parser->setStubIndirectObject([
[
'<<',
[
['/', 'Type', 0],
['/', 'XRef', 0],
['/', 'W', 0],
['[', [['numeric', '1', 0], ['numeric', '1', 0], ['numeric', '1', 0]], 0],
],
0,
],
['stream', $stream_data, 0, [$stream_data, []]],
]);
$this->expectException(PPException::class);
$this->expectExceptionMessageContains('Unable to determine xref stream Index coverage: missing Index and Size');
$parser->decodeXrefStreamPublic(100, ['xref' => []]);
}
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testDecodeXrefStreamWithoutPredictorUsesRawRows(): void
{
$stream_data = \pack('C*', 1, 9, 0);
$parser = new XrefHarness();
$parser->setStubRawObject(['objref', '5_0', 0]);
$parser->setStubIndirectObject([
[
'<<',
[
['/', 'Type', 0],
['/', 'XRef', 0],
['/', 'W', 0],
['[', [['numeric', '1', 0], ['numeric', '1', 0], ['numeric', '1', 0]], 0],
['/', 'Index', 0],
['[', [['numeric', '0', 0], ['numeric', '1', 0]], 0],
['/', 'Size', 0],
['numeric', '1', 0],
],
0,
],
['stream', $stream_data, 0, [$stream_data, []]],
]);
$xref = $parser->decodeXrefStreamPublic(100, ['xref' => []]);
$this->assertSame(9, $xref['xref']['0_0'] ?? null);
}
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testDecodeXrefBreaksWhenEntryPatternStartsAtDifferentOffset(): void
{
$pdf = "xref\r\nabc\r\n0 1 n\r\ntrailer << /Size 1 >>\r\n";
$parser = new XrefHarness();
$parser->setPdfDataPublic($pdf);
$xref = $parser->decodeXrefPublic(0, ['xref' => ['9_0' => 99]]);
$this->assertSame(99, $xref['xref']['9_0'] ?? null);
$this->assertSame(1, $xref['trailer']['size']);
}
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testParseXrefIndexSectionsRejectsNonArrayTokens(): void
{
$parser = new XrefStreamHarness();
$this->expectException(PPException::class);
$this->expectExceptionMessageContains('Invalid xref stream Index array: expected numeric values');
$parser->parseXrefIndexSectionsPublic(['[', [1 => ['numeric', '1', 0], 2 => ['numeric', '2', 0]], 0]);
}
/**
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function testProcessXrefTypeSkipsSlashTokenWithNonStringName(): void
{
$parser = new XrefStreamHarness();
$xref = [
'trailer' => [
'id' => [],
'info' => '',
'root' => '',
'size' => 0,
],
'xref' => [],
];
$wbt = [0, 0, 0];
$state = [
'index_sections' => null,
'prevxref' => null,
'predictor' => 0,
'columns' => 0,
'size' => null,
'valid_crs' => false,
];
$parser->processXrefTypePublic(
[
['/', [['numeric', '1', 0]], 0],
['numeric', '7', 0],
],
$xref,
$wbt,
$state,
true,
);
$this->assertSame([], $xref['xref']);
$this->assertFalse($state['valid_crs']);
}
public function testProcessXrefObjrefReturnsWhenObjrefValueIsNotString(): void
{
$parser = new XrefStreamHarness();
$xref = [
'trailer' => [
'id' => [],
'info' => '',
'root' => '',
'size' => 0,
],
'xref' => [],
];
$result = $parser->processXrefObjrefPublic(
'Root',
[
['/', 'Root', 0],
['objref', [['numeric', '1', 0]], 0],
],
0,
$xref,
);
$this->assertSame($xref, $result);
}
}