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);
+ }
+ }
}