Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
229 changes: 225 additions & 4 deletions lib/private/Image.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -258,11 +269,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')) {
Expand Down Expand Up @@ -361,7 +380,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) {
$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);
}

/**
Expand Down Expand Up @@ -787,6 +996,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;
}
Expand All @@ -806,6 +1021,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) {
Expand Down Expand Up @@ -835,6 +1051,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;
Expand Down Expand Up @@ -1120,6 +1337,7 @@ public function copy(): IImage {
$this->width(),
$this->height()
);
$image->iccProfile = $this->iccProfile;

return $image;
}
Expand All @@ -1129,6 +1347,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;
Expand All @@ -1139,6 +1358,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;
Expand All @@ -1149,6 +1369,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;
Expand Down
5 changes: 5 additions & 0 deletions lib/private/Preview/HEIC.php
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,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
Expand Down
Binary file added tests/data/testimage-icc-cmyk.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/data/testimage-icc-p3.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading