From b79d1f222d01f5152d11381c7b6694dae6665176 Mon Sep 17 00:00:00 2001 From: Kai Uwe Broulik Date: Sat, 26 Dec 2020 21:49:38 +0100 Subject: [PATCH] Add plugin for animated Windows cursors (ANI) --- README.md | 1 + autotests/CMakeLists.txt | 5 + autotests/ani/test.ani | Bin 0 -> 13050 bytes autotests/ani/test_1.png | Bin 0 -> 813 bytes autotests/ani/test_2.png | Bin 0 -> 697 bytes autotests/ani/test_3.png | Bin 0 -> 810 bytes autotests/anitest.cpp | 119 +++++++ src/imageformats/CMakeLists.txt | 5 + src/imageformats/ani.cpp | 563 ++++++++++++++++++++++++++++++++ src/imageformats/ani.desktop | 7 + src/imageformats/ani.json | 4 + src/imageformats/ani_p.h | 69 ++++ 12 files changed, 773 insertions(+) create mode 100644 autotests/ani/test.ani create mode 100644 autotests/ani/test_1.png create mode 100644 autotests/ani/test_2.png create mode 100644 autotests/ani/test_3.png create mode 100644 autotests/anitest.cpp create mode 100644 src/imageformats/ani.cpp create mode 100644 src/imageformats/ani.desktop create mode 100644 src/imageformats/ani.json create mode 100644 src/imageformats/ani_p.h diff --git a/README.md b/README.md index bc563e3..2deeaa4 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ image formats. The following image formats have read-only support: +- Animated Windows cursors (ani) - Gimp (xcf) - OpenEXR (exr) - Photoshop documents (psd) diff --git a/autotests/CMakeLists.txt b/autotests/CMakeLists.txt index 1eebcfc..8af6256 100644 --- a/autotests/CMakeLists.txt +++ b/autotests/CMakeLists.txt @@ -116,3 +116,8 @@ add_executable(pictest pictest.cpp) target_link_libraries(pictest Qt5::Gui Qt5::Test) ecm_mark_as_test(pictest) add_test(NAME kimageformats-pic COMMAND pictest) + +add_executable(anitest anitest.cpp) +target_link_libraries(anitest Qt5::Gui Qt5::Test) +ecm_mark_as_test(anitest) +add_test(NAME kimageformats-ani COMMAND anitest) diff --git a/autotests/ani/test.ani b/autotests/ani/test.ani new file mode 100644 index 0000000000000000000000000000000000000000..2c91aae69585f0ff70cc6d3c8e0cadf047d205bd GIT binary patch literal 13050 zcmeI2U1(HC6vx-ZZ)4(Dtkyu*HiWi9Y5HDlum-l8L=tV^T0|lZt+8q<eW85rBG#{Hx|G(=_yl!`syI=aU7yg-@xo75o z&Y5%Xotd1PKt;vhANqXdYpSc(2WmII@ALTrRTb5Ns`AP?`dwZX@NcZ!)u`6;nvMBt zS@+3W|C;UFwtd%7-`M2a+))2jiCRIfFkNt8(^Q!&Q10B^ShpZjUlJ*~x2tZ4pLQlgj*C`=j}nYTSGwPaxJP=L+~6o6$K{Zss5!f1&Q# zXJN5^1L6%m&|#)vV?Pu27j}1de<_ZUr%#{$dF$4#?{$@_zDz(De1jhNA=UVgHUEVV z9z6KAqoc#Lw6vHD7cQ8=!NIYCfq~AOH*fyX+}!+uczIr#lT_v*uK$l7Jz6T;!?m@w zPF&X1)V!pN^yB#}5SWW(eGrcyAJ12eU*5NG-~XP;wzf7C3Wd%|hPX}T8MOSwmizf* z+_^nHJ-aSmy!aA-b#=A5bLWmZbLPzFl0i@V9=7k4L`wLXKVsd@PkhZjdGh32?m6Jy zR9|0jMsyuJcC1n|ZwT{*RAPYV2pfhOpE6P>E@z9%nS;8zI&=Q~d9#22ezSM)UNbyA z96EdU?5C1JS7fmz5!S0`IAcQA*No9SuHy{P-8?6GVr@ zhYzE}Uy^}8l^8@1bbt?EP+D7Z0)fD+hYufC9654?XFu=SwQCFw>^qqbC^ zLFBO&$o?;M1 ze4Qq~*!s;1B^mI=PxLDbH~rY5usUKj%Wff2(2xF%!+2i5xU3FXnb7}Dp;%a%1n5V9 z#z|zAkH6;Vgy3gR3iLbaE6I}v?4PB+{a#NKCX-_v#uFIV%Nxpxy=Tv!wWx285RAsy z*jQgQqGclW9*xs1-eh7BeTf@Zs2144j-~MG)vNW=q5sO2D_f~Y+PQCKDe2ERUSluX zKlHP(`s5WA6|K-ts1(W6J~=rb|@%pX3@J%0T7a-FU<==a2l2X)%HFQnV|?$}{${2P7Z zLp>9IcXLnpKwO*}OxPc(XWDkF3-6a1;c)m8I_NZm65BKJ-m#p~r4tL{%_zb!04_ z50UWSjGtn7^(tRcukbpi7dbdtwM6eSSM*v)R4aalPR3w~i`9B_~P4o-p4A=T9$t2Rzq+H_Lj^^_&7JdfYh4?m>p z1IK=G5854+ms0Yh;!ekE-`UN8F?DyYmm30`iORxU#Lts zzx6x5UPL|yc^TwqC?3W-J3GyxLx+q`w#>bI_jY7}AKUSHf&LHO=H}eAX_Mn;@-~z+ z5+;u$L;S?s!v6mLbL3-0KYQeDNUzJ1Ar6_Fbk0Ba^A1~l`t<2q`PgjOu;C>g+S}WW zPCIr=hB+eM-TbLM|2F11DO*Y&hE9$~$jh)k!1v^BkiQ`r^yC?I^W&3b-+%BE_xYzz zomwj&2g%1ECxZM8@-*%NV>e4V6qk%s}d_?-L<@-)cT zP~OJZ5pLwCOV5KU-T58Z5o`RZ+9sYoOUxBeOrd4-@+k&rE?ZUJd2LcX3Am literal 0 HcmV?d00001 diff --git a/autotests/ani/test_1.png b/autotests/ani/test_1.png new file mode 100644 index 0000000000000000000000000000000000000000..78dcfc0ad04b6ad99a44e8008c5652c453122304 GIT binary patch literal 813 zcmV+|1JeA7P)Vi|K~z|U?N-lA z8$lR-vzv`SYD$Y1Z2yAZqz6F>CASg!T!xGMUUn&+|%_WwBH$y(BqEG8Fho6nV(D?KdRPZQFjW3~D5elarGd#bS}3 z=ar9-kKehjTTUjEOr=tE9Oo;^ny&EL^UDy4zy1L4Rg$3^H z>@b;3a%E+Oj^mtdZ*Na40YDG#Y&lK%&0;f1ZZg#>U27DdnVTng9U9Ffcwo zj>W}A%+JruYPFhe63{h5?Iz65&Q6=AsgI3~AsUThVq!v){F2Y-Kd!BQe^>2-Z?lpct+B#R4SkM_xBTtMB)L-QIf+d@(0w~;6>;v0VE?NZz;lI z^*p4A!zz76&;r!%2>kSHL^*!D+V2Gb1i1%*_CZ(xa0cK9fbXh52T%cE1NiAU&MN?V zP!IUEUZC!c8zhT!b8~Hj)5q4<*0~U37=R~)kU>N=?g5gK)6>)UBrn(jq?B&CTz;$K zp|yy0gofKx6!!rbP|d*K{}=qC;{vFv7RUq5t|sdF4Atvb2=M1$sJS%+tO1t@nl2@+ r4SBN&i2!%N9|g1s>C8YtVjsT%HlPrEN@fHg00000NkvXXu0mjfZ<=WL literal 0 HcmV?d00001 diff --git a/autotests/ani/test_2.png b/autotests/ani/test_2.png new file mode 100644 index 0000000000000000000000000000000000000000..5671cc071178ea0ee6e90b9354553ae870754c55 GIT binary patch literal 697 zcmV;q0!ICbP)a#zOY&9ZH5w?a=lY$h1Qjck7fT0uLgC zL4!Xajb?+vEv;lrIvw&H99K>(yV-o;AvpKl502iw?*VJ9vBnx}tg#|UN~`!U?JN?@ zA_f8Yk|Z%~9C^v)GJvS(c^3f2uIpX^hzkB`K~f?aA-PSGs;W|8TkxS&UAAHwKx~GK zEfX)4!{onfnwGRI>v-B`S=OhppITP&XhzV@dCHX`ile% z{m%{#4jS2P_PS6g+~f24JaW0*O{G%#h7ihzEr9KId)({wJ{=t$-80l`HFUe(4_8-L zBizn~Cn>-O;Finf&zVf-$3%sEK96iRYgMb&F92LDD1T^>lu5=&?wY1~+G@3^sw!Kp z7ERMUCAll?|8F3P!82Jh$tcNqI-P!L+xB(6UZ>+YH+y?~X_9eam#6(!NjyooF|`zu z&q$^W!#MFg?`N~wd`&VXcx7h)T|q&40?A3w+qV5iQIrz|v};hnIeqUsj`MMIbMq|% zX2ZM!WB?l^`_*bS9<(P68jZ#;Ns^KP#*!pGen7>gvNaeC-uu2k;tKG6e>4~j-jUoQ z8JW%XkODCPTVicsxr@sOFb41k!0#Xh=8a1!VkyqNw1Y*?yq4Q_K~z|U?N&`| z6HydBlSzg|ni4HS^$!SL=~6*T778vJ^bd4bBDiwZO=E@9fS?5fb<=;)&_&kW3B{(+ zfLM@rR|;*0CQa2OzM09)agjSUtxelZbRp!xfNv&Uw zKm?LFvFkF$8oBgo0|_w4lOP&j+&@|@?scEDXAc1I0Jy1C>htpQ^5pdNw6nLjhg>d) za=DCBsf1Rm^?dH<3sfl}aUYaBy%EW7{@1Ha7I} z@$sj^ub-jO(Xso2%m74$mrN$p`FtL(>z;bKwzh_NJpN3Qoy~D-{reKoXf(d2(`j#J zW(LV*647WB08lEG;JWVprKP1?edOqpxnnDmyfHgFdv|+#`&GSOFDj+nVzJ1Dg$1s! zuXBHY|0T(rBq=5&$?IayzPDq9WQ^o6$q|yHnM~&1&dyGz*=&9yY47gtPW~$anlxSN zLX6}P$%Kd*k?Z(n0>VKAXqrYL&>()0S037H?mzdhyCRT5B)M)`)*AqI017}8Km))J z0N}hAR(tPxp3SSFb$ooB7hjzG z4}zWtLTH!Nm^4GUy73W!1K=mxOJ!@C<^jQ%z%R#2`cvigf_Mi&3!Mj;fC79Y`X{5S o@c6rhoj&OM)Y3~qInRK<0L^YMT+(}vYybcN07*qoM6N<$f}_)Q!vFvP literal 0 HcmV?d00001 diff --git a/autotests/anitest.cpp b/autotests/anitest.cpp new file mode 100644 index 0000000..b4bdb2b --- /dev/null +++ b/autotests/anitest.cpp @@ -0,0 +1,119 @@ +/* + SPDX-FileCopyrightText: 2020 Kai Uwe Broulik + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include +#include +#include + +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 AniTests : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void initTestCase() + { + QCoreApplication::addLibraryPath(QStringLiteral(PLUGIN_DIR)); + } + + void testReadMetadata() + { + QImageReader reader(QFINDTESTDATA("ani/test.ani")); + + QVERIFY(reader.canRead()); + + QCOMPARE(reader.imageCount(), 4); + + QCOMPARE(reader.size(), QSize(32, 32)); + + QCOMPARE(reader.text(QStringLiteral("Title")), QStringLiteral("ANI Test")); + QCOMPARE(reader.text(QStringLiteral("Author")), QStringLiteral("KDE Community")); + } + + void textRead() + { + QImageReader reader(QFINDTESTDATA("ani/test.ani")); + QVERIFY(reader.canRead()); + QCOMPARE(reader.currentImageNumber(), 0); + + QImage aniFrame; + QVERIFY(reader.read(&aniFrame)); + + QImage img1(QFINDTESTDATA("ani/test_1.png")); + img1.convertTo(aniFrame.format()); + + QVERIFY(imgEquals(aniFrame, img1)); + + QCOMPARE(reader.nextImageDelay(), 166); // 10 "jiffies" + + 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("ani/test_2.png")); + img2.convertTo(aniFrame.format()); + + QVERIFY(imgEquals(aniFrame, img2)); + + // The "middle" frame has a longer delay than the others + QCOMPARE(reader.nextImageDelay(), 333); // 20 "jiffies" + + QVERIFY(reader.canRead()); + QCOMPARE(reader.currentImageNumber(), 2); + + QVERIFY(reader.read(&aniFrame)); + QImage img3(QFINDTESTDATA("ani/test_3.png")); + img3.convertTo(aniFrame.format()); + + QVERIFY(imgEquals(aniFrame, img3)); + + QCOMPARE(reader.nextImageDelay(), 166); + + QVERIFY(reader.canRead()); + QCOMPARE(reader.currentImageNumber(), 3); + + QVERIFY(reader.read(&aniFrame)); + // custom sequence in the ANI file should get us back to img2 + QVERIFY(imgEquals(aniFrame, img2)); + + QCOMPARE(reader.nextImageDelay(), 166); + + // We should have reached the end now + QVERIFY(!reader.canRead()); + QVERIFY(!reader.read(&aniFrame)); + + // Jump back to the start + QVERIFY(reader.jumpToImage(0)); + + QVERIFY(reader.canRead()); + QCOMPARE(reader.currentImageNumber(), 0); + + QCOMPARE(reader.nextImageDelay(), 166); + + QVERIFY(reader.read(&aniFrame)); + QVERIFY(imgEquals(aniFrame, img1)); + } +}; + +QTEST_MAIN(AniTests) + +#include "anitest.moc" diff --git a/src/imageformats/CMakeLists.txt b/src/imageformats/CMakeLists.txt index c2b442e..1888386 100644 --- a/src/imageformats/CMakeLists.txt +++ b/src/imageformats/CMakeLists.txt @@ -24,6 +24,11 @@ endfunction() ################################## +kimageformats_add_plugin(kimg_ani JSON "ani.json" SOURCES ani.cpp) +install(FILES ani.desktop DESTINATION ${KDE_INSTALL_KSERVICES5DIR}/qimageioplugins/) + +################################## + if (TARGET avif) kimageformats_add_plugin(kimg_avif JSON "avif.json" SOURCES "avif.cpp") target_link_libraries(kimg_avif "avif") diff --git a/src/imageformats/ani.cpp b/src/imageformats/ani.cpp new file mode 100644 index 0000000..a38b53f --- /dev/null +++ b/src/imageformats/ani.cpp @@ -0,0 +1,563 @@ +/* + SPDX-FileCopyrightText: 2020 Kai Uwe Broulik + + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#include "ani_p.h" + +#include +#include +#include + +namespace +{ + +struct ChunkHeader { + char magic[4]; + quint32_le size; +}; + +struct AniHeader { + quint32_le cbSize; + quint32_le nFrames; // number of actual frames in the file + quint32_le nSteps; // number of logical images + quint32_le iWidth; + quint32_le iHeight; + quint32_le iBitCount; + quint32_le nPlanes; + quint32_le iDispRate; + quint32_le bfAttributes; // attributes (0 = bitmap images, 1 = ico/cur, 3 = "seq" block available) +}; + +struct CurHeader { + quint16_le wReserved; // always 0 + quint16_le wResID; // always 2 + quint16_le wNumImages; +}; + +struct CursorDirEntry { + quint8 bWidth; + quint8 bHeight; + quint8 bColorCount; + quint8 bReserved; // always 0 + quint16_le wHotspotX; + quint16_le wHotspotY; + quint32_le dwBytesInImage; + quint32_le dwImageOffset; +}; + +} // namespace + +ANIHandler::ANIHandler() = default; + +bool ANIHandler::canRead() const +{ + if (canRead(device())) { + setFormat("ani"); + return true; + } + + // Check if there's another frame coming + const QByteArray nextFrame = device()->peek(sizeof(ChunkHeader)); + if (nextFrame.size() == sizeof(ChunkHeader)) { + const auto *header = reinterpret_cast(nextFrame.data()); + if (qstrncmp(header->magic, "icon", sizeof(header->magic)) == 0 + && header->size > 0) { + setFormat("ani"); + return true; + } + } + + return false; +} + +bool ANIHandler::read(QImage *outImage) +{ + if (!ensureScanned()) { + return false; + } + + if (device()->pos() < m_firstFrameOffset) { + device()->seek(m_firstFrameOffset); + } + + const QByteArray frameType = device()->read(4); + if (frameType != "icon") { + return false; + } + + const QByteArray frameSizeData = device()->read(sizeof(quint32_le)); + if (frameSizeData.count() != sizeof(quint32_le)) { + return false; + } + + const auto frameSize = *(reinterpret_cast(frameSizeData.data())); + if (!frameSize) { + return false; + } + + const QByteArray frameData = device()->read(frameSize); + + const bool ok = outImage->loadFromData(frameData, "cur"); + + ++m_currentImageNumber; + + // When we have a custom image sequence, seek to before the frame that would follow + if (!m_imageSequence.isEmpty()) { + if (m_currentImageNumber < m_imageSequence.count()) { + const int nextFrame = m_imageSequence.at(m_currentImageNumber); + const auto nextOffset = m_frameOffsets.at(nextFrame); + device()->seek(nextOffset); + } else if (m_currentImageNumber == m_imageSequence.count()) { + const auto endOffset = m_frameOffsets.last(); + if (device()->pos() != endOffset) { + device()->seek(endOffset); + } + } + } + + return ok; +} + +int ANIHandler::currentImageNumber() const +{ + if (!ensureScanned()) { + return 0; + } + return m_currentImageNumber; +} + +int ANIHandler::imageCount() const +{ + if (!ensureScanned()) { + return 0; + } + return m_imageCount; +} + +bool ANIHandler::jumpToImage(int imageNumber) +{ + if (!ensureScanned()) { + return false; + } + + if (imageNumber < 0) { + return false; + } + + if (imageNumber == m_currentImageNumber) { + return true; + } + + // If we have a custom image sequence we have a index of frames we can jump to + if (!m_imageSequence.isEmpty()) { + if (imageNumber >= m_imageSequence.count()) { + return false; + } + + const int targetFrame = m_imageSequence.at(imageNumber); + + const auto targetOffset = m_frameOffsets.value(targetFrame, -1); + + if (device()->seek(targetOffset)) { + m_currentImageNumber = imageNumber; + return true; + } + + return false; + } + + if (imageNumber >= m_frameCount) { + return false; + } + + // otherwise we need to jump from frame to frame + const auto oldPos = device()->pos(); + + if (imageNumber < m_currentImageNumber) { + // start from the beginning + if (!device()->seek(m_firstFrameOffset)) { + return false; + } + } + + while (m_currentImageNumber < imageNumber) { + if (!jumpToNextImage()) { + device()->seek(oldPos); + return false; + } + } + + m_currentImageNumber = imageNumber; + return true; +} + +bool ANIHandler::jumpToNextImage() +{ + if (!ensureScanned()) { + return false; + } + + // If we have a custom image sequence we have a index of frames we can jump to + // Delegate to jumpToImage + if (!m_imageSequence.isEmpty()) { + return jumpToImage(m_currentImageNumber + 1); + } + + if (device()->pos() < m_firstFrameOffset) { + if (!device()->seek(m_firstFrameOffset)) { + return false; + } + } + + const QByteArray nextFrame = device()->peek(sizeof(ChunkHeader)); + if (nextFrame.size() != sizeof(ChunkHeader)) { + return false; + } + + const auto *header = reinterpret_cast(nextFrame.data()); + if (qstrncmp(header->magic, "icon", sizeof(header->magic)) != 0) { + return false; + } + + const qint64 seekBy = sizeof(ChunkHeader) + header->size; + + if (!device()->seek(device()->pos() + seekBy)) { + return false; + } + + ++m_currentImageNumber; + return true; +} + +int ANIHandler::loopCount() const +{ + if (!ensureScanned()) { + return 0; + } + return -1; +} + +int ANIHandler::nextImageDelay() const +{ + if (!ensureScanned()) { + return 0; + } + + int rate = m_displayRate; + + if (!m_displayRates.isEmpty()) { + int previousImage = m_currentImageNumber - 1; + if (previousImage < 0) { + previousImage = m_displayRates.count() - 1; + } + rate = m_displayRates.at(previousImage); + } + + return rate * 1000 / 60; +} + +bool ANIHandler::supportsOption(ImageOption option) const +{ + return option == Size || option == Name || option == Description || option == Animation; +} + +QVariant ANIHandler::option(ImageOption option) const +{ + if (!supportsOption(option) || !ensureScanned()) { + return QVariant(); + } + + switch (option) { + case QImageIOHandler::Size: + return m_size; + // TODO QImageIOHandler::Format + // but both iBitCount in AniHeader and bColorCount are just zero most of the time + // so one would probably need to traverse even further down into IcoHeader and IconDirEntry... + // but Qt's ICO/CUR handler always seems to give us a ARB + case QImageIOHandler::Name: + return m_name; + case QImageIOHandler::Description: { + QString description; + if (!m_name.isEmpty()) { + description += QStringLiteral("Title: %1\n\n").arg(m_name); + } + if (!m_artist.isEmpty()) { + description += QStringLiteral("Author: %1\n\n").arg(m_artist); + } + return description; + } + + case QImageIOHandler::Animation: + return true; + default: + break; + } + + return QVariant(); +} + +bool ANIHandler::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 riffIntro = device()->read(4); + if (riffIntro != "RIFF") { + return false; + } + + const auto riffSizeData = device()->read(sizeof(quint32_le)); + const auto riffSize = *(reinterpret_cast(riffSizeData.data())); + // TODO do a basic sanity check if the size is enough to hold some metadata and a frame? + if (riffSize == 0) { + return false; + } + + mutableThis->m_displayRates.clear(); + mutableThis->m_imageSequence.clear(); + + while (device()->pos() < riffSize) { + const QByteArray chunkId = device()->read(4); + if (chunkId.length() != 4) { + return false; + } + + if (chunkId == "ACON") { + continue; + } + + const QByteArray chunkSizeData = device()->read(sizeof(quint32_le)); + if (chunkSizeData.length() != sizeof(quint32_le)) { + return false; + } + auto chunkSize = *(reinterpret_cast(chunkSizeData.data())); + + if (chunkId == "anih") { + if (chunkSize != sizeof(AniHeader)) { + qWarning() << "anih chunk size does not match ANIHEADER size"; + return false; + } + + const QByteArray anihData = device()->read(sizeof(AniHeader)); + if (anihData.size() != sizeof(AniHeader)) { + return false; + } + + auto *aniHeader = reinterpret_cast(anihData.data()); + + // The size in the ani header is usually 0 unfortunately, + // so we'll also check the first frame for its size further below + mutableThis->m_size = QSize(aniHeader->iWidth, aniHeader->iHeight); + mutableThis->m_frameCount = aniHeader->nFrames; + mutableThis->m_imageCount = aniHeader->nSteps; + mutableThis->m_displayRate = aniHeader->iDispRate; + } else if (chunkId == "rate" || chunkId == "seq ") { + const QByteArray data = device()->read(chunkSize); + if (static_cast(data.size()) != chunkSize + || data.size() % sizeof(quint32_le) != 0) { + return false; + } + + // TODO should we check that the number of rate entries matches nSteps? + auto *dataPtr = data.data(); + QVector list; + for (int i = 0; i < data.count(); i += sizeof(quint32_le)) { + const auto entry = *(reinterpret_cast(dataPtr + i)); + list.append(entry); + } + + if (chunkId == "rate") { + // should we check that the number of rate entries matches nSteps? + mutableThis->m_displayRates = list; + } else if (chunkId == "seq ") { + // Check if it's just an ascending sequence, don't bother with it then + bool isAscending = true; + for (int i = 0; i < list.count(); ++i) { + if (list.at(i) != i) { + isAscending = false; + break; + } + } + + if (!isAscending) { + mutableThis->m_imageSequence = list; + } + } + // IART and INAM are technically inside LIST->INFO but "INFO" is supposedly optional + // so just handle those two attributes wherever we encounter them + } else if (chunkId == "INAM" || chunkId == "IART") { + const QByteArray value = device()->read(chunkSize); + + if (static_cast(value.size()) != chunkSize) { + return false; + } + + // DWORDs are aligned to even sizes + if (chunkSize % 2 != 0) { + device()->read(1); + } + + // FIXME encoding + const QString stringValue = QString::fromLocal8Bit(value); + if (chunkId == "INAM") { + mutableThis->m_name = stringValue; + } else if (chunkId == "IART") { + mutableThis->m_artist = stringValue; + } + } else if (chunkId == "LIST") { + const QByteArray listType = device()->read(4); + + if (listType == "INFO") { + // Technically would contain INAM and IART but we handle them anywhere above + } else if (listType == "fram") { + quint64 read = 0; + while (read < chunkSize) { + const QByteArray chunkType = device()->read(4); + read += 4; + if (chunkType != "icon") { + break; + } + + if (!m_firstFrameOffset) { + mutableThis->m_firstFrameOffset = device()->pos() - 4; + mutableThis->m_currentImageNumber = 0; + + // If size in header isn't valid, use the first frame's size instead + if (!m_size.isValid() || m_size.isEmpty()) { + const auto oldPos = device()->pos(); + + device()->read(sizeof(quint32_le)); + + const QByteArray curHeaderData = device()->read(sizeof(CurHeader)); + const QByteArray cursorDirEntryData = device()->read(sizeof(CursorDirEntry)); + + if (curHeaderData.length() == sizeof(CurHeader) + && cursorDirEntryData.length() == sizeof(CursorDirEntry)) { + auto *cursorDirEntry = reinterpret_cast(cursorDirEntryData.data()); + mutableThis->m_size = QSize(cursorDirEntry->bWidth, cursorDirEntry->bHeight); + } + + device()->seek(oldPos); + } + + // If we don't have a custom image sequence we can stop scanning right here + if (m_imageSequence.isEmpty()) { + break; + } + } + + mutableThis->m_frameOffsets.append(device()->pos() - 4); + + const QByteArray frameSizeData = device()->read(sizeof(quint32_le)); + if (frameSizeData.size() != sizeof(quint32_le)) { + return false; + } + + const auto frameSize = *(reinterpret_cast(frameSizeData.data())); + device()->seek(device()->pos() + frameSize); + + read += frameSize; + + if (m_frameOffsets.count() == m_frameCount) { + // Also record the end of frame data + mutableThis->m_frameOffsets.append(device()->pos() - 4); + break; + } + } + break; + } + } + } + + if (m_imageCount != m_frameCount && m_imageSequence.isEmpty()) { + qWarning("ANIHandler: 'nSteps' is not equal to 'nFrames' but no 'seq' entries were provided"); + return false; + } + + if (!m_imageSequence.isEmpty() && m_imageSequence.count() != m_imageCount) { + qWarning("ANIHandler: count of entries in 'seq' does not match 'nSteps' in anih"); + return false; + } + + if (!m_displayRates.isEmpty() && m_displayRates.count() != m_imageCount) { + qWarning("ANIHandler: count of entries in 'rate' does not match 'nSteps' in anih"); + return false; + } + + if (!m_frameOffsets.isEmpty() && m_frameOffsets.count() != m_frameCount + 1) { + qWarning("ANIHandler: number of actual frames does not match 'nFrames' in anih"); + return false; + } + + mutableThis->m_scanned = true; + return true; +} + +bool ANIHandler::canRead(QIODevice *device) +{ + if (!device) { + qWarning("ANIHandler::canRead() called with no device"); + return false; + } + + const QByteArray riffIntro = device->peek(12); + + if (riffIntro.length() != 12) { + return false; + } + + if (!riffIntro.startsWith("RIFF")) { + return false; + } + + // TODO sanity check chunk size? + + if (riffIntro.mid(4 + 4, 4) != "ACON") { + return false; + } + + return true; +} + +QImageIOPlugin::Capabilities ANIPlugin::capabilities(QIODevice *device, const QByteArray &format) const +{ + if (format == "ani") { + return Capabilities(CanRead); + } + if (!format.isEmpty()) { + return {}; + } + if (!device->isOpen()) { + return {}; + } + + Capabilities cap; + if (device->isReadable() && ANIHandler::canRead(device)) { + cap |= CanRead; + } + return cap; +} + +QImageIOHandler *ANIPlugin::create(QIODevice *device, const QByteArray &format) const +{ + QImageIOHandler *handler = new ANIHandler; + handler->setDevice(device); + handler->setFormat(format); + return handler; +} diff --git a/src/imageformats/ani.desktop b/src/imageformats/ani.desktop new file mode 100644 index 0000000..122b71f --- /dev/null +++ b/src/imageformats/ani.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Type=Service +X-KDE-ServiceTypes=QImageIOPlugins +X-KDE-ImageFormat=ani +X-KDE-MimeType=application/x-navi-animation +X-KDE-Read=true +X-KDE-Write=false diff --git a/src/imageformats/ani.json b/src/imageformats/ani.json new file mode 100644 index 0000000..b90356c --- /dev/null +++ b/src/imageformats/ani.json @@ -0,0 +1,4 @@ +{ + "Keys": [ "ani" ], + "MimeTypes": [ "application/x-navi-animation" ] +} diff --git a/src/imageformats/ani_p.h b/src/imageformats/ani_p.h new file mode 100644 index 0000000..22e69ce --- /dev/null +++ b/src/imageformats/ani_p.h @@ -0,0 +1,69 @@ +/* + SPDX-FileCopyrightText: 2020 Kai Uwe Broulik + + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#ifndef KIMG_ANI_P_H +#define KIMG_ANI_P_H + +#include +#include + +class ANIHandler : public QImageIOHandler +{ +public: + ANIHandler(); + + 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; + + static bool canRead(QIODevice *device); + +private: + bool ensureScanned() const; + + bool m_scanned = false; + + int m_currentImageNumber = 0; + + int m_frameCount = 0; // "physical" frames + int m_imageCount = 0; // logical images + // Stores a custom sequence of images + QVector m_imageSequence; + // and the corresponding offsets where they are + // since we can't read the image data sequentally in this case then + QVector m_frameOffsets; + qint64 m_firstFrameOffset = 0; + + int m_displayRate = 0; + QVector m_displayRates; + + QString m_name; + QString m_artist; + QSize m_size; + +}; + +class ANIPlugin : public QImageIOPlugin +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID "org.qt-project.Qt.QImageIOHandlerFactoryInterface" FILE "ani.json") + +public: + Capabilities capabilities(QIODevice *device, const QByteArray &format) const override; + QImageIOHandler *create(QIODevice *device, const QByteArray &format = QByteArray()) const override; +}; + +#endif // KIMG_ANI_P_H