Files

1040 lines
34 KiB
PHP
Raw Permalink Normal View History

2026-10-08 23:10:11 +00:00
<?php
declare(strict_types=1);
/**
* Parser.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 Com\Tecnick\Pdf\Parser;
use Com\Tecnick\Pdf\Filter\Filter;
use Com\Tecnick\Pdf\Parser\Exception as PPException;
/**
* Com\Tecnick\Pdf\Parser\Parser
*
* PHP class for parsing PDF documents.
*
* @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
*
* @SuppressWarnings("PHPMD.ExcessiveClassComplexity")
*
* @phpstan-import-type RawObjectArray from \Com\Tecnick\Pdf\Parser\Process\RawObject
* @phpstan-import-type XrefData from \Com\Tecnick\Pdf\Parser\Process\XrefStream
*/
class Parser extends \Com\Tecnick\Pdf\Parser\Process\Xref
{
/**
* Cache of decoded object streams keyed by object stream reference.
*
* @var array<string, array<string, array<int, RawObjectArray>>>
*/
private array $objstmCache = [];
/**
* Array of configuration parameters.
*
* @var array<string, bool>
*/
private array $cfg = [
'ignore_filter_errors' => false,
'decode_streams' => true,
];
/**
* Initialize the PDF parser
*
* @param array<string, bool> $cfg Array of configuration parameters:
* 'ignore_filter_errors' :
* if true ignore filter decoding
* errors;
* 'decode_streams':
* if true, decode stream payloads while parsing
* regular indirect objects.
*/
public function __construct(array $cfg = [])
{
if (\array_key_exists('ignore_filter_errors', $cfg)) {
$this->cfg['ignore_filter_errors'] = $cfg['ignore_filter_errors'];
}
if (\array_key_exists('decode_streams', $cfg)) {
$this->cfg['decode_streams'] = $cfg['decode_streams'];
}
}
/**
* Parse a PDF document into an array of objects
*
* @param string $data PDF data to parse.
*
* @return array{
* 0: XrefData,
* 1: array<string, array<int, RawObjectArray>>,
* }
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
public function parse(string $data): array
{
if ($data === '') {
throw new PPException('Empty PDF data.');
}
// find the pdf header starting position
if (($trimpos = \strpos($data, '%PDF-')) === false) {
throw new PPException('Invalid PDF data: missing %PDF header.');
}
// get PDF content string
$this->pdfdata = \substr($data, $trimpos);
// reset per-document state so the same instance can be reused for multiple parses
$this->mrkoff = [];
$this->objstmCache = [];
$this->objects = [];
$this->xref = self::XREF_EMPTY;
// get xref and trailer data
$this->xref = $this->getXrefData();
// parse all document objects
$decodeStreams = $this->cfg['decode_streams'] ?? true;
foreach ($this->xref['xref'] as $obj => $offset) {
if (\array_key_exists($obj, $this->objects)) {
continue;
}
if (\is_int($offset)) {
if ($offset <= 0) {
continue;
}
// decode objects with positive offset
$this->objects[$obj] = $this->getIndirectObject($obj, $offset, $decodeStreams);
continue;
}
if (\preg_match('/^\d+_\d+_\d+$/', $offset) === 1) {
$compressedObj = $this->getCompressedObject($obj, $offset);
if ($compressedObj !== null) {
$this->objects[$obj] = $compressedObj;
}
}
}
$this->pdfdata = '';
return [$this->xref, $this->objects];
}
/**
* Get content of indirect object.
*
* @param string $obj_ref Object number and generation number separated by underscore character.
* @param int $offset Object offset.
* @param bool $decoding If true decode streams.
*
* @return array<int, RawObjectArray> Object data.
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
protected function getIndirectObject(string $obj_ref, int $offset = 0, bool $decoding = true): array
{
$obj = \explode('_', $obj_ref);
if (\count($obj) !== 2) {
throw new PPException('Invalid object reference: ' . \serialize($obj));
}
/** @var array{0: string, 1: string} $obj */
$objref = $obj[0] . ' ' . $obj[1] . ' obj';
// ignore leading zeros
$offset += \strspn($this->pdfdata, '0', $offset);
$objPos = \strpos($this->pdfdata, $objref, $offset);
if ((int) $objPos !== (int) $offset) {
++$offset;
$objPos = \strpos($this->pdfdata, $objref, $offset);
if ((int) $objPos !== (int) $offset) {
// an indirect reference to an undefined object shall be considered a reference to the null object
return [['null', 'null', $offset]];
}
}
// starting position of object content
$offset += \strlen($objref);
// return raw object content
return $this->getRawIndirectObject($offset, $decoding);
}
/**
* Get content of indirect object.
*
* @param int $offset Object offset.
* @param bool $decoding If true decode streams.
*
* @return array<int, RawObjectArray> Object data.
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
protected function getRawIndirectObject(int $offset, bool $decoding): array
{
// get array of object content
$objdata = [];
$idx = 0; // object main index
/** @var RawObjectArray|null $prevElement previously parsed element (null on first iteration) */
$prevElement = null;
do {
$oldoffset = $offset;
$element = $this->getRawObject($offset);
$offset = $element[2];
// capture the stream payload start before any nested tokenization can clobber it
$streamDataStart = $this->streamDataStart;
// decode stream using stream's dictionary information
if (
$element[0] === 'stream'
&& \is_array($prevElement)
&& $prevElement[0] === '<<'
&& \is_array($prevElement[1])
&& \is_string($element[1])
) {
/** @var array<int, RawObjectArray> $sdic */
$sdic = $prevElement[1];
// re-slice the payload using the declared /Length when the first "endstream"
// marker turned out to be a false positive inside the binary data
$reslice = $this->resliceStreamByLength($sdic, $streamDataStart, $element[1]);
if ($reslice !== null) {
$element[1] = $reslice['stream'];
$offset = $reslice['offset'];
}
if ($decoding) {
$element[3] = $this->decodeStream($sdic, $element[1]);
}
}
$objdata[$idx] = $element;
$prevElement = $element;
++$idx;
} while ($element[0] !== 'endobj' && (int) $offset !== (int) $oldoffset);
// remove closing delimiter
\array_pop($objdata);
// return raw object content
return $objdata;
}
/**
* Re-slice a stream payload using the declared /Length when the first "endstream"
* marker found by the tokenizer was a false positive inside the binary data.
*
* @param array<int, RawObjectArray> $sdic Stream's dictionary array.
* @param int $dataStart Offset where the stream payload starts.
* @param string $extracted Payload extracted up to the first "endstream".
*
* @return array{stream: string, offset: int}|null Corrected payload and next offset, or null.
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
private function resliceStreamByLength(array $sdic, int $dataStart, string $extracted): ?array
{
$length = $this->getDeclaredLength($sdic);
if ($length === null || $length <= 0) {
return null;
}
// only act when the declared length reaches further than the extracted payload
if ($length <= \strlen($extracted)) {
return null;
}
$pdfLen = \strlen($this->pdfdata);
$end = $dataStart + $length;
if ($dataStart < 0 || $end > $pdfLen) {
return null;
}
// the declared length must be followed (after optional EOL) by the real "endstream"
$tailStart = $end + \strspn($this->pdfdata, "\x00\x09\x0a\x0c\x0d\x20", $end);
if (\substr($this->pdfdata, $tailStart, 9) !== 'endstream') {
return null;
}
return [
'stream' => \substr($this->pdfdata, $dataStart, $length),
'offset' => $tailStart,
];
}
/**
* Resolve the declared /Length of a stream dictionary, following indirect references.
*
* @param array<int, RawObjectArray> $sdic Stream's dictionary array.
*
* @return int|null The declared length, or null when it cannot be determined.
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
private function getDeclaredLength(array $sdic): ?int
{
$count = \count($sdic);
for ($i = 0; $i < $count; ++$i) {
$key = $sdic[$i] ?? null;
if (!\is_array($key) || $key[0] !== '/' || $key[1] !== 'Length') {
continue;
}
$val = $sdic[$i + 1] ?? null;
if (!\is_array($val)) {
return null;
}
if ($val[0] === 'numeric' && \is_scalar($val[1])) {
return (int) $val[1];
}
if ($val[0] === 'objref') {
$resolved = $this->getObjectVal($val);
if ($resolved[0] === 'numeric' && \is_scalar($resolved[1])) {
return (int) $resolved[1];
}
}
return null;
}
return null;
}
/**
* Get the content of object, resolving indirect object reference if necessary.
*
* @param RawObjectArray $obj Object value.
*
* @return RawObjectArray Object data.
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
protected function getObjectVal(array $obj): array
{
if ($obj[0] === 'objref' && \is_string($obj[1])) {
// reference to indirect object
if (($this->objects[$obj[1]][0] ?? null) !== null) {
// this object has been already parsed
return $this->objects[$obj[1]][0];
}
if (isset($this->xref['xref'][$obj[1]])) {
$xrefOffset = $this->xref['xref'][$obj[1]];
if (\is_int($xrefOffset)) {
if ($xrefOffset <= 0) {
return $obj;
}
// parse new object
$this->objects[$obj[1]] = $this->getIndirectObject($obj[1], $xrefOffset, false);
if (($this->objects[$obj[1]][0] ?? null) !== null) {
return $this->objects[$obj[1]][0];
}
return $obj;
}
if (\preg_match('/^\d+_\d+_\d+$/', $xrefOffset) === 1) {
$compressedObj = $this->getCompressedObject($obj[1], $xrefOffset);
if ($compressedObj !== null) {
$this->objects[$obj[1]] = $compressedObj;
return $this->objects[$obj[1]][0] ?? $obj;
}
return $obj;
}
return $obj;
}
}
return $obj;
}
/**
* Resolve one compressed object by object-stream locator.
*
* @param string $objRef Target object reference (e.g. "14_0").
* @param string $locator Object-stream locator "streamObj_streamGen_index".
*
* @return array<int, RawObjectArray>|null
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
private function getCompressedObject(string $objRef, string $locator): ?array
{
$parts = \explode('_', $locator);
$streamRef = $parts[0] . '_' . ($parts[1] ?? '');
$cache = $this->objstmCache[$streamRef] ?? null;
if (!\is_array($cache)) {
$cache = $this->parseObjectStream($streamRef);
$this->objstmCache[$streamRef] = $cache;
}
$obj = $cache[$objRef] ?? null;
return \is_array($obj) ? $obj : null;
}
/**
* Parse a PDF object stream and return extracted objects keyed as "objNum_0".
*
* @param string $streamRef Object stream reference.
*
* @return array<string, array<int, RawObjectArray>>
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
private function parseObjectStream(string $streamRef): array
{
if (($this->objects[$streamRef][0] ?? null) === null) {
$streamOffset = $this->xref['xref'][$streamRef] ?? null;
if (!\is_int($streamOffset) || $streamOffset <= 0) {
return [];
}
$this->objects[$streamRef] = $this->getIndirectObject($streamRef, $streamOffset, true);
}
$streamObj = $this->objects[$streamRef];
if (!$this->hasDecodedStreamPayload($streamObj)) {
$streamOffset = $this->xref['xref'][$streamRef] ?? null;
if (!\is_int($streamOffset) || $streamOffset <= 0) {
return [];
}
$streamObj = $this->getIndirectObject($streamRef, $streamOffset, true);
$this->objects[$streamRef] = $streamObj;
}
[$dict, $decodedData] = $this->extractObjectStreamEnvelope($streamObj);
if ($dict === null || $decodedData === null) {
return [];
}
[$n, $first] = $this->readObjectStreamConfig($dict);
if ($n <= 0 || $first < 0) {
return [];
}
$index = $this->readObjectStreamIndex($decodedData, $n, $first);
if ($index === null) {
return [];
}
return $this->extractObjectsFromStream($decodedData, $first, $index['objNums'], $index['objOffsets']);
}
/**
* @param array<int, RawObjectArray> $streamObj
*
* @return array{0: array<int, RawObjectArray>|null, 1: string|null}
*/
private function extractObjectStreamEnvelope(array $streamObj): array
{
$dict = null;
$decodedData = null;
foreach ($streamObj as $element) {
if ($element[0] === '<<' && \is_array($element[1])) {
$dict = $element[1];
continue;
}
if ($element[0] === 'stream' && \is_array($element[3] ?? null)) {
$decodedData = $element[3][0];
}
}
return [
\is_array($dict) ? $dict : null,
\is_string($decodedData) ? $decodedData : null,
];
}
/**
* @param array<int, RawObjectArray> $dict
*
* @return array{0: int, 1: int}
*/
private function readObjectStreamConfig(array $dict): array
{
$n = 0;
$first = 0;
$dictCount = \count($dict);
for ($idx = 0; $idx < $dictCount; ++$idx) {
$key = $dict[$idx] ?? null;
$val = $dict[$idx + 1] ?? null;
if (!\is_array($key) || !\is_array($val)) {
continue;
}
if ($key[0] !== '/' || !\is_string($key[1])) {
continue;
}
if ($key[1] === 'N' && $val[0] === 'numeric' && \is_scalar($val[1])) {
$n = (int) $val[1];
continue;
}
if ($key[1] === 'First' && $val[0] === 'numeric' && \is_scalar($val[1])) {
$first = (int) $val[1];
}
}
return [$n, $first];
}
/**
* @param string $decodedData Decoded object stream payload.
* @param int $n Number of embedded objects.
* @param int $first Byte offset where object bodies begin.
*
* @return array{objNums: array<int, int>, objOffsets: array<int, int>}|null
*/
private function readObjectStreamIndex(string $decodedData, int $n, int $first): ?array
{
$header = \substr($decodedData, 0, $first);
$meta = \preg_split('/\s+/', \trim($header));
if (!\is_array($meta) || \count($meta) < (2 * $n)) {
return null;
}
$objNums = [];
$objOffsets = [];
for ($idx = 0; $idx < $n; ++$idx) {
$numTok = $meta[2 * $idx] ?? null;
$offTok = $meta[(2 * $idx) + 1] ?? null;
if (!\is_string($numTok) || !\is_string($offTok) || !\is_numeric($numTok) || !\is_numeric($offTok)) {
return null;
}
$objNums[$idx] = (int) $numTok;
$objOffsets[$idx] = (int) $offTok;
}
return [
'objNums' => $objNums,
'objOffsets' => $objOffsets,
];
}
/**
* @param string $decodedData Decoded object stream payload.
* @param int $first Byte offset where object bodies begin.
* @param array<int, int> $objNums Embedded object numbers.
* @param array<int, int> $objOffsets Embedded object offsets from $first.
*
* @return array<string, array<int, RawObjectArray>>
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
private function extractObjectsFromStream(string $decodedData, int $first, array $objNums, array $objOffsets): array
{
$result = [];
$n = \count($objNums);
$streamLen = \strlen($decodedData);
for ($idx = 0; $idx < $n; ++$idx) {
$start = $first + ($objOffsets[$idx] ?? 0);
$nextStart = $idx < ($n - 1) ? $first + ($objOffsets[$idx + 1] ?? 0) : $streamLen;
if ($start < 0 || $nextStart < $start || $start > $streamLen) {
continue;
}
$body = \trim(\substr($decodedData, $start, $nextStart - $start));
if ($body === '') {
continue;
}
$miniObj = $this->parseObjectBody($body);
$key = ($objNums[$idx] ?? 0) . '_0';
if ($key !== '0_0' && $miniObj !== null) {
$result[$key] = $miniObj;
}
}
return $result;
}
/**
* Check whether a parsed object already includes decoded stream payload.
*
* @param array<int, RawObjectArray> $streamObj
*/
private function hasDecodedStreamPayload(array $streamObj): bool
{
foreach ($streamObj as $element) {
if ($element[0] !== 'stream') {
continue;
}
if (!\is_array($element[3] ?? null)) {
continue;
}
return true;
}
return false;
}
/**
* Parse a raw object body into a parser token array.
*
* The body is parsed in an isolated sub-parser instance (so indirect references it
* may contain are not resolved against the outer document), but without bootstrapping
* a full synthetic PDF: the object is decoded directly, skipping the xref machinery.
*
* @param string $body Raw object body from an object stream.
*
* @return array<int, RawObjectArray>|null
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
private function parseObjectBody(string $body): ?array
{
$parser = new self($this->cfg);
$obj = $parser->parseStandaloneObject($body);
return $obj === [] ? null : $obj;
}
/**
* Decode a single indirect object body in isolation.
*
* @param string $body Raw object body (the content between "obj" and "endobj").
*
* @return array<int, RawObjectArray> Object data.
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
protected function parseStandaloneObject(string $body): array
{
$this->mrkoff = [];
$this->objstmCache = [];
$this->objects = [];
$this->xref = self::XREF_EMPTY;
$this->pdfdata = "1 0 obj\n" . $body . "\nendobj\n";
$obj = $this->getIndirectObject('1_0', 0, $this->cfg['decode_streams'] ?? true);
$this->pdfdata = '';
return $obj;
}
/**
* Decode the specified stream.
*
* @param array<int, RawObjectArray> $sdic Stream's dictionary array.
* @param string $stream Stream to decode.
*
* @return array{
* 0: string,
* 1: array<string>,
* } Decoded stream data and remaining filters.
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
* @SuppressWarnings("PHPMD.CyclomaticComplexity")
*/
protected function decodeStream(array $sdic, string $stream): array
{
// get stream length and filters
$slength = \strlen($stream);
if ($slength <= 0) {
return ['', []];
}
$filters = [];
$params = [];
foreach ($sdic as $key => $val) {
if (!\is_string($val[1])) {
continue;
}
if ($val[0] === '/') {
$nextSdic = $sdic[$key + 1] ?? null;
if ($val[1] === 'Length' && \is_array($nextSdic) && $nextSdic[0] === 'numeric') {
// get declared stream length
$this->getDeclaredStreamLength($stream, $slength, $sdic, $key);
} elseif ($val[1] === 'Filter' && ($sdic[$key + 1] ?? null) !== null) {
$filters = $this->getFilters($filters, $sdic, $key);
} elseif ($val[1] === 'DecodeParms' && ($sdic[$key + 1] ?? null) !== null) {
$params = $this->getDecodeParms($sdic, $key);
}
}
}
return $this->getDecodedStream($filters, $stream, $params);
}
/**
* Get declared stream length
*
* @param string $stream Stream
* @param int $slength Stream length
* @param array<int, RawObjectArray> $sdic Stream's dictionary array.
* @param int $key Index
*/
protected function getDeclaredStreamLength(string &$stream, int &$slength, array $sdic, int $key): void
{
// get declared stream length
$declength = (int) ($sdic[$key + 1][1] ?? 0);
if ($declength < $slength) {
$stream = \substr($stream, 0, $declength);
$slength = $declength;
}
}
/**
* Get Filters
*
* @param array<string> $filters Array of Filters
* @param array<int, RawObjectArray> $sdic Stream's dictionary array.
* @param int $key Index
*
* @return array<string> Array of filters
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
protected function getFilters(array $filters, array $sdic, int $key): array
{
// resolve indirect object
$nextElem = $sdic[$key + 1] ?? null;
if (!\is_array($nextElem)) {
return $filters;
}
$elem = $nextElem;
$objval = $this->getObjectVal($elem);
switch ($objval[0]) {
case '/':
// single filter
if (\is_string($objval[1])) {
$filters[] = $objval[1];
}
break;
case '[':
if (!\is_array($objval[1])) {
break;
}
foreach ($objval[1] as $flt) {
if ($flt[0] !== '/') {
continue;
}
if (!\is_string($flt[1])) {
continue;
}
$filters[] = $flt[1];
}
break;
}
return $filters;
}
/**
* Get DecodeParms
*
* @param array<int, RawObjectArray> $sdic Stream's dictionary array.
* @param int $key Index
*
* @return array<string, mixed> Decode parameters
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
* @SuppressWarnings("PHPMD.CyclomaticComplexity")
*/
protected function getDecodeParms(array $sdic, int $key): array
{
// resolve indirect object
$nextElem = $sdic[$key + 1] ?? null;
if (!\is_array($nextElem)) {
return [];
}
$elem = $nextElem;
$objval = $this->getObjectVal($elem);
$params = [];
switch ($objval[0]) {
case '<<':
// single DecodeParms dictionary
if (\is_array($objval[1])) {
$params = $this->buildDecodeParms($objval[1]);
}
break;
case '[':
// array of DecodeParms (one per filter)
if (\is_array($objval[1])) {
foreach ($objval[1] as $parm) {
if ($parm[0] === '<<') {
if (\is_array($parm[1])) {
$params = $this->buildDecodeParms($parm[1]);
break;
}
} elseif ($parm[0] === 'null') {
continue;
}
}
}
break;
}
return $params;
}
/**
* Build DecodeParms associative array from raw dictionary
*
* @param array<int, RawObjectArray> $parmdict Raw parameter dictionary.
*
* @return array<string, mixed> Decoded parameters
*
* @throws \Com\Tecnick\Pdf\Parser\Exception
*/
private function buildDecodeParms(array $parmdict): array
{
$params = [];
$count = \count($parmdict);
for ($i = 0; $i < $count; $i += 2) {
if (!\is_array($parmdict[$i] ?? null)) {
continue;
}
if ($parmdict[$i][0] !== '/' || !\is_string($parmdict[$i][1])) {
continue;
}
$key = $parmdict[$i][1];
$nextVal = $parmdict[$i + 1] ?? null;
if (!\is_array($nextVal)) {
continue;
}
$val = $nextVal;
// resolve indirect references
$val = $this->getObjectVal($val);
// extract the value based on type
$paramVal = $this->extractParamValue($val);
if ($paramVal !== null) {
$params[$key] = $paramVal;
}
}
return $params;
}
/**
* Extract parameter value from a raw object
*
* @param RawObjectArray $val Raw object value
*
* @return int|string|bool|null The extracted parameter value, or null if unable to extract
*
* @SuppressWarnings("PHPMD.CyclomaticComplexity")
*/
private function extractParamValue(array $val): int|string|bool|null
{
$val1 = $val[1];
return match ($val[0]) {
'numeric' => \is_string($val1) ? (int) $val1 : null,
'/' => \is_string($val1) ? $val1 : null,
'string' => \is_string($val1) ? $val1 : null,
'true' => true,
'false' => false,
default => null,
};
}
/**
* Decode the specified stream.
*
* @param array<string> $filters Array of decoding filters to apply
* @param string $stream Stream to decode.
* @param array<string, mixed> $params DecodeParms dictionary (optional).
*
* @return array{
* 0: string,
* 1: array<string>,
* } Decoded stream data and remaining filters.
*
* @throws \Com\Tecnick\Pdf\Parser\Exception If filter decoding fails and ignore_filter_errors is false.
*/
protected function getDecodedStream(array $filters, string $stream, array $params = []): array
{
// decode the stream
$errorfilters = [];
try {
$filter = new Filter();
$stream = $filter->decodeAll($filters, $stream, $params);
} catch (\Com\Tecnick\Pdf\Filter\Exception $exception) {
if ($this->cfg['ignore_filter_errors'] ?? false) {
$errorfilters = $filters;
} else {
throw new PPException($exception->getMessage());
}
}
// reverse the PNG/TIFF predictor declared in DecodeParms (the filter layer only
// inflates/decompresses and leaves predictor metadata in place)
if ($errorfilters === [] && $this->applyStreamPredictor) {
$stream = $this->applyPredictor($stream, $params);
}
return [$stream, $errorfilters];
}
/**
* Reverse a PNG/TIFF predictor as described by the stream's DecodeParms.
*
* @param string $data Decompressed (still predicted) stream data.
* @param array<string, mixed> $params DecodeParms dictionary.
*
* @return string The un-predicted data (unchanged when no predictor applies).
*/
private function applyPredictor(string $data, array $params): string
{
$predictor = (int) ($params['Predictor'] ?? 1);
if ($predictor <= 1 || $data === '') {
return $data;
}
$colors = \max(1, (int) ($params['Colors'] ?? 1));
$bpc = \max(1, (int) ($params['BitsPerComponent'] ?? 8));
$columns = \max(1, (int) ($params['Columns'] ?? 1));
$bpp = \max(1, (int) \ceil(($colors * $bpc) / 8));
$rowlen = (int) \ceil(($colors * $bpc * $columns) / 8);
if ($rowlen <= 0) {
return $data;
}
if ($predictor === 2) {
return $this->applyTiffPredictor($data, $rowlen, $bpp, $bpc);
}
// PNG predictors (10-15): each row is prefixed with a filter-type byte
return $this->applyPngPredictor($data, $rowlen, $bpp);
}
/**
* Reverse a TIFF Predictor 2 (horizontal differencing) over 8-bit samples.
*
* @param string $data Predicted data.
* @param int $rowlen Number of bytes per row.
* @param int $bpp Number of bytes per pixel/sample group.
* @param int $bpc Bits per component.
*
* @return string Un-predicted data.
*/
private function applyTiffPredictor(string $data, int $rowlen, int $bpp, int $bpc): string
{
if ($bpc !== 8) {
// sub-byte TIFF prediction is not supported; leave the data unchanged
return $data;
}
/** @var array<int, int> $bytes */
$bytes = \array_values((array) \unpack('C*', $data));
$rows = \intdiv(\strlen($data), $rowlen);
for ($r = 0; $r < $rows; ++$r) {
$base = $r * $rowlen;
for ($i = $bpp; $i < $rowlen; ++$i) {
$sample = $bytes[$base + $i] ?? 0;
$left = $bytes[$base + $i - $bpp] ?? 0;
$bytes[$base + $i] = ($sample + $left) & 0xff;
}
}
return \pack('C*', ...$bytes);
}
/**
* Reverse the PNG predictors (filter types 0-4) declared per row.
*
* @param string $data Predicted data (each row prefixed by its filter-type byte).
* @param int $rowlen Number of bytes per row (excluding the filter-type byte).
* @param int $bpp Number of bytes per pixel (offset to the "left" byte).
*
* @return string Un-predicted data.
*/
private function applyPngPredictor(string $data, int $rowlen, int $bpp): string
{
$stride = $rowlen + 1;
$len = \strlen($data);
$out = '';
$prev = \array_fill(0, \max(0, $rowlen), 0);
for ($pos = 0; ($pos + $stride) <= $len; $pos += $stride) {
$filterType = \ord($data[$pos]);
/** @var array<int, int> $cur */
$cur = \array_values((array) \unpack('C*', \substr($data, $pos + 1, $rowlen)));
for ($i = 0; $i < $rowlen; ++$i) {
$sample = $cur[$i] ?? 0;
$left = $i >= $bpp ? $cur[$i - $bpp] ?? 0 : 0;
$up = $prev[$i] ?? 0;
$upleft = $i >= $bpp ? $prev[$i - $bpp] ?? 0 : 0;
$cur[$i] = match ($filterType) {
1 => ($sample + $left) & 0xff,
2 => ($sample + $up) & 0xff,
3 => ($sample + \intdiv($left + $up, 2)) & 0xff,
4 => ($sample + $this->paethPredictor($left, $up, $upleft)) & 0xff,
default => $sample & 0xff,
};
}
$out .= \pack('C*', ...$cur);
$prev = $cur;
}
return $out;
}
/**
* PNG Paeth predictor function.
*
* @param int $left Byte to the left.
* @param int $up Byte above.
* @param int $upleft Byte above-left.
*/
private function paethPredictor(int $left, int $up, int $upleft): int
{
$estimate = $left + $up - $upleft;
$distLeft = \abs($estimate - $left);
$distUp = \abs($estimate - $up);
$distUpLeft = \abs($estimate - $upleft);
if ($distLeft <= $distUp && $distLeft <= $distUpLeft) {
return $left;
}
if ($distUp <= $distUpLeft) {
return $up;
}
return $upleft;
}
}