summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGiuseppe D'Angelo <giuseppe.dangelo@kdab.com>2023-10-24 02:27:52 +0200
committerAllan Sandfeld Jensen <allan.jensen@qt.io>2024-04-03 22:15:42 +0100
commit3fb3d95c335fecf005c938b2a7011286f592325a (patch)
tree923a10b3d3afb911520e981a74be006f699d279d
parente68b57e2e0983d66cdf1d48e9216d32fa1e63f68 (diff)
Add support for CMYK file I/O in JPEG
JPEG part 6 defines CMYK support. This commit adds such support to the JPEG plugin. A *very* interesting discovery is the fact that Photoshop inverts the meaning of the CMYK color channels when saving into JPEG: 0 means "full ink", and 255 means "no ink". Most other image viewers/editors follow the same interpretation, I imagine for compatibility. But others, like Adobe Reader, don't (???) -- a PDF expects a DCT encoding with 0 meaning "no ink". I am adding a SubType to the image I/O handler to let the user choose what they want, defaulting to Photoshop behavior. Also, turns out that Qt was already loading CMYK files and converting them to RGB. I don't think we should do automatic, lossy conversions (we were not taking into account an eventual colorspace...), so I'm changing that loading to yield a CMYK QImage. Finally: save the colorspace, even if it's a CMYK image. QColorSpace doesn't support anything but RGB matrix-based colorspaces. Yet, it can load an arbitrary ICC profile, and will store it even if it's unable to use it. We can use this fact to preserve the colorspace embedded in CMYK images, or let users set an arbitrary ICC profile on them through Qt APIs, and then saving the result in JPEG. [ChangeLog][QtGui][JPEG] Added support for loading and saving of JPEG files in 8-bit CMYK format. When loading a CMYK JPEG file, Qt used to convert it automatically to a RGB image; now instead it's kept as-is. This work has been kindly sponsored by the QGIS project (https://qgis.org/). Change-Id: Ibdbfa16aa35814f5dba28c2df89577175162b731 Reviewed-by: Allan Sandfeld Jensen <allan.jensen@qt.io>
-rw-r--r--src/plugins/imageformats/jpeg/qjpeghandler.cpp103
-rw-r--r--tests/auto/gui/image/qimagereader/tst_qimagereader.cpp67
2 files changed, 140 insertions, 30 deletions
diff --git a/src/plugins/imageformats/jpeg/qjpeghandler.cpp b/src/plugins/imageformats/jpeg/qjpeghandler.cpp
index 6bc7712c61..9fc49104e5 100644
--- a/src/plugins/imageformats/jpeg/qjpeghandler.cpp
+++ b/src/plugins/imageformats/jpeg/qjpeghandler.cpp
@@ -172,9 +172,14 @@ inline static bool read_jpeg_format(QImage::Format &format, j_decompress_ptr cin
format = QImage::Format_Grayscale8;
break;
case 3:
- case 4:
format = QImage::Format_RGB32;
break;
+ case 4:
+ if (cinfo->out_color_space == JCS_CMYK)
+ format = QImage::Format_CMYK32;
+ else
+ format = QImage::Format_RGB32;
+ break;
default:
result = false;
break;
@@ -192,9 +197,14 @@ static bool ensureValidImage(QImage *dest, struct jpeg_decompress_struct *info,
format = QImage::Format_Grayscale8;
break;
case 3:
- case 4:
format = QImage::Format_RGB32;
break;
+ case 4:
+ if (info->out_color_space == JCS_CMYK)
+ format = QImage::Format_CMYK32;
+ else
+ format = QImage::Format_RGB32;
+ break;
default:
return false; // unsupported format
}
@@ -206,7 +216,7 @@ static bool read_jpeg_image(QImage *outImage,
QSize scaledSize, QRect scaledClipRect,
QRect clipRect, int quality,
Rgb888ToRgb32Converter converter,
- j_decompress_ptr info, struct my_error_mgr* err )
+ j_decompress_ptr info, struct my_error_mgr* err, bool invertCMYK)
{
if (!setjmp(err->setjmp_buffer)) {
// -1 means default quality.
@@ -348,6 +358,17 @@ static bool read_jpeg_image(QImage *outImage,
QRgb *out = (QRgb*)outImage->scanLine(y);
converter(out, in, clip.width());
} else if (info->out_color_space == JCS_CMYK) {
+ uchar *in = rows[0] + clip.x() * 4;
+ quint32 *out = (quint32*)outImage->scanLine(y);
+ if (invertCMYK) {
+ for (int i = 0; i < clip.width(); ++i) {
+ *out++ = 0xffffffffu - (in[0] | in[1] << 8 | in[2] << 16 | in[3] << 24);
+ in += 4;
+ }
+ } else {
+ memcpy(out, in, clip.width() * 4);
+ }
+#if 0
// Convert CMYK->RGB.
uchar *in = rows[0] + clip.x() * 4;
QRgb *out = (QRgb*)outImage->scanLine(y);
@@ -357,6 +378,7 @@ static bool read_jpeg_image(QImage *outImage,
k * in[2] / 255);
in += 4;
}
+#endif
} else if (info->output_components == 1) {
// Grayscale.
memcpy(outImage->scanLine(y),
@@ -493,7 +515,8 @@ static bool do_write_jpeg_image(struct jpeg_compress_struct &cinfo,
int sourceQuality,
const QString &description,
bool optimize,
- bool progressive)
+ bool progressive,
+ bool invertCMYK)
{
bool success = false;
const QList<QRgb> cmap = image.colorTable();
@@ -538,6 +561,10 @@ static bool do_write_jpeg_image(struct jpeg_compress_struct &cinfo,
cinfo.input_components = 1;
cinfo.in_color_space = JCS_GRAYSCALE;
break;
+ case QImage::Format_CMYK32:
+ cinfo.input_components = 4;
+ cinfo.in_color_space = JCS_CMYK;
+ break;
default:
cinfo.input_components = 3;
cinfo.in_color_space = JCS_RGB;
@@ -570,7 +597,7 @@ static bool do_write_jpeg_image(struct jpeg_compress_struct &cinfo,
jpeg_start_compress(&cinfo, TRUE);
set_text(image, &cinfo, description);
- if (cinfo.in_color_space == JCS_RGB)
+ if (cinfo.in_color_space == JCS_RGB || cinfo.in_color_space == JCS_CMYK)
write_icc_profile(image, &cinfo);
row_pointer[0] = new uchar[cinfo.image_width*cinfo.input_components];
@@ -654,6 +681,17 @@ static bool do_write_jpeg_image(struct jpeg_compress_struct &cinfo,
}
}
break;
+ case QImage::Format_CMYK32: {
+ auto *cmykIn = reinterpret_cast<const quint32 *>(image.constScanLine(cinfo.next_scanline));
+ auto *cmykOut = reinterpret_cast<quint32 *>(row);
+ if (invertCMYK) {
+ for (int i = 0; i < w; ++i)
+ cmykOut[i] = 0xffffffffu - cmykIn[i];
+ } else {
+ memcpy(cmykOut, cmykIn, w * 4);
+ }
+ break;
+ }
default:
{
// (Testing shows that this way is actually faster than converting to RGB888 + memcpy)
@@ -689,7 +727,8 @@ static bool write_jpeg_image(const QImage &image,
int sourceQuality,
const QString &description,
bool optimize,
- bool progressive)
+ bool progressive,
+ bool invertCMYK)
{
// protect these objects from the setjmp/longjmp pair inside
// do_write_jpeg_image (by making them non-local).
@@ -700,7 +739,7 @@ static bool write_jpeg_image(const QImage &image,
const bool success = do_write_jpeg_image(cinfo, row_pointer,
image, device,
sourceQuality, description,
- optimize, progressive);
+ optimize, progressive, invertCMYK);
delete [] row_pointer[0];
return success;
@@ -745,6 +784,21 @@ public:
QStringList readTexts;
QByteArray iccProfile;
+ // Photoshop historically invertes the quantities in CMYK JPEG files:
+ // 0 means 100% ink, 255 means no ink. Every reader does the same,
+ // for compatibility reasons.
+ // Use such an interpretation by default, but also offer the alternative
+ // of not inverting the channels.
+ // This is just a "fancy" API; it could be reduced to a boolean setting
+ // for CMYK files.
+ enum class SubType {
+ Automatic,
+ Inverted_CMYK,
+ CMYK,
+ NSubTypes
+ };
+ SubType subType = SubType::Automatic;
+
struct jpeg_decompress_struct info;
struct my_jpeg_source_mgr * iod_src;
struct my_error_mgr err;
@@ -759,6 +813,14 @@ public:
QJpegHandler *q;
};
+static const char SupportedJPEGSubtypes[][14] = {
+ "Automatic",
+ "Inverted_CMYK",
+ "CMYK"
+};
+
+static_assert(std::size(SupportedJPEGSubtypes) == size_t(QJpegHandlerPrivate::SubType::NSubTypes));
+
static bool readExifHeader(QDataStream &stream)
{
char prefix[6];
@@ -975,7 +1037,8 @@ bool QJpegHandlerPrivate::read(QImage *image)
if (state == ReadHeader)
{
- bool success = read_jpeg_image(image, scaledSize, scaledClipRect, clipRect, quality, rgb888ToRgb32ConverterPtr, &info, &err);
+ const bool invertCMYK = subType != QJpegHandlerPrivate::SubType::CMYK;
+ bool success = read_jpeg_image(image, scaledSize, scaledClipRect, clipRect, quality, rgb888ToRgb32ConverterPtr, &info, &err, invertCMYK);
if (success) {
for (int i = 0; i < readTexts.size()-1; i+=2)
image->setText(readTexts.at(i), readTexts.at(i+1));
@@ -1061,13 +1124,14 @@ extern void qt_imageTransform(QImage &src, QImageIOHandler::Transformations orie
bool QJpegHandler::write(const QImage &image)
{
+ const bool invertCMYK = d->subType != QJpegHandlerPrivate::SubType::CMYK;
if (d->transformation != QImageIOHandler::TransformationNone) {
// We don't support writing EXIF headers so apply the transform to the data.
QImage img = image;
qt_imageTransform(img, d->transformation);
- return write_jpeg_image(img, device(), d->quality, d->description, d->optimize, d->progressive);
+ return write_jpeg_image(img, device(), d->quality, d->description, d->optimize, d->progressive, invertCMYK);
}
- return write_jpeg_image(image, device(), d->quality, d->description, d->optimize, d->progressive);
+ return write_jpeg_image(image, device(), d->quality, d->description, d->optimize, d->progressive, invertCMYK);
}
bool QJpegHandler::supportsOption(ImageOption option) const
@@ -1078,6 +1142,8 @@ bool QJpegHandler::supportsOption(ImageOption option) const
|| option == ClipRect
|| option == Description
|| option == Size
+ || option == SubType
+ || option == SupportedSubTypes
|| option == ImageFormat
|| option == OptimizedWrite
|| option == ProgressiveScanWrite
@@ -1101,6 +1167,13 @@ QVariant QJpegHandler::option(ImageOption option) const
case Size:
d->readJpegHeader(device());
return d->size;
+ case SubType:
+ return QByteArray(SupportedJPEGSubtypes[int(d->subType)]);
+ case SupportedSubTypes: {
+ QByteArrayList list(std::begin(SupportedJPEGSubtypes),
+ std::end(SupportedJPEGSubtypes));
+ return QVariant::fromValue(list);
+ }
case ImageFormat:
d->readJpegHeader(device());
return d->format;
@@ -1136,6 +1209,16 @@ void QJpegHandler::setOption(ImageOption option, const QVariant &value)
case Description:
d->description = value.toString();
break;
+ case SubType: {
+ const QByteArray subType = value.toByteArray();
+ for (size_t i = 0; i < std::size(SupportedJPEGSubtypes); ++i) {
+ if (subType == SupportedJPEGSubtypes[i]) {
+ d->subType = QJpegHandlerPrivate::SubType(i);
+ break;
+ }
+ }
+ break;
+ }
case OptimizedWrite:
d->optimize = value.toBool();
break;
diff --git a/tests/auto/gui/image/qimagereader/tst_qimagereader.cpp b/tests/auto/gui/image/qimagereader/tst_qimagereader.cpp
index 6d875ec0ab..4ea874cec0 100644
--- a/tests/auto/gui/image/qimagereader/tst_qimagereader.cpp
+++ b/tests/auto/gui/image/qimagereader/tst_qimagereader.cpp
@@ -303,25 +303,52 @@ void tst_QImageReader::jpegRgbCmyk()
QImage image1(prefix + QLatin1String("YCbCr_cmyk.jpg"));
QImage image2(prefix + QLatin1String("YCbCr_cmyk.png"));
- if (image1 != image2) {
- // first, do some obvious tests
- QCOMPARE(image1.height(), image2.height());
- QCOMPARE(image1.width(), image2.width());
- QCOMPARE(image1.format(), image2.format());
- QCOMPARE(image1.format(), QImage::Format_RGB32);
-
- // compare all the pixels with a slack of 3. This ignores rounding errors
- // in libjpeg/libpng, where some versions sacrifice accuracy for speed.
- for (int h = 0; h < image1.height(); ++h) {
- const uchar *s1 = image1.constScanLine(h);
- const uchar *s2 = image2.constScanLine(h);
- for (int w = 0; w < image1.width() * 4; ++w) {
- if (*s1 != *s2) {
- QVERIFY2(qAbs(*s1 - *s2) <= 3, qPrintable(QString("images differ in line %1, col %2 (image1: %3, image2: %4)").arg(h).arg(w).arg(*s1, 0, 16).arg(*s2, 0, 16)));
- }
- s1++;
- s2++;
- }
+ QVERIFY(!image1.isNull());
+ QVERIFY(!image2.isNull());
+
+ QCOMPARE(image1.height(), image2.height());
+ QCOMPARE(image1.width(), image2.width());
+
+ QCOMPARE(image1.format(), QImage::Format_CMYK32);
+ QCOMPARE(image2.format(), QImage::Format_RGB32);
+
+ // compare all the pixels with a slack of 3. This ignores rounding errors
+ // in libjpeg/libpng, where some versions sacrifice accuracy for speed.
+ const auto fuzzyCompareColors = [](const QColor &c1, const QColor &c2) {
+ int c1rgba[4];
+ int c2rgba[4];
+
+ c1.getRgb(c1rgba + 0,
+ c1rgba + 1,
+ c1rgba + 2,
+ c1rgba + 3);
+
+ c2.getRgb(c2rgba + 0,
+ c2rgba + 1,
+ c2rgba + 2,
+ c2rgba + 3);
+
+ const auto fuzzyCompare = [](int a, int b) {
+ return qAbs(a - b) <= 3;
+ };
+
+ return fuzzyCompare(c1rgba[0], c2rgba[0]) &&
+ fuzzyCompare(c1rgba[1], c2rgba[1]) &&
+ fuzzyCompare(c1rgba[2], c2rgba[2]) &&
+ fuzzyCompare(c1rgba[3], c2rgba[3]);
+ };
+
+ for (int h = 0; h < image1.height(); ++h) {
+ const uchar *sl1 = image1.constScanLine(h);
+ const uchar *sl2 = image2.constScanLine(h);
+ for (int w = 0; w < image1.width(); ++w) {
+ const uchar *s1 = sl1 + w * 4;
+ const uchar *s2 = sl2 + w * 4;
+
+ QColor c1 = QColor::fromCmyk(s1[0], s1[1], s1[2], s1[3]);
+ QColor c2 = QColor::fromRgb(s2[2], s2[1], s2[0]);
+ QVERIFY2(fuzzyCompareColors(c1, c2),
+ qPrintable(QString("images differ in line %1, col %2").arg(h).arg(w)));
}
}
}
@@ -589,7 +616,7 @@ void tst_QImageReader::imageFormat_data()
QTest::newRow("ppm-4") << QString("test.ppm") << QByteArray("ppm") << QImage::Format_RGB32;
QTest::newRow("jpeg-1") << QString("beavis.jpg") << QByteArray("jpeg") << QImage::Format_Grayscale8;
- QTest::newRow("jpeg-2") << QString("YCbCr_cmyk.jpg") << QByteArray("jpeg") << QImage::Format_RGB32;
+ QTest::newRow("jpeg-2") << QString("YCbCr_cmyk.jpg") << QByteArray("jpeg") << QImage::Format_CMYK32;
QTest::newRow("jpeg-3") << QString("YCbCr_rgb.jpg") << QByteArray("jpeg") << QImage::Format_RGB32;
QTest::newRow("gif-1") << QString("earth.gif") << QByteArray("gif") << QImage::Format_Invalid;