* @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-image * * This file is part of tc-lib-pdf-image software library. */ namespace Com\Tecnick\Pdf\Image; use Com\Tecnick\File\File as ObjFile; use Com\Tecnick\Pdf\Encrypt\Encrypt; use Com\Tecnick\Pdf\Image\Exception as ImageException; /** * Com\Tecnick\Pdf\Image\Output * * @since 2011-05-23 * @category Library * @package PdfImage * @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-image * * @phpstan-type TFileOptions array{ * allowedHosts?: array, * maxRemoteSize?: int, * curlopts?: array, * defaultCurlOpts?: array, * fixedCurlOpts?: array * } * * @phpstan-import-type ImageBaseData from \Com\Tecnick\Pdf\Image\Import * @phpstan-import-type ImageRawData from \Com\Tecnick\Pdf\Image\Import * * @SuppressWarnings("PHPMD.ExcessiveClassComplexity") */ abstract class Output { /** * Current PDF object number. */ protected int $pon; /** * Store image object IDs for the XObject Dictionary. * * @var array */ protected array $xobjdict = []; /** * Stack of added images. * * @var array, * }> */ protected array $image = []; /** * Cache used to store imported image data. * The same image data can be reused multiple times. * * @var array */ protected array $cache = []; /** * Initialize images data. * * @param float $kunit Unit of measure conversion ratio. * @param Encrypt $encrypt Encrypt object. * @param ObjFile $fileHelper File helper for image loading and writing. * @param bool $pdfa True if we are in PDF/A mode. * @param bool $compress Set to false to disable stream compression. * @param ?ImageCacheInterface $imageCache Optional external cache to persist processed image * data across instances and processes (null = disabled). */ public function __construct( protected float $kunit, protected Encrypt $encrypt, protected ObjFile $fileHelper, protected bool $pdfa = false, protected bool $compress = true, protected ?ImageCacheInterface $imageCache = null, ) { $this->fileHelper = $fileHelper; } /** * Returns current PDF object number. */ public function getObjectNumber(): int { return $this->pon; } /** * Run a callback with a temporary file helper. * * @template TResult * * @param ObjFile $fileHelper Temporary file helper to use while the callback executes. * @param callable(): TResult $callback Callback executed with the temporary file helper. * * @return TResult */ public function withFileHelper(ObjFile $fileHelper, callable $callback): mixed { $previousFileHelper = $this->fileHelper; $this->fileHelper = $fileHelper; try { return $callback(); } finally { $this->fileHelper = $previousFileHelper; } } /** * Get the PDF output string to print the specified image ID. * * @param int $iid Image ID. * @param float $xpos Abscissa (X coordinate) of the upper-left Image corner in user units. * @param float $ypos Ordinate (Y coordinate) of the upper-left Image corner in user units. * @param float $width Image width in user units. * @param float $height Image height in user units. * @param float $pageheight Page height in user units. * * @return string Image PDF page content. * * @throws \Com\Tecnick\Pdf\Image\Exception If the image ID is not found. */ public function getSetImage( int $iid, float $xpos, float $ypos, float $width, float $height, float $pageheight, ): string { $img = $this->image[$iid] ?? null; if ($img === null) { throw new ImageException('Unknown image ID: ' . $iid); } $out = 'q'; $out .= \sprintf( ' %F 0 0 %F %F %F cm', $width * $this->kunit, $height * $this->kunit, $xpos * $this->kunit, ($pageheight - $ypos - $height) * $this->kunit, // reverse coordinate ); if (!isset($this->cache[$img['key']]['mask'])) { return $out . ' /IMG' . $iid . ' Do Q' . "\n"; } if (!isset($this->cache[$img['key']]['plain'])) { return $out . ' /IMGmask' . $iid . ' Do Q' . "\n"; } return $out . ' /IMGplain' . $iid . ' Do Q' . "\n"; } /** * Get the PDF output string for Images. * * @param int $pon Current PDF Object Number. * * @return string PDF code for the images block. * * @throws \Com\Tecnick\Pdf\Encrypt\Exception If image object encryption fails. */ public function getOutImagesBlock(int $pon): string { $this->pon = $pon; $out = ''; foreach ($this->image as $iid => $img) { if (!isset($this->cache[$img['key']]['out'])) { if (isset($this->cache[$img['key']]['mask'])) { /** @var ImageRawData $mask */ $mask = &$this->cache[$img['key']]['mask']; $out .= $this->getOutImage($img, $mask, 'mask'); if (isset($this->cache[$img['key']]['plain'])) { /** @var ImageRawData $plain */ $plain = &$this->cache[$img['key']]['plain']; $out .= $this->getOutImage($img, $plain, 'plain'); } } if (!isset($this->cache[$img['key']]['mask'])) { $out .= $this->getOutImage($img, $this->cache[$img['key']]); } unset($mask, $plain); $this->image[$iid] = $img; } if (($this->cache[$img['key']]['mask']['obj'] ?? 0) === 0) { $this->xobjdict['IMG' . $img['iid']] = $this->cache[$img['key']]['obj']; } else { $maskData = $this->cache[$img['key']]['mask'] ?? null; if ($maskData === null) { $this->xobjdict['IMG' . $img['iid']] = $this->cache[$img['key']]['obj']; continue; } $plainData = $this->cache[$img['key']]['plain'] ?? null; if (($plainData['obj'] ?? 0) === 0) { $this->xobjdict['IMGmask' . $img['iid']] = (int) $maskData['obj']; } elseif ($plainData !== null) { $this->xobjdict['IMGplain' . $img['iid']] = (int) $plainData['obj']; } } } return $out; } /** * Get the PDF output string for Image object. * * @param array{ * 'iid': int, * 'key': string, * 'width': int, * 'height': int, * 'defprint': bool, * 'altimgs'?: array, * } $img Image reference. * @param ImageRawData $data Image raw data. * @param string $sub Sub image ('mask', 'plain' or empty string). * * @return string PDF Image object. * * @throws \Com\Tecnick\Pdf\Encrypt\Exception If image data encryption fails. */ protected function getOutImage(array &$img, array &$data, string $sub = ''): string { $out = $this->getOutIcc($data) . $this->getOutPalette($data) . $this->getOutAltImages($img, $data, $sub); $data['obj'] = ++$this->pon; $out .= $data['obj'] . ' 0 obj' . "\n" . '<<' . ' /Type /XObject' . ' /Subtype /Image' . ' /Width ' . $data['width'] . ' /Height ' . $data['height'] . $this->getOutColorInfo($data); if ($data['exturl']) { // external stream $out .= ' /Length 0 /F' . ' <<' . ' /FS /URL /F ' . $this->encrypt->escapeDataString('true', $this->pon) . ' >>'; if ($data['filter'] !== '') { $out .= ' /FFilter /' . $data['filter']; } $out .= ' >>' . "\n"; } else { if ($data['filter'] !== '') { $out .= ' /Filter /' . $data['filter']; } if ($data['parms'] !== '') { $out .= ' ' . $data['parms']; } // Colour Key Masking if ($data['trns'] !== []) { $trns = $this->getOutTransparency($data); if ($trns !== '') { $out .= ' /Mask [ ' . $trns . ']'; } } $stream = $this->encrypt->encryptString($data['data'], $this->pon); $out .= ' /Length ' . \strlen($stream) . '>> stream' . "\n" . $stream . "\n" . 'endstream' . "\n"; } $out .= 'endobj' . "\n"; $this->cache[$img['key']]['out'] = true; // mark this as done return $out; } /** * Return XObjects Dictionary portion for the images. */ public function getXobjectDict(): string { $out = ''; foreach ($this->xobjdict as $iid => $objid) { $out .= ' /' . $iid . ' ' . $objid . ' 0 R'; } return $out; } /** * Return XObjects Dictionary. * * @param array $keys Image IDs. */ public function getXobjectDictByKeys(array $keys): string { if ($keys === []) { return ''; } $out = ''; foreach ($keys as $iid) { $key = 'IMG' . $iid; if (isset($this->xobjdict[$key])) { $out .= ' /' . $key . ' ' . $this->xobjdict[$key] . ' 0 R'; continue; } $key = 'IMGplain' . $iid; if (isset($this->xobjdict[$key])) { $out .= ' /' . $key . ' ' . $this->xobjdict[$key] . ' 0 R'; continue; } $key = 'IMGmask' . $iid; if (isset($this->xobjdict[$key])) { $out .= ' /' . $key . ' ' . $this->xobjdict[$key] . ' 0 R'; continue; } } return $out; } /** * Get the PDF output string for ICC object. * * @param ImageRawData $data Image raw data. * * @throws \Com\Tecnick\Pdf\Encrypt\Exception If ICC profile encryption fails. */ protected function getOutIcc(array &$data): string { if ($data['icc'] === '') { return ''; } $data['obj_icc'] = ++$this->pon; $out = $data['obj_icc'] . ' 0 obj' . "\n" . '<<' . ' /N ' . $data['channels'] . ' /Alternate /' . $data['colspace']; $icc = $data['icc']; if ($this->compress) { $out .= ' /Filter /FlateDecode'; $cicc = \gzcompress($icc); if ($cicc !== false) { $icc = $cicc; } } $stream = $this->encrypt->encryptString($icc, $this->pon); return ( $out . ( ' /Length ' . \strlen($stream) . ' >>' . ' stream' . "\n" . $stream . "\n" . 'endstream' . "\n" . 'endobj' . "\n" ) ); } /** * Get the PDF output string for Indexed palette object. * * @param ImageRawData $data Image raw data. * * @throws \Com\Tecnick\Pdf\Encrypt\Exception If palette encryption fails. */ protected function getOutPalette(array &$data): string { if ($data['colspace'] !== 'Indexed') { return ''; } $data['obj_pal'] = ++$this->pon; $out = $data['obj_pal'] . ' 0 obj' . "\n" . '<<'; $pal = $data['pal']; if ($this->compress) { $out .= '/Filter /FlateDecode'; $cpal = \gzcompress($pal); if ($cpal !== false) { $pal = $cpal; } } $stream = $this->encrypt->encryptString($pal, $this->pon); return ( $out . ( ' /Length ' . \strlen($stream) . '>>' . ' stream' . "\n" . $stream . "\n" . 'endstream' . "\n" . 'endobj' . "\n" ) ); } /** * Get the PDF output string for color and mask information. * * @param ImageRawData $data Image raw data. */ protected function getOutColorInfo(array $data): string { $out = ''; // set color space if ($data['obj_icc'] !== 0) { // ICC Colour Space $out .= ' /ColorSpace [/ICCBased ' . $data['obj_icc'] . ' 0 R]'; } elseif ($data['obj_pal'] !== 0) { // Indexed Colour Space $out .= ' /ColorSpace [/Indexed /DeviceRGB ' . ((\strlen($data['pal']) / 3) - 1) . ' ' . $data['obj_pal'] . ' 0 R]'; } else { // Device Colour Space $out .= ' /ColorSpace /' . $data['colspace']; } if ($data['colspace'] === 'DeviceCMYK') { $out .= ' /Decode [1 0 1 0 1 0 1 0]'; } $out .= ' /BitsPerComponent ' . $data['bits']; $maskobj = (int) ($this->cache[$data['key']]['mask']['obj'] ?? 0); if (!$data['ismask'] && $maskobj > 0) { $out .= ' /SMask ' . $maskobj . ' 0 R'; } if ($data['obj_alt'] !== 0) { // reference to alternate images dictionary $out .= ' /Alternates ' . $data['obj_alt'] . ' 0 R'; } return $out; } /** * Get the PDF output string for Alternate images object. * * @param array{ * 'iid': int, * 'key': string, * 'width': int, * 'height': int, * 'defprint': bool, * 'altimgs'?: array, * } $img Image reference. * @param ImageRawData $data Image raw data. * @param string $sub Sub image ('mask', 'plain' or empty string). */ protected function getOutAltImages(array $img, array &$data, string $sub = ''): string { if ($this->pdfa || !isset($img['altimgs']) || $img['altimgs'] === [] || $sub === 'mask') { return ''; } $data['obj_alt'] = ++$this->pon; $out = $this->pon . ' 0 obj' . "\n" . '['; foreach ($img['altimgs'] as $iid) { $altimg = $this->image[$iid] ?? null; if ($altimg === null) { continue; } $altobj = (int) ($this->cache[$altimg['key']]['obj'] ?? 0); if ($altobj === 0) { continue; } $out .= ' << /Image ' . $altobj . ' 0 R' . ' /DefaultForPrinting ' . ($altimg['defprint'] ? 'true' : 'false') . ' >>'; } return $out . (' ]' . "\n" . 'endobj' . "\n"); } /** * Get the PDF output string for color transparency. * * @param ImageRawData $data Image raw data. */ protected function getOutTransparency(array $data): string { $trns = ''; if ($data['colspace'] === 'Indexed') { // Indexed: trns holds one alpha byte per palette entry; mask the // fully-transparent entries (alpha 0) by their palette index. foreach ($data['trns'] as $idx => $val) { if ($val !== 0) { continue; } $trns .= $idx . ' ' . $idx . ' '; } return $trns; } // DeviceRGB / DeviceGray: trns holds the transparent colour sample // values themselves, emitted as single-value colour-key ranges. foreach ($data['trns'] as $val) { $trns .= $val . ' ' . $val . ' '; } return $trns; } }