Initial commit

This commit is contained in:
2026-09-07 22:04:36 +00:00
commit d1334bf406
2334 changed files with 646393 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
/**
* Exception.php
*
* @since 2011-05-23
* @category Library
* @package PdfImage
* @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-image
*
* This file is part of tc-lib-pdf-image software library.
*/
namespace Com\Tecnick\Pdf\Image;
/**
* Com\Tecnick\Pdf\Image\Exception
*
* Custom Exception class
*
* @since 2011-05-23
* @category Library
* @package PdfImage
* @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-image
*/
class Exception extends \Exception {}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
/**
* ImageCacheInterface.php
*
* @since 2026-06-16
* @category Library
* @package PdfImage
* @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-image
*
* This file is part of tc-lib-pdf-image software library.
*/
namespace Com\Tecnick\Pdf\Image;
/**
* Com\Tecnick\Pdf\Image\ImageCacheInterface
*
* Optional external cache used to persist processed image data across
* Import instances and PHP processes, avoiding recomputation of images that
* have already been imported, resized and encoded.
*
* Implementations are a thin bridge to any backend (filesystem, APCu, Redis,
* a PSR-16 cache, ...). They only need to store and retrieve plain arrays;
* (de)serialization and eviction are the backend's responsibility.
*
* The cached value is the import-time snapshot of an image: it never contains
* PDF object numbers (those are assigned per document at output time). Both
* methods MUST be best-effort and MUST NOT throw on a backend miss or
* transient backend failure, so that a cache problem never breaks PDF
* generation.
*
* Security: the cache store is a trust boundary. The stored arrays (image
* data, palette and ICC bytes) are embedded verbatim into generated PDFs, so
* anyone able to write to the backend can influence document output. Use a
* store only your application can write to, and when an implementation
* deserializes data it MUST disable object restoration
* (e.g. unserialize($s, ['allowed_classes' => false])).
*
* @since 2026-06-16
* @category Library
* @package PdfImage
* @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-image
*
* @phpstan-import-type ImageRawData from \Com\Tecnick\Pdf\Image\Import
*/
interface ImageCacheInterface
{
/**
* Retrieve a previously stored image data array.
*
* @param string $key Image cache key.
*
* @return ImageRawData|null Stored image data array, or null on a miss.
*/
public function get(string $key): ?array;
/**
* Store an image data array.
*
* @param string $key Image cache key.
* @param ImageRawData $data Image data array to store.
*/
public function set(string $key, array $data): void;
}
+930
View File
@@ -0,0 +1,930 @@
<?php
declare(strict_types=1);
/**
* Import.php
*
* @since 2011-05-23
* @category Library
* @package PdfImage
* @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-image
*
* This file is part of tc-lib-pdf-image software library.
*/
namespace Com\Tecnick\Pdf\Image;
use Com\Tecnick\Pdf\Image\Exception as ImageException;
use Com\Tecnick\Pdf\Image\Import\ImageImportInterface;
/**
* Com\Tecnick\Pdf\Image\Import
*
* @since 2011-05-23
* @category Library
* @package PdfImage
* @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-image
*
* @phpstan-type ImageBaseData array{
* 'bits': int,
* 'channels': int,
* 'colspace': string,
* 'data': string,
* 'exturl': bool,
* 'file': string,
* 'filter': string,
* 'height': int,
* 'icc': string,
* 'ismask': bool,
* 'key': string,
* 'mapto': int,
* 'native': bool,
* 'obj': int,
* 'obj_alt': int,
* 'obj_icc': int,
* 'obj_pal': int,
* 'pal': string,
* 'parms': string,
* 'raw': string,
* 'recode': bool,
* 'recoded': bool,
* 'splitalpha': bool,
* 'trns': array<int, int>,
* 'type': int,
* 'width': int,
* }
*
* @phpstan-type ImageRawData array{
* 'bits': int,
* 'channels': int,
* 'colspace': string,
* 'data': string,
* 'exturl': bool,
* 'file': string,
* 'filter': string,
* 'height': int,
* 'icc': string,
* 'ismask': bool,
* 'key': string,
* 'mapto': int,
* 'mask'?: ImageBaseData,
* 'native': bool,
* 'obj': int,
* 'obj_alt': int,
* 'obj_icc': int,
* 'obj_pal': int,
* 'out'?: true,
* 'pal': string,
* 'parms': string,
* 'plain'?: ImageBaseData,
* 'raw': string,
* 'recode': bool,
* 'recoded': bool,
* 'splitalpha': bool,
* 'trns': array<int, int>,
* 'type': int,
* 'width': int,
* }
*
* @SuppressWarnings("PHPMD.ExcessiveClassLength")
* @SuppressWarnings("PHPMD.ExcessiveClassComplexity")
*/
class Import extends \Com\Tecnick\Pdf\Image\Output
{
/**
* Image index.
* Count the number of added images.
*/
protected int $iid = 0;
/**
* Namespace + schema version prefix for external cache keys.
* Bump the version when the ImageRawData shape changes so that stale
* persisted entries are ignored instead of being fed into output.
*/
private const CACHE_PREFIX = 'tc-lib-pdf-image:v2:';
/**
* Native image types and associated importing class.
* (Image types for which we have an import method).
*
* @var array<int, string>
*/
private const NATIVE = [
IMAGETYPE_PNG => 'Png',
IMAGETYPE_JPEG => 'Jpeg',
];
/**
* Lossless image types.
*
* @var array<int>
*/
protected const LOSSLESS = [
IMAGETYPE_GIF, // 1
IMAGETYPE_PNG, // 3
IMAGETYPE_PSD, // 5
IMAGETYPE_BMP, // 6
IMAGETYPE_WBMP, // 15
IMAGETYPE_XBM, // 16
IMAGETYPE_TIFF_II, // 7
IMAGETYPE_TIFF_MM, // 8
IMAGETYPE_IFF, // 14
IMAGETYPE_SWC, // 13
IMAGETYPE_ICO, // 17
];
/**
* Map number of channels with color space name.
*
* @var array<int, string>
*/
protected const COLSPACEMAP = [
1 => 'DeviceGray',
3 => 'DeviceRGB',
4 => 'DeviceCMYK',
];
/**
* Add a new image.
*
* @param string $image Image file name, URL or a '@' character followed by the image data string.
* To link an image without embedding it on the document, set an asterisk
* character before the URL (i.e.: '*http://www.example.com/image.jpg').
* @param ?int $width New width in pixels or null to keep the original value.
* @param ?int $height New height in pixels or null to keep the original value.
* @param bool $ismask True if the image is a transparency mask.
* @param int $quality Quality for JPEG files (0 = max compression; 100 = best quality, bigger file).
* @param bool $defprint Indicate if the image is the default
* for printing when used as alternative image.
* @param array<int, int> $altimgs Arrays of alternate image keys.
*
* @return int Image ID.
*
* @throws \Com\Tecnick\Pdf\Image\Exception If the image cannot be added.
* @throws \Com\Tecnick\File\Exception If reading image content fails.
*/
public function add(
string $image,
?int $width = null,
?int $height = null,
bool $ismask = false,
int $quality = 100,
bool $defprint = false,
array $altimgs = [],
): int {
$data = $this->import($image, $width, $height, $ismask, $quality);
++$this->iid;
$this->image[$this->iid] = [
'iid' => $this->iid,
'key' => $data['key'],
'width' => $data['width'],
'height' => $data['height'],
'defprint' => $defprint,
'altimgs' => $altimgs,
];
return $this->iid;
}
/**
* Get the Image key used for caching.
*
* @param string $image Image file name or content.
* @param int $width Width in pixels.
* @param int $height Height in pixels.
* @param int $quality Quality for JPEG files.
*/
public function getKey(string $image, int $width = 0, int $height = 0, int $quality = 100): string
{
// The parts are joined with a delimiter so that distinct inputs cannot
// collapse into the same digest (e.g. ('img', 12, 3) vs ('img1', 2, 3)).
$seed = $image . '|' . $width . '|' . $height . '|' . $quality;
return \strtr(\rtrim(\base64_encode(\pack('H*', \md5($seed))), '='), '+/', '-_');
}
/**
* Get an imported image by key.
*
* @param string $key Image key.
*
* @return ImageRawData Image raw data array.
*
* @throws \Com\Tecnick\Pdf\Image\Exception If the key is not found.
*/
public function getImageDataByKey(string $key): array
{
$cache = $this->cache[$key] ?? null;
if ($cache === null) {
throw new ImageException('Unknown key');
}
return $cache;
}
/**
* Resolve the image dimensions for a cached image.
*
* - If both $width and $height are zero, the original image dimensions are returned.
* - If only one of $width and $height is greater than zero, the other is
* calculated preserving the original aspect ratio.
* - If both are greater than zero, they are returned as-is, unless $fit is
* true, in which case the image is scaled to fit within the $width x $height
* bounding box preserving the original aspect ratio.
*
* @param string $key Image key.
* @param int $width Target width in pixels (0 = unspecified).
* @param int $height Target height in pixels (0 = unspecified).
* @param bool $fit If true and both sides are specified, scale to fit
* within the bounding box preserving the aspect ratio.
*
* @return array{width: int, height: int} Image dimensions in pixels.
*
* @throws \Com\Tecnick\Pdf\Image\Exception If the key is not found.
*/
public function getImageDimensionsByKey(string $key, int $width = 0, int $height = 0, bool $fit = false): array
{
$data = $this->getImageDataByKey($key);
[$width, $height] = $this->scaleToAspect(
$data['width'],
$data['height'],
$width > 0 ? $width : null,
$height > 0 ? $height : null,
$fit,
);
return [
'width' => $width,
'height' => $height,
];
}
/**
* Import the original image raw data.
*
* @param string $image Image file name, URL or a '@' character followed by the image data string.
* To link an image without embedding it on the document, set an asterisk
* character before the URL (i.e.: '*http://www.example.com/image.jpg').
* @param ?int $width New width in pixels or null to keep the original value.
* @param ?int $height New height in pixels or null to keep the original value.
* @param bool $ismask True if the image is a transparency mask.
* @param int $quality Quality for JPEG files (0 = max compression; 100 = best quality, bigger file).
*
* @return ImageRawData Image raw data array
*
* @throws \Com\Tecnick\Pdf\Image\Exception If the image cannot be imported.
* @throws \Com\Tecnick\File\Exception If reading image content fails.
*/
protected function import(
string $image,
?int $width = null,
?int $height = null,
bool $ismask = false,
int $quality = 100,
): array {
$quality = \max(0, \min(100, $quality));
$imgkey = $this->getKey($image, (int) $width, (int) $height, $quality);
// Tier 1: in-process memory cache (fast path, also dedups within a document).
if (isset($this->cache[$imgkey])) {
return $this->cache[$imgkey];
}
// Tier 2: optional external cache (survives across instances and processes).
$cache = $this->imageCache;
$extkey = '';
if ($cache !== null) {
$extkey = $this->getExternalCacheKey($image, $imgkey, (int) $width, (int) $height, $quality);
$cached = $cache->get($extkey);
if ($cached !== null) {
$cached['key'] = $imgkey;
$this->cache[$imgkey] = $cached;
return $cached;
}
}
$data = $this->getRawData($image);
$data['key'] = $imgkey;
[$width, $height] = $this->resolveDimensions($data, $width, $height);
if (!$data['native'] || $width !== $data['width'] || $height !== $data['height']) {
$data = $this->getResizedRawData($this->getBaseData($data), $width, $height, true, $quality);
}
$data = $this->getData($data, $width, $height, $quality);
$data = $this->enrichMaskData($data, $width, $height, $quality, $ismask);
// store data in the in-process cache
$this->cache[$imgkey] = $data;
// write-through to the external cache (best-effort, persistable snapshot)
if ($cache !== null) {
$cache->set($extkey, $this->getPersistableData($data));
}
return $data;
}
/**
* Build the persistent (external) cache key for an image.
*
* For local file inputs the file modification time and size are folded into
* the key, so that editing a file in place invalidates stale persisted
* entries. Inline data ('@...') and remote URLs are identified by their
* string, which already carries their content/identity.
*
* @param string $image Image file name, URL or '@'-prefixed data string.
* @param string $imgkey Image key already computed from the raw image string.
* @param int $width Width in pixels.
* @param int $height Height in pixels.
* @param int $quality Quality for JPEG files.
*/
private function getExternalCacheKey(string $image, string $imgkey, int $width, int $height, int $quality): string
{
$identity = $image;
if ($image !== '' && $image[0] !== '@') {
$path = $image[0] === '*' ? \substr($image, 1) : $image;
if (\is_file($path)) {
$mtime = \filemtime($path);
$size = \filesize($path);
if ($mtime !== false && $size !== false) {
$identity = $image . ':' . $mtime . ':' . $size;
}
}
}
// When the identity is just the raw image string the key is identical
// to the one already computed by the caller, so reuse it and avoid a
// second md5/base64 round.
$key = $identity === $image ? $imgkey : $this->getKey($identity, $width, $height, $quality);
return self::CACHE_PREFIX . $key;
}
/**
* Produce a persistable snapshot of the imported image data.
*
* Drops the original raw bytes (not used at output time) to shrink the
* stored payload. PDF object numbers and the output flag are intentionally
* left as imported (all zero / unset), so a loaded entry is ready for a
* fresh document without any reset.
*
* @param ImageRawData $data Imported image data.
*
* @return ImageRawData Persistable image data.
*/
private function getPersistableData(array $data): array
{
$data['raw'] = '';
if (isset($data['mask'])) {
$data['mask']['raw'] = '';
}
if (isset($data['plain'])) {
$data['plain']['raw'] = '';
}
return $data;
}
/**
* Resolve target image dimensions preserving aspect ratio when one side is omitted.
*
* @param ImageRawData $data Image raw data.
* @param ?int $width Target width in pixels (null = unspecified, derive from aspect ratio).
* @param ?int $height Target height in pixels (null = unspecified, derive from aspect ratio).
*
* @return array{0: int, 1: int}
*/
private function resolveDimensions(array $data, ?int $width, ?int $height): array
{
return $this->scaleToAspect($data['width'], $data['height'], $width, $height);
}
/**
* Scale target dimensions against a source size, preserving the aspect ratio.
*
* - If both $width and $height are null (unspecified), the source dimensions
* are returned.
* - If only one side is specified, the other is derived from the source aspect
* ratio.
* - If both sides are specified, they are returned as-is, unless $fit is true,
* in which case the result is scaled to fit within the $width x $height box
* while preserving the source aspect ratio.
*
* An explicit zero (as opposed to null) is kept as-is so that callers can
* detect and reject invalid target dimensions downstream.
*
* @param int $srcwidth Source width in pixels.
* @param int $srcheight Source height in pixels.
* @param ?int $width Target width in pixels (null = unspecified).
* @param ?int $height Target height in pixels (null = unspecified).
* @param bool $fit Scale to fit within the box when both sides are given.
*
* @return array{0: int, 1: int}
*/
private function scaleToAspect(int $srcwidth, int $srcheight, ?int $width, ?int $height, bool $fit = false): array
{
if ($width === null && $height === null) {
return [\max(0, $srcwidth), \max(0, $srcheight)];
}
if ($fit && $width !== null && $width > 0 && $height !== null && $height > 0) {
return $this->fitToBox($srcwidth, $srcheight, $width, $height);
}
if ($width !== null && $height === null && $srcwidth > 0) {
$height = $this->scaleSide($width, $srcheight, $srcwidth);
}
if ($height !== null && $width === null && $srcheight > 0) {
$width = $this->scaleSide($height, $srcwidth, $srcheight);
}
return [\max(0, $width ?? $srcwidth), \max(0, $height ?? $srcheight)];
}
/**
* Scale a single dimension proportionally: round($value * $numerator / $denominator).
*
* @param int $value The known side to scale from (e.g. the specified width or height).
* @param int $numerator Source size of the side being computed.
* @param int $denominator Source size of the side $value corresponds to.
*
* @return int Scaled size of the computed side, in pixels.
*/
private function scaleSide(int $value, int $numerator, int $denominator): int
{
return (int) \round(((float) $value * (float) $numerator) / (float) $denominator);
}
/**
* Scale the source size to fit within the $width x $height box, preserving the aspect ratio.
*
* @param int $srcwidth Source width in pixels.
* @param int $srcheight Source height in pixels.
* @param int $width Bounding box width in pixels.
* @param int $height Bounding box height in pixels.
*
* @return array{0: int, 1: int}
*/
private function fitToBox(int $srcwidth, int $srcheight, int $width, int $height): array
{
if ($srcwidth <= 0 || $srcheight <= 0) {
return [\max(0, $width), \max(0, $height)];
}
$scale = \min((float) $width / (float) $srcwidth, (float) $height / (float) $srcheight);
return [
\max(0, (int) \round((float) $srcwidth * $scale)),
\max(0, (int) \round((float) $srcheight * $scale)),
];
}
/**
* Add plain/mask variants when required by mask mode or alpha splitting.
*
* @param ImageRawData $data Image raw data.
* @param int $width Width in pixels.
* @param int $height Height in pixels.
* @param int $quality Quality for JPEG files.
* @param bool $ismask True if the image is a transparency mask.
*
* @return ImageRawData
*
* @throws \Com\Tecnick\Pdf\Image\Exception If the image cannot be imported.
*/
private function enrichMaskData(array $data, int $width, int $height, int $quality, bool $ismask): array
{
$basedata = $this->getBaseData($data);
if ($ismask) {
$data['mask'] = $basedata;
return $data;
}
if (!$data['splitalpha']) {
return $data;
}
// create 2 separate images: plain + mask
$data['plain'] = $this->createPlainImage($basedata, $width, $height, $quality);
$data['mask'] = $this->createMaskImage($basedata, $width, $height, $quality);
return $data;
}
/**
* @param ImageBaseData $basedata Image base data.
* @param int $width Width in pixels.
* @param int $height Height in pixels.
* @param int $quality Quality for JPEG files.
*
* @return ImageBaseData
*
* @throws \Com\Tecnick\Pdf\Image\Exception If the image cannot be imported.
*/
private function createPlainImage(array $basedata, int $width, int $height, int $quality): array
{
$plain = $this->getResizedRawData($basedata, $width, $height, false, $quality);
$plain = $this->getData($plain, $width, $height, $quality);
return $this->getBaseData($plain);
}
/**
* @param ImageBaseData $basedata Image base data.
* @param int $width Width in pixels.
* @param int $height Height in pixels.
* @param int $quality Quality for JPEG files.
*
* @return ImageBaseData
*
* @throws \Com\Tecnick\Pdf\Image\Exception If the image cannot be imported.
*/
private function createMaskImage(array $basedata, int $width, int $height, int $quality): array
{
$mask = $this->getAlphaChannelRawData($basedata);
$mask = $this->getData($mask, $width, $height, $quality);
$mask = $this->getBaseData($mask);
$mask['colspace'] = 'DeviceGray';
return $mask;
}
/**
* Extract the relevant data from the image.
*
* @param ImageRawData $data Image raw data.
* @param int $width Width in pixels.
* @param int $height Height in pixels.
* @param int $quality Quality for JPEG files.
*
* @return ImageRawData Image raw data array.
*
* @throws \Com\Tecnick\Pdf\Image\Exception If the image cannot be imported.
*/
protected function getData(array $data, int $width, int $height, int $quality): array
{
if (!$data['native']) {
throw new ImageException('Unable to import image');
}
$imageImport = $this->createImportImage($data);
$data = $imageImport->getData($this->getBaseData($data));
if ($data['recode']) {
// re-encode the image as it was not possible to decode it
$data = $this->getResizedRawData($this->getBaseData($data), $width, $height, true, $quality);
$data = $imageImport->getData($this->getBaseData($data));
}
return $data;
}
/**
* @param array{'type': int}|ImageRawData $data Image raw data.
*
* @throws \Com\Tecnick\Pdf\Image\Exception If the image type is unknown.
*/
private function createImportImage(array $data): ImageImportInterface
{
if (!isset(self::NATIVE[$data['type']])) {
throw new ImageException('Unable to import image: unknown type');
}
return match (self::NATIVE[$data['type']]) {
'Png' => new \Com\Tecnick\Pdf\Image\Import\Png(),
'Jpeg' => new \Com\Tecnick\Pdf\Image\Import\Jpeg(),
default => throw new ImageException('Unable to import image: unknown type'),
};
}
/**
* Normalize raw image data to its base shape.
*
* @param ImageRawData $data Image raw data.
*
* @return ImageBaseData Image base data.
*/
private function getBaseData(array $data): array
{
return [
'bits' => $data['bits'],
'channels' => $data['channels'],
'colspace' => $data['colspace'],
'data' => $data['data'],
'exturl' => $data['exturl'],
'file' => $data['file'],
'filter' => $data['filter'],
'height' => $data['height'],
'icc' => $data['icc'],
'ismask' => $data['ismask'],
'key' => $data['key'],
'mapto' => $data['mapto'],
'native' => $data['native'],
'obj' => $data['obj'],
'obj_alt' => $data['obj_alt'],
'obj_icc' => $data['obj_icc'],
'obj_pal' => $data['obj_pal'],
'pal' => $data['pal'],
'parms' => $data['parms'],
'raw' => $data['raw'],
'recode' => $data['recode'],
'recoded' => $data['recoded'],
'splitalpha' => $data['splitalpha'],
'trns' => $data['trns'],
'type' => $data['type'],
'width' => $data['width'],
];
}
/**
* Get the original image raw data.
*
* @param string $image Image file name, URL or a '@' character followed by the image data string.
* To link an image without embedding it on the document, set an asterisk character
* before the URL (i.e.: '*http://www.example.com/image.jpg').
*
* @return ImageRawData Image data array.
*
* @throws \Com\Tecnick\Pdf\Image\Exception If the image cannot be read.
* @throws \Com\Tecnick\File\Exception If reading image content fails.
*/
protected function getRawData(string $image): array
{
// default data to return
$data = [
'bits' => 8, // number of bits per channel
'channels' => 3, // number of channels
'colspace' => 'DeviceRGB', // color space
'data' => '', // PDF image data
'exturl' => false, // true if the image is an exernal URL that should not be embedded
'file' => '', // source file name or URL
'filter' => 'FlateDecode', // decoding filter
'height' => 0, // image height in pixels
'icc' => '', // ICC profile
'ismask' => false, // true if the image is a transparency mask
'key' => '', // image key
'mapto' => IMAGETYPE_PNG, // type to convert to
'native' => false, // true if the image is PNG or JPEG
'obj' => 0, // PDF object number
'obj_alt' => 0,
'obj_icc' => 0,
'obj_pal' => 0,
'pal' => '', // colour palette
'parms' => '', // additional PDF decoding parameters
'raw' => '', // raw image data
'recode' => false,
'recoded' => false,
'splitalpha' => false,
'trns' => [], // colour key masking
'type' => 0, // image type constant: IMAGETYPE_XXX
'width' => 0, // image width in pixels
];
if ($image === '' || ($image[0] === '@' || $image[0] === '*') && \strlen($image) === 1) {
throw new ImageException('Empty image');
}
if ($image[0] === '@') { // image from string
$data['raw'] = \substr($image, 1);
return $this->getMetaData($data);
}
if ($image[0] === '*') { // not-embedded external URL
$data['exturl'] = true;
$image = \substr($image, 1);
}
$data['file'] = $image;
$raw = $this->fileHelper->getFileData($image);
if ($raw === false) {
throw new ImageException('Unable to read image file: ' . $image);
}
$data['raw'] = $raw;
return $this->getMetaData($data);
}
/**
* Get the image meta data.
*
* @param ImageBaseData $data Image raw data.
*
* @return ImageRawData Image raw data array.
*
* @throws \Com\Tecnick\Pdf\Image\Exception If the image format is invalid.
*/
protected function getMetaData(array $data): array
{
// getimagesizefromstring() does not throw: it emits warnings (silenced
// below) and returns false on failure, which is handled right after.
\set_error_handler(static fn(): bool => true);
try {
$meta = \getimagesizefromstring($data['raw']);
} finally {
\restore_error_handler();
}
if ($meta === false) {
throw new ImageException('Invalid image format');
}
$data['width'] = $meta[0];
$data['height'] = $meta[1];
$data['type'] = $meta[2];
$data['native'] = isset(self::NATIVE[$data['type']]);
$data['mapto'] = \in_array($data['type'], self::LOSSLESS, true) ? IMAGETYPE_PNG : IMAGETYPE_JPEG;
/** @var array{'bits'?: int, 'channels'?: int} $meta */
if (isset($meta['bits'])) {
$data['bits'] = $meta['bits'];
}
if (isset($meta['channels']) && $meta['channels'] !== 0) {
$data['channels'] = (int) $meta['channels'];
}
if (isset(self::COLSPACEMAP[$data['channels']])) {
$data['colspace'] = self::COLSPACEMAP[$data['channels']];
}
return $data;
}
/**
* Get the resized image raw data
* (always convert the image type to a native format: PNG or JPEG).
*
* @param ImageBaseData $data Image raw data as returned by getRawData.
* @param int $width New width in pixels.
* @param int $height New height in pixels.
* @param bool $alpha If true save the alpha channel information,
* if false merge the alpha channel (PNG mode).
* @param int $quality Quality for JPEG files
* (0 = max compression; 100 = best quality, bigger file).
*
* @return ImageRawData Image raw data array.
*
* @throws \Com\Tecnick\Pdf\Image\Exception If the image cannot be resized.
* @SuppressWarnings("PHPMD.CyclomaticComplexity")
* @SuppressWarnings("PHPMD.NPathComplexity")
*/
protected function getResizedRawData(
array $data,
int $width,
int $height,
bool $alpha = true,
int $quality = 100,
): array {
if ($width <= 0 || $height <= 0) {
throw new ImageException('Image width and/or height are empty');
}
$img = \imagecreatefromstring($data['raw']);
if ($img === false) {
throw new ImageException('Unable to create new image from string');
}
$newimg = \imagecreatetruecolor($width, $height);
if ($newimg === false) {
throw new ImageException('Unable to create new resized image');
}
\imageinterlace($newimg, false);
if ($alpha) {
$trid = \imagecolorallocate($newimg, 0, 0, 0);
if ($trid === false) {
throw new ImageException('Unable to allocate alpha color for transparency');
}
\imagecolortransparent($newimg, $trid);
} else {
$tid = \imagecolortransparent($img);
$palsize = \imagecolorstotal($img);
if ($tid >= 0 && $palsize > 0 && $tid < $palsize) {
// set transparency for Indexed image
/** @var array{'red': int, 'green': int, 'blue': int, 'alpha': int} $tcol */
$tcol = \imagecolorsforindex($img, $tid);
$trid = \imagecolorallocate($newimg, $tcol['red'], $tcol['green'], $tcol['blue']);
if ($trid === false) {
throw new ImageException('Unable to allocate color for transparency');
}
\imagefill($newimg, 0, 0, $trid);
\imagecolortransparent($newimg, $trid);
}
}
\imagealphablending($newimg, !$alpha);
\imagesavealpha($newimg, $alpha);
\imagecopyresampled($newimg, $img, 0, 0, 0, 0, $width, $height, $data['width'], $data['height']);
\ob_start();
if ($data['mapto'] === IMAGETYPE_PNG) {
\imagepng($newimg, null, 9, PNG_ALL_FILTERS);
} else {
\imagejpeg($newimg, null, $quality);
}
$ogc = \ob_get_clean();
if ($ogc === false) {
throw new ImageException('Unable to extract alpha channel');
}
$data['raw'] = $ogc;
$data['exturl'] = false;
$data['recoded'] = true;
return $this->getMetaData($data);
}
/**
* Extract the alpha channel as separate image to be used as a mask.
*
* @param ImageBaseData $data Image raw data as returned by getRawData.
*
* @return ImageRawData Image raw data array.
*
* @throws \Com\Tecnick\Pdf\Image\Exception If the alpha channel cannot be extracted.
*/
protected function getAlphaChannelRawData(array $data): array
{
$img = \imagecreatefromstring($data['raw']);
if ($img === false) {
throw new ImageException('Unable to create alpha channel image from string');
}
$newimg = \imagecreate(\max(1, $data['width']), \max(1, $data['height']));
if ($newimg === false) {
throw new ImageException('Unable to create new empty alpha channel image');
}
\imageinterlace($newimg, false);
// generate gray scale palette (0 -> 255)
for ($col = 0; $col < 256; ++$col) {
\imagecolorallocate($newimg, $col, $col, $col);
}
// Precompute the gamma-corrected output for each of the 128 possible
// GD alpha values (7-bit: 0 -> 127), so the expensive pow() runs 128
// times instead of once per pixel.
$alphamap = [];
for ($alf = 0; $alf < 128; ++$alf) {
// GD alpha is only 7 bit (0 -> 127); 2.2 is the gamma value
$alphamap[$alf] = (int) ((((float) (127 - $alf) / 127) ** 2.2) * 255);
}
$truecolor = \imageistruecolor($img);
// extract alpha channel
for ($xpx = 0; $xpx < $data['width']; ++$xpx) {
for ($ypx = 0; $ypx < $data['height']; ++$ypx) {
$colindex = \imagecolorat($img, $xpx, $ypx);
if ($colindex === false) {
throw new ImageException('Unable to extract alpha channel color index');
}
// get and correct gamma color (keys 0-127 are always present)
if ($truecolor) {
// For truecolor images the alpha is packed in bits 24-30 of
// the pixel value, so it can be read directly instead of
// allocating a colour-component array per pixel.
$alpha = $alphamap[($colindex >> 24) & 0x7F] ?? 0;
} else {
/** @var array{'red': int, 'green': int, 'blue': int, 'alpha': int} $color */
$color = \imagecolorsforindex($img, $colindex);
$alpha = $alphamap[$color['alpha']] ?? 0;
}
\imagesetpixel($newimg, $xpx, $ypx, $alpha);
}
}
\ob_start();
\imagepng($newimg, null, 9, PNG_ALL_FILTERS);
$ogc = \ob_get_clean();
if ($ogc === false) {
throw new ImageException('Unable to extract alpha channel');
}
$data['raw'] = $ogc;
$data['channels'] = 1;
$data['colspace'] = 'DeviceGray';
$data['exturl'] = false;
$data['recoded'] = true;
$data['ismask'] = true;
return $this->getMetaData($data);
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/**
* ImageImportInterface.php
*
* @since 2011-05-23
* @category Library
* @package PdfImage
* @author jmleroux <jmleroux.pro@gmail.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-image
*
* This file is part of tc-lib-pdf-image software library.
*/
namespace Com\Tecnick\Pdf\Image\Import;
/**
* Com\Tecnick\Pdf\Image\Import\ImageImportInterface
*
* @since 2011-05-23
* @category Library
* @package PdfImage
* @author jmleroux <jmleroux.pro@gmail.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-image
*
* @phpstan-import-type ImageBaseData from \Com\Tecnick\Pdf\Image\Import
*/
interface ImageImportInterface
{
/**
* Extract data from an image.
*
* @param ImageBaseData $data Image raw data.
*
* @return ImageBaseData Image raw data array.
*/
public function getData(array $data): array;
}
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
/**
* Jpeg.php
*
* @since 2011-05-23
* @category Library
* @package PdfImage
* @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-image
*
* This file is part of tc-lib-pdf-image software library.
*/
namespace Com\Tecnick\Pdf\Image\Import;
use Com\Tecnick\File\Byte;
/**
* Com\Tecnick\Pdf\Image\Import\Jpeg
*
* @since 2011-05-23
* @category Library
* @package PdfImage
* @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-image
*
* @phpstan-import-type ImageBaseData from \Com\Tecnick\Pdf\Image\Import
*/
class Jpeg implements ImageImportInterface
{
/**
* Extract data from a JPEG image.
*
* @param ImageBaseData $data Image raw data.
*
* @return ImageBaseData Image raw data array.
*
* @throws \RangeException If the byte offset is out of range.
*/
public function getData(array $data): array
{
$data['filter'] = 'DCTDecode';
$data['data'] = $data['raw'];
$byte = new Byte($data['raw']);
// extract embedded ICC profile (if any)
$icc = [];
$offset = 0;
// Length in bytes of the "ICC_PROFILE" marker string, used as the
// minimum forward step so a malformed segment can never stall the scan.
$markerlen = 11;
while (($pos = \strpos($data['raw'], 'ICC_PROFILE', $offset)) !== false) {
if ($pos < 2) {
// Not enough bytes before the marker to read the APP2 length.
$offset = $pos + $markerlen;
continue;
}
// get ICC sequence length
$length = $byte->getUShort($pos - 2) - 16;
if ($length <= 0) {
// Malformed or spurious marker: skip past it so $offset always
// advances and the loop cannot get stuck re-finding this match.
$offset = $pos + $markerlen;
continue;
}
// marker sequence number
$msn = \max(1, \ord($data['raw'][$pos + 12] ?? "\x00"));
// number of markers (total of APP2 used)
//$nom = \max(1, \ord($data['raw'][($pos + 13)]));
// get sequence segment
$icc[$msn - 1] = \substr($data['raw'], $pos + 14, $length);
// move forward to next sequence
$offset = $pos + 14 + $length;
}
// order and compact ICC segments
if ($icc !== []) {
\ksort($icc);
$icc = \implode('', $icc);
if (\substr($icc, 36, 4) === 'acsp') {
// valid ICC profile
$data['icc'] = $icc;
}
}
return $data;
}
}
+325
View File
@@ -0,0 +1,325 @@
<?php
declare(strict_types=1);
/**
* Png.php
*
* @since 2011-05-23
* @category Library
* @package PdfImage
* @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-image
*
* This file is part of tc-lib-pdf-image software library.
*/
namespace Com\Tecnick\Pdf\Image\Import;
use Com\Tecnick\File\Byte;
use Com\Tecnick\Pdf\Image\Exception as ImageException;
/**
* Com\Tecnick\Pdf\Image\Import\Png
*
* @since 2011-05-23
* @category Library
* @package PdfImage
* @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-image
*
* @phpstan-import-type ImageBaseData from \Com\Tecnick\Pdf\Image\Import
*/
class Png implements ImageImportInterface
{
/**
* Extract data from a PNG image.
*
* @param ImageBaseData $data Image raw data.
*
* @return ImageBaseData Image raw data array.
*
* @throws \Com\Tecnick\Pdf\Image\Exception If the image is invalid.
* @throws \RangeException If the byte offset is out of range.
*/
public function getData(array $data): array
{
$data['filter'] = 'FlateDecode';
$byte = new Byte($data['raw']);
$offset = 0;
// check signature
if (\substr($data['raw'], $offset, 8) !== \chr(137) . 'PNG' . \chr(13) . \chr(10) . \chr(26) . \chr(10)) {
// @codeCoverageIgnoreStart
throw new ImageException('Not a PNG image');
// @codeCoverageIgnoreEnd
}
$offset += 8;
$offset += 4;
$data = $this->getIhdrChunk($byte, $data, $offset);
$offset += 3;
// check compression, filter and interlacing settings
if (
$byte->getByte($offset - 3) !== 0
|| $byte->getByte($offset - 2) !== 0
|| $byte->getByte($offset - 1) !== 0
) {
if ($data['recoded']) {
// this image has been already re-encoded
// @codeCoverageIgnoreStart
throw new ImageException('Unsupported feature');
// @codeCoverageIgnoreEnd
}
// re-encode PNG
$data['recode'] = true;
return $data;
}
if (\str_contains($data['colspace'], '+Alpha')) {
// alpha channel: split images (plain + alpha)
$data['splitalpha'] = true;
$data['colspace'] = \substr($data['colspace'], 0, -6);
return $data;
}
$data['parms'] =
'/DecodeParms << /Predictor 15 /Colors '
. $data['channels']
. ' /BitsPerComponent '
. $data['bits']
. ' /Columns '
. $data['width']
. ' >>';
$offset += 4;
return $this->getChunks($byte, $data, $offset);
}
/**
* Extract the IHDR chunk data.
*
* The header chunk (IHDR) contains basic information about the image data and must appear as the first chunk,
* and there must only be one header chunk in a PNG data stream.
*
* @param Byte $byte Byte class object wrapping the raw image data.
* @param ImageBaseData $data Image raw data.
* @param int $offset Current byte offset.
*
* @return ImageBaseData Image raw data array.
*
* @throws \Com\Tecnick\Pdf\Image\Exception If the image is invalid.
* @throws \RangeException If the byte offset is out of range.
*/
protected function getIhdrChunk(Byte $byte, array $data, int &$offset): array
{
if (\substr($data['raw'], $offset, 4) !== 'IHDR') {
// @codeCoverageIgnoreStart
throw new ImageException('Invalid PNG image');
// @codeCoverageIgnoreEnd
}
$offset += 4;
$data['width'] = $byte->getULong($offset);
$offset += 4;
$data['height'] = $byte->getULong($offset);
$offset += 4;
$data['bits'] = $byte->getByte($offset);
++$offset;
$chc = $byte->getByte($offset); // channels code
++$offset;
$data['channels'] = $chc === 2 ? 3 : 1;
$chcmap = [
0 => 'DeviceGray',
2 => 'DeviceRGB',
3 => 'Indexed',
4 => 'DeviceGray+Alpha',
6 => 'DeviceRGB+Alpha',
];
if (isset($chcmap[$chc])) {
$data['colspace'] = $chcmap[$chc];
} else {
// @codeCoverageIgnoreStart
throw new ImageException('Unknown color mode');
// @codeCoverageIgnoreEnd
}
return $data;
}
/**
* Extract chunks data from a PNG image.
*
* @param Byte $byte Byte class object wrapping the raw image data.
* @param ImageBaseData $data Image raw data.
* @param int $offset Current byte offset.
*
* @return ImageBaseData Image raw data array.
*
* @throws \Com\Tecnick\Pdf\Image\Exception If the image is invalid.
* @throws \RangeException If the byte offset is out of range.
*/
protected function getChunks(Byte $byte, array $data, int $offset): array
{
// getULong() is unsigned so a "len >= 0" guard would always be true; the
// loop instead ends on the IEND chunk, or on the RangeException raised
// when a (truncated) stream is read past its end.
while (true) {
$len = $byte->getULong($offset);
$offset += 4;
$type = \substr($data['raw'], $offset, 4);
$offset += 4;
if ($type === 'PLTE') {
$data = $this->getPlteChunk($data, $offset, $len);
} elseif ($type === 'tRNS') {
$data = $this->getTrnsChunk($data, $offset, $len);
} elseif ($type === 'IDAT') {
$data = $this->getIdatChunk($data, $offset, $len);
} elseif ($type === 'iCCP') {
$data = $this->getIccpChunk($byte, $data, $offset, $len);
} elseif ($type === 'IEND') {
// The image trailer chunk (IEND) must be the final chunk
// and marks the end of the PNG file or data stream.
break;
} else {
$offset += $len;
$offset += 4;
}
}
if ($data['colspace'] === 'Indexed' && $data['pal'] === '') {
// @codeCoverageIgnoreStart
throw new ImageException('The color palette is missing');
// @codeCoverageIgnoreEnd
}
return $data;
}
/**
* Extract the PLTE chunk data.
*
* The palette chunk (PLTE) stores the colormap data associated with the image data.
* This chunk is present only if the image data uses a color palette and must appear before the image data chunk.
*
* @param ImageBaseData $data Image raw data.
* @param int $offset Current byte offset.
* @param int $len Number of bytes in this chunk.
*
* @return ImageBaseData Image raw data array.
*/
protected function getPlteChunk(array $data, int &$offset, int $len): array
{
$data['pal'] = \substr($data['raw'], $offset, $len);
$offset += $len;
$offset += 4;
return $data;
}
/**
* Extract the tRNS chunk data.
*
* @param ImageBaseData $data Image raw data.
* @param int $offset Current byte offset.
* @param int $len Number of bytes in this chunk.
*
* @return ImageBaseData Image raw data array.
*/
protected function getTrnsChunk(array $data, int &$offset, int $len): array
{
// read transparency info
$trns = \substr($data['raw'], $offset, $len);
$offset += $len;
if ($data['colspace'] === 'DeviceGray') {
// DeviceGray
$data['trns'][] = \ord($trns[1]);
} elseif ($data['colspace'] === 'DeviceRGB') {
// DeviceRGB
$data['trns'][] = \ord($trns[1]);
$data['trns'][] = \ord($trns[3]);
$data['trns'][] = \ord($trns[5]);
} else {
// Indexed
$data['trns'] = \array_map('ord', \str_split($trns));
}
$offset += 4;
return $data;
}
/**
* Extract the IDAT chunk data.
*
* The image data chunk (IDAT) stores the actual image data,
* and multiple image data chunks may occur in a data stream and must be stored in contiguous order.
*
* @param ImageBaseData $data Image raw data.
* @param int $offset Current byte offset.
* @param int $len Number of bytes in this chunk.
*
* @return ImageBaseData Image raw data array.
*/
protected function getIdatChunk(array $data, int &$offset, int $len): array
{
$data['data'] .= \substr($data['raw'], $offset, $len);
$offset += $len;
$offset += 4;
return $data;
}
/**
* Extract the iCCP chunk data.
*
* @param Byte $byte Byte class object.
* @param ImageBaseData $data Image raw data.
* @param int $offset Current byte offset.
* @param int $len Number of bytes in this chunk.
*
* @return ImageBaseData Image raw data array.
*
* @throws \Com\Tecnick\Pdf\Image\Exception If the filter method is unknown.
* @throws \RangeException If attempting to read beyond available bytes.
*/
protected function getIccpChunk(Byte $byte, array $data, int &$offset, int $len): array
{
// skip profile name
$pos = 0;
while ($byte->getByte($offset++) !== 0 && $pos < 80) {
++$pos;
}
// get compression method
if ($byte->getByte($offset++) !== 0) {
// @codeCoverageIgnoreStart
throw new ImageException('Unknown filter method');
// @codeCoverageIgnoreEnd
}
// read ICC Color Profile
$len -= $pos + 2;
$icc = \gzuncompress(\substr($data['raw'], $offset, $len));
if ($icc !== false) {
$data['icc'] = $icc;
} else {
throw new ImageException('Error while decompressing ICC profile');
}
$offset += $len;
$offset += 4;
return $data;
}
}
+578
View File
@@ -0,0 +1,578 @@
<?php
declare(strict_types=1);
/**
* Output.php
*
* @since 2011-05-23
* @category Library
* @package PdfImage
* @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-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 <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-image
*
* @phpstan-type TFileOptions array{
* allowedHosts?: array<string>,
* maxRemoteSize?: int,
* curlopts?: array<int, bool|int|string>,
* defaultCurlOpts?: array<int, bool|int|string>,
* fixedCurlOpts?: array<int, bool|int|string>
* }
*
* @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<string, int>
*/
protected array $xobjdict = [];
/**
* Stack of added images.
*
* @var array<int, array{
* 'iid': int,
* 'key': string,
* 'width': int,
* 'height': int,
* 'defprint': bool,
* 'altimgs'?: array<int, int>,
* }>
*/
protected array $image = [];
/**
* Cache used to store imported image data.
* The same image data can be reused multiple times.
*
* @var array<string, ImageRawData>
*/
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<int, int>,
* } $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<int> $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<int, int>,
* } $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;
}
}