diff --git a/build/psalm-baseline.xml b/build/psalm-baseline.xml index 46861a20bdc1f..c62f31020a9e7 100644 --- a/build/psalm-baseline.xml +++ b/build/psalm-baseline.xml @@ -3620,11 +3620,6 @@ - - - - - diff --git a/lib/private/Image.php b/lib/private/Image.php index 85f3e94a2b3e4..462e927e3e234 100644 --- a/lib/private/Image.php +++ b/lib/private/Image.php @@ -31,6 +31,25 @@ class Image implements IImage { // Default quality for webp images protected const DEFAULT_WEBP_QUALITY = 80; + // ICC profile marker in JPEG APP2 segments + private const JPEG_ICC_IDENTIFIER = "ICC_PROFILE\x00"; + + // Max ICC bytes per APP2 segment: 0xFFFF - 2 (length) - 12 (marker) - 2 (index and count) + private const JPEG_ICC_MAX_CHUNK_SIZE = 65519; + + // Upper bound for carried ICC profiles, display profiles are a few KiB at most + private const ICC_MAX_PROFILE_SIZE = 64 * 1024; + + // D50 adapted sRGB primaries as stored in the colorant tags of ICC profiles + private const SRGB_COLORANTS = [ + 'rXYZ' => [0.4361, 0.2225, 0.0139], + 'gXYZ' => [0.3851, 0.7169, 0.0971], + 'bXYZ' => [0.1431, 0.0606, 0.7141], + ]; + + // Output of the sRGB tone curve for an input of 0.5 + private const SRGB_TRC_MIDPOINT = 0.2140; + // tmp resource. protected GdImage|false $resource = false; // Default to png if file type isn't evident. @@ -43,6 +62,8 @@ class Image implements IImage { private IAppConfig $appConfig; private IConfig $config; private ?array $exif = null; + // Colour profile carried from the source into generated output + private ?string $iccProfile = null; /** * @throws \InvalidArgumentException in case the $imageRef parameter is not null @@ -261,11 +282,19 @@ private function _output(?string $filePath = null, ?string $mimeType = null): bo $retVal = imagegif($this->resource, $filePath); break; case IMAGETYPE_JPEG: - imageinterlace($this->resource, true); - $retVal = imagejpeg($this->resource, $filePath, $this->getJpegQuality()); + if ($this->iccProfile !== null) { + $retVal = $this->outputWithIccProfile(IMAGETYPE_JPEG, $filePath); + } else { + imageinterlace($this->resource, true); + $retVal = imagejpeg($this->resource, $filePath, $this->getJpegQuality()); + } break; case IMAGETYPE_PNG: - $retVal = imagepng($this->resource, $filePath); + if ($this->iccProfile !== null) { + $retVal = $this->outputWithIccProfile(IMAGETYPE_PNG, $filePath); + } else { + $retVal = imagepng($this->resource, $filePath); + } break; case IMAGETYPE_XBM: if (function_exists('imagexbm')) { @@ -367,7 +396,341 @@ public function data(): ?string { if (!$res) { $this->logger->error('Image->data. Error getting image data.', ['app' => 'core']); } - return ob_get_clean(); + $data = ob_get_clean(); + if ($data === false) { + return null; + } + return self::embedIccProfile($data, $this->iccProfile); + } + + /** + * Re-embeds the ICC profile into a freshly encoded image, then writes it to + * $filePath or outputs it directly when no path is given. + */ + private function outputWithIccProfile(int $imageType, ?string $filePath): bool { + ob_start(); + if ($imageType === IMAGETYPE_PNG) { + $res = imagepng($this->resource); + } else { + imageinterlace($this->resource, true); + $res = imagejpeg($this->resource, null, $this->getJpegQuality()); + } + $data = ob_get_clean(); + if (!$res || $data === false) { + return false; + } + $data = self::embedIccProfile($data, $this->iccProfile); + if ($filePath === null || $filePath === '') { + echo $data; + return true; + } + return file_put_contents($filePath, $data) !== false; + } + + /** + * Returns the ICC profile of an encoded JPEG or PNG image that has to be + * carried into images derived from it, or null if there is none. + * + * Only RGB profiles are kept: GD converts CMYK and grayscale sources to RGB + * pixel data on load, so their source profiles no longer describe the image. + */ + public static function getIccProfile(string $data): ?string { + return self::readIccProfile(self::stringReader($data)); + } + + /** + * Embeds the ICC profile into an encoded JPEG or PNG image without a profile. + * Other image formats are returned unchanged. + */ + public static function embedIccProfile(string $data, ?string $profile): string { + if ($profile === null) { + return $data; + } + if (str_starts_with($data, "\xFF\xD8")) { + return self::embedIccProfileInJpeg($data, $profile); + } + if (str_starts_with($data, "\x89PNG\r\n\x1a\n")) { + return self::embedIccProfileInPng($data, $profile); + } + return $data; + } + + /** + * @param \Closure(int, int): string $read + */ + private static function readIccProfile(\Closure $read): ?string { + $signature = $read(0, 8); + if (str_starts_with($signature, "\xFF\xD8")) { + $profile = self::extractIccProfileFromJpeg($read); + } elseif ($signature === "\x89PNG\r\n\x1a\n") { + $profile = self::extractIccProfileFromPng($read); + } else { + return null; + } + if ($profile === null || !self::isUsableRgbProfile($profile) || self::isSrgbProfile($profile)) { + return null; + } + return $profile; + } + + /** + * @return \Closure(int, int): string reads up to $length bytes at $offset of $data + */ + private static function stringReader(string $data): \Closure { + return static fn (int $offset, int $length): string => substr($data, $offset, $length); + } + + /** + * @param resource $handle seekable stream + * @return \Closure(int, int): string reads up to $length bytes at $offset of the stream + */ + private static function streamReader($handle): \Closure { + return static function (int $offset, int $length) use ($handle): string { + if ($length < 1 || fseek($handle, $offset) !== 0) { + return ''; + } + return (string)fread($handle, $length); + }; + } + + /** + * Whether the profile describes sRGB, which is what untagged images are + * displayed as anyway, so embedding it would only grow the output. + */ + private static function isSrgbProfile(string $profile): bool { + $tags = self::iccTags($profile); + foreach (self::SRGB_COLORANTS as $signature => $expected) { + if (!isset($tags[$signature]) || substr($profile, $tags[$signature], 4) !== 'XYZ ') { + return false; + } + foreach ($expected as $index => $value) { + $actual = self::iccS15Fixed16($profile, $tags[$signature] + 8 + 4 * $index); + if ($actual === null || abs($actual - $value) > 0.002) { + return false; + } + } + } + foreach (['rTRC', 'gTRC', 'bTRC'] as $signature) { + $midpoint = isset($tags[$signature]) ? self::iccCurveMidpoint($profile, $tags[$signature]) : null; + if ($midpoint === null || abs($midpoint - self::SRGB_TRC_MIDPOINT) > 0.01) { + return false; + } + } + return true; + } + + /** + * @return array offsets of the tag data by tag signature + */ + private static function iccTags(string $profile): array { + $length = strlen($profile); + $count = unpack('N', $profile, 128)[1]; + if (132 + 12 * $count > $length) { + return []; + } + $tags = []; + for ($i = 0; $i < $count; $i++) { + ['offset' => $offset, 'size' => $size] = unpack('Noffset/Nsize', $profile, 132 + 12 * $i + 4); + if ($offset + $size <= $length) { + $tags[substr($profile, 132 + 12 * $i, 4)] = $offset; + } + } + return $tags; + } + + private static function iccS15Fixed16(string $profile, int $offset): ?float { + if ($offset + 4 > strlen($profile)) { + return null; + } + $value = unpack('N', $profile, $offset)[1]; + return ($value >= 0x80000000 ? $value - 0x100000000 : $value) / 65536; + } + + /** + * Evaluates a 'curv' or 'para' tone curve tag at an input of 0.5. + */ + private static function iccCurveMidpoint(string $profile, int $offset): ?float { + $type = substr($profile, $offset, 4); + if ($type === 'curv') { + $count = unpack('N', $profile, $offset + 8)[1]; + if ($count === 0) { + return 0.5; + } + if ($offset + 12 + 2 * $count > strlen($profile)) { + return null; + } + if ($count === 1) { + return 0.5 ** (unpack('n', $profile, $offset + 12)[1] / 256); + } + $position = ($count - 1) / 2; + $low = unpack('n', $profile, $offset + 12 + 2 * (int)floor($position))[1]; + $high = unpack('n', $profile, $offset + 12 + 2 * (int)ceil($position))[1]; + return ($low + $high) / 2 / 65535; + } + if ($type === 'para') { + $function = unpack('n', $profile, $offset + 8)[1]; + $parameterCount = [1, 3, 4, 5, 7][$function] ?? null; + if ($parameterCount === null) { + return null; + } + $parameters = []; + for ($i = 0; $i < $parameterCount; $i++) { + $parameters[] = self::iccS15Fixed16($profile, $offset + 12 + 4 * $i); + } + if (in_array(null, $parameters, true)) { + return null; + } + [$g, $a, $b, $c, $d, $e, $f] = array_pad($parameters, 7, 0.0); + $x = 0.5; + return match ($function) { + 0 => $x ** $g, + 1 => ($a * $x + $b >= 0) ? ($a * $x + $b) ** $g : 0.0, + 2 => ($a * $x + $b >= 0) ? ($a * $x + $b) ** $g + $c : $c, + 3 => $x >= $d ? ($a * $x + $b) ** $g : $c * $x, + 4 => $x >= $d ? ($a * $x + $b) ** $g + $e : $c * $x + $f, + default => null, + }; + } + return null; + } + + private static function isUsableRgbProfile(string $profile): bool { + return strlen($profile) >= 132 // ICC header plus tag count + && strlen($profile) <= self::ICC_MAX_PROFILE_SIZE + && unpack('N', $profile)[1] === strlen($profile) // declared profile size + && substr($profile, 36, 4) === 'acsp' // ICC profile signature + && substr($profile, 16, 4) === 'RGB '; // data colour space + } + + /** + * @param \Closure(int, int): string $read + */ + private static function extractIccProfileFromJpeg(\Closure $read): ?string { + $identifierLength = strlen(self::JPEG_ICC_IDENTIFIER); + $pos = 2; + $chunks = []; + $chunkCount = null; + $profileSize = 0; + while (strlen($header = $read($pos, 4)) === 4) { + if ($header[0] !== "\xFF") { + return null; + } + $marker = ord($header[1]); + if ($marker === 0xFF) { + // fill byte before a marker + $pos++; + continue; + } + if ($marker === 0x01 || ($marker >= 0xD0 && $marker <= 0xD8)) { + // standalone marker without a length field + $pos += 2; + continue; + } + if ($marker === 0xDA || $marker === 0xD9) { + // start of scan or end of image: no more metadata segments + break; + } + $segmentLength = unpack('n', $header, 2)[1]; + if ($segmentLength < 2) { + return null; + } + if ($marker === 0xE2 && $segmentLength >= 2 + $identifierLength + 2) { + $payload = $read($pos + 4, $segmentLength - 2); + if (strlen($payload) !== $segmentLength - 2) { + return null; + } + if (str_starts_with($payload, self::JPEG_ICC_IDENTIFIER)) { + $sequence = ord($payload[$identifierLength]); + $total = ord($payload[$identifierLength + 1]); + if ($total === 0 || ($chunkCount !== null && $total !== $chunkCount)) { + return null; + } + $chunkCount = $total; + $chunks[$sequence] = substr($payload, $identifierLength + 2); + $profileSize += strlen($chunks[$sequence]); + if ($profileSize > self::ICC_MAX_PROFILE_SIZE) { + return null; + } + } + } + $pos += 2 + $segmentLength; + } + if ($chunkCount === null || count($chunks) !== $chunkCount) { + return null; + } + ksort($chunks); + return implode('', $chunks); + } + + /** + * @param \Closure(int, int): string $read + */ + private static function extractIccProfileFromPng(\Closure $read): ?string { + $pos = 8; + while (strlen($header = $read($pos, 8)) === 8) { + $chunkLength = unpack('N', $header)[1]; + $type = substr($header, 4, 4); + if ($type === 'IDAT' || $type === 'IEND') { + break; + } + if ($type === 'iCCP') { + if ($chunkLength > self::ICC_MAX_PROFILE_SIZE) { + return null; + } + $chunk = $read($pos + 8, $chunkLength); + if (strlen($chunk) !== $chunkLength) { + return null; + } + $separator = strpos($chunk, "\x00"); + if ($separator === false || $separator < 1 || $separator > 79 || strlen($chunk) < $separator + 2) { + return null; + } + if (ord($chunk[$separator + 1]) !== 0) { + // unknown compression method + return null; + } + $profile = @gzuncompress(substr($chunk, $separator + 2), self::ICC_MAX_PROFILE_SIZE); + return $profile === false ? null : $profile; + } + $pos += 12 + $chunkLength; + } + return null; + } + + private static function embedIccProfileInJpeg(string $data, string $profile): string { + $chunks = str_split($profile, self::JPEG_ICC_MAX_CHUNK_SIZE); + $total = count($chunks); + // APP2 segments belong before the image data, after the APP0/APP1 + // (JFIF/EXIF) segments the encoder may have written + $len = strlen($data); + $pos = 2; + while ($pos + 4 <= $len + && $data[$pos] === "\xFF" + && (ord($data[$pos + 1]) === 0xE0 || ord($data[$pos + 1]) === 0xE1)) { + $segmentLength = (ord($data[$pos + 2]) << 8) | ord($data[$pos + 3]); + if ($segmentLength < 2 || $pos + 2 + $segmentLength > $len) { + return $data; + } + $pos += 2 + $segmentLength; + } + $segments = ''; + foreach ($chunks as $index => $chunk) { + $payload = self::JPEG_ICC_IDENTIFIER . chr($index + 1) . chr($total) . $chunk; + $segments .= "\xFF\xE2" . pack('n', strlen($payload) + 2) . $payload; + } + return substr($data, 0, $pos) . $segments . substr($data, $pos); + } + + private static function embedIccProfileInPng(string $data, string $profile): string { + // IHDR is required to be first and has a fixed size; iCCP belongs before PLTE and IDAT + $ihdrEnd = 8 + 8 + 13 + 4; + if (strlen($data) < $ihdrEnd || substr($data, 12, 4) !== 'IHDR') { + return $data; + } + $chunkData = "ICC profile\x00\x00" . gzcompress($profile); + $payload = 'iCCP' . $chunkData; + $chunk = pack('N', strlen($chunkData)) . $payload . pack('N', crc32($payload)); + return substr($data, 0, $ihdrEnd) . $chunk . substr($data, $ihdrEnd); } /** @@ -806,6 +1169,14 @@ public function loadFromFile($imagePath = false) { $this->imageType = $iType; $this->mimeType = image_type_to_mime_type($iType); $this->filePath = $imagePath; + $this->iccProfile = null; + if ($iType === IMAGETYPE_JPEG || $iType === IMAGETYPE_PNG) { + $handle = @fopen($imagePath, 'rb'); + if ($handle !== false) { + $this->iccProfile = self::readIccProfile(self::streamReader($handle)); + fclose($handle); + } + } } return $this->resource; } @@ -825,6 +1196,7 @@ public function loadFromData(string $str): GdImage|false { if ($this->valid()) { imagealphablending($this->resource, false); imagesavealpha($this->resource, true); + $this->iccProfile = self::getIccProfile($str); } if (!$this->resource) { @@ -854,6 +1226,7 @@ public function loadFromBase64(string $str) { $this->logger->debug('Image->loadFromBase64, could not load', ['app' => 'core']); return false; } + $this->iccProfile = self::getIccProfile($data); return $this->resource; } else { return false; @@ -1139,6 +1512,7 @@ public function copy(): IImage { $this->width(), $this->height() ); + $image->iccProfile = $this->iccProfile; return $image; } @@ -1148,6 +1522,7 @@ public function cropCopy(int $x, int $y, int $w, int $h): IImage { $image = new self($this->logger, $this->appConfig, $this->config); $image->imageType = $this->imageType; $image->mimeType = $this->mimeType; + $image->iccProfile = $this->iccProfile; $image->resource = $this->cropNew($x, $y, $w, $h); return $image; @@ -1158,6 +1533,7 @@ public function preciseResizeCopy(int $width, int $height): IImage { $image = new self($this->logger, $this->appConfig, $this->config); $image->imageType = $this->imageType; $image->mimeType = $this->mimeType; + $image->iccProfile = $this->iccProfile; $image->resource = $this->preciseResizeNew($width, $height); return $image; @@ -1168,6 +1544,7 @@ public function resizeCopy(int $maxSize): IImage { $image = new self($this->logger, $this->appConfig, $this->config); $image->imageType = $this->imageType; $image->mimeType = $this->mimeType; + $image->iccProfile = $this->iccProfile; $image->resource = $this->resizeNew($maxSize); return $image; diff --git a/lib/private/Preview/Heif.php b/lib/private/Preview/Heif.php index deb1cf1e982b2..53c0a0d50403e 100644 --- a/lib/private/Preview/Heif.php +++ b/lib/private/Preview/Heif.php @@ -151,8 +151,13 @@ private function resize($bp, $maxX, $maxY) { if ($previewWidth > $maxX || $previewHeight > $maxY) { // If we want a small image (thumbnail) let's be most space- and time-efficient if ($maxX <= 500 && $maxY <= 500) { + $profiles = $bp->getImageProfiles('icc', true); $bp->thumbnailImage($maxY, $maxX, true); $bp->stripImage(); + // keep the colour profile that stripImage() also removed + if (isset($profiles['icc'])) { + $bp->profileImage('icc', $profiles['icc']); + } } else { // A bigger image calls for some better resizing algorithm // According to http://www.imagemagick.org/Usage/filter/#lanczos diff --git a/lib/private/Preview/Imaginary.php b/lib/private/Preview/Imaginary.php index baa010ff8a1da..1ba77703316d4 100644 --- a/lib/private/Preview/Imaginary.php +++ b/lib/private/Preview/Imaginary.php @@ -19,8 +19,8 @@ use Psr\Log\LoggerInterface; class Imaginary extends ProviderV2 { - /** Covers the EXIF segment at the start of a JPEG */ - private const EXIF_HEAD_SIZE = 256 * 1024; + /** Covers the EXIF and ICC profile segments at the start of a JPEG or PNG */ + private const HEAD_SIZE = 256 * 1024; /** @var IConfig */ private $config; @@ -109,15 +109,20 @@ public function getCroppedThumbnail(File $file, int $maxX, int $maxY, bool $crop } $body = $stream; - if ($autorotate && $file->getMimeType() === 'image/jpeg') { - // Each pipeline operation re-encodes the full image, only rotate when needed - $head = fread($stream, self::EXIF_HEAD_SIZE); + $head = ''; + if (in_array($file->getMimeType(), ['image/jpeg', 'image/png'], true)) { + $head = fread($stream, self::HEAD_SIZE); if ($head === false) { return null; } - $autorotate = $this->needsRotation($head); $body = new AppendStream([Utils::streamFor($head), Utils::streamFor($stream)]); } + if ($autorotate && $file->getMimeType() === 'image/jpeg') { + // Each pipeline operation re-encodes the full image, only rotate when needed + $autorotate = $this->needsRotation($head); + } + // Imaginary strips the colour profile along with the other metadata + $iccProfile = $mimeType === 'webp' ? null : Image::getIccProfile($head); $operations = []; @@ -183,19 +188,27 @@ public function getCroppedThumbnail(File $file, int $maxX, int $maxY, bool $crop return null; } + $responseBody = $response->getBody(); + if ($iccProfile !== null) { + $data = Image::embedIccProfile(is_resource($responseBody) ? (string)stream_get_contents($responseBody) : $responseBody, $iccProfile); + $responseBody = fopen('php://temp', 'r+'); + fwrite($responseBody, $data); + rewind($responseBody); + } + // This is not optimal but previews are distorted if the wrong width and height values are // used. Both dimension headers are only sent when passing the option "-return-size" to // Imaginary. if ($response->getHeader('Image-Width') && $response->getHeader('Image-Height')) { $image = new StreamImage( - $response->getBody(), + $responseBody, $response->getHeader('Content-Type'), (int)$response->getHeader('Image-Width'), (int)$response->getHeader('Image-Height'), ); } else { $image = new Image(); - $image->loadFromFileHandle($response->getBody()); + $image->loadFromFileHandle($responseBody); } return $image->valid() ? $image : null; diff --git a/tests/data/REUSE.toml b/tests/data/REUSE.toml index a2111a539e8d3..13c1e23a83da7 100644 --- a/tests/data/REUSE.toml +++ b/tests/data/REUSE.toml @@ -39,6 +39,12 @@ precedence = "aggregate" SPDX-FileCopyrightText = "2013 ownCloud, Inc." SPDX-License-Identifier = "AGPL-3.0-only" +[[annotations]] +path = ["testimage-icc-cmyk.jpg", "testimage-icc-p3.jpg", "testimage-icc-p3.png", "testimage-icc-srgb.jpg"] +precedence = "aggregate" +SPDX-FileCopyrightText = ["2026 Nextcloud GmbH and Nextcloud contributors", "Clinton Ingram "] +SPDX-License-Identifier = "AGPL-3.0-or-later AND CC0-1.0" + [[annotations]] path = ["lorem-big.txt", "strängé**filename**(duplicate**#2).txt"] precedence = "aggregate" diff --git a/tests/data/testimage-icc-cmyk.jpg b/tests/data/testimage-icc-cmyk.jpg new file mode 100644 index 0000000000000..5bb4268ae2aaa Binary files /dev/null and b/tests/data/testimage-icc-cmyk.jpg differ diff --git a/tests/data/testimage-icc-p3.jpg b/tests/data/testimage-icc-p3.jpg new file mode 100644 index 0000000000000..c7183ea1bc8ae Binary files /dev/null and b/tests/data/testimage-icc-p3.jpg differ diff --git a/tests/data/testimage-icc-p3.png b/tests/data/testimage-icc-p3.png new file mode 100644 index 0000000000000..c624300dc6be9 Binary files /dev/null and b/tests/data/testimage-icc-p3.png differ diff --git a/tests/data/testimage-icc-srgb.jpg b/tests/data/testimage-icc-srgb.jpg new file mode 100644 index 0000000000000..8bffa89b88ee3 Binary files /dev/null and b/tests/data/testimage-icc-srgb.jpg differ diff --git a/tests/lib/ImageTest.php b/tests/lib/ImageTest.php index 22246f8e43785..3ee8f6eef2983 100644 --- a/tests/lib/ImageTest.php +++ b/tests/lib/ImageTest.php @@ -381,4 +381,270 @@ public function testMemoryLimitFromData(): void { $img->loadFromData($data); $this->assertFalse($img->valid()); } + + /** + * Returns the reassembled ICC profile from the APP2 segments of a JPEG, + * or null if there is none. + */ + private static function iccProfileOfJpeg(string $data): ?string { + if (!str_starts_with($data, "\xFF\xD8")) { + throw new \UnexpectedValueException('Not a JPEG'); + } + $chunks = []; + $pos = 2; + while (true) { + if ($pos + 4 > strlen($data) || $data[$pos] !== "\xFF") { + throw new \UnexpectedValueException('Malformed JPEG segment at ' . $pos); + } + $marker = ord($data[$pos + 1]); + if ($marker === 0xDA) { + break; + } + $length = unpack('n', $data, $pos + 2)[1]; + $payload = substr($data, $pos + 4, $length - 2); + if ($marker === 0xE2 && str_starts_with($payload, "ICC_PROFILE\x00")) { + $chunks[ord($payload[12])] = substr($payload, 14); + } + $pos += 2 + $length; + } + if ($chunks === []) { + return null; + } + ksort($chunks); + return implode('', $chunks); + } + + /** + * Returns the ICC profile from the iCCP chunk of a PNG, or null if there is none. + */ + private static function iccProfileOfPng(string $data): ?string { + if (!str_starts_with($data, "\x89PNG\r\n\x1a\n")) { + throw new \UnexpectedValueException('Not a PNG'); + } + $pos = 8; + while ($pos + 12 <= strlen($data)) { + $length = unpack('N', $data, $pos)[1]; + $type = substr($data, $pos + 4, 4); + if ($type === 'IDAT') { + break; + } + if ($type === 'iCCP') { + $chunk = substr($data, $pos + 8, $length); + if (unpack('N', $data, $pos + 8 + $length)[1] !== crc32($type . $chunk)) { + throw new \UnexpectedValueException('Invalid iCCP checksum'); + } + $separator = strpos($chunk, "\x00"); + return gzuncompress(substr($chunk, $separator + 2)); + } + $pos += 12 + $length; + } + return null; + } + + public function testIccProfilePreservedInJpeg(): void { + $source = file_get_contents(OC::$SERVERROOT . '/tests/data/testimage-icc-p3.jpg'); + $sourceProfile = self::iccProfileOfJpeg($source); + $this->assertNotNull($sourceProfile); + + $img = new Image(); + $img->loadFromFile(OC::$SERVERROOT . '/tests/data/testimage-icc-p3.jpg'); + $this->assertTrue($img->valid()); + $this->assertEquals($sourceProfile, self::iccProfileOfJpeg($img->data())); + + $img = new Image(); + $img->loadFromData($source); + $this->assertTrue($img->valid()); + $this->assertEquals($sourceProfile, self::iccProfileOfJpeg($img->data())); + + $img = new Image(); + $img->loadFromBase64(base64_encode($source)); + $this->assertTrue($img->valid()); + $this->assertEquals($sourceProfile, self::iccProfileOfJpeg($img->data())); + } + + public function testIccProfilePreservedAcrossResizeAndCrop(): void { + $source = file_get_contents(OC::$SERVERROOT . '/tests/data/testimage-icc-p3.jpg'); + $sourceProfile = self::iccProfileOfJpeg($source); + + $img = new Image(); + $img->loadFromFile(OC::$SERVERROOT . '/tests/data/testimage-icc-p3.jpg'); + $img->resize(32); + $this->assertEquals($sourceProfile, self::iccProfileOfJpeg($img->data())); + + $img = new Image(); + $img->loadFromFile(OC::$SERVERROOT . '/tests/data/testimage-icc-p3.jpg'); + $this->assertEquals($sourceProfile, self::iccProfileOfJpeg($img->resizeCopy(32)->data())); + $this->assertEquals($sourceProfile, self::iccProfileOfJpeg($img->preciseResizeCopy(32, 16)->data())); + $this->assertEquals($sourceProfile, self::iccProfileOfJpeg($img->cropCopy(0, 0, 16, 16)->data())); + } + + public function testIccProfilePreservedInPngOutput(): void { + $source = file_get_contents(OC::$SERVERROOT . '/tests/data/testimage-icc-p3.jpg'); + $sourceProfile = self::iccProfileOfJpeg($source); + + $img = new Image(); + $img->loadFromFile(OC::$SERVERROOT . '/tests/data/testimage-icc-p3.jpg'); + $tempFile = tempnam(sys_get_temp_dir(), 'img-test'); + $img->save($tempFile, 'image/png'); + $this->assertEquals($sourceProfile, self::iccProfileOfPng(file_get_contents($tempFile))); + unlink($tempFile); + } + + public function testIccProfilePreservedFromPngSource(): void { + $source = file_get_contents(OC::$SERVERROOT . '/tests/data/testimage-icc-p3.png'); + $sourceProfile = self::iccProfileOfPng($source); + $this->assertNotNull($sourceProfile); + + $img = new Image(); + $img->loadFromFile(OC::$SERVERROOT . '/tests/data/testimage-icc-p3.png'); + $this->assertTrue($img->valid()); + $this->assertEquals($sourceProfile, self::iccProfileOfPng($img->data())); + } + + public function testIccProfileNotInventedForUntaggedImage(): void { + $img = new Image(); + $img->loadFromFile(OC::$SERVERROOT . '/tests/data/testimage.jpg'); + $this->assertTrue($img->valid()); + $this->assertNull(self::iccProfileOfJpeg($img->data())); + } + + /** + * Returns a structurally valid RGB ICC profile of the given size. + */ + private static function rgbIccProfile(int $size): string { + $header = pack('N', $size) . str_repeat("\x00", 12) . 'RGB XYZ ' . str_repeat("\x00", 12) . 'acsp'; + return str_pad($header, $size, "\x00"); + } + + /** + * Inserts the profile as APP2 segments right after the SOI marker of a JPEG. + */ + private static function jpegWithIccProfile(string $jpeg, string $profile): string { + $chunks = str_split($profile, 65519); + $segments = ''; + foreach ($chunks as $index => $chunk) { + $payload = "ICC_PROFILE\x00" . chr($index + 1) . chr(count($chunks)) . $chunk; + $segments .= "\xFF\xE2" . pack('n', strlen($payload) + 2) . $payload; + } + return substr($jpeg, 0, 2) . $segments . substr($jpeg, 2); + } + + public function testOversizedIccProfileNotPreserved(): void { + $jpeg = file_get_contents(OC::$SERVERROOT . '/tests/data/testimage.jpg'); + + $img = new Image(); + $img->loadFromData(self::jpegWithIccProfile($jpeg, self::rgbIccProfile(1024))); + $this->assertNotNull(self::iccProfileOfJpeg($img->data())); + + $img = new Image(); + $img->loadFromData(self::jpegWithIccProfile($jpeg, self::rgbIccProfile(100 * 1024))); + $this->assertTrue($img->valid()); + $this->assertNull(self::iccProfileOfJpeg($img->data())); + } + + public function testIccProfileWithWrongDeclaredSizeNotPreserved(): void { + $jpeg = file_get_contents(OC::$SERVERROOT . '/tests/data/testimage.jpg'); + $profile = substr_replace(self::rgbIccProfile(1024), pack('N', 2048), 0, 4); + + $img = new Image(); + $img->loadFromData(self::jpegWithIccProfile($jpeg, $profile)); + $this->assertTrue($img->valid()); + $this->assertNull(self::iccProfileOfJpeg($img->data())); + } + + public function testIccProfileReadFromFileAfterLargeMetadata(): void { + $source = file_get_contents(OC::$SERVERROOT . '/tests/data/testimage-icc-p3.jpg'); + $sourceProfile = self::iccProfileOfJpeg($source); + $padding = str_repeat("\xFF\xE1" . pack('n', 65535) . str_repeat("\x00", 65533), 140); + $tempFile = tempnam(sys_get_temp_dir(), 'img-test'); + file_put_contents($tempFile, substr($source, 0, 2) . $padding . substr($source, 2)); + + $img = new Image(); + $img->loadFromFile($tempFile); + unlink($tempFile); + $this->assertTrue($img->valid()); + $this->assertEquals($sourceProfile, self::iccProfileOfJpeg($img->data())); + } + + public function testCompressedIccProfileBombNotInflated(): void { + ob_start(); + imagepng(imagecreatetruecolor(4, 4)); + $png = ob_get_clean(); + $chunkData = "ICC profile\x00\x00" . gzcompress(str_repeat("\x00", 16 * 1024 * 1024), 9); + $chunk = pack('N', strlen($chunkData)) . 'iCCP' . $chunkData . pack('N', crc32('iCCP' . $chunkData)); + $png = substr($png, 0, 33) . $chunk . substr($png, 33); + + $memoryBefore = memory_get_usage(); + memory_reset_peak_usage(); + $img = new Image(); + $img->loadFromData($png); + $this->assertLessThan(4 * 1024 * 1024, memory_get_peak_usage() - $memoryBefore); + $this->assertTrue($img->valid()); + $this->assertNull(self::iccProfileOfPng($img->data())); + } + + public function testSrgbIccProfileNotEmbedded(): void { + $source = file_get_contents(OC::$SERVERROOT . '/tests/data/testimage-icc-srgb.jpg'); + $this->assertNotNull(self::iccProfileOfJpeg($source)); + + $img = new Image(); + $img->loadFromFile(OC::$SERVERROOT . '/tests/data/testimage-icc-srgb.jpg'); + $this->assertTrue($img->valid()); + $this->assertNull(self::iccProfileOfJpeg($img->data())); + } + + /** + * Returns a matrix/TRC RGB ICC profile with the given colorants and a pure gamma tone curve. + * + * @param list> $colorants red, green and blue XYZ values + */ + private static function matrixIccProfile(array $colorants, float $gamma): string { + $tags = []; + foreach (['rXYZ', 'gXYZ', 'bXYZ'] as $index => $signature) { + $tags[$signature] = 'XYZ ' . str_repeat("\x00", 4) + . implode('', array_map(fn (float $value) => pack('N', (int)round($value * 65536) & 0xFFFFFFFF), $colorants[$index])); + } + foreach (['rTRC', 'gTRC', 'bTRC'] as $signature) { + $tags[$signature] = 'curv' . str_repeat("\x00", 4) . pack('N', 1) . pack('n', (int)round($gamma * 256)) . "\x00\x00"; + } + $offset = 132 + 12 * count($tags); + $table = pack('N', count($tags)); + $data = ''; + foreach ($tags as $signature => $tag) { + $table .= $signature . pack('NN', $offset + strlen($data), strlen($tag)); + $data .= $tag; + } + $size = $offset + strlen($data); + $header = pack('N', $size) . str_repeat("\x00", 12) . 'RGB XYZ ' . str_repeat("\x00", 12) . 'acsp'; + return str_pad($header, 128, "\x00") . $table . $data; + } + + public static function dataMatrixIccProfiles(): array { + $srgb = [[0.4361, 0.2225, 0.0139], [0.3851, 0.7169, 0.0971], [0.1431, 0.0606, 0.7141]]; + $displayP3 = [[0.5151, 0.2412, -0.0011], [0.2920, 0.6922, 0.0419], [0.1571, 0.0666, 0.7841]]; + return [ + 'sRGB primaries with gamma 2.2' => [$srgb, 2.2, false], + 'linear sRGB' => [$srgb, 1.0, true], + 'Display P3 primaries' => [$displayP3, 2.2, true], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataMatrixIccProfiles')] + public function testOnlyNonSrgbIccProfilesEmbedded(array $colorants, float $gamma, bool $embedded): void { + $jpeg = file_get_contents(OC::$SERVERROOT . '/tests/data/testimage.jpg'); + $profile = self::matrixIccProfile($colorants, $gamma); + + $img = new Image(); + $img->loadFromData(self::jpegWithIccProfile($jpeg, $profile)); + $this->assertTrue($img->valid()); + $this->assertSame($embedded ? $profile : null, self::iccProfileOfJpeg($img->data())); + } + + public function testNonRgbIccProfileNotPreserved(): void { + // GD converts CMYK pixel data to RGB on load, the source profile no longer applies + $img = new Image(); + $img->loadFromFile(OC::$SERVERROOT . '/tests/data/testimage-icc-cmyk.jpg'); + $this->assertTrue($img->valid()); + $this->assertNull(self::iccProfileOfJpeg($img->data())); + } } diff --git a/tests/lib/Preview/ImaginaryTest.php b/tests/lib/Preview/ImaginaryTest.php index 484f02ecc7555..7390ea8a1aacc 100644 --- a/tests/lib/Preview/ImaginaryTest.php +++ b/tests/lib/Preview/ImaginaryTest.php @@ -9,6 +9,7 @@ namespace Test\Preview; +use OC\Image; use OC\Preview\Imaginary; use OCP\Files\File; use OCP\Http\Client\IClient; @@ -90,4 +91,44 @@ public function testAutorotateOnlyWhenNeeded(?int $orientation, bool $expectAuto $preview = (new Imaginary([]))->getThumbnail($file, 256, 256); $this->assertNotNull($preview); } + + #[TestWith(['testimage-icc-p3.jpg', true, true])] + #[TestWith(['testimage-icc-p3.jpg', false, true])] + #[TestWith(['testimage-icc-srgb.jpg', true, false])] + #[TestWith(['testimage.jpg', true, false])] + public function testColourProfileOfSourceReattached(string $source, bool $withSizeHeaders, bool $expectProfile): void { + $jpeg = file_get_contents(\OC::$SERVERROOT . '/tests/data/' . $source); + $stream = fopen('php://memory', 'r+'); + fwrite($stream, $jpeg); + rewind($stream); + + $file = $this->createMock(File::class); + $file->method('getSize')->willReturn(strlen($jpeg)); + $file->method('getMimeType')->willReturn('image/jpeg'); + $file->method('fopen')->willReturn($stream); + + // Imaginary strips all metadata from its output + $stripped = $this->getJpeg(null); + $response = $this->createMock(IResponse::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getHeader')->willReturnCallback(fn (string $name): string => match ($name) { + 'Image-Width' => $withSizeHeaders ? '40' : '', + 'Image-Height' => $withSizeHeaders ? '30' : '', + 'Content-Type' => 'image/jpeg', + default => '', + }); + $responseBody = fopen('php://memory', 'r+'); + fwrite($responseBody, $stripped); + rewind($responseBody); + $response->method('getBody')->willReturn($responseBody); + $this->client->method('post')->willReturn($response); + + $preview = (new Imaginary([]))->getThumbnail($file, 256, 256); + $this->assertNotNull($preview); + $data = $withSizeHeaders ? stream_get_contents($preview->resource()) : $preview->data(); + $this->assertSame($expectProfile ? Image::getIccProfile($jpeg) : null, Image::getIccProfile($data)); + if (!$expectProfile && $withSizeHeaders) { + $this->assertSame($stripped, $data); + } + } }