This commit is contained in:
s0len 2026-06-13 08:15:39 +02:00 committed by GitHub
commit 0658cca2ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 330 additions and 5 deletions

View file

@ -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"

View file

@ -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;

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -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()));
}
}