diff --git a/REUSE.toml b/REUSE.toml index ffc5fed40a8..63555a85551 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -88,7 +88,7 @@ SPDX-FileCopyrightText = "2012 ownCloud, Inc." SPDX-License-Identifier = "AGPL-3.0-only" [[annotations]] -path = ["robots.txt", "tests/data/testavatar.png", "tests/data/testimage.gif", "tests/data/testimage.jpg", "tests/data/testimage.png"] +path = ["robots.txt", "tests/data/testavatar.png", "tests/data/testimage.gif", "tests/data/testimage.jpg", "tests/data/testimage.png", "tests/data/testimage-icc-p3.jpg", "tests/data/testimage-icc-cmyk.jpg"] precedence = "aggregate" SPDX-FileCopyrightText = "2013 ownCloud, Inc." SPDX-License-Identifier = "AGPL-3.0-only" diff --git a/lib/private/Image.php b/lib/private/Image.php index d1a2f164ed5..1397df326f6 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 @@ -258,11 +269,19 @@ class Image implements IImage { $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')) { @@ -361,7 +380,197 @@ class Image implements IImage { if (!$res) { $this->logger->error('Image->data. Error getting image data.', ['app' => 'core']); } - return ob_get_clean(); + $data = ob_get_clean(); + if ($data !== false) { + $data = $this->embedIccProfile($data); + } + return $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); } /** @@ -787,6 +996,12 @@ class Image implements IImage { $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; } @@ -806,6 +1021,7 @@ class Image implements IImage { if ($this->valid()) { imagealphablending($this->resource, false); imagesavealpha($this->resource, true); + $this->rememberIccProfile($str); } if (!$this->resource) { @@ -835,6 +1051,7 @@ class Image implements IImage { $this->logger->debug('Image->loadFromBase64, could not load', ['app' => 'core']); return false; } + $this->rememberIccProfile($data); return $this->resource; } else { return false; @@ -1120,6 +1337,7 @@ class Image implements IImage { $this->width(), $this->height() ); + $image->iccProfile = $this->iccProfile; return $image; } @@ -1129,6 +1347,7 @@ class Image implements 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; @@ -1139,6 +1358,7 @@ class Image implements 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; @@ -1149,6 +1369,7 @@ class Image implements 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/HEIC.php b/lib/private/Preview/HEIC.php index e56101e8e40..bc68d6316ea 100644 --- a/lib/private/Preview/HEIC.php +++ b/lib/private/Preview/HEIC.php @@ -140,8 +140,13 @@ class HEIC extends ProviderV2 { 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/testimage-icc-cmyk.jpg b/tests/data/testimage-icc-cmyk.jpg new file mode 100644 index 00000000000..a33471d6d73 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 00000000000..afe79a757ab Binary files /dev/null and b/tests/data/testimage-icc-p3.jpg differ diff --git a/tests/lib/ImageTest.php b/tests/lib/ImageTest.php index 22246f8e437..255e8934581 100644 --- a/tests/lib/ImageTest.php +++ b/tests/lib/ImageTest.php @@ -381,4 +381,103 @@ class ImageTest extends \Test\TestCase { $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())); + $this->assertEquals($sourceProfile, self::iccProfileOfJpeg($img->copy()->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())); + } }