| 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 |
| 20 | Q_LOGGING_CATEGORY(LOG_QOIPLUGIN, "kf.imageformats.plugins.qoi" , QtDebugMsg) |
| 21 | #else |
| 22 | Q_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 | |
| 35 | namespace // 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 14 |
| 48 | #define QOI_END_STREAM_PAD 8 |
| 49 | |
| 50 | struct { |
| 51 | () |
| 52 | : MagicNumber(0) |
| 53 | , Width(0) |
| 54 | , Height(0) |
| 55 | , Channels(0) |
| 56 | , Colorspace(2) |
| 57 | { |
| 58 | } |
| 59 | |
| 60 | (const QoiHeader&) = default; |
| 61 | QoiHeader& (const QoiHeader&) = default; |
| 62 | |
| 63 | quint32 ; |
| 64 | quint32 ; |
| 65 | quint32 ; |
| 66 | quint8 ; |
| 67 | quint8 ; |
| 68 | }; |
| 69 | |
| 70 | struct 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 | |
| 81 | static QDataStream &(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 | |
| 91 | static QDataStream &(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 | |
| 101 | static bool (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 | |
| 118 | static int QoiHash(const Px &px) |
| 119 | { |
| 120 | return px.r * 3 + px.g * 5 + px.b * 7 + px.a * 11; |
| 121 | } |
| 122 | |
| 123 | static QImage::Format (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 | |
| 131 | static bool (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 | |
| 222 | static bool (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 | |
| 329 | class QOIHandlerPrivate |
| 330 | { |
| 331 | public: |
| 332 | QOIHandlerPrivate() {} |
| 333 | ~QOIHandlerPrivate() {} |
| 334 | |
| 335 | QoiHeader m_header; |
| 336 | }; |
| 337 | |
| 338 | |
| 339 | QOIHandler::QOIHandler() |
| 340 | : QImageIOHandler() |
| 341 | , d(new QOIHandlerPrivate) |
| 342 | { |
| 343 | } |
| 344 | |
| 345 | bool QOIHandler::canRead() const |
| 346 | { |
| 347 | if (canRead(device: device())) { |
| 348 | setFormat("qoi" ); |
| 349 | return true; |
| 350 | } |
| 351 | return false; |
| 352 | } |
| 353 | |
| 354 | bool 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 | |
| 374 | bool 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 | |
| 399 | bool 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 | |
| 426 | bool 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 | |
| 437 | QVariant QOIHandler::option(ImageOption option) const |
| 438 | { |
| 439 | QVariant v; |
| 440 | |
| 441 | if (option == QImageIOHandler::Size) { |
| 442 | auto&& = 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&& = 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 | |
| 472 | QImageIOPlugin::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 | |
| 494 | QImageIOHandler *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 | |