1/*
2 This file is part of the KDE project
3 SPDX-FileCopyrightText: 2023 Ernest Gupik <ernestgupik@wp.pl>
4 SPDX-FileCopyrightText: 2023 Mirco Miranda <mircomir@outlook.com>
5
6 SPDX-License-Identifier: LGPL-2.0-or-later
7*/
8
9#include "qoi_p.h"
10#include "scanlineconverter_p.h"
11#include "util_p.h"
12
13#include <QColorSpace>
14#include <QFile>
15#include <QIODevice>
16#include <QImage>
17#include <QLoggingCategory>
18
19#ifdef QT_DEBUG
20Q_LOGGING_CATEGORY(LOG_QOIPLUGIN, "kf.imageformats.plugins.qoi", QtDebugMsg)
21#else
22Q_LOGGING_CATEGORY(LOG_QOIPLUGIN, "kf.imageformats.plugins.qoi", QtWarningMsg)
23#endif
24
25/* *** QOI_MAX_IMAGE_WIDTH and QOI_MAX_IMAGE_HEIGHT ***
26 * The maximum size in pixel allowed by the plugin.
27 */
28#ifndef QOI_MAX_IMAGE_WIDTH
29#define QOI_MAX_IMAGE_WIDTH KIF_LARGE_IMAGE_PIXEL_LIMIT
30#endif
31#ifndef QOI_MAX_IMAGE_HEIGHT
32#define QOI_MAX_IMAGE_HEIGHT QOI_MAX_IMAGE_WIDTH
33#endif
34
35namespace // Private
36{
37
38#define QOI_OP_INDEX 0x00 /* 00xxxxxx */
39#define QOI_OP_DIFF 0x40 /* 01xxxxxx */
40#define QOI_OP_LUMA 0x80 /* 10xxxxxx */
41#define QOI_OP_RUN 0xc0 /* 11xxxxxx */
42#define QOI_OP_RGB 0xfe /* 11111110 */
43#define QOI_OP_RGBA 0xff /* 11111111 */
44#define QOI_MASK_2 0xc0 /* 11000000 */
45
46#define QOI_MAGIC (((unsigned int)'q') << 24 | ((unsigned int)'o') << 16 | ((unsigned int)'i') << 8 | ((unsigned int)'f'))
47#define QOI_HEADER_SIZE 14
48#define QOI_END_STREAM_PAD 8
49
50struct QoiHeader {
51 QoiHeader()
52 : MagicNumber(0)
53 , Width(0)
54 , Height(0)
55 , Channels(0)
56 , Colorspace(2)
57 {
58 }
59
60 QoiHeader(const QoiHeader&) = default;
61 QoiHeader& operator=(const QoiHeader&) = default;
62
63 quint32 MagicNumber;
64 quint32 Width;
65 quint32 Height;
66 quint8 Channels;
67 quint8 Colorspace;
68};
69
70struct Px {
71 bool operator==(const Px &other) const
72 {
73 return r == other.r && g == other.g && b == other.b && a == other.a;
74 }
75 quint8 r;
76 quint8 g;
77 quint8 b;
78 quint8 a;
79};
80
81static QDataStream &operator>>(QDataStream &s, QoiHeader &head)
82{
83 s >> head.MagicNumber;
84 s >> head.Width;
85 s >> head.Height;
86 s >> head.Channels;
87 s >> head.Colorspace;
88 return s;
89}
90
91static QDataStream &operator<<(QDataStream &s, const QoiHeader &head)
92{
93 s << head.MagicNumber;
94 s << head.Width;
95 s << head.Height;
96 s << head.Channels;
97 s << head.Colorspace;
98 return s;
99}
100
101static bool IsSupported(const QoiHeader &head)
102{
103 // Check magic number
104 if (head.MagicNumber != QOI_MAGIC) {
105 return false;
106 }
107 // Check if the header is a valid QOI header
108 if (head.Width == 0 || head.Height == 0 || head.Channels < 3 || head.Colorspace > 1) {
109 return false;
110 }
111 // Set a reasonable upper limit
112 if (head.Width > QOI_MAX_IMAGE_WIDTH || head.Height > QOI_MAX_IMAGE_HEIGHT) {
113 return false;
114 }
115 return true;
116}
117
118static int QoiHash(const Px &px)
119{
120 return px.r * 3 + px.g * 5 + px.b * 7 + px.a * 11;
121}
122
123static QImage::Format imageFormat(const QoiHeader &head)
124{
125 if (IsSupported(head)) {
126 return (head.Channels == 3 ? QImage::Format_RGB32 : QImage::Format_ARGB32);
127 }
128 return QImage::Format_Invalid;
129}
130
131static bool LoadQOI(QIODevice *device, const QoiHeader &qoi, QImage &img)
132{
133 Px index[64] = {Px{.r: 0, .g: 0, .b: 0, .a: 0}};
134 Px px = Px{.r: 0, .g: 0, .b: 0, .a: 255};
135
136 // The px_len should be enough to read a complete "compressed" row: an uncompressible row can become
137 // larger than the row itself. It should never be more than 1/3 (RGB) or 1/4 (RGBA) the length of the
138 // row itself (see test bnm_rgb*.qoi) so I set the extra data to 1/2.
139 // The minimum value is to ensure that enough bytes are read when the image is very small (e.g. 1x1px):
140 // it can be set as large as you like.
141 quint64 px_len = std::max(a: quint64(1024), b: quint64(qoi.Width) * qoi.Channels * 3 / 2);
142 if (px_len > kMaxQVectorSize) {
143 return false;
144 }
145
146 // Allocate image
147 img = imageAlloc(width: qoi.Width, height: qoi.Height, format: imageFormat(head: qoi));
148 if (img.isNull()) {
149 return false;
150 }
151
152 // Set the image colorspace based on the qoi.Colorspace value
153 // As per specification: 0 = sRGB with linear alpha, 1 = all channels linear
154 if (qoi.Colorspace) {
155 img.setColorSpace(QColorSpace(QColorSpace::SRgbLinear));
156 } else {
157 img.setColorSpace(QColorSpace(QColorSpace::SRgb));
158 }
159
160 // Handle the byte stream
161 QByteArray ba;
162 for (quint32 y = 0, run = 0; y < qoi.Height; ++y) {
163 if (quint64(ba.size()) < px_len && !device->atEnd()) {
164 ba.append(a: device->read(maxlen: px_len));
165 }
166
167 if (ba.size() < QOI_END_STREAM_PAD) {
168 return false;
169 }
170
171 quint64 chunks_len = ba.size() - QOI_END_STREAM_PAD;
172 quint64 p = 0;
173 QRgb *scanline = reinterpret_cast<QRgb *>(img.scanLine(y));
174 const quint8 *input = reinterpret_cast<const quint8 *>(ba.constData());
175 for (quint32 x = 0; x < qoi.Width; ++x) {
176 if (run > 0) {
177 run--;
178 } else if (p < chunks_len) {
179 quint32 b1 = input[p++];
180
181 if (b1 == QOI_OP_RGB) {
182 px.r = input[p++];
183 px.g = input[p++];
184 px.b = input[p++];
185 } else if (b1 == QOI_OP_RGBA) {
186 px.r = input[p++];
187 px.g = input[p++];
188 px.b = input[p++];
189 px.a = input[p++];
190 } else if ((b1 & QOI_MASK_2) == QOI_OP_INDEX) {
191 px = index[b1];
192 } else if ((b1 & QOI_MASK_2) == QOI_OP_DIFF) {
193 px.r += ((b1 >> 4) & 0x03) - 2;
194 px.g += ((b1 >> 2) & 0x03) - 2;
195 px.b += (b1 & 0x03) - 2;
196 } else if ((b1 & QOI_MASK_2) == QOI_OP_LUMA) {
197 quint32 b2 = input[p++];
198 quint32 vg = (b1 & 0x3f) - 32;
199 px.r += vg - 8 + ((b2 >> 4) & 0x0f);
200 px.g += vg;
201 px.b += vg - 8 + (b2 & 0x0f);
202 } else if ((b1 & QOI_MASK_2) == QOI_OP_RUN) {
203 run = (b1 & 0x3f);
204 }
205 index[QoiHash(px) & 0x3F] = px;
206 }
207 // Set the values for the pixel at (x, y)
208 scanline[x] = qRgba(r: px.r, g: px.g, b: px.b, a: px.a);
209 }
210
211 if (p) {
212 ba.remove(index: 0, len: p);
213 }
214 }
215
216 // From specs the byte stream's end is marked with 7 0x00 bytes followed by a single 0x01 byte.
217 // NOTE: Instead of using "ba == QByteArray::fromRawData("\x00\x00\x00\x00\x00\x00\x00\x01", 8)"
218 // we preferred a generic check that allows data to exist after the end of the file.
219 return (ba.startsWith(bv: QByteArray::fromRawData(data: "\x00\x00\x00\x00\x00\x00\x00\x01", size: 8)));
220}
221
222static bool SaveQOI(QIODevice *device, const QoiHeader &qoi, const QImage &img)
223{
224 Px index[64] = {Px{.r: 0, .g: 0, .b: 0, .a: 0}};
225 Px px = Px{.r: 0, .g: 0, .b: 0, .a: 255};
226 Px px_prev = px;
227
228 auto run = 0;
229 auto channels = qoi.Channels;
230
231 QByteArray ba;
232 ba.reserve(asize: img.width() * channels * 3 / 2);
233
234 ScanLineConverter converter(channels == 3 ? QImage::Format_RGB888 : QImage::Format_RGBA8888);
235 converter.setTargetColorSpace(QColorSpace(qoi.Colorspace == 1 ? QColorSpace::SRgbLinear : QColorSpace::SRgb));
236
237 for (auto h = img.height(), y = 0; y < h; ++y) {
238 auto pixels = converter.convertedScanLine(image: img, y);
239 if (pixels == nullptr) {
240 return false;
241 }
242
243 for (auto w = img.width() * channels, px_pos = 0; px_pos < w; px_pos += channels) {
244 px.r = pixels[px_pos + 0];
245 px.g = pixels[px_pos + 1];
246 px.b = pixels[px_pos + 2];
247
248 if (channels == 4) {
249 px.a = pixels[px_pos + 3];
250 }
251
252 if (px == px_prev) {
253 run++;
254 if (run == 62 || (px_pos == w - channels && y == h - 1)) {
255 ba.append(QOI_OP_RUN | (run - 1));
256 run = 0;
257 }
258 } else {
259 int index_pos;
260
261 if (run > 0) {
262 ba.append(QOI_OP_RUN | (run - 1));
263 run = 0;
264 }
265
266 index_pos = QoiHash(px) & 0x3F;
267
268 if (index[index_pos] == px) {
269 ba.append(QOI_OP_INDEX | index_pos);
270 } else {
271 index[index_pos] = px;
272
273 if (px.a == px_prev.a) {
274 signed char vr = px.r - px_prev.r;
275 signed char vg = px.g - px_prev.g;
276 signed char vb = px.b - px_prev.b;
277
278 signed char vg_r = vr - vg;
279 signed char vg_b = vb - vg;
280
281 if (vr > -3 && vr < 2 && vg > -3 && vg < 2 && vb > -3 && vb < 2) {
282 ba.append(QOI_OP_DIFF | (vr + 2) << 4 | (vg + 2) << 2 | (vb + 2));
283 } else if (vg_r > -9 && vg_r < 8 && vg > -33 && vg < 32 && vg_b > -9 && vg_b < 8) {
284 ba.append(QOI_OP_LUMA | (vg + 32));
285 ba.append(c: (vg_r + 8) << 4 | (vg_b + 8));
286 } else {
287 ba.append(c: char(QOI_OP_RGB));
288 ba.append(c: px.r);
289 ba.append(c: px.g);
290 ba.append(c: px.b);
291 }
292 } else {
293 ba.append(c: char(QOI_OP_RGBA));
294 ba.append(c: px.r);
295 ba.append(c: px.g);
296 ba.append(c: px.b);
297 ba.append(c: px.a);
298 }
299 }
300 }
301 px_prev = px;
302 }
303
304 auto written = device->write(data: ba);
305 if (written < 0) {
306 return false;
307 }
308 if (written) {
309 ba.remove(index: 0, len: written);
310 }
311 }
312
313 // QOI end of stream
314 ba.append(a: QByteArray::fromRawData(data: "\x00\x00\x00\x00\x00\x00\x00\x01", size: 8));
315
316 // write remaining data
317 for (qint64 w = 0, write = 0, size = ba.size(); write < size; write += w) {
318 w = device->write(data: ba.constData() + write, len: size - write);
319 if (w < 0) {
320 return false;
321 }
322 }
323
324 return true;
325}
326
327} // namespace
328
329class QOIHandlerPrivate
330{
331public:
332 QOIHandlerPrivate() {}
333 ~QOIHandlerPrivate() {}
334
335 QoiHeader m_header;
336};
337
338
339QOIHandler::QOIHandler()
340 : QImageIOHandler()
341 , d(new QOIHandlerPrivate)
342{
343}
344
345bool QOIHandler::canRead() const
346{
347 if (canRead(device: device())) {
348 setFormat("qoi");
349 return true;
350 }
351 return false;
352}
353
354bool QOIHandler::canRead(QIODevice *device)
355{
356 if (!device) {
357 qCWarning(LOG_QOIPLUGIN) << "QOIHandler::canRead() called with no device";
358 return false;
359 }
360
361 auto head = device->peek(QOI_HEADER_SIZE);
362 if (head.size() < QOI_HEADER_SIZE) {
363 return false;
364 }
365
366 QDataStream stream(head);
367 stream.setByteOrder(QDataStream::BigEndian);
368 QoiHeader qoi;
369 stream >> qoi;
370
371 return IsSupported(head: qoi);
372}
373
374bool QOIHandler::read(QImage *image)
375{
376 QDataStream s(device());
377 s.setByteOrder(QDataStream::BigEndian);
378
379 // Read image header
380 auto&& qoi = d->m_header;
381 s >> qoi;
382
383 // Check if file is supported
384 if (!IsSupported(head: qoi)) {
385 return false;
386 }
387
388 QImage img;
389 bool result = LoadQOI(device: s.device(), qoi, img);
390
391 if (result == false) {
392 return false;
393 }
394
395 *image = img;
396 return true;
397}
398
399bool QOIHandler::write(const QImage &image)
400{
401 if (image.isNull()) {
402 return false;
403 }
404
405 QoiHeader qoi;
406 qoi.MagicNumber = QOI_MAGIC;
407 qoi.Width = image.width();
408 qoi.Height = image.height();
409 qoi.Channels = image.hasAlphaChannel() ? 4 : 3;
410 qoi.Colorspace = image.colorSpace().transferFunction() == QColorSpace::TransferFunction::Linear ? 1 : 0;
411
412 if (!IsSupported(head: qoi)) {
413 return false;
414 }
415
416 QDataStream s(device());
417 s.setByteOrder(QDataStream::BigEndian);
418 s << qoi;
419 if (s.status() != QDataStream::Ok) {
420 return false;
421 }
422
423 return SaveQOI(device: s.device(), qoi, img: image);
424}
425
426bool QOIHandler::supportsOption(ImageOption option) const
427{
428 if (option == QImageIOHandler::Size) {
429 return true;
430 }
431 if (option == QImageIOHandler::ImageFormat) {
432 return true;
433 }
434 return false;
435}
436
437QVariant QOIHandler::option(ImageOption option) const
438{
439 QVariant v;
440
441 if (option == QImageIOHandler::Size) {
442 auto&& header = d->m_header;
443 if (IsSupported(head: header)) {
444 v = QVariant::fromValue(value: QSize(header.Width, header.Height));
445 } else if (auto d = device()) {
446 QDataStream s(d->peek(maxlen: sizeof(QoiHeader)));
447 s.setByteOrder(QDataStream::BigEndian);
448 s >> header;
449 if (s.status() == QDataStream::Ok && IsSupported(head: header)) {
450 v = QVariant::fromValue(value: QSize(header.Width, header.Height));
451 }
452 }
453 }
454
455 if (option == QImageIOHandler::ImageFormat) {
456 auto&& header = d->m_header;
457 if (IsSupported(head: header)) {
458 v = QVariant::fromValue(value: imageFormat(head: header));
459 } else if (auto d = device()) {
460 QDataStream s(d->peek(maxlen: sizeof(QoiHeader)));
461 s.setByteOrder(QDataStream::BigEndian);
462 s >> header;
463 if (s.status() == QDataStream::Ok && IsSupported(head: header)) {
464 v = QVariant::fromValue(value: imageFormat(head: header));
465 }
466 }
467 }
468
469 return v;
470}
471
472QImageIOPlugin::Capabilities QOIPlugin::capabilities(QIODevice *device, const QByteArray &format) const
473{
474 if (format == "qoi" || format == "QOI") {
475 return Capabilities(CanRead | CanWrite);
476 }
477 if (!format.isEmpty()) {
478 return {};
479 }
480 if (!device->isOpen()) {
481 return {};
482 }
483
484 Capabilities cap;
485 if (device->isReadable() && QOIHandler::canRead(device)) {
486 cap |= CanRead;
487 }
488 if (device->isWritable()) {
489 cap |= CanWrite;
490 }
491 return cap;
492}
493
494QImageIOHandler *QOIPlugin::create(QIODevice *device, const QByteArray &format) const
495{
496 QImageIOHandler *handler = new QOIHandler;
497 handler->setDevice(device);
498 handler->setFormat(format);
499 return handler;
500}
501
502#include "moc_qoi_p.cpp"
503

source code of kimageformats/src/imageformats/qoi.cpp