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

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