PSD: support native CMYK introduced by Qt 6.8

Qt 6.8 will introduce native support for the CMYK (8-bit) format.
With this patch you will finally be able to correctly see the colors of CMYK images with ICC profile.
The testing part has been updated with the addition of an (optional) json file for each image to test. Inside you enter which image to use depending on the Qt version.

In short:
- Added native CMYK suport to PSD reader
- CMYK with alpha is converted using QColorSpace in a RGBA image
- Read tests changed to use the correct comparison image based on the Qt version
- Fixed also XCF tests: now works with all Qt version (see also [QTBUG-120614](https://bugreports.qt.io/browse/QTBUG-120614))
- Work around for CCBUG: 468288
This commit is contained in:
Mirco Miranda 2024-06-07 10:16:58 +00:00 committed by Albert Astals Cid
parent a54c5e876c
commit 4995c9cd15
26 changed files with 425 additions and 30 deletions

View File

@ -11,7 +11,7 @@ macro(kimageformats_read_tests)
endif()
if (NOT TARGET readtest)
add_executable(readtest readtest.cpp)
add_executable(readtest readtest.cpp templateimage.cpp)
target_link_libraries(readtest Qt6::Gui)
target_compile_definitions(readtest
PRIVATE IMAGEDIR="${CMAKE_CURRENT_SOURCE_DIR}/read")

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

View File

@ -0,0 +1,11 @@
[
{
"minQtVersion" : "6.8.0",
"fileName" : "cmyk16_testcard_qt6_8.tif"
},
{
"minQtVersion" : "6.0.0",
"maxQtVersion" : "6.7.99",
"fileName" : "cmyk16_testcard.png"
}
]

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

View File

@ -0,0 +1,11 @@
[
{
"minQtVersion" : "6.8.0",
"fileName" : "cmyk8_testcard_qt6_8.tif"
},
{
"minQtVersion" : "6.0.0",
"maxQtVersion" : "6.7.99",
"fileName" : "cmyk8_testcard.png"
}
]

Binary file not shown.

View File

@ -0,0 +1,11 @@
[
{
"minQtVersion" : "6.8.0",
"fileName" : "cmyka-16bits_qt6_8.png"
},
{
"minQtVersion" : "6.0.0",
"maxQtVersion" : "6.7.99",
"fileName" : "cmyka-16bits.png"
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

View File

@ -0,0 +1,11 @@
[
{
"minQtVersion" : "6.8.0",
"fileName" : "cmyka-8bits_qt6_8.png"
},
{
"minQtVersion" : "6.0.0",
"maxQtVersion" : "6.7.99",
"fileName" : "cmyka-8bits.png"
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View File

@ -0,0 +1,11 @@
[
{
"minQtVersion" : "6.8.0",
"fileName" : "mch-16bits_qt_6_8.tif"
},
{
"minQtVersion" : "6.0.0",
"maxQtVersion" : "6.7.99",
"fileName" : "mch-16bits.png"
}
]

Binary file not shown.

View File

@ -0,0 +1,11 @@
[
{
"minQtVersion" : "6.8.0",
"fileName" : "mch-8bits_qt_6.8.tif"
},
{
"minQtVersion" : "6.0.0",
"maxQtVersion" : "6.7.99",
"fileName" : "mch-8bits.png"
}
]

Binary file not shown.

View File

@ -0,0 +1,32 @@
[
{
"minQtVersion" : "6.7.0",
"fileName" : "birthday16.png",
"seeAlso" : "https://bugreports.qt.io/browse/QTBUG-120614"
},
{
"minQtVersion" : "6.0.0",
"maxQtVersion" : "6.2.10",
"fileName" : "birthday16_alphabug.png"
},
{
"minQtVersion" : "6.3.0",
"maxQtVersion" : "6.3.2",
"fileName" : "birthday32_alphabug.png"
},
{
"minQtVersion" : "6.4.0",
"maxQtVersion" : "6.4.3",
"fileName" : "birthday32_alphabug.png"
},
{
"minQtVersion" : "6.5.0",
"maxQtVersion" : "6.5.4",
"fileName" : "birthday16_alphabug.png"
},
{
"minQtVersion" : "6.6.0",
"maxQtVersion" : "6.6.1",
"fileName" : "birthday16_alphabug.png"
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View File

@ -0,0 +1,32 @@
[
{
"minQtVersion" : "6.7.0",
"fileName" : "birthday32.png",
"seeAlso" : "https://bugreports.qt.io/browse/QTBUG-120614"
},
{
"minQtVersion" : "6.0.0",
"maxQtVersion" : "6.2.10",
"fileName" : "birthday32_alphabug.png"
},
{
"minQtVersion" : "6.3.0",
"maxQtVersion" : "6.3.2",
"fileName" : "birthday32_alphabug.png"
},
{
"minQtVersion" : "6.4.0",
"maxQtVersion" : "6.4.3",
"fileName" : "birthday32_alphabug.png"
},
{
"minQtVersion" : "6.5.0",
"maxQtVersion" : "6.5.4",
"fileName" : "birthday32_alphabug.png"
},
{
"minQtVersion" : "6.6.0",
"maxQtVersion" : "6.6.1",
"fileName" : "birthday32_alphabug.png"
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View File

@ -16,6 +16,7 @@
#include <QTextStream>
#include "../tests/format-enum.h"
#include "templateimage.h"
#include "fuzzyeq.cpp"
@ -95,7 +96,7 @@ int main(int argc, char **argv)
QCoreApplication::removeLibraryPath(QStringLiteral(PLUGIN_DIR));
QCoreApplication::addLibraryPath(QStringLiteral(PLUGIN_DIR));
QCoreApplication::setApplicationName(QStringLiteral("readtest"));
QCoreApplication::setApplicationVersion(QStringLiteral("1.1.0"));
QCoreApplication::setApplicationVersion(QStringLiteral("1.2.0"));
QCommandLineParser parser;
parser.setApplicationDescription(QStringLiteral("Performs basic image conversion checking."));
@ -159,22 +160,24 @@ int main(int argc, char **argv)
QTextStream(stdout) << "* Run on RANDOM ACCESS device\n";
}
for (const QFileInfo &fi : lstImgDir) {
if (!fi.suffix().compare("png", Qt::CaseInsensitive) || !fi.suffix().compare("tif", Qt::CaseInsensitive)) {
TemplateImage timg(fi);
if (timg.isTemplate()) {
continue;
}
int suffixPos = fi.filePath().size() - suffix.size();
QString inputfile = fi.filePath();
QString fmt = QStringLiteral("png");
QString expfile = fi.filePath().replace(suffixPos, suffix.size(), fmt);
if (!QFile::exists(expfile)) { // try with tiff
fmt = QStringLiteral("tif");
expfile = fi.filePath().replace(suffixPos, suffix.size(), fmt);
}
QString expfilename = QFileInfo(expfile).fileName();
std::unique_ptr<QIODevice> inputDevice(seq ? new SequentialFile(inputfile) : new QFile(inputfile));
QFileInfo expFileInfo = timg.compareImage();
if (!formatStrings.contains(expFileInfo.suffix(), Qt::CaseInsensitive)) {
// Work Around for CCBUG: 468288
QTextStream(stdout) << "SKIP : " << fi.fileName() << ": comparison image " << expFileInfo.fileName() << " cannot be loaded due to the lack of "
<< expFileInfo.suffix().toUpper() << " plugin!\n";
++skipped;
continue;
}
QString expfilename = expFileInfo.fileName();
std::unique_ptr<QIODevice> inputDevice(seq ? new SequentialFile(fi.filePath()) : new QFile(fi.filePath()));
QImageReader inputReader(inputDevice.get(), format);
QImageReader expReader(expfile, fmt.toLatin1());
QImageReader expReader(expFileInfo.filePath());
QImage inputImage;
QImage expImage;

View File

@ -0,0 +1,98 @@
/*
SPDX-FileCopyrightText: 2024 Mirco Miranda <mircomir@outlook.com>
SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
*/
#include "templateimage.h"
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QVersionNumber>
TemplateImage::TemplateImage(const QFileInfo &fi) :
m_fi(fi)
{
}
bool TemplateImage::isTemplate() const
{
auto list = suffixes();
for (auto&& suffix : list) {
if (!m_fi.suffix().compare(suffix, Qt::CaseInsensitive))
return true;
}
return false;
}
QFileInfo TemplateImage::compareImage() const
{
auto fi = jsonImage();
if (fi.exists()) {
return fi;
}
return legacyImage();
}
QStringList TemplateImage::suffixes()
{
return QStringList({"png", "tif", "tiff", "json"});
}
QFileInfo TemplateImage::legacyImage() const
{
auto list = suffixes();
for (auto&& suffix : list) {
auto fi = QFileInfo(QStringLiteral("%1/%2.%3").arg(m_fi.path(), m_fi.completeBaseName(), suffix));
if (fi.exists()) {
return fi;
}
}
return {};
}
QFileInfo TemplateImage::jsonImage() const
{
auto fi = QFileInfo(QStringLiteral("%1.json").arg(m_fi.filePath()));
if (!fi.exists()) {
return {};
}
QFile f(fi.filePath());
if (!f.open(QFile::ReadOnly)) {
return {};
}
QJsonParseError err;
auto doc = QJsonDocument::fromJson(f.readAll(), &err);
if (err.error != QJsonParseError::NoError || !doc.isArray()) {
return {};
}
auto currentQt = QVersionNumber::fromString(qVersion());
auto arr = doc.array();
for (auto val : arr) {
if (!val.isObject())
continue;
auto obj = val.toObject();
auto minQt = QVersionNumber::fromString(obj.value("minQtVersion").toString());
auto maxQt = QVersionNumber::fromString(obj.value("maxQtVersion").toString());
auto name = obj.value("fileName").toString();
// filter
if (name.isEmpty())
continue;
if (!minQt.isNull() && currentQt < minQt)
continue;
if (!maxQt.isNull() && currentQt > maxQt)
continue;
return QFileInfo(QStringLiteral("%1/%2").arg(fi.path(), name));
}
return {};
}

72
autotests/templateimage.h Normal file
View File

@ -0,0 +1,72 @@
/*
SPDX-FileCopyrightText: 2024 Mirco Miranda <mircomir@outlook.com>
SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
*/
#ifndef TEMPLATEIMAGE_H
#define TEMPLATEIMAGE_H
#include <QFileInfo>
/*!
* \brief The TemplateImage class
* Given an image name, it decides the template image to compare it with.
*/
class TemplateImage
{
public:
/*!
* \brief TemplateImage
* \param fi The image to test.
*/
TemplateImage(const QFileInfo& fi);
/*!
* \brief TemplateImage
* Default copy constructor.
*/
TemplateImage(const TemplateImage& other) = default;
/*!
* \brief operator =
* Default copy operator
*/
TemplateImage& operator=(const TemplateImage& other) = default;
/*!
* \brief isTemplate
* \return True if the image is a template, false otherwise.
* \sa suffixes
*/
bool isTemplate() const;
/*!
* \brief compareImage
* \return The template image to use for the comparison.
*/
QFileInfo compareImage() const;
/*!
* \brief suffixes
* \return The list of suffixes considered templates.
*/
static QStringList suffixes();
private:
/*!
* \brief legacyImage
* \return The template image calculated from the source image name.
*/
QFileInfo legacyImage() const;
/*!
* \brief jsonImage
* \return The template image read from the corresponding JSON.
*/
QFileInfo jsonImage() const;
private:
QFileInfo m_fi;
};
#endif // TEMPLATEIMAGE_H

View File

@ -83,7 +83,7 @@ kimageformats_add_plugin(kimg_pic SOURCES pic.cpp)
##################################
kimageformats_add_plugin(kimg_psd SOURCES psd.cpp)
kimageformats_add_plugin(kimg_psd SOURCES psd.cpp scanlineconverter.cpp)
##################################

View File

@ -23,7 +23,7 @@
* - Color spaces other than RGB/Grayscale cannot be read due to lack of QImage
* support. Where possible, a conversion to RGB is done:
* - CMYK images are converted using an approximated way that ignores the color
* information (ICC profile).
* information (ICC profile) with Qt less than 6.8.
* - LAB images are converted to sRGB using literature formulas.
* - MULICHANNEL images more than 3 channels are converted as CMYK images.
* - DUOTONE images are considered as Grayscale images.
@ -34,6 +34,7 @@
#include "fastmath_p.h"
#include "psd_p.h"
#include "scanlineconverter_p.h"
#include "util_p.h"
#include <QDataStream>
@ -60,9 +61,24 @@ typedef quint8 uchar;
*/
//#define PSD_FAST_LAB_CONVERSION
/*
* Since Qt version 6.8, the 8-bit CMYK format is natively supported.
* If you encounter problems with native CMYK support you can continue to force the plugin to convert
* to RGB as in previous versions by defining PSD_NATIVE_CMYK_SUPPORT_DISABLED.
*/
//#define PSD_NATIVE_CMYK_SUPPORT_DISABLED
namespace // Private.
{
#if QT_VERSION < QT_VERSION_CHECK(6, 8, 0) || defined(PSD_NATIVE_CMYK_SUPPORT_DISABLED)
# define CMYK_FORMAT QImage::Format_Invalid
#else
# define CMYK_FORMAT QImage::Format_CMYK8888
#endif
#define NATIVE_CMYK (CMYK_FORMAT != QImage::Format_Invalid)
enum Signature : quint32 {
S_8BIM = 0x3842494D, // '8BIM'
S_8B64 = 0x38423634, // '8B64'
@ -477,7 +493,7 @@ PSDColorModeDataSection readColorModeDataSection(QDataStream &s, bool *ok = null
*/
static bool setColorSpace(QImage& img, const PSDImageResourceSection& irs)
{
if (!irs.contains(IRI_ICCPROFILE))
if (!irs.contains(IRI_ICCPROFILE) || img.isNull())
return false;
auto irb = irs.value(IRI_ICCPROFILE);
auto cs = QColorSpace::fromIccProfile(irb.data);
@ -742,7 +758,9 @@ static QImage::Format imageFormat(const PSDHeader &header, bool alpha)
break;
case CM_MULTICHANNEL: // Treat MCH as CMYK (number of channel check is done in IsSupported())
case CM_CMYK: // Photoshop supports CMYK/MCH 8-bits and 16-bits only
if (header.depth == 16)
if (NATIVE_CMYK && header.channel_count == 4 && (header.depth == 16 || header.depth == 8))
format = CMYK_FORMAT;
else if (header.depth == 16)
format = header.channel_count < 5 || !alpha ? QImage::Format_RGBX64 : QImage::Format_RGBA64;
else if (header.depth == 8)
format = header.channel_count < 5 || !alpha ? QImage::Format_RGB888 : QImage::Format_RGBA8888;
@ -844,6 +862,18 @@ inline void planarToChunchy(uchar *target, const char *source, qint32 width, qin
}
}
template<class T>
inline void planarToChunchyCMYK(uchar *target, const char *source, qint32 width, qint32 c, qint32 cn)
{
auto s = reinterpret_cast<const T*>(source);
auto t = reinterpret_cast<quint8*>(target);
const T d = std::numeric_limits<T>::max() / std::numeric_limits<quint8>::max();
for (qint32 x = 0; x < width; ++x) {
t[x * cn + c] = quint8((std::numeric_limits<T>::max() - xchg(s[x])) / d);
}
}
template<class T>
inline void planarToChunchyFloatToUInt16(uchar *target, const char *source, qint32 width, qint32 c, qint32 cn)
{
@ -903,6 +933,19 @@ inline void monoInvert(uchar *target, const char* source, qint32 bytes)
}
}
template<class T>
inline void rawChannelsCopyToCMYK(uchar *target, qint32 targetChannels, const char *source, qint32 sourceChannels, qint32 width)
{
auto s = reinterpret_cast<const T*>(source);
auto t = reinterpret_cast<quint8*>(target);
const T d = std::numeric_limits<T>::max() / std::numeric_limits<quint8>::max();
for (qint32 c = 0, cs = std::min(targetChannels, sourceChannels); c < cs; ++c) {
for (qint32 x = 0; x < width; ++x) {
t[x * targetChannels + c] = (std::numeric_limits<T>::max() - s[x * sourceChannels + c]) / d;
}
}
}
template<class T>
inline void rawChannelsCopy(uchar *target, qint32 targetChannels, const char *source, qint32 sourceChannels, qint32 width)
{
@ -915,6 +958,17 @@ inline void rawChannelsCopy(uchar *target, qint32 targetChannels, const char *so
}
}
template<class T>
inline void rawChannelCopy(uchar *target, qint32 targetChannels, qint32 targetChannel, const char *source, qint32 sourceChannels, qint32 sourceChannel, qint32 width)
{
auto s = reinterpret_cast<const T*>(source);
auto t = reinterpret_cast<T*>(target);
for (qint32 x = 0; x < width; ++x) {
t[x * targetChannels + targetChannel] = s[x * sourceChannels + sourceChannel];
}
}
template<class T>
inline void cmykToRgb(uchar *target, qint32 targetChannels, const char *source, qint32 sourceChannels, qint32 width, bool alpha = false)
{
@ -1103,6 +1157,7 @@ static bool LoadPSD(QDataStream &stream, const PSDHeader &header, QImage &img)
auto imgChannels = imageChannels(img.format());
auto channel_num = std::min(qint32(header.channel_count), imgChannels);
auto raw_count = qsizetype(header.width * header.depth + 7) / 8;
auto native_cmyk = img.format() == CMYK_FORMAT;
if (header.height > kMaxQVectorSize / header.channel_count / sizeof(quint32)) {
qWarning() << "LoadPSD() header height/channel_count too big" << header.height << header.channel_count;
@ -1138,13 +1193,25 @@ static bool LoadPSD(QDataStream &stream, const PSDHeader &header, QImage &img)
// clang-format off
// checks the need of color conversion (that requires random access to the image)
auto randomAccess = (header.color_mode == CM_CMYK) ||
auto randomAccess = (header.color_mode == CM_CMYK && !native_cmyk) ||
(header.color_mode == CM_MULTICHANNEL && !native_cmyk) ||
(header.color_mode == CM_LABCOLOR) ||
(header.color_mode == CM_MULTICHANNEL) ||
(header.color_mode != CM_INDEXED && img.hasAlphaChannel());
// clang-format on
if (randomAccess) {
// CMYK with spots (e.g. CMYKA) ICC conversion to RGBA/RGBX
QImage tmpCmyk;
ScanLineConverter iccConv(img.format());
#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) && !defined(PSD_NATIVE_CMYK_SUPPORT_DISABLED)
if (header.color_mode == CM_CMYK && img.format() != QImage::Format_CMYK8888) {
auto tmpi = QImage(header.width, 1, QImage::Format_CMYK8888);
if (setColorSpace(tmpi, irs))
tmpCmyk = tmpi;
iccConv.setTargetColorSpace(QColorSpace(QColorSpace::SRgb));
}
#endif
// In order to make a colorspace transformation, we need all channels of a scanline
QByteArray psdScanline;
psdScanline.resize(qsizetype(header.width * header.depth * header.channel_count + 7) / 8);
@ -1200,11 +1267,27 @@ static bool LoadPSD(QDataStream &stream, const PSDHeader &header, QImage &img)
// Conversion to RGB
if (header.color_mode == CM_CMYK || header.color_mode == CM_MULTICHANNEL) {
if (tmpCmyk.isNull()) {
if (header.depth == 8)
cmykToRgb<quint8>(img.scanLine(y), imgChannels, psdScanline.data(), header.channel_count, header.width, alpha);
else if (header.depth == 16)
cmykToRgb<quint16>(img.scanLine(y), imgChannels, psdScanline.data(), header.channel_count, header.width, alpha);
}
else if (header.depth == 8) {
rawChannelsCopyToCMYK<quint8>(tmpCmyk.bits(), 4, psdScanline.data(), header.channel_count, header.width);
if (auto rgbPtr = iccConv.convertedScanLine(tmpCmyk, 0))
std::memcpy(img.scanLine(y), rgbPtr, img.bytesPerLine());
if (imgChannels == 4 && header.channel_count >= 5)
rawChannelCopy<quint8>(img.scanLine(y), imgChannels, 3, psdScanline.data(), header.channel_count, 4, header.width);
}
else if (header.depth == 16) {
rawChannelsCopyToCMYK<quint16>(tmpCmyk.bits(), 4, psdScanline.data(), header.channel_count, header.width);
if (auto rgbPtr = iccConv.convertedScanLine(tmpCmyk, 0))
std::memcpy(img.scanLine(y), rgbPtr, img.bytesPerLine());
if (imgChannels == 4 && header.channel_count >= 5)
rawChannelCopy<quint16>(img.scanLine(y), imgChannels, 3, psdScanline.data(), header.channel_count, 4, header.width);
}
}
if (header.color_mode == CM_LABCOLOR) {
if (header.depth == 8)
labToRgb<quint8>(img.scanLine(y), imgChannels, psdScanline.data(), header.channel_count, header.width, alpha);
@ -1235,10 +1318,16 @@ static bool LoadPSD(QDataStream &stream, const PSDHeader &header, QImage &img)
if (header.depth == 1) { // Bitmap
monoInvert(scanLine, rawStride.data(), std::min(rawStride.size(), img.bytesPerLine()));
}
else if (header.depth == 8) { // 8-bits images: Indexed, Grayscale, RGB/RGBA
else if (header.depth == 8) { // 8-bits images: Indexed, Grayscale, RGB/RGBA, CMYK, MCH4
if (native_cmyk)
planarToChunchyCMYK<quint8>(scanLine, rawStride.data(), header.width, c, imgChannels);
else
planarToChunchy<quint8>(scanLine, rawStride.data(), header.width, c, imgChannels);
}
else if (header.depth == 16) { // 16-bits integer images: Grayscale, RGB/RGBA
else if (header.depth == 16) { // 16-bits integer images: Grayscale, RGB/RGBA, CMYK, MCH4
if (native_cmyk)
planarToChunchyCMYK<quint16>(scanLine, rawStride.data(), header.width, c, imgChannels);
else
planarToChunchy<quint16>(scanLine, rawStride.data(), header.width, c, imgChannels);
}
else if (header.depth == 32 && header.color_mode == CM_RGB) { // 32-bits float images: RGB/RGBA
@ -1251,7 +1340,6 @@ static bool LoadPSD(QDataStream &stream, const PSDHeader &header, QImage &img)
}
}
// Resolution info
if (!setResolution(img, irs)) {
// qDebug() << "No resolution info found!";
@ -1384,7 +1472,11 @@ bool PSDHandler::canRead(QIODevice *device)
}
if (device->isSequential()) {
if (header.color_mode == CM_CMYK || header.color_mode == CM_LABCOLOR || header.color_mode == CM_MULTICHANNEL) {
if (header.color_mode == CM_CMYK || header.color_mode == CM_MULTICHANNEL) {
if (header.channel_count != 4 || !NATIVE_CMYK)
return false;
}
if (header.color_mode == CM_LABCOLOR) {
return false;
}
if (header.color_mode == CM_RGB && header.channel_count > 3) {