From 3454f9ec14f1398c04157cfc3b9de8ec892d14b9 Mon Sep 17 00:00:00 2001 From: Mattias Svensson Date: Thu, 18 Jun 2026 22:01:41 +0200 Subject: [PATCH 1/7] fix(preview): preserve ICC colour profiles in generated previews GD strips the ICC colour profile when loading an image but keeps the pixel values, so previews of wide-gamut photos (Display P3, Adobe RGB) are served as untagged JPEGs that browsers render as sRGB, looking washed out compared to the original. Remember the source profile in OC\Image, carry it across the resize and crop copies the preview generator uses, and re-embed it into the encoded JPEG (APP2) or PNG (iCCP) output. No colour conversion is done: the pixels already match the profile, they were only mistagged. Only RGB profiles are kept, since GD converts CMYK and grayscale sources to RGB. Also re-attach the profile in the HEIC provider after stripImage() removes it from small thumbnails. Closes #22951 Assisted-by: ClaudeCode:claude-opus-4-8 Signed-off-by: Mattias Svensson --- build/psalm-baseline.xml | 5 - lib/private/Image.php | 229 +++++++++++++++++++++++++++++- lib/private/Preview/Heif.php | 5 + tests/data/REUSE.toml | 2 +- tests/data/testimage-icc-cmyk.jpg | Bin 0 -> 57509 bytes tests/data/testimage-icc-p3.jpg | Bin 0 -> 2106 bytes tests/lib/ImageTest.php | 98 +++++++++++++ 7 files changed, 329 insertions(+), 10 deletions(-) create mode 100644 tests/data/testimage-icc-cmyk.jpg create mode 100644 tests/data/testimage-icc-p3.jpg 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..8c637913fe1c7 100644 --- a/lib/private/Image.php +++ b/lib/private/Image.php @@ -31,6 +31,15 @@ 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; + + // ICC profiles precede the image data, so the scan can stop early + private const ICC_SCAN_BYTE_LIMIT = 8 * 1024 * 1024; + // tmp resource. protected GdImage|false $resource = false; // Default to png if file type isn't evident. @@ -43,6 +52,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 +272,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 +386,197 @@ 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 $this->embedIccProfile($data); + } + + /** + * 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 = $this->embedIccProfile($data); + if ($filePath === null || $filePath === '') { + echo $data; + return true; + } + return file_put_contents($filePath, $data) !== false; + } + + /** + * Remembers the source ICC profile for re-embedding into generated output. + * + * 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. + */ + private function rememberIccProfile(string $data): void { + $this->iccProfile = null; + if (str_starts_with($data, "\xFF\xD8")) { + $profile = self::extractIccProfileFromJpeg($data); + } elseif (str_starts_with($data, "\x89PNG\r\n\x1a\n")) { + $profile = self::extractIccProfileFromPng($data); + } else { + return; + } + if ($profile !== null && self::isUsableRgbProfile($profile)) { + $this->iccProfile = $profile; + } + } + + private static function isUsableRgbProfile(string $profile): bool { + return strlen($profile) >= 132 // ICC header plus tag count + && substr($profile, 36, 4) === 'acsp' // ICC profile signature + && substr($profile, 16, 4) === 'RGB '; // data colour space + } + + private static function extractIccProfileFromJpeg(string $data): ?string { + $len = strlen($data); + $identifierLength = strlen(self::JPEG_ICC_IDENTIFIER); + $pos = 2; + $chunks = []; + $chunkCount = null; + while ($pos + 4 <= $len) { + if ($data[$pos] !== "\xFF") { + return null; + } + $marker = ord($data[$pos + 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 = (ord($data[$pos + 2]) << 8) | ord($data[$pos + 3]); + if ($segmentLength < 2 || $pos + 2 + $segmentLength > $len) { + return null; + } + if ($marker === 0xE2 && $segmentLength >= 2 + $identifierLength + 2) { + $payload = substr($data, $pos + 4, $segmentLength - 2); + 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); + } + } + $pos += 2 + $segmentLength; + } + if ($chunkCount === null || count($chunks) !== $chunkCount) { + return null; + } + ksort($chunks); + return implode('', $chunks); + } + + private static function extractIccProfileFromPng(string $data): ?string { + $len = strlen($data); + $pos = 8; + while ($pos + 8 <= $len) { + $header = unpack('NchunkLength', $data, $pos); + if ($header === false) { + return null; + } + $chunkLength = $header['chunkLength']; + $type = substr($data, $pos + 4, 4); + if ($type === 'IDAT' || $type === 'IEND') { + break; + } + if ($type === 'iCCP') { + if ($pos + 8 + $chunkLength > $len) { + return null; + } + $chunk = substr($data, $pos + 8, $chunkLength); + $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)); + return $profile === false ? null : $profile; + } + $pos += 12 + $chunkLength; + } + return null; + } + + private function embedIccProfile(string $data): string { + if ($this->iccProfile === null) { + return $data; + } + if (str_starts_with($data, "\xFF\xD8")) { + return $this->embedIccProfileInJpeg($data); + } + if (str_starts_with($data, "\x89PNG\r\n\x1a\n")) { + return $this->embedIccProfileInPng($data); + } + return $data; + } + + private function embedIccProfileInJpeg(string $data): string { + $chunks = str_split($this->iccProfile, self::JPEG_ICC_MAX_CHUNK_SIZE); + $total = count($chunks); + if ($total > 255) { + return $data; + } + // 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 function embedIccProfileInPng(string $data): 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($this->iccProfile); + $payload = 'iCCP' . $chunkData; + $chunk = pack('N', strlen($chunkData)) . $payload . pack('N', crc32($payload)); + return substr($data, 0, $ihdrEnd) . $chunk . substr($data, $ihdrEnd); } /** @@ -806,6 +1015,12 @@ public function loadFromFile($imagePath = false) { $this->imageType = $iType; $this->mimeType = image_type_to_mime_type($iType); $this->filePath = $imagePath; + if ($iType === IMAGETYPE_JPEG || $iType === IMAGETYPE_PNG) { + $header = @file_get_contents($imagePath, false, null, 0, self::ICC_SCAN_BYTE_LIMIT); + if ($header !== false) { + $this->rememberIccProfile($header); + } + } } return $this->resource; } @@ -825,6 +1040,7 @@ public function loadFromData(string $str): GdImage|false { if ($this->valid()) { imagealphablending($this->resource, false); imagesavealpha($this->resource, true); + $this->rememberIccProfile($str); } if (!$this->resource) { @@ -854,6 +1070,7 @@ public function loadFromBase64(string $str) { $this->logger->debug('Image->loadFromBase64, could not load', ['app' => 'core']); return false; } + $this->rememberIccProfile($data); return $this->resource; } else { return false; @@ -1139,6 +1356,7 @@ public function copy(): IImage { $this->width(), $this->height() ); + $image->iccProfile = $this->iccProfile; return $image; } @@ -1148,6 +1366,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 +1377,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 +1388,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/tests/data/REUSE.toml b/tests/data/REUSE.toml index a2111a539e8d3..142ea91db8f27 100644 --- a/tests/data/REUSE.toml +++ b/tests/data/REUSE.toml @@ -34,7 +34,7 @@ SPDX-FileCopyrightText = "2012 ownCloud, Inc." SPDX-License-Identifier = "AGPL-3.0-only" [[annotations]] -path = ["testavatar.png", "testimage.avif", "testimage.gif", "testimage.jp2", "testimage.jpg", "testimage.png"] +path = ["testavatar.png", "testimage.avif", "testimage.gif", "testimage.jp2", "testimage.jpg", "testimage.png", "testimage-icc-p3.jpg", "testimage-icc-cmyk.jpg"] precedence = "aggregate" SPDX-FileCopyrightText = "2013 ownCloud, Inc." SPDX-License-Identifier = "AGPL-3.0-only" diff --git a/tests/data/testimage-icc-cmyk.jpg b/tests/data/testimage-icc-cmyk.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a33471d6d7339b7c9d179245fbffd0c84a44394c GIT binary patch literal 57509 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 z

WT`Bg}s&cJn!E^ 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 literal 0 HcmV?d00001 diff --git a/tests/lib/ImageTest.php b/tests/lib/ImageTest.php index 22246f8e43785..404783cd7828f 100644 --- a/tests/lib/ImageTest.php +++ b/tests/lib/ImageTest.php @@ -381,4 +381,102 @@ 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 { + $chunks = []; + $offset = 0; + while (($pos = strpos($data, "ICC_PROFILE\x00", $offset)) !== false) { + $length = (ord($data[$pos - 2]) << 8) | ord($data[$pos - 1]); + $sequence = ord($data[$pos + 12]); + $chunks[$sequence] = substr($data, $pos + 14, $length - 2 - 12 - 2); + $offset = $pos + 14; + } + 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 { + $pos = strpos($data, 'iCCP'); + if ($pos === false) { + return null; + } + $length = unpack('N', substr($data, $pos - 4, 4))[1]; + $chunk = substr($data, $pos + 4, $length); + $separator = strpos($chunk, "\x00"); + return gzuncompress(substr($chunk, $separator + 2)) ?: 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 testIccProfileNotInventedForUntaggedImage(): void { + $img = new Image(); + $img->loadFromFile(OC::$SERVERROOT . '/tests/data/testimage.jpg'); + $this->assertTrue($img->valid()); + $this->assertNull(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())); + } } From 24fb301d9d00092caa11a05e4dc627baf4eb824d Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 29 Oct 2026 23:45:24 +0100 Subject: [PATCH 2/7] test(preview): use freely licensed ICC profiles in test fixtures The colour profile fixtures embedded profiles copyrighted by Apple with all rights reserved and were annotated as 2013 ownCloud images. Regenerate them as synthetic gradients tagged with the CC0 Display P3 and CGATS001 CMYK profiles from Compact-ICC-Profiles, annotate them separately and add a PNG fixture to cover reading the iCCP chunk. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Ferdinand Thiessen --- tests/data/REUSE.toml | 8 +++++++- tests/data/testimage-icc-cmyk.jpg | Bin 57509 -> 9232 bytes tests/data/testimage-icc-p3.jpg | Bin 2106 -> 998 bytes tests/data/testimage-icc-p3.png | Bin 0 -> 589 bytes tests/lib/ImageTest.php | 11 +++++++++++ 5 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 tests/data/testimage-icc-p3.png diff --git a/tests/data/REUSE.toml b/tests/data/REUSE.toml index 142ea91db8f27..a7bf0de905c9e 100644 --- a/tests/data/REUSE.toml +++ b/tests/data/REUSE.toml @@ -34,11 +34,17 @@ SPDX-FileCopyrightText = "2012 ownCloud, Inc." SPDX-License-Identifier = "AGPL-3.0-only" [[annotations]] -path = ["testavatar.png", "testimage.avif", "testimage.gif", "testimage.jp2", "testimage.jpg", "testimage.png", "testimage-icc-p3.jpg", "testimage-icc-cmyk.jpg"] +path = ["testavatar.png", "testimage.avif", "testimage.gif", "testimage.jp2", "testimage.jpg", "testimage.png"] 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"] +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 index a33471d6d7339b7c9d179245fbffd0c84a44394c..5bb4268ae2aaaf982077b3fb9004dd2b8ab68bc8 100644 GIT binary patch literal 9232 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 z

WT`Bg}s&cJn!E^ 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 diff --git a/tests/data/testimage-icc-p3.png b/tests/data/testimage-icc-p3.png new file mode 100644 index 0000000000000000000000000000000000000000..c624300dc6be924e09a2b19feb8b9d1abf61ff74 GIT binary patch literal 589 zcmV-T0bKyV2PA~MnQ5C*gAdvw-;7Um?P6pC{fLJNH zps0j_fl&d(E-xtnv0Z>{k>rfT6d*eS$kr(WIuOWi0g0zW*fT)vBnW#Ah+PyCjB18mi94K)C#JFpb~6NkVZF#U9Er~%*|massertNotNull($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'); From 26727facd274f2eb2ebaa3baabba1472cd9df791 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 29 Oct 2026 23:47:17 +0100 Subject: [PATCH 3/7] fix(preview): bound the size of carried ICC profiles The iCCP chunk of a PNG was inflated without a limit, so a few hundred KiB of compressed zeros made a single preview request allocate hundreds of MiB and run into the memory limit. Cap profiles at 64 KiB, which is far above real display profiles: bound the decompression, stop collecting JPEG APP2 chunks beyond it and only accept profiles whose declared size matches their actual size. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Ferdinand Thiessen --- lib/private/Image.php | 14 ++++++++-- tests/lib/ImageTest.php | 61 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/lib/private/Image.php b/lib/private/Image.php index 8c637913fe1c7..8498b024905a1 100644 --- a/lib/private/Image.php +++ b/lib/private/Image.php @@ -40,6 +40,9 @@ class Image implements IImage { // ICC profiles precede the image data, so the scan can stop early private const ICC_SCAN_BYTE_LIMIT = 8 * 1024 * 1024; + // Upper bound for carried ICC profiles, display profiles are a few KiB at most + private const ICC_MAX_PROFILE_SIZE = 64 * 1024; + // tmp resource. protected GdImage|false $resource = false; // Default to png if file type isn't evident. @@ -439,6 +442,8 @@ private function rememberIccProfile(string $data): void { 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 } @@ -449,6 +454,7 @@ private static function extractIccProfileFromJpeg(string $data): ?string { $pos = 2; $chunks = []; $chunkCount = null; + $profileSize = 0; while ($pos + 4 <= $len) { if ($data[$pos] !== "\xFF") { return null; @@ -482,6 +488,10 @@ private static function extractIccProfileFromJpeg(string $data): ?string { } $chunkCount = $total; $chunks[$sequence] = substr($payload, $identifierLength + 2); + $profileSize += strlen($chunks[$sequence]); + if ($profileSize > self::ICC_MAX_PROFILE_SIZE) { + return null; + } } } $pos += 2 + $segmentLength; @@ -507,7 +517,7 @@ private static function extractIccProfileFromPng(string $data): ?string { break; } if ($type === 'iCCP') { - if ($pos + 8 + $chunkLength > $len) { + if ($chunkLength > self::ICC_MAX_PROFILE_SIZE || $pos + 8 + $chunkLength > $len) { return null; } $chunk = substr($data, $pos + 8, $chunkLength); @@ -519,7 +529,7 @@ private static function extractIccProfileFromPng(string $data): ?string { // unknown compression method return null; } - $profile = @gzuncompress(substr($chunk, $separator + 2)); + $profile = @gzuncompress(substr($chunk, $separator + 2), self::ICC_MAX_PROFILE_SIZE); return $profile === false ? null : $profile; } $pos += 12 + $chunkLength; diff --git a/tests/lib/ImageTest.php b/tests/lib/ImageTest.php index a917a277acad3..561611da067cc 100644 --- a/tests/lib/ImageTest.php +++ b/tests/lib/ImageTest.php @@ -483,6 +483,67 @@ public function testIccProfileNotInventedForUntaggedImage(): void { $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 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 testNonRgbIccProfileNotPreserved(): void { // GD converts CMYK pixel data to RGB on load, the source profile no longer applies $img = new Image(); From bffff41f4c908d199897f7adbe94f52863fae960 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 29 Oct 2026 23:49:04 +0100 Subject: [PATCH 4/7] perf(preview): do not embed sRGB colour profiles into previews Many cameras and photo editors tag their output with an sRGB profile. Untagged images are displayed as sRGB anyway, so carrying that profile only grows every preview: the common 3 KiB sRGB IEC61966-2.1 profile quadruples a 64px thumbnail. Detect sRGB by its colorants and tone curve instead of the profile name, which varies between vendors, so linear or otherwise differently encoded profiles with sRGB primaries are still preserved. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Ferdinand Thiessen --- lib/private/Image.php | 113 +++++++++++++++++++++++++++++- tests/data/REUSE.toml | 2 +- tests/data/testimage-icc-srgb.jpg | Bin 0 -> 998 bytes tests/lib/ImageTest.php | 57 +++++++++++++++ 4 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 tests/data/testimage-icc-srgb.jpg diff --git a/lib/private/Image.php b/lib/private/Image.php index 8498b024905a1..29997b1d15929 100644 --- a/lib/private/Image.php +++ b/lib/private/Image.php @@ -43,6 +43,16 @@ class Image implements IImage { // 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. @@ -435,11 +445,112 @@ private function rememberIccProfile(string $data): void { } else { return; } - if ($profile !== null && self::isUsableRgbProfile($profile)) { + if ($profile !== null && self::isUsableRgbProfile($profile) && !self::isSrgbProfile($profile)) { $this->iccProfile = $profile; } } + /** + * 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 diff --git a/tests/data/REUSE.toml b/tests/data/REUSE.toml index a7bf0de905c9e..13c1e23a83da7 100644 --- a/tests/data/REUSE.toml +++ b/tests/data/REUSE.toml @@ -40,7 +40,7 @@ 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"] +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" diff --git a/tests/data/testimage-icc-srgb.jpg b/tests/data/testimage-icc-srgb.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8bffa89b88ee37b4ff5130ca5fbd35aaf93eab8f GIT binary patch literal 998 zcmb7?Z%7ky7{{O6?cCgMbLVc;pky=&+6=<>q8F0kYI7qoHg(R@xJ{>$+S*Mt-`2n& zGNVb@n+h$8P(m;eBoQIVg1DkTR-o(Ed^Jt#MalL2CNZ&k(R26v-RJkc=lOk~`*HGn zc^M2|m&XMZ1wi2ia*E1$oX#^ReYGyniQ`)@mb=2;qS6A;-4l=b+*OvwV3S3a0vdS8 z2R-Z&!eX?>@2Ud~QRoxH@mLq(Tzq>=b_aNUphM_sy^wy|&~${FzGzLx%;o)+ zG_7r77~lNFQ5ufM;@GSOd2e4lO86KuAMOxZksFcAV%Q+^C8D=)@lC>^Eq+8eR_AkK z&K$C(eH(|iad%fQ@g+gJXWM$}{rFVl*bO51zztRZ$jQs*IKc@A0mymb8Y6cOV&qVH zR-)KS0OqqSf87V5x(2X$U6y~`k>$<10Q4t-x%0XDbGHEwucJPc(?TNvPc8!#FXgmS z%qvI$OfN)*n2`0MaaKe+0oLyW=z;*oR{)w*@<;5ERw&4gp~Ra}GmKKnXjG~^HLGD+ ztwyWW=?e`yJ*U@d4Q2yp$E!lzeAn_PL0QP zl%_aPa1_l^@&cIgAI$zb*^kIhN)^!!f9OU5^&*sa6y&MYq=4J0124!+z|s`D(HvC5 zqV2_(XJ_AyS7#=@!z(RG>-?{;>v-?S{19!fMPB)|qyEKYceq!BO zVp_GWrW`c`f$HZQ6T``Lv(+xmxB}zxE0f;zu9l=!KI>`5PHs6ShF2m<33s1Cw?K7f S%9}>JX0*4D^#>>A55EBYbM37F literal 0 HcmV?d00001 diff --git a/tests/lib/ImageTest.php b/tests/lib/ImageTest.php index 561611da067cc..c9bdb4c21d233 100644 --- a/tests/lib/ImageTest.php +++ b/tests/lib/ImageTest.php @@ -544,6 +544,63 @@ public function testCompressedIccProfileBombNotInflated(): void { $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(); From 85d40fcff698f6fb45fa9aacc2ebd2a2e6f981e7 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 29 Oct 2026 23:50:30 +0100 Subject: [PATCH 5/7] perf(preview): read the ICC profile of image files incrementally loadFromFile() read up to 8 MiB of every JPEG and PNG a second time just to find the colour profile, and missed profiles behind larger metadata. Parse the segment and chunk headers through a reader closure instead, backed by fseek()/fread() for files, so only the headers and the profile itself are read. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Ferdinand Thiessen --- lib/private/Image.php | 89 ++++++++++++++++++++++++++--------------- tests/lib/ImageTest.php | 14 +++++++ 2 files changed, 71 insertions(+), 32 deletions(-) diff --git a/lib/private/Image.php b/lib/private/Image.php index 29997b1d15929..413e11a85d3cf 100644 --- a/lib/private/Image.php +++ b/lib/private/Image.php @@ -37,9 +37,6 @@ class Image implements IImage { // Max ICC bytes per APP2 segment: 0xFFFF - 2 (length) - 12 (marker) - 2 (index and count) private const JPEG_ICC_MAX_CHUNK_SIZE = 65519; - // ICC profiles precede the image data, so the scan can stop early - private const ICC_SCAN_BYTE_LIMIT = 8 * 1024 * 1024; - // Upper bound for carried ICC profiles, display profiles are a few KiB at most private const ICC_MAX_PROFILE_SIZE = 64 * 1024; @@ -436,12 +433,13 @@ private function outputWithIccProfile(int $imageType, ?string $filePath): bool { * 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. */ - private function rememberIccProfile(string $data): void { + private function rememberIccProfile(\Closure $read): void { $this->iccProfile = null; - if (str_starts_with($data, "\xFF\xD8")) { - $profile = self::extractIccProfileFromJpeg($data); - } elseif (str_starts_with($data, "\x89PNG\r\n\x1a\n")) { - $profile = self::extractIccProfileFromPng($data); + $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; } @@ -450,6 +448,26 @@ private function rememberIccProfile(string $data): void { } } + /** + * @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. @@ -559,18 +577,20 @@ private static function isUsableRgbProfile(string $profile): bool { && substr($profile, 16, 4) === 'RGB '; // data colour space } - private static function extractIccProfileFromJpeg(string $data): ?string { - $len = strlen($data); + /** + * @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 ($pos + 4 <= $len) { - if ($data[$pos] !== "\xFF") { + while (strlen($header = $read($pos, 4)) === 4) { + if ($header[0] !== "\xFF") { return null; } - $marker = ord($data[$pos + 1]); + $marker = ord($header[1]); if ($marker === 0xFF) { // fill byte before a marker $pos++; @@ -585,12 +605,15 @@ private static function extractIccProfileFromJpeg(string $data): ?string { // start of scan or end of image: no more metadata segments break; } - $segmentLength = (ord($data[$pos + 2]) << 8) | ord($data[$pos + 3]); - if ($segmentLength < 2 || $pos + 2 + $segmentLength > $len) { + $segmentLength = unpack('n', $header, 2)[1]; + if ($segmentLength < 2) { return null; } if ($marker === 0xE2 && $segmentLength >= 2 + $identifierLength + 2) { - $payload = substr($data, $pos + 4, $segmentLength - 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]); @@ -614,24 +637,25 @@ private static function extractIccProfileFromJpeg(string $data): ?string { return implode('', $chunks); } - private static function extractIccProfileFromPng(string $data): ?string { - $len = strlen($data); + /** + * @param \Closure(int, int): string $read + */ + private static function extractIccProfileFromPng(\Closure $read): ?string { $pos = 8; - while ($pos + 8 <= $len) { - $header = unpack('NchunkLength', $data, $pos); - if ($header === false) { - return null; - } - $chunkLength = $header['chunkLength']; - $type = substr($data, $pos + 4, 4); + 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 || $pos + 8 + $chunkLength > $len) { + if ($chunkLength > self::ICC_MAX_PROFILE_SIZE) { + return null; + } + $chunk = $read($pos + 8, $chunkLength); + if (strlen($chunk) !== $chunkLength) { return null; } - $chunk = substr($data, $pos + 8, $chunkLength); $separator = strpos($chunk, "\x00"); if ($separator === false || $separator < 1 || $separator > 79 || strlen($chunk) < $separator + 2) { return null; @@ -1137,9 +1161,10 @@ public function loadFromFile($imagePath = false) { $this->mimeType = image_type_to_mime_type($iType); $this->filePath = $imagePath; if ($iType === IMAGETYPE_JPEG || $iType === IMAGETYPE_PNG) { - $header = @file_get_contents($imagePath, false, null, 0, self::ICC_SCAN_BYTE_LIMIT); - if ($header !== false) { - $this->rememberIccProfile($header); + $handle = @fopen($imagePath, 'rb'); + if ($handle !== false) { + $this->rememberIccProfile(self::streamReader($handle)); + fclose($handle); } } } @@ -1161,7 +1186,7 @@ public function loadFromData(string $str): GdImage|false { if ($this->valid()) { imagealphablending($this->resource, false); imagesavealpha($this->resource, true); - $this->rememberIccProfile($str); + $this->rememberIccProfile(self::stringReader($str)); } if (!$this->resource) { @@ -1191,7 +1216,7 @@ public function loadFromBase64(string $str) { $this->logger->debug('Image->loadFromBase64, could not load', ['app' => 'core']); return false; } - $this->rememberIccProfile($data); + $this->rememberIccProfile(self::stringReader($data)); return $this->resource; } else { return false; diff --git a/tests/lib/ImageTest.php b/tests/lib/ImageTest.php index c9bdb4c21d233..80443999f5c29 100644 --- a/tests/lib/ImageTest.php +++ b/tests/lib/ImageTest.php @@ -527,6 +527,20 @@ public function testIccProfileWithWrongDeclaredSizeNotPreserved(): void { $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)); From 4a80aae82c9da9df02749b574e9b31fe03603186 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 29 Oct 2026 23:52:33 +0100 Subject: [PATCH 6/7] fix(preview): re-attach ICC colour profiles to Imaginary previews Imaginary is asked to strip metadata so that EXIF data like the location does not leak into previews, but libvips drops the colour profile along with it and does not convert the pixels. Wide gamut JPEG and PNG previews generated by Imaginary therefore still looked washed out. Read the profile from the head of the source that is already read for the orientation check and splice it into the encoded response. For that the profile handling of OC\Image is exposed as static helpers. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Ferdinand Thiessen --- lib/private/Image.php | 72 ++++++++++++++++------------- lib/private/Preview/Imaginary.php | 29 ++++++++---- tests/lib/Preview/ImaginaryTest.php | 41 ++++++++++++++++ 3 files changed, 103 insertions(+), 39 deletions(-) diff --git a/lib/private/Image.php b/lib/private/Image.php index 413e11a85d3cf..462e927e3e234 100644 --- a/lib/private/Image.php +++ b/lib/private/Image.php @@ -400,7 +400,7 @@ public function data(): ?string { if ($data === false) { return null; } - return $this->embedIccProfile($data); + return self::embedIccProfile($data, $this->iccProfile); } /** @@ -419,7 +419,7 @@ private function outputWithIccProfile(int $imageType, ?string $filePath): bool { if (!$res || $data === false) { return false; } - $data = $this->embedIccProfile($data); + $data = self::embedIccProfile($data, $this->iccProfile); if ($filePath === null || $filePath === '') { echo $data; return true; @@ -428,24 +428,49 @@ private function outputWithIccProfile(int $imageType, ?string $filePath): bool { } /** - * Remembers the source ICC profile for re-embedding into generated output. + * 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. */ - private function rememberIccProfile(\Closure $read): void { - $this->iccProfile = null; + 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; + return null; } - if ($profile !== null && self::isUsableRgbProfile($profile) && !self::isSrgbProfile($profile)) { - $this->iccProfile = $profile; + if ($profile === null || !self::isUsableRgbProfile($profile) || self::isSrgbProfile($profile)) { + return null; } + return $profile; } /** @@ -672,25 +697,9 @@ private static function extractIccProfileFromPng(\Closure $read): ?string { return null; } - private function embedIccProfile(string $data): string { - if ($this->iccProfile === null) { - return $data; - } - if (str_starts_with($data, "\xFF\xD8")) { - return $this->embedIccProfileInJpeg($data); - } - if (str_starts_with($data, "\x89PNG\r\n\x1a\n")) { - return $this->embedIccProfileInPng($data); - } - return $data; - } - - private function embedIccProfileInJpeg(string $data): string { - $chunks = str_split($this->iccProfile, self::JPEG_ICC_MAX_CHUNK_SIZE); + private static function embedIccProfileInJpeg(string $data, string $profile): string { + $chunks = str_split($profile, self::JPEG_ICC_MAX_CHUNK_SIZE); $total = count($chunks); - if ($total > 255) { - return $data; - } // APP2 segments belong before the image data, after the APP0/APP1 // (JFIF/EXIF) segments the encoder may have written $len = strlen($data); @@ -712,13 +721,13 @@ private function embedIccProfileInJpeg(string $data): string { return substr($data, 0, $pos) . $segments . substr($data, $pos); } - private function embedIccProfileInPng(string $data): string { + 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($this->iccProfile); + $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); @@ -1160,10 +1169,11 @@ 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->rememberIccProfile(self::streamReader($handle)); + $this->iccProfile = self::readIccProfile(self::streamReader($handle)); fclose($handle); } } @@ -1186,7 +1196,7 @@ public function loadFromData(string $str): GdImage|false { if ($this->valid()) { imagealphablending($this->resource, false); imagesavealpha($this->resource, true); - $this->rememberIccProfile(self::stringReader($str)); + $this->iccProfile = self::getIccProfile($str); } if (!$this->resource) { @@ -1216,7 +1226,7 @@ public function loadFromBase64(string $str) { $this->logger->debug('Image->loadFromBase64, could not load', ['app' => 'core']); return false; } - $this->rememberIccProfile(self::stringReader($data)); + $this->iccProfile = self::getIccProfile($data); return $this->resource; } else { return false; 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/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); + } + } } From e9f92ddb7f5c0c899cbfae77372578eb59301254 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 29 Oct 2026 23:53:04 +0100 Subject: [PATCH 7/7] test(preview): parse JPEG segments and PNG chunks in ICC test helpers The helpers searched the whole file for the ICC markers, which can also occur inside compressed image data, and did not notice malformed output. Walk the segment and chunk structure instead and verify the iCCP CRC. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Ferdinand Thiessen --- tests/lib/ImageTest.php | 51 ++++++++++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/tests/lib/ImageTest.php b/tests/lib/ImageTest.php index 80443999f5c29..3ee8f6eef2983 100644 --- a/tests/lib/ImageTest.php +++ b/tests/lib/ImageTest.php @@ -387,13 +387,25 @@ public function testMemoryLimitFromData(): void { * 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 = []; - $offset = 0; - while (($pos = strpos($data, "ICC_PROFILE\x00", $offset)) !== false) { - $length = (ord($data[$pos - 2]) << 8) | ord($data[$pos - 1]); - $sequence = ord($data[$pos + 12]); - $chunks[$sequence] = substr($data, $pos + 14, $length - 2 - 12 - 2); - $offset = $pos + 14; + $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; @@ -406,14 +418,27 @@ private static function iccProfileOfJpeg(string $data): ?string { * Returns the ICC profile from the iCCP chunk of a PNG, or null if there is none. */ private static function iccProfileOfPng(string $data): ?string { - $pos = strpos($data, 'iCCP'); - if ($pos === false) { - return null; + 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; } - $length = unpack('N', substr($data, $pos - 4, 4))[1]; - $chunk = substr($data, $pos + 4, $length); - $separator = strpos($chunk, "\x00"); - return gzuncompress(substr($chunk, $separator + 2)) ?: null; + return null; } public function testIccProfilePreservedInJpeg(): void {