diff --git a/README.md b/README.md index fd7a25c..38fa3c6 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ The following image formats have read-only support: - Krita (kra) - OpenRaster (ora) - Pixar raster (pxr) +- PlayStation graphics (tim) - Portable FloatMap/HalfMap (pfm, phm) - Photoshop documents (psd, psb, pdd, psdt) - Radiance HDR (hdr) @@ -250,6 +251,7 @@ limit depends on the format encoding). - RAW: 65,535 x 65,535 pixels - RGB: 65,535 x 65,535 pixels - SCT: 300,000 x 300,000 pixels +- TIM: 65,535 x 65,535 pixels - TGA: 65,535 x 65,535 pixels - XCF: 300,000 x 300,000 pixels diff --git a/autotests/CMakeLists.txt b/autotests/CMakeLists.txt index 145aea1..3fe173e 100644 --- a/autotests/CMakeLists.txt +++ b/autotests/CMakeLists.txt @@ -86,6 +86,7 @@ kimageformats_read_tests( ras rgb sct + tim tga ) diff --git a/autotests/ossfuzz/build_fuzzers.sh b/autotests/ossfuzz/build_fuzzers.sh index 37f9919..d9082a0 100755 --- a/autotests/ossfuzz/build_fuzzers.sh +++ b/autotests/ossfuzz/build_fuzzers.sh @@ -180,6 +180,7 @@ HANDLER_TYPES="ANIHandler ani RAWHandler raw RGBHandler rgb ScitexHandler sct + TIMHandler tim TGAHandler tga XCFHandler xcf" diff --git a/autotests/ossfuzz/kimgio_fuzzer.cc b/autotests/ossfuzz/kimgio_fuzzer.cc index 82d0f94..e3408fa 100644 --- a/autotests/ossfuzz/kimgio_fuzzer.cc +++ b/autotests/ossfuzz/kimgio_fuzzer.cc @@ -23,7 +23,7 @@ Usage: python infra/helper.py build_image kimageformats python infra/helper.py build_fuzzers --sanitizer undefined|address|memory kimageformats - python infra/helper.py run_fuzzer kimageformats kimgio_[ani|avif|dds|exr|hdr|heif|iff|jp2|jxl|jxr|kra|ora|pcx|pfm|pic|psd|pxr|qoi|ras|raw|rgb|sct|tga|xcf]_fuzzer + python infra/helper.py run_fuzzer kimageformats kimgio_[ani|avif|dds|exr|hdr|heif|iff|jp2|jxl|jxr|kra|ora|pcx|pfm|pic|psd|pxr|qoi|ras|raw|rgb|sct|tim|tga|xcf]_fuzzer */ #include @@ -52,6 +52,7 @@ #include "raw_p.h" #include "rgb_p.h" #include "sct_p.h" +#include "tim_p.h" #include "tga_p.h" #include "xcf_p.h" diff --git a/autotests/read/tim/testcard_idx4.png b/autotests/read/tim/testcard_idx4.png new file mode 100644 index 0000000..2e5c5a8 Binary files /dev/null and b/autotests/read/tim/testcard_idx4.png differ diff --git a/autotests/read/tim/testcard_idx4.tim b/autotests/read/tim/testcard_idx4.tim new file mode 100644 index 0000000..f1fcb80 Binary files /dev/null and b/autotests/read/tim/testcard_idx4.tim differ diff --git a/autotests/read/tim/testcard_idx8.png b/autotests/read/tim/testcard_idx8.png new file mode 100644 index 0000000..06efdae Binary files /dev/null and b/autotests/read/tim/testcard_idx8.png differ diff --git a/autotests/read/tim/testcard_idx8.tim b/autotests/read/tim/testcard_idx8.tim new file mode 100644 index 0000000..5227063 Binary files /dev/null and b/autotests/read/tim/testcard_idx8.tim differ diff --git a/autotests/read/tim/testcard_rgb16.png b/autotests/read/tim/testcard_rgb16.png new file mode 100644 index 0000000..486d22f Binary files /dev/null and b/autotests/read/tim/testcard_rgb16.png differ diff --git a/autotests/read/tim/testcard_rgb16.tim b/autotests/read/tim/testcard_rgb16.tim new file mode 100644 index 0000000..d6312cb Binary files /dev/null and b/autotests/read/tim/testcard_rgb16.tim differ diff --git a/autotests/read/tim/testcard_rgb24.png b/autotests/read/tim/testcard_rgb24.png new file mode 100644 index 0000000..7c6431c Binary files /dev/null and b/autotests/read/tim/testcard_rgb24.png differ diff --git a/autotests/read/tim/testcard_rgb24.tim b/autotests/read/tim/testcard_rgb24.tim new file mode 100644 index 0000000..8c6c7ea Binary files /dev/null and b/autotests/read/tim/testcard_rgb24.tim differ diff --git a/src/imageformats/CMakeLists.txt b/src/imageformats/CMakeLists.txt index e02347f..12cc318 100644 --- a/src/imageformats/CMakeLists.txt +++ b/src/imageformats/CMakeLists.txt @@ -137,6 +137,10 @@ kimageformats_add_plugin(kimg_sct SOURCES sct.cpp) ################################## +kimageformats_add_plugin(kimg_tim SOURCES tim.cpp) + +################################## + kimageformats_add_plugin(kimg_tga SOURCES tga.cpp microexif.cpp scanlineconverter.cpp) ################################## diff --git a/src/imageformats/tim.cpp b/src/imageformats/tim.cpp new file mode 100644 index 0000000..312886b --- /dev/null +++ b/src/imageformats/tim.cpp @@ -0,0 +1,398 @@ +/* + This file is part of the KDE project + SPDX-FileCopyrightText: 2026 Mirco Miranda + + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#include "tim_p.h" +#include "util_p.h" + +#include +#include +#include + +Q_DECLARE_LOGGING_CATEGORY(LOG_TIMPLUGIN) +Q_LOGGING_CATEGORY(LOG_TIMPLUGIN, "kf.imageformats.plugins.tim", QtWarningMsg) + +#define TYPE_4BPP 0 // never seen +#define TYPE_IDX_4BPP 8 +#define TYPE_8BPP 1 // never seen +#define TYPE_IDX_8BPP 9 +#define TYPE_16BPP 2 +#define TYPE_24BPP 3 + +#define HEADER_SIZE 20 + +class TIMHeader +{ +private: + QByteArray m_rawHeader; + + quint16 ui16(quint8 c1, quint8 c2) const { + return (quint16(c2) << 8) | quint16(c1); + } + + quint32 ui32(quint8 c1, quint8 c2, quint8 c3, quint8 c4) const { + return (quint32(c4) << 24) | (quint32(c3) << 16) | (quint32(c2) << 8) | quint32(c1); + } + +public: + TIMHeader() + { + + } + + quint32 type() const + { + if (m_rawHeader.size() < HEADER_SIZE) { + return 0; + } + return ui32(m_rawHeader.at(4), m_rawHeader.at(5), m_rawHeader.at(6), m_rawHeader.at(7)) & 0xF; + } + + quint32 offset() const + { + if (m_rawHeader.size() < HEADER_SIZE) { + return 0; + } + auto o = quint32(HEADER_SIZE); + auto t = type(); + if (t == TYPE_IDX_4BPP || t == TYPE_IDX_8BPP) { // indexed + o += ui32(m_rawHeader.at(8), m_rawHeader.at(9), m_rawHeader.at(10), m_rawHeader.at(11)); + } + return o; + } + + bool isValid(quint32 size = 0) const + { + if (m_rawHeader.size() < HEADER_SIZE) { + return false; + } + if (size == 0) { + size = offset(); + } + if (m_rawHeader.size() < size) { + return false; + } + return (m_rawHeader.startsWith(QByteArray::fromRawData("\x10\x00\x00\x00", 4))); + } + + bool isSupported() const + { + return format() != QImage::Format_Invalid; + } + + qint32 width() const + { + auto strideLen = strideSize(); + auto t = type(); + if (t == TYPE_4BPP || t == TYPE_IDX_4BPP) { + return strideLen * 2; + } + if (t == TYPE_8BPP || t == TYPE_IDX_8BPP) { + return strideLen; + } + if (t == TYPE_24BPP) { + return strideLen / 3; + } + return strideLen / 2; + } + + qint32 height() const + { + auto o = offset(); + if (!isValid(o)) { + return 0; + } + return qint32(ui16(m_rawHeader.at(o - 2), m_rawHeader.at(o - 1))); + } + + QSize size() const + { + return QSize(width(), height()); + } + + QImage::Format format() const + { + auto t = type(); + if (t == TYPE_IDX_4BPP || t == TYPE_IDX_8BPP || t == TYPE_4BPP) { + return QImage::Format_Indexed8; + } + if (t == TYPE_IDX_8BPP) { + return QImage::Format_Grayscale8; + } + if (t == TYPE_16BPP) { + return QImage::Format_RGB555; + } + if (t == TYPE_24BPP) { + return QImage::Format_RGB888; + } + return QImage::Format_Invalid; + } + + quint32 strideSize() const + { + auto o = offset(); + if (!isValid(o)) { + return 0; + } + return ui16(m_rawHeader.at(o - 4), m_rawHeader.at(o - 3)) * 2; + } + + qint32 paletteColors() const + { + if (this->format() != QImage::Format_Indexed8) { + return 0; + } + return qint32(ui16(m_rawHeader.at(16), m_rawHeader.at(17))); + } + + qint32 paletteCount() const + { + if (this->format() != QImage::Format_Indexed8) { + return 0; + } + return qint32(ui16(m_rawHeader.at(18), m_rawHeader.at(19))); + } + + QList palette() const + { + if (format() != QImage::Format_Indexed8) { + return {}; + } + + // 4bpp without CLUT is treated as indexed + if (type() == TYPE_4BPP) { + QList pal; + for (auto i = 0; i < 16; ++i) { + auto v = i * 17; + pal << qRgb(v, v, v); + } + return pal; + } + + // read the first paette only + auto len = paletteColors(); + if (!isValid(HEADER_SIZE + len * 2)) { + return {}; + } + QList clut; + for (auto i = 0; i < len; ++i) { + auto v = ui16(m_rawHeader.at(HEADER_SIZE + i * 2), m_rawHeader.at(HEADER_SIZE + i * 2 + 1)); + // in some specs, the bit 15 is the alpha but with the image sample used, transparencies appear + // where there shouldn't be any (so, disabled for now) + clut << qRgba((v & 0x1F) * 255 / 31, ((v >> 5) & 0x1F) * 255 / 31, ((v >> 10) & 0x1F) * 255 / 31, 255); + } + return clut; + } + + bool read(QIODevice *d) + { + m_rawHeader = d->read(HEADER_SIZE); + if (m_rawHeader.size() != HEADER_SIZE) { + return false; + } + auto o = offset() - HEADER_SIZE; + if (o > kMaxQVectorSize - HEADER_SIZE) { + return false; + } + m_rawHeader.append(d->read(o)); + return isValid(); + } + + bool peek(QIODevice *d) + { + m_rawHeader = d->peek(HEADER_SIZE); + if (m_rawHeader.size() != HEADER_SIZE) { + return false; + } + auto o = offset(); + if (o > kMaxQVectorSize - HEADER_SIZE) { + return false; + } + if (o > m_rawHeader.size()) { + m_rawHeader = d->peek(o); + } + return isValid(); + } + + bool jumpToImageData(QIODevice *d) const + { + if (d->isSequential()) { + if (auto sz = std::max(offset() - quint32(m_rawHeader.size()), quint32())) { + return d->read(sz).size() == sz; + } + return true; + } + return d->seek(offset()); + } +}; + +class TIMHandlerPrivate +{ +public: + TIMHandlerPrivate() {} + ~TIMHandlerPrivate() {} + + TIMHeader m_header; +}; + +TIMHandler::TIMHandler() + : QImageIOHandler() + , d(new TIMHandlerPrivate) +{ +} + +bool TIMHandler::canRead() const +{ + if (canRead(device())) { + setFormat("tim"); + return true; + } + return false; +} + +bool TIMHandler::canRead(QIODevice *device) +{ + if (!device) { + qCWarning(LOG_TIMPLUGIN) << "TIMHandler::canRead() called with no device"; + return false; + } + + TIMHeader h; + if (!h.peek(device)) { + return false; + } + + return h.isSupported(); +} + +bool TIMHandler::read(QImage *image) +{ + auto&& header = d->m_header; + + if (!header.read(device())) { + qCWarning(LOG_TIMPLUGIN) << "TIMHandler::read() invalid header"; + return false; + } + + auto img = imageAlloc(header.size(), header.format()); + if (img.isNull()) { + qCWarning(LOG_TIMPLUGIN) << "TIMHandler::read() error while allocating the image"; + return false; + } + + if (img.format() == QImage::Format_Indexed8) { + auto pal = header.palette(); + if (pal.isEmpty()) { + qCWarning(LOG_TIMPLUGIN) << "TIMHandler::read() error while reading the palette"; + return false; + } + img.setColorTable(pal); + } + + auto d = device(); + if (!header.jumpToImageData(d)) { + qCWarning(LOG_TIMPLUGIN) << "TIMHandler::read() error while seeking image data"; + return false; + } + + auto size = std::min(img.bytesPerLine(), qsizetype(header.strideSize())); + QByteArray tmpBuff; + auto conv_4bpp = (header.type() == TYPE_4BPP || header.type() == TYPE_IDX_4BPP); + if (conv_4bpp && size * 2 <= img.bytesPerLine()) { + tmpBuff.resize(size); + } + for (auto y = 0, h = img.height(); y < h; ++y) { + auto line = reinterpret_cast(img.scanLine(y)); + auto tbuf = tmpBuff.isEmpty() ? line : tmpBuff.data(); + if (d->read(tbuf, size) != size) { + qCWarning(LOG_TIMPLUGIN) << "TIMHandler::read() error while reading image scanline"; + return false; + } + if (conv_4bpp) { + for (auto x = 0, w = qint32(tmpBuff.size()); x < w; ++x) { + auto &&v = tmpBuff.at(x); + line[x * 2 + 1] = (v >> 4) & 0xF; + line[x * 2] = v & 0xF; + } + } + } + + if (img.format() == QImage::Format_RGB555) { + img.rgbSwap(); + } + + *image = img; + return true; +} + +bool TIMHandler::supportsOption(ImageOption option) const +{ + if (option == QImageIOHandler::Size) { + return true; + } + if (option == QImageIOHandler::ImageFormat) { + return true; + } + return false; +} + +QVariant TIMHandler::option(ImageOption option) const +{ + QVariant v; + + if (option == QImageIOHandler::Size) { + auto&& h = d->m_header; + if (h.isValid()) { + v = QVariant::fromValue(h.size()); + } else if (auto d = device()) { + if (h.peek(d)) { + v = QVariant::fromValue(h.size()); + } + } + } + + if (option == QImageIOHandler::ImageFormat) { + auto&& h = d->m_header; + if (h.isValid()) { + v = QVariant::fromValue(h.format()); + } else if (auto d = device()) { + if (h.peek(d)) { + v = QVariant::fromValue(h.format()); + } + } + } + + return v; +} + +QImageIOPlugin::Capabilities TIMPlugin::capabilities(QIODevice *device, const QByteArray &format) const +{ + if (format == "tim") { + return Capabilities(CanRead); + } + if (!format.isEmpty()) { + return {}; + } + if (!device->isOpen()) { + return {}; + } + + Capabilities cap; + if (device->isReadable() && TIMHandler::canRead(device)) { + cap |= CanRead; + } + return cap; +} + +QImageIOHandler *TIMPlugin::create(QIODevice *device, const QByteArray &format) const +{ + QImageIOHandler *handler = new TIMHandler; + handler->setDevice(device); + handler->setFormat(format); + return handler; +} + +#include "moc_tim_p.cpp" diff --git a/src/imageformats/tim.json b/src/imageformats/tim.json new file mode 100644 index 0000000..04371a9 --- /dev/null +++ b/src/imageformats/tim.json @@ -0,0 +1,4 @@ +{ + "Keys": [ "tim" ], + "MimeTypes": [ "image/x-tim" ] +} diff --git a/src/imageformats/tim_p.h b/src/imageformats/tim_p.h new file mode 100644 index 0000000..2494f86 --- /dev/null +++ b/src/imageformats/tim_p.h @@ -0,0 +1,42 @@ +/* + This file is part of the KDE project + SPDX-FileCopyrightText: 2026 Mirco Miranda + + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#ifndef KIMG_TIM_P_H +#define KIMG_TIM_P_H + +#include +#include + +class TIMHandlerPrivate; +class TIMHandler : public QImageIOHandler +{ +public: + TIMHandler(); + + bool canRead() const override; + bool read(QImage *image) override; + + bool supportsOption(QImageIOHandler::ImageOption option) const override; + QVariant option(QImageIOHandler::ImageOption option) const override; + + static bool canRead(QIODevice *device); + +private: + const QScopedPointer d; +}; + +class TIMPlugin : public QImageIOPlugin +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID "org.qt-project.Qt.QImageIOHandlerFactoryInterface" FILE "tim.json") + +public: + Capabilities capabilities(QIODevice *device, const QByteArray &format) const override; + QImageIOHandler *create(QIODevice *device, const QByteArray &format = QByteArray()) const override; +}; + +#endif // KIMG_TIM_P_H