* @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\Process; use Com\Tecnick\Pdf\Parser\Exception as PPException; /** * Com\Tecnick\Pdf\Parser\Process\XrefStream * * Process XREF * * @since 2011-05-23 * @category Library * @package PdfParser * @author Nicola Asuni * @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 * * @phpstan-import-type RawObjectArray from \Com\Tecnick\Pdf\Parser\Process\RawObject * * @phpstan-type XrefData array{ * 'trailer': array{ * 'encrypt'?: string, * 'id': array, * 'info': string, * 'root': string, * 'size': int, * }, * 'xref': array, * } * * @SuppressWarnings("PHPMD.ExcessiveClassComplexity") */ abstract class XrefStream extends \Com\Tecnick\Pdf\Parser\Process\RawObject { /** * Process object indexes * * @param XrefData $xref XREF data * @param int $obj_num Object number * @param array> $sdata Stream data * * @throws \Com\Tecnick\Pdf\Parser\Exception */ protected function processObjIndexes(array &$xref, int &$obj_num, array $sdata): void { foreach ($sdata as $sdatum) { $this->processSingleObjIndex($xref, $obj_num, $sdatum); ++$obj_num; } } /** * Process object indexes using explicit object numbers. * * @param XrefData $xref XREF data * @param array $objNumbers Object numbers for each stream row * @param array> $sdata Stream data * * @throws \Com\Tecnick\Pdf\Parser\Exception */ protected function processObjIndexesMap(array &$xref, array $objNumbers, array $sdata): void { $objCount = \count($objNumbers); $rowCount = \count($sdata); if ($rowCount !== $objCount) { throw new PPException( 'Invalid xref stream row count: expected ' . $objCount . ' rows from Index, got ' . $rowCount, ); } foreach ($objNumbers as $idx => $objNum) { $row = $sdata[$idx] ?? null; if (!\is_array($row)) { throw new PPException('Invalid xref stream row at index ' . $idx); } $this->processSingleObjIndex($xref, $objNum, $row); } } /** * Process a single xref stream row. * * @param XrefData $xref XREF data * @param int $objNum Object number for the row * @param array $sdatum Stream row data * * @throws \Com\Tecnick\Pdf\Parser\Exception */ protected function processSingleObjIndex(array &$xref, int $objNum, array $sdatum): void { $entryType = (int) ($sdatum[0] ?? 0); switch ($entryType) { case 0: // (f) linked list of free objects break; case 1: if (!\array_key_exists(1, $sdatum)) { throw new PPException( 'Invalid xref stream entry for object ' . $objNum . ': missing offset for in-use entry', ); } // (n) objects that are in use but are not compressed // create unique object index: [object number]_[generation number] $index = $objNum . '_' . (int) ($sdatum[2] ?? 0); // check if object already exist if (!\array_key_exists($index, $xref['xref'])) { // store object offset position $xref['xref'][$index] = (int) $sdatum[1]; } break; case 2: if (!\array_key_exists(1, $sdatum) || !\array_key_exists(2, $sdatum)) { throw new PPException( 'Invalid xref stream entry for object ' . $objNum . ': missing object stream reference for compressed entry', ); } // compressed objects // $row[1] = object number of the object stream in which this object is stored // $row[2] = index of this object within the object stream $index = $objNum . '_0'; if (!\array_key_exists($index, $xref['xref'])) { $xref['xref'][$index] = (int) $sdatum[1] . '_0_' . (int) $sdatum[2]; } break; default: // null objects break; } } /** * PNG Unpredictor * * @param array> $sdata Stream data * @param array> $ddata Decoded data * @param int $columns Number of columns * @param array $prev_row Previous row * * @throws \Com\Tecnick\Pdf\Parser\Exception */ protected function pngUnpredictor(array $sdata, array &$ddata, int $columns, array $prev_row): void { // for each row apply PNG unpredictor foreach ($sdata as $key => $row) { // initialize new row $filllen = \max(0, $columns); $ddata[$key] = \array_fill(0, $filllen, 0); // get PNG predictor value $predictor = 10 + (int) ($row[0] ?? 0); // for each byte on the row for ($idx = 1; $idx <= $columns; ++$idx) { // new index $jdx = $idx - 1; $row_up = (int) ($prev_row[$jdx] ?? 0); if ($idx === 1) { $row_left = 0; $row_upleft = 0; } else { $row_left = (int) ($row[$idx - 1] ?? 0); $row_upleft = (int) ($prev_row[$jdx - 1] ?? 0); } $row_value = (int) ($row[$idx] ?? 0); switch ($predictor) { case 10: // PNG prediction (on encoding, PNG None on all rows) $ddata[$key][$jdx] = $row_value; break; case 11: // PNG prediction (on encoding, PNG Sub on all rows) $ddata[$key][$jdx] = ($row_value + $row_left) & 0xff; break; case 12: // PNG prediction (on encoding, PNG Up on all rows) $ddata[$key][$jdx] = ($row_value + $row_up) & 0xff; break; case 13: // PNG prediction (on encoding, PNG Average on all rows) $ddata[$key][$jdx] = ($row_value + \intdiv($row_left + $row_up, 2)) & 0xff; break; case 14: // PNG prediction (on encoding, PNG Paeth on all rows) $this->minDistance($ddata, $key, $row_value, $jdx, [$row_left, $row_up, $row_upleft]); break; default: // PNG prediction (on encoding, PNG optimum) throw new PPException('Unknown PNG predictor'); } } $prev_row = $ddata[$key]; } // end for each row } /** * Return minimum distance for PNG unpredictor * * @param array> $ddata Decoded data * @param int $key Key * @param int $row_value Current row value * @param int $jdx Jdx * @param array{0:int, 1:int, 2:int} $rows Left, up and up-left row values */ protected function minDistance(array &$ddata, int $key, int $row_value, int $jdx, array $rows): void { $row_left = $rows[0]; $row_up = $rows[1]; $row_upleft = $rows[2]; // initial estimate $pos = $row_left + $row_up - $row_upleft; // distances $psa = \abs($pos - $row_left); $psb = \abs($pos - $row_up); $psc = \abs($pos - $row_upleft); $pmin = \min($psa, $psb, $psc); switch ($pmin) { case $psa: $ddata[$key][$jdx] = ($row_value + $row_left) & 0xff; break; case $psb: $ddata[$key][$jdx] = ($row_value + $row_up) & 0xff; break; case $psc: $ddata[$key][$jdx] = ($row_value + $row_upleft) & 0xff; break; } } /** * Process XREF types * * @param array $sarr Stream data * @param XrefData $xref XREF data * @param array $wbt WBT data * @param array{ * index_sections: array|null, * prevxref: int|null, * predictor: int, * columns: int, * size: int|null, * valid_crs: bool * } $state Parsing state * @param bool $filltrailer Fill trailer * * @throws \Com\Tecnick\Pdf\Parser\Exception * * @SuppressWarnings("PHPMD.CyclomaticComplexity") */ protected function processXrefType(array $sarr, array &$xref, array &$wbt, array &$state, bool $filltrailer): void { foreach ($sarr as $key => $val) { if ($val[0] !== '/') { continue; } if (!\is_string($val[1])) { continue; } $next = $sarr[$key + 1] ?? null; switch ($val[1]) { case 'Type': $state['valid_crs'] = \is_array($next) && $next[0] === '/' && $next[1] === 'XRef'; break; case 'Index': $state['index_sections'] = $this->parseXrefIndexSections($next); break; case 'Prev': $this->processXrefPrev($next, $state['prevxref']); break; case 'Size': if (\is_array($next) && $next[0] === 'numeric') { $state['size'] = (int) $next[1]; } break; case 'W': // number of bytes (in the decoded stream) of the corresponding field if (\is_array($next)) { $wbt[0] = (int) ($next[1][0][1] ?? 0); $wbt[1] = (int) ($next[1][1][1] ?? 0); $wbt[2] = (int) ($next[1][2][1] ?? 0); } break; case 'DecodeParms': $this->processXrefDecodeParms($next, $state['columns'], $state['predictor']); break; } $this->processXrefTypeFt($val[1], $sarr, $key, $xref, $filltrailer); } } /** * Parse the xref stream Index array into normalized [startObj, count] pairs. * * @param RawObjectArray|null $indexObj Index object token. * * @return array|null * * @throws \Com\Tecnick\Pdf\Parser\Exception */ protected function parseXrefIndexSections(?array $indexObj): ?array { if (!\is_array($indexObj)) { return null; } $values = $indexObj[1]; if (!\is_array($values)) { return null; } if ((\count($values) % 2) !== 0) { throw new PPException('Invalid xref stream Index array: expected even number of values'); } $sections = []; for ($idx = 0, $max = \count($values); $idx < $max; $idx += 2) { $startToken = $values[$idx] ?? null; $countToken = $values[$idx + 1] ?? null; if (!\is_array($startToken) || !\is_array($countToken)) { throw new PPException('Invalid xref stream Index array: expected numeric values'); } if ($startToken[0] !== 'numeric' || $countToken[0] !== 'numeric') { throw new PPException('Invalid xref stream Index array: expected numeric values'); } $startObj = (int) $startToken[1]; $count = (int) $countToken[1]; if ($startObj < 0 || $count < 0) { throw new PPException('Invalid xref stream Index array: values must be non-negative'); } $sections[] = [$startObj, $count]; } return $sections; } /** * Build the full object-number map for decoded xref stream rows. * * @param array $indexSections Normalized Index sections. * * @return array */ protected function buildXrefObjectNumbers(array $indexSections): array { $objNumbers = []; foreach ($indexSections as $section) { $startObj = $section[0]; $count = $section[1]; $limit = $startObj + $count; for ($objNum = $startObj; $objNum < $limit; ++$objNum) { $objNumbers[] = $objNum; } } return $objNumbers; } /** * Process XREF type Prev * * @param RawObjectArray|null $next Next token * @param int|null $prevxref Previous XREF */ protected function processXrefPrev(?array $next, ?int &$prevxref): void { if (\is_array($next) && $next[0] === 'numeric') { // get previous xref offset $prevxref = (int) $next[1]; } } /** * Process XREF type DecodeParms * * @param RawObjectArray|null $next Next token * @param int $columns Number of columns * @param int $predictor Predictor value */ protected function processXrefDecodeParms(?array $next, int &$columns, int &$predictor): void { $decpar = $next[1] ?? null; if (!\is_array($decpar)) { return; } foreach ($decpar as $kdc => $vdc) { $nextDecpar = $decpar[$kdc + 1] ?? null; if (\is_array($nextDecpar) && $vdc[0] === '/' && $vdc[1] === 'Columns' && $nextDecpar[0] === 'numeric') { $columns = (int) $nextDecpar[1]; continue; } if (\is_array($nextDecpar) && $vdc[0] === '/' && $vdc[1] === 'Predictor' && $nextDecpar[0] === 'numeric') { $predictor = (int) $nextDecpar[1]; } } $columns = \max(0, $columns); $predictor = \max(0, $predictor); } /** * Process XREF type * * @param string $type Type * @param array $sarr Stream data * @param int $key Key * @param XrefData $xref XREF data * @param bool $filltrailer Fill trailer */ protected function processXrefTypeFt(string $type, array $sarr, int $key, array &$xref, bool $filltrailer): void { if (!$filltrailer) { return; } $next = $sarr[$key + 1] ?? null; switch ($type) { case 'Size': if (\is_array($next) && $next[0] === 'numeric') { $xref['trailer']['size'] = (int) $next[1]; } break; case 'ID': $id0 = $next[1][0][1] ?? null; $id1 = $next[1][1][1] ?? null; if (!\is_array($next) || !\is_string($id0) || !\is_string($id1) || $id0 === '' || $id1 === '') { break; } $xref['trailer']['id'] = [ $id0, $id1, ]; break; default: $xref = $this->processXrefObjref($type, $sarr, $key, $xref); break; } } /** * Process XREF type Objref * * @param string $type Type * @param array $sarr Stream data * @param int $key Key * @param XrefData $xref XREF data * * @return XrefData XREF data. */ protected function processXrefObjref(string $type, array $sarr, int $key, array $xref): array { $next = $sarr[$key + 1] ?? null; if (!\is_array($next) || $next[0] !== 'objref') { return $xref; } $val = $next[1]; if (!\is_string($val)) { return $xref; } switch ($type) { case 'Root': $xref['trailer']['root'] = $val; break; case 'Info': $xref['trailer']['info'] = $val; break; case 'Encrypt': $xref['trailer']['encrypt'] = $val; break; } return $xref; } }