diff --git a/autotests/CMakeLists.txt b/autotests/CMakeLists.txt index 145aea1..2824f5a 100644 --- a/autotests/CMakeLists.txt +++ b/autotests/CMakeLists.txt @@ -247,3 +247,8 @@ add_executable(anitest anitest.cpp) target_link_libraries(anitest Qt6::Gui Qt6::Test) ecm_mark_as_test(anitest) add_test(NAME kimageformats-ani COMMAND anitest) + +add_executable(xcursortest xcursortest.cpp) +target_link_libraries(xcursortest Qt6::Gui Qt6::Test) +ecm_mark_as_test(xcursortest) +add_test(NAME kimageformats-xcursortest COMMAND xcursortest) diff --git a/autotests/xcursor/wait b/autotests/xcursor/wait new file mode 100644 index 0000000..556453a Binary files /dev/null and b/autotests/xcursor/wait differ diff --git a/autotests/xcursor/wait_24_1.png b/autotests/xcursor/wait_24_1.png new file mode 100644 index 0000000..d0b4762 Binary files /dev/null and b/autotests/xcursor/wait_24_1.png differ diff --git a/autotests/xcursor/wait_24_2.png b/autotests/xcursor/wait_24_2.png new file mode 100644 index 0000000..4d2ebe3 Binary files /dev/null and b/autotests/xcursor/wait_24_2.png differ diff --git a/autotests/xcursor/wait_24_3.png b/autotests/xcursor/wait_24_3.png new file mode 100644 index 0000000..8d8e85f Binary files /dev/null and b/autotests/xcursor/wait_24_3.png differ diff --git a/autotests/xcursor/wait_48_1.png b/autotests/xcursor/wait_48_1.png new file mode 100644 index 0000000..71d3eb8 Binary files /dev/null and b/autotests/xcursor/wait_48_1.png differ diff --git a/autotests/xcursor/wait_48_2.png b/autotests/xcursor/wait_48_2.png new file mode 100644 index 0000000..35308a2 Binary files /dev/null and b/autotests/xcursor/wait_48_2.png differ diff --git a/autotests/xcursor/wait_48_3.png b/autotests/xcursor/wait_48_3.png new file mode 100644 index 0000000..cd7062b Binary files /dev/null and b/autotests/xcursor/wait_48_3.png differ diff --git a/autotests/xcursor/wait_72_1.png b/autotests/xcursor/wait_72_1.png new file mode 100644 index 0000000..fc8b04f Binary files /dev/null and b/autotests/xcursor/wait_72_1.png differ diff --git a/autotests/xcursor/wait_72_2.png b/autotests/xcursor/wait_72_2.png new file mode 100644 index 0000000..c121067 Binary files /dev/null and b/autotests/xcursor/wait_72_2.png differ diff --git a/autotests/xcursor/wait_72_3.png b/autotests/xcursor/wait_72_3.png new file mode 100644 index 0000000..1abace6 Binary files /dev/null and b/autotests/xcursor/wait_72_3.png differ diff --git a/autotests/xcursortest.cpp b/autotests/xcursortest.cpp new file mode 100644 index 0000000..36ccc32 --- /dev/null +++ b/autotests/xcursortest.cpp @@ -0,0 +1,129 @@ +/* + * SPDX-FileCopyrightText: 2026 Kai Uwe Broulik + * + * SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL + */ + +#include +#include +#include + +using namespace Qt::StringLiterals; + +static bool imgEquals(const QImage &im1, const QImage &im2) +{ + const int height = im1.height(); + const int width = im1.width(); + for (int i = 0; i < height; ++i) { + const auto *line1 = reinterpret_cast(im1.scanLine(i)); + const auto *line2 = reinterpret_cast(im2.scanLine(i)); + for (int j = 0; j < width; ++j) { + if (line1[j] - line2[j] != 0) { + return false; + } + } + } + return true; +} + +class XCursorTests : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void initTestCase() + { + QCoreApplication::addLibraryPath(QStringLiteral(PLUGIN_DIR)); + } + + void testReadMetadata() + { + QImageReader reader(QFINDTESTDATA("xcursor/wait")); + + QVERIFY(reader.canRead()); + + QCOMPARE(reader.imageCount(), 18); + + // By default it chooses the largest size + QCOMPARE(reader.size(), QSize(72, 72)); + + QCOMPARE(reader.text(u"Sizes"_s), u"24,48,72"_s); + } + + void testRead_data() + { + QTest::addColumn("size"); + QTest::addColumn("reference"); + + // It prefers downsampling over upsampling. + QTest::newRow("12px") << 12 << 24; + QTest::newRow("24px") << 24 << 24; + QTest::newRow("48px") << 48 << 48; + QTest::newRow("50px") << 50 << 72; + QTest::newRow("72px") << 72 << 72; + QTest::newRow("default") << 0 << 72; + } + + void testRead() + { + QFETCH(int, size); + QFETCH(int, reference); + + QImageReader reader(QFINDTESTDATA("xcursor/wait")); + QVERIFY(reader.canRead()); + QCOMPARE(reader.currentImageNumber(), 0); + + if (size) { + reader.setScaledSize(QSize(size, size)); + } + + QCOMPARE(reader.size(), QSize(reference, reference)); + + QImage aniFrame; + QVERIFY(reader.read(&aniFrame)); + + QImage img1(QFINDTESTDATA(u"xcursor/wait_%1_1.png"_s.arg(reference))); + img1.convertTo(aniFrame.format()); + + QVERIFY(imgEquals(aniFrame, img1)); + + QCOMPARE(reader.nextImageDelay(), 40); + QCOMPARE(reader.text(u"HotspotX"_s), u"48"_s); + QCOMPARE(reader.text(u"HotspotY"_s), u"48"_s); + + QVERIFY(reader.canRead()); + // that read() above should have advanced us to the next frame + QCOMPARE(reader.currentImageNumber(), 1); + + QVERIFY(reader.read(&aniFrame)); + QImage img2(QFINDTESTDATA(u"xcursor/wait_%1_2.png"_s.arg(reference))); + img2.convertTo(aniFrame.format()); + + QVERIFY(imgEquals(aniFrame, img2)); + + // Would be nice to have a cursor with variable delay and hotspot :-) + QCOMPARE(reader.nextImageDelay(), 40); + QCOMPARE(reader.text(u"HotspotX"_s), u"48"_s); + QCOMPARE(reader.text(u"HotspotY"_s), u"48"_s); + + QVERIFY(reader.canRead()); + QCOMPARE(reader.currentImageNumber(), 2); + + QVERIFY(reader.read(&aniFrame)); + QImage img3(QFINDTESTDATA(u"xcursor/wait_%1_3.png"_s.arg(reference))); + img3.convertTo(aniFrame.format()); + + QVERIFY(imgEquals(aniFrame, img3)); + + QCOMPARE(reader.text(u"HotspotX"_s), u"48"_s); + QCOMPARE(reader.text(u"HotspotY"_s), u"48"_s); + QCOMPARE(reader.nextImageDelay(), 40); + + QVERIFY(reader.canRead()); + QCOMPARE(reader.currentImageNumber(), 3); + } +}; + +QTEST_MAIN(XCursorTests) + +#include "xcursortest.moc" diff --git a/src/imageformats/CMakeLists.txt b/src/imageformats/CMakeLists.txt index e02347f..3baa69e 100644 --- a/src/imageformats/CMakeLists.txt +++ b/src/imageformats/CMakeLists.txt @@ -145,6 +145,10 @@ kimageformats_add_plugin(kimg_xcf SOURCES xcf.cpp) ################################## +kimageformats_add_plugin(kimg_xcursor SOURCES xcursor.cpp) + +################################## + if (LibRaw_FOUND) kimageformats_add_plugin(kimg_raw SOURCES raw.cpp) kde_enable_exceptions() diff --git a/src/imageformats/xcursor.cpp b/src/imageformats/xcursor.cpp new file mode 100644 index 0000000..6e9db90 --- /dev/null +++ b/src/imageformats/xcursor.cpp @@ -0,0 +1,349 @@ +/* + * SPDX-FileCopyrightText: 2026 Kai Uwe Broulik + * + * SPDX-License-Identifier: LGPL-2.0-or-later + */ + +#include "xcursor_p.h" + +#include +#include +#include +#include + +#include + +#include "util_p.h" + +#ifdef QT_DEBUG +Q_LOGGING_CATEGORY(LOG_XCURSORPLUGIN, "kf.imageformats.plugins.xcursor", QtDebugMsg) +#else +Q_LOGGING_CATEGORY(LOG_XCURSORPLUGIN, "kf.imageformats.plugins.xcursor", QtWarningMsg) +#endif + +using namespace Qt::StringLiterals; + +static constexpr quint32 XCURSOR_MAGIC = 0x72756358; // "Xcur" +static constexpr quint32 XCURSOR_IMAGE_TYPE = 0xfffd0002; + +XCursorHandler::XCursorHandler() = default; + +bool XCursorHandler::canRead() const +{ + if (canRead(device())) { + setFormat("xcursor"); + return true; + } + + // Check if there's another frame coming. + QDataStream stream(device()); + stream.setByteOrder(QDataStream::LittleEndian); + + // no peek on QDataStream... + const auto oldPos = device()->pos(); + auto cleanup = qScopeGuard([this, oldPos] { + device()->seek(oldPos); + }); + + quint32 headerSize, type, subtype, version, width, height, xhot, yhot, delay; + stream >> headerSize >> type >> subtype >> version >> width >> height >> xhot >> yhot >> delay; + + if (type != XCURSOR_IMAGE_TYPE || width == 0 || height == 0) { + return false; + } + + return true; +} + +bool XCursorHandler::read(QImage *outImage) +{ + if (!ensureScanned()) { + return false; + } + + const auto firstFrameOffset = m_images.value(m_currentSize).first(); + if (device()->pos() < firstFrameOffset) { + device()->seek(firstFrameOffset); + } + + QDataStream stream(device()); + stream.setByteOrder(QDataStream::LittleEndian); + + quint32 headerSize, type, subtype, version, width, height, xhot, yhot, delay; + stream >> headerSize >> type >> subtype >> version >> width >> height >> xhot >> yhot >> delay; + + if (type != XCURSOR_IMAGE_TYPE || width == 0 || height == 0) { + return false; + } + + QImage image = imageAlloc(width, height, QImage::Format_ARGB32); + if (image.isNull()) { + return false; + } + + const qsizetype byteCount = width * height * sizeof(quint32); + if (stream.readRawData(reinterpret_cast(image.bits()), byteCount) != byteCount) { + return false; + } + + *outImage = image; + ++m_currentImageNumber; + m_nextImageDelay = delay; + m_hotspot = QPoint(xhot, yhot); + + return !image.isNull(); +} + +int XCursorHandler::currentImageNumber() const +{ + if (!ensureScanned()) { + return 0; + } + return m_currentImageNumber; +} + +int XCursorHandler::imageCount() const +{ + if (!ensureScanned()) { + return 0; + } + return m_images.value(m_currentSize).count(); +} + +bool XCursorHandler::jumpToImage(int imageNumber) +{ + if (!ensureScanned()) { + return false; + } + + if (imageNumber < 0) { + return false; + } + + if (imageNumber == m_currentImageNumber) { + return true; + } + + if (imageNumber >= imageCount()) { + return false; + } + + if (!device()->seek(m_images.value(m_currentSize).at(imageNumber))) { + return false; + } + + return true; +} + +bool XCursorHandler::jumpToNextImage() +{ + if (!ensureScanned()) { + return false; + } + return jumpToImage(m_currentImageNumber + 1); +} + +int XCursorHandler::loopCount() const +{ + if (!ensureScanned()) { + return 0; + } + return -1; +} + +int XCursorHandler::nextImageDelay() const +{ + if (!ensureScanned()) { + return 0; + } + return m_nextImageDelay; +} + +bool XCursorHandler::supportsOption(ImageOption option) const +{ + return option == Size || option == ScaledSize || option == Description || option == Animation; +} + +QVariant XCursorHandler::option(ImageOption option) const +{ + if (!supportsOption(option) || !ensureScanned()) { + return QVariant(); + } + + switch (option) { + case QImageIOHandler::Size: + return QSize(m_currentSize, m_currentSize); + case QImageIOHandler::Description: { + QString description; + + if (m_hotspot.has_value()) { + description.append(u"HotspotX: %1\n\n"_s.arg(m_hotspot->x())); + description.append(u"HotspotY: %1\n\n"_s.arg(m_hotspot->y())); + } + + // TODO std::transform... + QStringList stringSizes; + stringSizes.reserve(m_images.size()); + for (auto it = m_images.keyBegin(); it != m_images.keyEnd(); ++it) { + stringSizes.append(QString::number(*it)); + } + description.append(u"Sizes: %1\n\n"_s.arg(stringSizes.join(','_L1))); + + return description; + } + + case QImageIOHandler::Animation: + return imageCount() > 1; + default: + break; + } + + return QVariant(); +} + +void XCursorHandler::setOption(ImageOption option, const QVariant &value) +{ + switch (option) { + case QImageIOHandler::ScaledSize: + m_scaledSize = value.toSize(); + pickSize(); + break; + default: + break; + } +} + +bool XCursorHandler::ensureScanned() const +{ + if (m_scanned) { + return true; + } + + if (device()->isSequential()) { + return false; + } + + auto *mutableThis = const_cast(this); + + const auto oldPos = device()->pos(); + auto cleanup = qScopeGuard([this, oldPos] { + device()->seek(oldPos); + }); + + device()->seek(0); + + const QByteArray intro = device()->read(4); + if (intro != "Xcur") { + return false; + } + + QDataStream stream(device()); + stream.setByteOrder(QDataStream::LittleEndian); + + quint32 headerSize, version, ntoc; + stream >> headerSize >> version >> ntoc; + + // TODO headerSize + // TODO version + + if (!ntoc) { + return false; + } + + mutableThis->m_images.clear(); + + for (quint32 i = 0; i < ntoc; ++i) { + quint32 type, size, position; + stream >> type >> size >> position; + + if (type != XCURSOR_IMAGE_TYPE) { + continue; + } + + mutableThis->m_images[size].append(position); + } + + mutableThis->pickSize(); + + return !m_images.isEmpty(); +} + +void XCursorHandler::pickSize() +{ + if (m_images.isEmpty()) { + return; + } + + // If a scaled size was requested, find the closest match. + const auto sizes = m_images.keys(); + // If no scaled size is specified, use the biggest one available. + m_currentSize = sizes.last(); + + if (!m_scaledSize.isEmpty()) { + // TODO Use some clever algo iterator thing instead of keys()... + const int wantedSize = std::max(m_scaledSize.width(), m_scaledSize.height()); + // Prefer downsampling over upsampling. + for (int i = sizes.size() - 1; i >= 0; --i) { + const int size = sizes.at(i); + if (size < wantedSize) { + break; + } + + m_currentSize = size; + } + } +} + +bool XCursorHandler::canRead(QIODevice *device) +{ + if (!device) { + qCWarning(LOG_XCURSORPLUGIN) << "XCurosorHandler::canRead() called with no device"; + return false; + } + if (device->isSequential()) { + return false; + } + + const QByteArray intro = device->peek(4 * 4); + + if (intro.length() != 4 * 4) { + return false; + } + + if (!intro.startsWith("Xcur")) { + return false; + } + + // TODO sanity check sizes? + + return true; +} + +QImageIOPlugin::Capabilities XCursorPlugin::capabilities(QIODevice *device, const QByteArray &format) const +{ + if (format == "xcursor") { + return Capabilities(CanRead); + } + if (!format.isEmpty()) { + return {}; + } + if (!device->isOpen()) { + return {}; + } + + Capabilities cap; + if (device->isReadable() && XCursorHandler::canRead(device)) { + cap |= CanRead; + } + return cap; +} + +QImageIOHandler *XCursorPlugin::create(QIODevice *device, const QByteArray &format) const +{ + QImageIOHandler *handler = new XCursorHandler; + handler->setDevice(device); + handler->setFormat(format); + return handler; +} + +#include "moc_xcursor_p.cpp" diff --git a/src/imageformats/xcursor.json b/src/imageformats/xcursor.json new file mode 100644 index 0000000..8746e7b --- /dev/null +++ b/src/imageformats/xcursor.json @@ -0,0 +1,8 @@ +{ + "Keys": [ + "xcursor" + ], + "MimeTypes": [ + "image/x-xcursor" + ] +} diff --git a/src/imageformats/xcursor_p.h b/src/imageformats/xcursor_p.h new file mode 100644 index 0000000..3170c2b --- /dev/null +++ b/src/imageformats/xcursor_p.h @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: 2026 Kai Uwe Broulik + * + * SPDX-License-Identifier: LGPL-2.0-or-later + */ + +#ifndef KIMG_XCURSOR_P_H +#define KIMG_XCURSOR_P_H + +#include +#include + +#include + +struct XCursorImage { + qint64 offset; + quint32 delay; +}; + +class XCursorHandler : public QImageIOHandler +{ +public: + XCursorHandler(); + + bool canRead() const override; + bool read(QImage *image) override; + + int currentImageNumber() const override; + int imageCount() const override; + bool jumpToImage(int imageNumber) override; + bool jumpToNextImage() override; + + int loopCount() const override; + int nextImageDelay() const override; + + bool supportsOption(ImageOption option) const override; + QVariant option(ImageOption option) const override; + void setOption(ImageOption option, const QVariant &value) override; + + static bool canRead(QIODevice *device); + +private: + bool ensureScanned() const; + void pickSize(); + + bool m_scanned = false; + + int m_currentImageNumber = 0; + + QSize m_scaledSize; + int m_currentSize = 0; + + QMap> m_images; + + int m_nextImageDelay = 0; + std::optional m_hotspot; +}; + +class XCursorPlugin : public QImageIOPlugin +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID "org.qt-project.Qt.QImageIOHandlerFactoryInterface" FILE "xcursor.json") + +public: + Capabilities capabilities(QIODevice *device, const QByteArray &format) const override; + QImageIOHandler *create(QIODevice *device, const QByteArray &format = QByteArray()) const override; +}; + +#endif // KIMG_XCURSOR_P_H