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

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