| 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 | |
| 18 | namespace // 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 14 |
| 31 | #define QOI_END_STREAM_PAD 8 |
| 32 | |
| 33 | struct { |
| 34 | () |
| 35 | : MagicNumber(0) |
| 36 | , Width(0) |
| 37 | , Height(0) |
| 38 | , Channels(0) |
| 39 | , Colorspace(2) |
| 40 | { |
| 41 | } |
| 42 | |
| 43 | (const QoiHeader&) = default; |
| 44 | QoiHeader& (const QoiHeader&) = default; |
| 45 | |
| 46 | quint32 ; |
| 47 | quint32 ; |
| 48 | quint32 ; |
| 49 | quint8 ; |
| 50 | quint8 ; |
| 51 | }; |
| 52 | |
| 53 | struct 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 | |
| 64 | static QDataStream &(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 | |
| 74 | static QDataStream &(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 | |
| 84 | static bool (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 | |
| 101 | static int QoiHash(const Px &px) |
| 102 | { |
| 103 | return px.r * 3 + px.g * 5 + px.b * 7 + px.a * 11; |
| 104 | } |
| 105 | |
| 106 | static QImage::Format (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 | |
| 114 | static bool (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 | |
| 205 | static bool (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 | |
| 312 | class QOIHandlerPrivate |
| 313 | { |
| 314 | public: |
| 315 | QOIHandlerPrivate() {} |
| 316 | ~QOIHandlerPrivate() {} |
| 317 | |
| 318 | QoiHeader m_header; |
| 319 | }; |
| 320 | |
| 321 | |
| 322 | QOIHandler::QOIHandler() |
| 323 | : QImageIOHandler() |
| 324 | , d(new QOIHandlerPrivate) |
| 325 | { |
| 326 | } |
| 327 | |
| 328 | bool QOIHandler::canRead() const |
| 329 | { |
| 330 | if (canRead(device: device())) { |
| 331 | setFormat("qoi" ); |
| 332 | return true; |
| 333 | } |
| 334 | return false; |
| 335 | } |
| 336 | |
| 337 | bool 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 | |
| 357 | bool 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 | |
| 382 | bool 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 | |
| 409 | bool 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 | |
| 420 | QVariant QOIHandler::option(ImageOption option) const |
| 421 | { |
| 422 | QVariant v; |
| 423 | |
| 424 | if (option == QImageIOHandler::Size) { |
| 425 | auto&& = 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&& = 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 | |
| 455 | QImageIOPlugin::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 | |
| 477 | QImageIOHandler *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 | |