Compare commits
1 Commits
v6.23.0-rc
...
work/kbrou
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c25cb5b8c |
@ -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)
|
||||
|
||||
BIN
autotests/xcursor/wait
Normal file
BIN
autotests/xcursor/wait_24_1.png
Normal file
|
After Width: | Height: | Size: 533 B |
BIN
autotests/xcursor/wait_24_2.png
Normal file
|
After Width: | Height: | Size: 906 B |
BIN
autotests/xcursor/wait_24_3.png
Normal file
|
After Width: | Height: | Size: 943 B |
BIN
autotests/xcursor/wait_48_1.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
autotests/xcursor/wait_48_2.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
autotests/xcursor/wait_48_3.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
autotests/xcursor/wait_72_1.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
autotests/xcursor/wait_72_2.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
autotests/xcursor/wait_72_3.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
129
autotests/xcursortest.cpp
Normal file
@ -0,0 +1,129 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Kai Uwe Broulik <kde@broulik.de>
|
||||
*
|
||||
* SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
|
||||
*/
|
||||
|
||||
#include <QImage>
|
||||
#include <QImageReader>
|
||||
#include <QTest>
|
||||
|
||||
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<const quint8 *>(im1.scanLine(i));
|
||||
const auto *line2 = reinterpret_cast<const quint8 *>(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<int>("size");
|
||||
QTest::addColumn<int>("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"
|
||||
@ -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()
|
||||
|
||||
349
src/imageformats/xcursor.cpp
Normal file
@ -0,0 +1,349 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Kai Uwe Broulik <kde@broulik.de>
|
||||
*
|
||||
* SPDX-License-Identifier: LGPL-2.0-or-later
|
||||
*/
|
||||
|
||||
#include "xcursor_p.h"
|
||||
|
||||
#include <QImage>
|
||||
#include <QLoggingCategory>
|
||||
#include <QScopeGuard>
|
||||
#include <QVariant>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#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<char *>(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<XCursorHandler *>(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"
|
||||
8
src/imageformats/xcursor.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"Keys": [
|
||||
"xcursor"
|
||||
],
|
||||
"MimeTypes": [
|
||||
"image/x-xcursor"
|
||||
]
|
||||
}
|
||||
69
src/imageformats/xcursor_p.h
Normal file
@ -0,0 +1,69 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Kai Uwe Broulik <kde@broulik.de>
|
||||
*
|
||||
* SPDX-License-Identifier: LGPL-2.0-or-later
|
||||
*/
|
||||
|
||||
#ifndef KIMG_XCURSOR_P_H
|
||||
#define KIMG_XCURSOR_P_H
|
||||
|
||||
#include <QImageIOPlugin>
|
||||
#include <QSize>
|
||||
|
||||
#include <optional>
|
||||
|
||||
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<int /*size*/, QVector<qint64 /*offset*/>> m_images;
|
||||
|
||||
int m_nextImageDelay = 0;
|
||||
std::optional<QPoint> 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
|
||||